Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9f8e0a1f8 | ||
|
|
3dd7766b08 | ||
|
|
21e8f0bc39 | ||
|
|
b0eda35195 | ||
|
|
65ff14a613 | ||
|
|
dc99a40384 | ||
|
|
2c6ee041b9 | ||
|
|
091ad975aa | ||
|
|
e9e8783cbd | ||
|
|
9bcd2de587 | ||
|
|
85b5f80c59 | ||
|
|
6e9393c9c7 | ||
|
|
bd72a3f277 | ||
|
|
09a4b9ce60 | ||
|
|
2c83db3b49 | ||
|
|
bbf9c824f1 | ||
|
|
ad37b030e2 | ||
|
|
d8643174d7 | ||
|
|
6f6c45f6e2 | ||
|
|
820ea5eeab | ||
|
|
43feca0244 | ||
|
|
c14719d55a | ||
|
|
f025b0c25b | ||
|
|
d1c5738ca8 | ||
|
|
ebb3b82cf8 | ||
|
|
94c1d08519 | ||
|
|
15559a8fdf | ||
|
|
01c1f7e13a | ||
|
|
5a8138ea03 | ||
|
|
906879caf1 | ||
|
|
dd1937a3af | ||
|
|
a0c9e59c34 | ||
|
|
853125c80c | ||
|
|
d098e3e9dd | ||
|
|
d2e34b323a | ||
|
|
74652686ca | ||
|
|
b47f71916d |
@@ -0,0 +1,270 @@
|
||||
name: Module Package Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: Existing protected version tag to publish
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
publish-packages:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Select and validate protected release tag
|
||||
shell: bash
|
||||
env:
|
||||
REQUESTED_TAG: ${{ inputs.release_tag }}
|
||||
TRIGGER_TAG: ${{ gitea.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
|
||||
case "$tag" in
|
||||
v[0-9]*.[0-9]*.[0-9]*) ;;
|
||||
*) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;;
|
||||
esac
|
||||
git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main
|
||||
tag_commit="$(git rev-list -n 1 "$tag")"
|
||||
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || {
|
||||
echo "Release tag is not contained in main" >&2
|
||||
exit 1
|
||||
}
|
||||
git checkout --detach "$tag"
|
||||
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
|
||||
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
|
||||
- name: Validate package versions
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
import os
|
||||
import re
|
||||
import tomllib
|
||||
|
||||
tag = os.environ["RELEASE_TAG"]
|
||||
expected = tag.removeprefix("v")
|
||||
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||
if project.get("version") != expected:
|
||||
raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}")
|
||||
if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None:
|
||||
raise SystemExit("Python distribution name must use the govoplan-* namespace")
|
||||
webui = Path("webui/package.json")
|
||||
if webui.is_file():
|
||||
package = json.loads(webui.read_text(encoding="utf-8"))
|
||||
if package.get("version") != expected:
|
||||
raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}")
|
||||
if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None:
|
||||
raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace")
|
||||
release = Path("webui/package.release.json")
|
||||
if release.is_file():
|
||||
release_package = json.loads(release.read_text(encoding="utf-8"))
|
||||
if (
|
||||
release_package.get("name") != package.get("name")
|
||||
or release_package.get("version") != expected
|
||||
):
|
||||
raise SystemExit("WebUI release package identity does not match package.json and the release tag")
|
||||
PY
|
||||
- name: Build immutable package artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
|
||||
rm -rf dist .package-webui
|
||||
python -m build --wheel --outdir dist
|
||||
python -m twine check dist/*.whl
|
||||
if [[ -f webui/package.json ]]; then
|
||||
mkdir .package-webui
|
||||
cp -a webui/. .package-webui/
|
||||
rm -rf .package-webui/node_modules .package-webui/dist
|
||||
if [[ -f .package-webui/package.release.json ]]; then
|
||||
cp .package-webui/package.release.json .package-webui/package.json
|
||||
fi
|
||||
node <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const path = ".package-webui/package.json";
|
||||
const packageJson = JSON.parse(fs.readFileSync(path, "utf8"));
|
||||
const groups = ["dependencies", "optionalDependencies", "peerDependencies"];
|
||||
for (const group of groups) {
|
||||
for (const [name, specifier] of Object.entries(packageJson[group] || {})) {
|
||||
if (!name.startsWith("@govoplan/")) continue;
|
||||
if (typeof specifier !== "string") {
|
||||
throw new Error(`${group}.${name} must use a string version`);
|
||||
}
|
||||
const packageSlug = name.slice("@govoplan/".length);
|
||||
if (!packageSlug.endsWith("-webui")) {
|
||||
throw new Error(`${group}.${name} is outside the WebUI package namespace`);
|
||||
}
|
||||
const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`;
|
||||
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const gitTag = specifier.match(
|
||||
new RegExp(
|
||||
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
|
||||
),
|
||||
);
|
||||
if (gitTag) {
|
||||
packageJson[group][name] = gitTag[1];
|
||||
continue;
|
||||
}
|
||||
if (specifier.startsWith("file:") || specifier.startsWith("git+")) {
|
||||
throw new Error(
|
||||
`${group}.${name} must resolve to an exact registry version for publication`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
delete packageJson.private;
|
||||
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
NODE
|
||||
npm pkg delete private --prefix .package-webui
|
||||
(cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist)
|
||||
fi
|
||||
python - <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
artifacts = []
|
||||
for path in sorted(Path("dist").iterdir()):
|
||||
if path.suffix not in {".whl", ".tgz"}:
|
||||
continue
|
||||
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size})
|
||||
payload = {
|
||||
"schema_version": "1",
|
||||
"repository": os.environ["GITEA_REPOSITORY"],
|
||||
"tag": os.environ["RELEASE_TAG"],
|
||||
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
|
||||
"artifacts": artifacts,
|
||||
}
|
||||
Path("dist/package-artifacts.json").write_text(
|
||||
json.dumps(payload, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
PY
|
||||
- name: Retain package hash evidence
|
||||
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
|
||||
with:
|
||||
name: module-packages-${{ gitea.ref_name }}
|
||||
path: dist/package-artifacts.json
|
||||
- name: Check immutable registry state
|
||||
shell: bash
|
||||
env:
|
||||
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$PACKAGE_TOKEN"
|
||||
python - <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tomllib
|
||||
from urllib.error import HTTPError
|
||||
from urllib.parse import quote
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
|
||||
token = os.environ["PACKAGE_TOKEN"]
|
||||
|
||||
def should_publish(kind, name, version, path):
|
||||
package_url = "/".join(
|
||||
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
|
||||
)
|
||||
request = Request(
|
||||
package_url,
|
||||
headers={"Accept": "application/json", "Authorization": f"token {token}"},
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=30) as response:
|
||||
files = json.load(response)
|
||||
except HTTPError as exc:
|
||||
if exc.code == 404:
|
||||
print(f"{kind} package {name}=={version} is not published yet")
|
||||
return True
|
||||
raise
|
||||
if not isinstance(files, list) or len(files) != 1:
|
||||
raise SystemExit(
|
||||
f"immutable {kind} package {name}=={version} has an unexpected file set"
|
||||
)
|
||||
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
if files[0].get("sha256") != expected_sha256:
|
||||
raise SystemExit(
|
||||
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
|
||||
)
|
||||
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
|
||||
return False
|
||||
|
||||
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||
wheels = tuple(Path("dist").glob("*.whl"))
|
||||
if len(wheels) != 1:
|
||||
raise SystemExit("release build must contain exactly one wheel")
|
||||
publish_pypi = should_publish(
|
||||
"pypi", str(project["name"]), str(project["version"]), wheels[0]
|
||||
)
|
||||
|
||||
tarballs = tuple(Path("dist").glob("*.tgz"))
|
||||
if len(tarballs) > 1:
|
||||
raise SystemExit("release build must contain at most one npm package")
|
||||
publish_npm = False
|
||||
if tarballs:
|
||||
webui = json.loads(
|
||||
Path(".package-webui/package.json").read_text(encoding="utf-8")
|
||||
)
|
||||
publish_npm = should_publish(
|
||||
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
|
||||
)
|
||||
|
||||
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
|
||||
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
|
||||
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
|
||||
PY
|
||||
- name: Publish wheel and WebUI package
|
||||
shell: bash
|
||||
env:
|
||||
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
|
||||
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$PACKAGE_USERNAME"
|
||||
test -n "$PACKAGE_TOKEN"
|
||||
if [[ "$PUBLISH_PYPI" == 1 ]]; then
|
||||
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
|
||||
python -m twine upload --non-interactive \
|
||||
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
|
||||
dist/*.whl
|
||||
else
|
||||
echo "Exact wheel is already present; skipping immutable retry."
|
||||
fi
|
||||
shopt -s nullglob
|
||||
webui_packages=(dist/*.tgz)
|
||||
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
|
||||
npmrc="$(mktemp)"
|
||||
trap 'rm -f "$npmrc"' EXIT
|
||||
chmod 600 "$npmrc"
|
||||
printf '%s\n' \
|
||||
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
|
||||
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
|
||||
> "$npmrc"
|
||||
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
|
||||
--ignore-scripts --access public \
|
||||
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
|
||||
elif (( ${#webui_packages[@]} )); then
|
||||
echo "Exact WebUI package is already present; skipping immutable retry."
|
||||
fi
|
||||
@@ -0,0 +1,16 @@
|
||||
# GovOPlaN IDM Codex Guide
|
||||
|
||||
## Scope
|
||||
|
||||
This repository owns identity-to-organization function assignments, governed request/grant workflows, effective dates, and external directory reconciliation.
|
||||
|
||||
## 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 IDM internals.
|
||||
- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Identity owns people; Organizations owns structures and functions; Access maps resulting facts to authority.
|
||||
- Preserve effective dates, provenance, approval evidence, and reconciliation state.
|
||||
@@ -1,5 +1,9 @@
|
||||
# GovOPlaN IDM
|
||||
|
||||
<!-- govoplan-repository-type:start -->
|
||||
**Repository type:** module (platform).
|
||||
<!-- govoplan-repository-type:end -->
|
||||
|
||||
`govoplan-idm` is the planned integration module for external identity
|
||||
management systems. It does not own GovOPlaN's internal identity, organization,
|
||||
account, role, or tenant tables; those remain with `govoplan-identity`,
|
||||
@@ -16,6 +20,7 @@ vendor or protocol.
|
||||
- inbound synchronization from external identity-management systems
|
||||
- identity lifecycle import, update, disable, and reconciliation jobs
|
||||
- identity-to-organization-function assignment links inside GovOPlaN
|
||||
- typed business groups and effective-dated identity relationship links
|
||||
- bridge views that combine identity and organization facts, such as identity
|
||||
candidates for organization function assignments
|
||||
- mapping external identities, accounts, groups, organizational units,
|
||||
@@ -69,6 +74,12 @@ assignment links:
|
||||
- `GET /api/v1/idm/organization-function-assignments`
|
||||
- `POST /api/v1/idm/organization-function-assignments`
|
||||
- `PATCH /api/v1/idm/organization-function-assignments/{assignment_id}`
|
||||
- `GET|POST /api/v1/idm/typed-groups`
|
||||
- `PATCH /api/v1/idm/typed-groups/{group_id}`
|
||||
- `GET|POST /api/v1/idm/relationships`
|
||||
- `PATCH /api/v1/idm/relationships/{relationship_id}`
|
||||
- `POST /api/v1/idm/relationships/{relationship_id}/revoke`
|
||||
- `GET /api/v1/idm/typed-groups/{group_id}/memberships`
|
||||
|
||||
The candidate endpoint returns searchable identity/account candidates for IDM
|
||||
assignment forms. Assignment writes validate the identity/account link through
|
||||
@@ -80,6 +91,19 @@ planning.
|
||||
The WebUI exposed by this repository is a normal module UI at `/idm`. It is the
|
||||
editing surface for identity-to-organization-function assignment links.
|
||||
|
||||
The module also publishes `privacy.dsar.idm`. The provider finds tenant-scoped
|
||||
function assignments, typed relationships, governed assignment changes, and
|
||||
lifecycle events using corroborated identity/account selectors. Automated
|
||||
exports minimize other candidates and actors and exclude opaque settings,
|
||||
properties, provenance, external source references, policy/workflow internals,
|
||||
idempotency material, evidence payloads, comments, and event details. Assignment
|
||||
or relationship changes require the normal governed IDM lifecycle; immutable
|
||||
change and event evidence is retained with an explicit reason.
|
||||
|
||||
Its interface archetypes, consequence classes, contextual-help contract, and
|
||||
accessibility evidence are recorded in
|
||||
[`docs/INTERFACE_PATTERN_MIGRATION.md`](docs/INTERFACE_PATTERN_MIGRATION.md).
|
||||
|
||||
## Migration And Permission Transition
|
||||
|
||||
The initial IDM migration creates new IDM-owned tables only. No data is migrated
|
||||
@@ -124,6 +148,21 @@ Delegated and acting-for assignments are source-specific:
|
||||
acting-for account on that source identity, and the organization function must
|
||||
allow acting in place.
|
||||
|
||||
Function requests and holder/authority initiated grants require a governed
|
||||
change record before the effective assignment is created. The target journeys,
|
||||
grant profiles, state model, and module boundaries are documented in
|
||||
[Function assignment request and grant workflows](docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md).
|
||||
|
||||
Assignments with a future `valid_until` are resolved as ineffective immediately
|
||||
after that boundary. When Celery beat and an IDM-capable worker are running, the
|
||||
shared `govoplan.idm.expire_assignments` task also emits the corresponding
|
||||
`idm.function_assignment.expired.v1` event. IDM records the event marker in the
|
||||
same database transaction, making repeated sweeps idempotent.
|
||||
|
||||
Typed group and relationship behavior, including effective-time resolution and
|
||||
the provider-neutral capability consumed by Distribution Lists, is documented
|
||||
in [Typed groups and effective-dated relationships](docs/TYPED_RELATIONSHIPS.md).
|
||||
|
||||
## First Milestone
|
||||
|
||||
The first useful milestone is a read-only synchronization preview:
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
# Function Assignment Request And Grant Workflows
|
||||
|
||||
## Purpose
|
||||
|
||||
Assigning an identity to an organization function is a governed business
|
||||
change, not just an IDM table edit. IDM owns the request or grant record and the
|
||||
resulting effective-dated assignment. Organizations owns units and function
|
||||
definitions. Policy resolves who may initiate, clear, accept, reject, revoke,
|
||||
or recover a change. Workflow Engine runs the selected, version-pinned process.
|
||||
|
||||
An assignment workflow never grants generic application permissions by itself.
|
||||
Access may project an accepted function assignment into roles only through an
|
||||
explicit function mapping.
|
||||
|
||||
## Two User Journeys
|
||||
|
||||
### Request A Function
|
||||
|
||||
An eligible user finds a function, sees whether it can be requested and why,
|
||||
provides the required justification/evidence, and submits a request. The
|
||||
effective policy determines whether the request goes to a current holder, a
|
||||
clearing authority, both in sequence, or is unavailable because only an
|
||||
authority may initiate the assignment.
|
||||
|
||||
The requester can inspect progress, respond to questions, withdraw while
|
||||
permitted, and receive the final decision. No effective assignment exists until
|
||||
all required approvals and any configured recipient acceptance are complete.
|
||||
|
||||
### Bestow A Function
|
||||
|
||||
An effective function holder or authority selects an eligible identity and
|
||||
proposes a function assignment with scope, validity, delegation, and evidence.
|
||||
The policy determines whether the holder may complete the grant, whether an
|
||||
authority must clear it, or whether only the authority can initiate and grant
|
||||
it. Recipient acceptance can be required or waived only by an explicit policy
|
||||
with recorded provenance.
|
||||
|
||||
## Delegation, Substitution, And Acting In Place
|
||||
|
||||
These modes are explicit assignment sources; ordinary group membership never
|
||||
creates them:
|
||||
|
||||
- A **delegated assignment** is a bounded substitution. For example, a registry
|
||||
lead may delegate the same delegable function to a deputy until Friday. The
|
||||
deputy acts as themself, and Access receives both the derived assignment ID
|
||||
and its source assignment ID.
|
||||
- An **acting-for assignment** is a bounded representation context. For example,
|
||||
an assistant may select an active acting context for the represented function
|
||||
holder. Access records the real account, the selected assignment, and the
|
||||
represented account on the session and in audit evidence. No acting-for
|
||||
authority is effective until that exact context is selected.
|
||||
- A **direct assignment** is the holder's own function fact and has no source
|
||||
assignment or represented account.
|
||||
|
||||
The Organizations function must permit the requested mode. The source and
|
||||
derived assignments must belong to the same tenant, function, and unit scope.
|
||||
IDM walks the complete source chain at submission, every decision, recovery,
|
||||
and final application. Cycles, missing or inactive sources, expired windows,
|
||||
child windows outside their source, and chains beyond the current Policy depth
|
||||
ceiling fail closed with a specific explanation. Tightening Policy therefore
|
||||
invalidates a formerly acceptable route; captured submission authority is
|
||||
evidence, not a future permission grant. An actor also needs the assignment-write
|
||||
scope; where a governance profile is enabled, Policy must authorize the
|
||||
request/grant or an administrator must use the recorded emergency-override
|
||||
path. Validity windows make substitutions expire automatically. Deactivation
|
||||
revokes an assignment without deleting its provenance. IDM emits changed,
|
||||
revoked, and expired lifecycle events and writes the normal assignment audit
|
||||
record; effective-directory reads immediately exclude inactive, future,
|
||||
expired, or source-invalid derived assignments.
|
||||
|
||||
## Grant Profiles
|
||||
|
||||
The first policy profiles are:
|
||||
|
||||
- `holder_grant`: an effective holder may initiate and approve within the
|
||||
configured scope and duration ceilings.
|
||||
- `holder_with_authority_clearance`: a holder or eligible requester may
|
||||
initiate, but the configured authority must clear the change.
|
||||
- `authority_only`: only the configured authority may initiate and approve;
|
||||
other users receive an unavailable action with an explanation.
|
||||
|
||||
Profiles may additionally configure self-request eligibility, recipient
|
||||
acceptance, evidence requirements, separation of duties, quorum, expiry,
|
||||
maximum validity, delegation limits, vacancy routing, and escalation. Function
|
||||
or function-type configuration references a profile; Policy produces the
|
||||
effective decision and provenance rather than IDM duplicating policy logic.
|
||||
|
||||
## IDM Aggregate
|
||||
|
||||
IDM persists one function-assignment change aggregate for both journeys:
|
||||
|
||||
- change ID, tenant, kind (`request` or `grant`), function, unit, candidate
|
||||
identity/account, requested validity, and assignment source
|
||||
- initiator, represented actor/function where applicable, policy profile and
|
||||
decision revision
|
||||
- pinned workflow definition/revision and workflow instance reference
|
||||
- justification, evidence references, comments, and redacted process variables
|
||||
- state and append-only state history
|
||||
- resulting assignment ID, or rejection/withdrawal/expiry reason
|
||||
- idempotency key and optimistic-concurrency revision
|
||||
|
||||
Candidate states are `draft`, `submitted`, `awaiting_holder`,
|
||||
`awaiting_authority`, `awaiting_recipient`, `changes_requested`, `blocked`,
|
||||
`escalated`, `approved`, `accepted`, `applied`, `rejected`, `withdrawn`, `expired`,
|
||||
`cancelled`, and `failed_manual_review`. Not every profile uses every state.
|
||||
|
||||
The workflow instance coordinates the process, but the IDM change record is the
|
||||
business source of truth. A workflow callback applies the assignment exactly
|
||||
once after successful completion. Failed or outcome-unknown application is
|
||||
reconciled without starting a second grant.
|
||||
|
||||
## Required Guarantees
|
||||
|
||||
- Every action rechecks current identity, holder, authority, function, and
|
||||
Policy facts. Authorization captured at submission is evidence, not a future
|
||||
permission grant.
|
||||
- A holder who loses the source function cannot approve later unless Policy
|
||||
explicitly permits continuity for an already-open case.
|
||||
- Vacancy and unavailable authority routes produce a visible blocked/escalated
|
||||
state rather than silently granting or losing the request.
|
||||
- The proposer and required clearer cannot be the same actor where separation
|
||||
of duties applies.
|
||||
- Effective dates, revocation, expiry, delegation, and acting-for semantics use
|
||||
the canonical IDM assignment contract.
|
||||
- Notifications are emitted for durable waiting states and final outcomes when
|
||||
Notifications is installed.
|
||||
- Audit receives non-secret evidence for every transition when Audit is
|
||||
installed.
|
||||
- The direct administrative write path is either disabled for governed
|
||||
functions or treated as an explicit emergency override with reason,
|
||||
provenance, and equivalent evidence.
|
||||
|
||||
## Configuration And Runtime Contract
|
||||
|
||||
Function settings use `assignment_governance`. Tenant defaults may be supplied
|
||||
through `settings.function_assignment_governance_defaults`; function values
|
||||
override only the corresponding defaults. Supported keys include
|
||||
`request_profile`, `grant_profile`, `authority_function_id`,
|
||||
`recipient_acceptance_required`, `evidence_required`,
|
||||
`separation_of_duties`, `quorum`, `maximum_validity_days`, and
|
||||
`request_expiry_hours`. Delegation uses `delegation_allowed`,
|
||||
`maximum_delegation_depth`, and `maximum_delegated_validity_days`. The optional
|
||||
`escalation` object has `holder`, `authority`, or `recipient` entries; each entry
|
||||
requires an exact `target_function_id` and a bounded `timeout_hours`. Missing or
|
||||
malformed profiles and half-configured escalation rules fail closed. Tenant
|
||||
administrators can edit these defaults in the IDM governance panel, while a
|
||||
function-specific Organizations setting may only tighten or deliberately
|
||||
override the corresponding default with visible Policy provenance.
|
||||
|
||||
IDM exposes governed changes at
|
||||
`/api/v1/idm/function-assignment-changes`. Mutations require a strong `If-Match`
|
||||
precondition and the aggregate revision. Requests and grants pin the exact
|
||||
Workflow definition revision and hash, retain append-only transition evidence,
|
||||
support review change requests and responses, and apply an assignment exactly
|
||||
once. Vacant holder or authority functions create a visible `blocked` state;
|
||||
the lifecycle worker expires overdue open changes durably. When a configured
|
||||
review deadline elapses, the worker atomically changes the aggregate to
|
||||
`escalated`, retains the original review state and exact target function,
|
||||
notifies the participants and target holders, and records change, Platform
|
||||
Event, and Audit evidence. It never marks the review complete. A current holder
|
||||
of that explicit target must make a normal revision-checked decision. Recovery
|
||||
rechecks the current route and cannot manufacture an approver.
|
||||
|
||||
Direct administration remains available for independently deployed IDM. When
|
||||
a function has an enabled governance profile, however, direct create or update
|
||||
requires `idm:function_change:admin` plus an emergency override reason. IDM
|
||||
stores the actor, time, reason, and evidence references with the assignment and
|
||||
includes them in the normal Audit change record.
|
||||
|
||||
## Module Boundaries
|
||||
|
||||
- Organizations: function definitions and assignment-policy profile reference.
|
||||
- IDM: request/grant aggregate, candidate validation, effective assignment, and
|
||||
assignment lifecycle.
|
||||
- Policy: effective initiator/approver/acceptance/quorum/expiry decisions and
|
||||
explanations.
|
||||
- Workflow Engine: versioned definition installation and process execution.
|
||||
- Workflow: optional authoring, inspection, and override UI.
|
||||
- Notifications: waiting-state and outcome notifications.
|
||||
- Audit: immutable transition and assignment evidence.
|
||||
- Access: optional projection of accepted function facts into roles/rights.
|
||||
@@ -0,0 +1,62 @@
|
||||
# IDM Interface Pattern Migration
|
||||
|
||||
This document records the bounded migration of IDM-owned WebUI surfaces to the
|
||||
GovOPlaN interface pattern language. Identity owns people and account links,
|
||||
Organizations owns functions and units, Access owns application authority, and
|
||||
IDM owns effective identity-to-function facts and their governed lifecycle.
|
||||
|
||||
## Surface Inventory
|
||||
|
||||
| Surface | Archetype | Consequence class | Contract |
|
||||
| --- | --- | --- | --- |
|
||||
| `/idm` assignments | Repeated administration and governed relationship directory | Create, change, deactivate, delegate, or act for an assignment | Shared DataGrid/dialog/actions, accurate draft baseline, explicit permission and organization prerequisites, contextual field help |
|
||||
| IDM governance settings | Effective tenant configuration | Require change evidence and alter audit retention | Shared card/form/toggles, dirty-state guard, write blocker, consequence help |
|
||||
| Function request/grant list | Governed work queue | Start or inspect a function change | Shared grid/loading/status/action slot, localized state and workflow vocabulary |
|
||||
| Request/grant editor | Guided consequential editor | Submit a governed assignment change | Shared segmented control/dialog/forms, guarded draft, effective dates, justification and evidence help |
|
||||
| Function-change detail | Decision and provenance record | Approve, reject, accept, request changes, withdraw, respond, or recover | Shared confirmation, available-action contract, retained actor/policy/workflow/history evidence |
|
||||
| Typed-group directory | Repeated administration | Create, edit, activate, or deactivate a tenant business group | Shared grid/card/dialog/action bar, optimistic revision, source and provenance fields, exact contextual help |
|
||||
| Effective relationship directory | Effective-dated administration | Create, change, expire, or irreversibly revoke a business relationship | Searchable identity/group selectors, four distinct lifecycle states, dirty guard, reasoned destructive confirmation |
|
||||
| Membership inspector | Point-in-time evidence reader | Resolve included and excluded identities for a group, time, and relationship kind | Shared resolver endpoint, localized time, decision codes, identity lifecycle explanation |
|
||||
| `idm.action.view-function-assignments` | Contextual cross-module action | Navigate with function context | Declared capability surface, permission guard, no Organizations-private import |
|
||||
|
||||
## Consequence And Availability Rules
|
||||
|
||||
- Opening an existing assignment does not mark it dirty. The guard compares the
|
||||
editor to the loaded baseline and protects only actual changes.
|
||||
- Direct assignment changes require IDM write authority. Governed functions use
|
||||
request/grant workflows for normal changes; direct edits are emergency
|
||||
overrides with a retained reason and optional evidence references.
|
||||
- Delegated and acting-for assignments remain tied to a valid source assignment
|
||||
and the Organizations flags that permit those semantics.
|
||||
- Assignment and governance settings do not create application permissions.
|
||||
Access must separately map accepted institutional facts to assignable roles.
|
||||
- Decisions are confirmed before execution. The resulting actor, comment,
|
||||
policy decision, workflow revision, state transition, and evidence remain in
|
||||
the governed record.
|
||||
- Deactivation and expiry remove a fact from effective resolution while
|
||||
retaining provenance and lifecycle evidence.
|
||||
- Future, active, expired, and revoked relationships remain visually distinct.
|
||||
Revocation requires a reason, acts immediately, and leaves the record
|
||||
immutable; later reuse requires a new relationship.
|
||||
- Typed relationship managers use searchable Identity and group references.
|
||||
External source, revision, properties, and provenance remain inspectable and
|
||||
editable under optimistic concurrency.
|
||||
- Membership inspection uses the production resolution capability and shows
|
||||
excluded decisions instead of presenting only a flattened member list.
|
||||
- Missing permission, identity search, and organization functions identify the
|
||||
required action, responsible administrator, and destination.
|
||||
|
||||
## State And Accessibility Evidence
|
||||
|
||||
The module uses Core page/card/grid/dialog/loading/alert/status/action-blocker,
|
||||
field-help, disabled-reason, confirmation, and unsaved-change controls. Shared
|
||||
dialogs retain focus containment and return behavior; stable grid actions remain
|
||||
keyboard reachable. Existing responsive CSS collapses summaries and histories
|
||||
to one column at narrow widths.
|
||||
|
||||
English and German catalogues cover route metadata, assignment and relationship
|
||||
fields, governed states, workflow steps, membership decisions, confirmations,
|
||||
and accessible labels. Dates follow the selected platform locale. Manifest
|
||||
topics provide stable route, field, blocker, workflow, lifecycle, provenance,
|
||||
and consequence references without importing optional Policy, Audit,
|
||||
Notifications, Access, or Workflow Engine implementations.
|
||||
@@ -0,0 +1,18 @@
|
||||
# SCIM 2.0 provisioning foundation
|
||||
|
||||
IDM uses SCIM 2.0 as the first provisioning boundary. OIDC remains the authentication boundary: a successful login is not provisioning evidence, and a SCIM resource does not grant application authority.
|
||||
|
||||
## Reconciliation model
|
||||
|
||||
The connector reads RFC 7643 User and Group resources using RFC 7644 one-based pagination. A snapshot is complete only after every advertised page for both collections has been read without totals changing. An outage, malformed page, pagination stall, or configured item limit fails the snapshot; it never implies that an external object was deleted.
|
||||
|
||||
Each binding must select a provider-owned immutable match attribute. User name, display name, and email are deliberately rejected as defaults because they are mutable and collision-prone. The SCIM provider `id` is retained after linking, `externalId` remains provider/client correlation when supplied, and the source representation is digest-bound.
|
||||
|
||||
The dry-run planner emits create, link, update, deactivate, or quarantine operations with expected local revisions. Duplicate provider IDs, multiple immutable matches, and changes to a bound immutable value are quarantined. Deactivation is possible only from a complete snapshot and only under a reviewed provider policy; review is the default.
|
||||
|
||||
## Authority boundary
|
||||
|
||||
SCIM Users can become candidates for Identity-owned people and accounts. SCIM Groups and memberships are projected only as business membership facts into IDM. They never become Access roles, permissions, or authorization decisions automatically. Organizations continues to own organization structures and functions, and Access continues to own application authority.
|
||||
|
||||
This slice performs discovery and deterministic planning only. Applying a plan requires a later governed execution slice with persisted provider configuration, operator review, audit evidence, idempotency, conflict checks, and recovery.
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# Typed groups and effective-dated relationships
|
||||
|
||||
IDM owns tenant-scoped business group facts and the effective-dated links from
|
||||
identities to those groups. Identity lifecycle state remains owned by Identity;
|
||||
an active group membership never activates an identity and never grants an
|
||||
application permission.
|
||||
|
||||
## Contract
|
||||
|
||||
The `idm.relationships` capability exposes immutable Core DTOs. Consumers can:
|
||||
|
||||
- list typed groups without importing IDM persistence models;
|
||||
- resolve one or many identities to their current relationships;
|
||||
- resolve one or many groups to current identities at a caller-supplied time;
|
||||
- request a membership decision set that includes future, expired, revoked,
|
||||
inactive-group, and inactive-identity exclusions;
|
||||
- retain source provider, external resource, revision, typed properties, and
|
||||
provenance data in downstream evidence.
|
||||
|
||||
Cross-tenant group references are rejected. The contract carries facts only and
|
||||
does not imply a right, role, or permission.
|
||||
|
||||
## Persistence and lifecycle
|
||||
|
||||
`idm_typed_groups` stores the stable tenant/type/key identity and external source
|
||||
reference. `idm_identity_relationships` stores one identity-to-group or
|
||||
identity-to-identity link, its relationship kind, validity window, source,
|
||||
properties, provenance, and optimistic revision.
|
||||
|
||||
Create, change, revoke, and elapsed-validity transitions emit versioned platform
|
||||
events:
|
||||
|
||||
- `idm.typed_group.created.v1`
|
||||
- `idm.typed_group.changed.v1`
|
||||
- `idm.relationship.created.v1`
|
||||
- `idm.relationship.changed.v1`
|
||||
- `idm.relationship.revoked.v1`
|
||||
- `idm.relationship.expired.v1`
|
||||
|
||||
The existing IDM lifecycle worker claims an elapsed relationship and records its
|
||||
event marker in the same transaction. Repeated or concurrent sweeps therefore do
|
||||
not publish duplicate expiry events.
|
||||
|
||||
## Administration workspace
|
||||
|
||||
The `/idm` workspace exposes typed groups and effective relationships to users
|
||||
with `idm:relationship:read`. Mutations require `idm:relationship:write`; the
|
||||
write permission also permits the identity search used by the subject and
|
||||
related-identity selectors without broadening read-only relationship access.
|
||||
|
||||
Group and relationship editors retain external provider, resource, revision,
|
||||
property, and provenance values. Updates carry the loaded optimistic revision,
|
||||
so a stale editor receives a conflict instead of overwriting another
|
||||
administrator's change. The relationship directory distinguishes future,
|
||||
active, expired, and revoked states from the validity window and lifecycle
|
||||
record. Revocation requires a reason, takes effect immediately, and leaves the
|
||||
record immutable as evidence.
|
||||
|
||||
The membership inspector accepts an effective time and one or more relationship
|
||||
kinds. It shows both included and excluded decisions with stable reason codes
|
||||
and identity lifecycle state. This is the same resolution contract used by
|
||||
downstream consumers; it is not a preview with different semantics.
|
||||
|
||||
## Distribution Lists
|
||||
|
||||
When Distribution Lists is enabled, an `idm_group` entry resolves through this
|
||||
capability. Every effective identity becomes an internal-mail candidate when an
|
||||
active linked account exists. Every rejected relationship remains visible in the
|
||||
expansion evidence with a stable reason code. Distribution Lists stores only the
|
||||
provider reference and frozen expansion evidence, not IDM records.
|
||||
+7
-7
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/idm-webui",
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.22",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "webui/src/index.ts",
|
||||
@@ -19,14 +19,14 @@
|
||||
"LICENSE"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.7",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"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",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.6"
|
||||
"vite": "^7.3.6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
|
||||
+4
-4
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-idm"
|
||||
version = "0.1.7"
|
||||
version = "0.1.22"
|
||||
description = "GovOPlaN identity management bridge module."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = [
|
||||
"govoplan-core>=0.1.7",
|
||||
"govoplan-identity>=0.1.7",
|
||||
"govoplan-organizations>=0.1.7",
|
||||
"govoplan-core>=0.1.29",
|
||||
"govoplan-identity>=0.1.18",
|
||||
"govoplan-organizations>=0.1.18",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
@@ -0,0 +1,579 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from math import ceil
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal
|
||||
from govoplan_core.core.access import CAPABILITY_AUDIT_RECORDER
|
||||
from govoplan_core.core.concurrency import (
|
||||
ConcurrencyError,
|
||||
MissingPreconditionError,
|
||||
RevisionConflictError,
|
||||
assert_revision_precondition,
|
||||
)
|
||||
from govoplan_core.core.identity import (
|
||||
CAPABILITY_IDENTITY_DIRECTORY,
|
||||
IdentityDirectory,
|
||||
)
|
||||
from govoplan_core.core.organizations import (
|
||||
CAPABILITY_ORGANIZATION_DIRECTORY,
|
||||
OrganizationDirectory,
|
||||
OrganizationFunctionRef,
|
||||
)
|
||||
from govoplan_core.core.policy import (
|
||||
CAPABILITY_POLICY_FUNCTION_ASSIGNMENT_GOVERNANCE,
|
||||
)
|
||||
from govoplan_core.core.runtime import get_registry
|
||||
from govoplan_core.core.workflows import CAPABILITY_WORKFLOW_ORCHESTRATION
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_idm.backend.db.models import IdmFunctionAssignmentChange
|
||||
from govoplan_idm.backend.function_assignment_changes import (
|
||||
FunctionAssignmentChangeConflict,
|
||||
FunctionAssignmentChangeUnavailable,
|
||||
available_change_actions,
|
||||
change_events,
|
||||
create_function_assignment_change,
|
||||
resolve_submission_capability,
|
||||
transition_function_assignment_change,
|
||||
visible_change_filter,
|
||||
)
|
||||
|
||||
from .schemas import (
|
||||
FunctionAssignmentCapabilityItem,
|
||||
FunctionAssignmentChangeActionRequest,
|
||||
FunctionAssignmentChangeCreateRequest,
|
||||
FunctionAssignmentChangeEventItem,
|
||||
FunctionAssignmentChangeItem,
|
||||
FunctionAssignmentChangeKind,
|
||||
FunctionAssignmentChangeList,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/function-assignment-changes")
|
||||
READ_SCOPES = (
|
||||
"idm:function_change:read",
|
||||
"idm:function_request:create",
|
||||
"idm:function_grant:create",
|
||||
"idm:function_change:decide",
|
||||
"idm:function_change:admin",
|
||||
"idm:organization_assignment:write",
|
||||
)
|
||||
|
||||
|
||||
def _require_any(principal: ApiPrincipal, *scopes: str) -> None:
|
||||
if not any(principal.has(scope) for scope in scopes):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Requires one of: {', '.join(scopes)}",
|
||||
)
|
||||
|
||||
|
||||
def _registry():
|
||||
registry = get_registry()
|
||||
if registry is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="The module registry is unavailable.",
|
||||
)
|
||||
return registry
|
||||
|
||||
|
||||
def _organization_directory() -> OrganizationDirectory:
|
||||
registry = _registry()
|
||||
capability = registry.capability(CAPABILITY_ORGANIZATION_DIRECTORY)
|
||||
if not isinstance(capability, OrganizationDirectory):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="The Organizations directory is unavailable.",
|
||||
)
|
||||
return capability
|
||||
|
||||
|
||||
def _identity_directory() -> IdentityDirectory:
|
||||
registry = _registry()
|
||||
capability = registry.capability(CAPABILITY_IDENTITY_DIRECTORY)
|
||||
if not isinstance(capability, IdentityDirectory):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="The Identity directory is unavailable.",
|
||||
)
|
||||
return capability
|
||||
|
||||
|
||||
def _function(function_id: str, tenant_id: str) -> OrganizationFunctionRef:
|
||||
function = _organization_directory().get_function(function_id)
|
||||
if function is None or function.tenant_id != tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Organization function not found.",
|
||||
)
|
||||
return function
|
||||
|
||||
|
||||
def _historical_function(
|
||||
function_id: str,
|
||||
tenant_id: str,
|
||||
) -> OrganizationFunctionRef | None:
|
||||
try:
|
||||
function = _organization_directory().get_function(function_id)
|
||||
except HTTPException:
|
||||
return None
|
||||
if function is None or function.tenant_id != tenant_id:
|
||||
return None
|
||||
return function
|
||||
|
||||
|
||||
def _validate_candidate(identity_id: str, account_id: str | None) -> None:
|
||||
directory = _identity_directory()
|
||||
if directory.get_identity(identity_id) is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Identity not found.",
|
||||
)
|
||||
if account_id is not None and not any(
|
||||
link.account_id == account_id
|
||||
for link in directory.accounts_for_identity(identity_id)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="Account is not linked to the selected identity.",
|
||||
)
|
||||
|
||||
|
||||
def _change_query(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
):
|
||||
statement = select(IdmFunctionAssignmentChange).where(
|
||||
IdmFunctionAssignmentChange.tenant_id == principal.tenant_id
|
||||
)
|
||||
visibility = visible_change_filter(session, principal)
|
||||
return statement.where(visibility) if visibility is not None else statement
|
||||
|
||||
|
||||
def _get_change(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
change_id: str,
|
||||
) -> IdmFunctionAssignmentChange:
|
||||
change = session.scalar(
|
||||
_change_query(session, principal).where(
|
||||
IdmFunctionAssignmentChange.id == change_id
|
||||
)
|
||||
)
|
||||
if change is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Function assignment change not found.",
|
||||
)
|
||||
return change
|
||||
|
||||
|
||||
def _event_item(event) -> FunctionAssignmentChangeEventItem:
|
||||
return FunctionAssignmentChangeEventItem(
|
||||
id=event.id,
|
||||
sequence=event.sequence,
|
||||
action=event.action,
|
||||
from_state=event.from_state,
|
||||
to_state=event.to_state,
|
||||
actor_account_id=event.actor_account_id,
|
||||
actor_identity_id=event.actor_identity_id,
|
||||
actor_assignment_id=event.actor_assignment_id,
|
||||
comment=event.comment,
|
||||
evidence=list(event.evidence),
|
||||
policy_decision=dict(event.policy_decision),
|
||||
workflow_step_id=event.workflow_step_id,
|
||||
details=dict(event.details),
|
||||
created_at=event.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _change_item(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
change: IdmFunctionAssignmentChange,
|
||||
*,
|
||||
include_events: bool,
|
||||
) -> FunctionAssignmentChangeItem:
|
||||
function = _historical_function(change.function_id, change.tenant_id)
|
||||
if function is None:
|
||||
actions, reason = [], "The referenced organization function is no longer available."
|
||||
else:
|
||||
try:
|
||||
actions, reason = available_change_actions(
|
||||
session,
|
||||
principal=principal,
|
||||
registry=get_registry(),
|
||||
change=change,
|
||||
function=function,
|
||||
)
|
||||
except FunctionAssignmentChangeUnavailable as exc:
|
||||
actions, reason = [], str(exc)
|
||||
return FunctionAssignmentChangeItem(
|
||||
id=change.id,
|
||||
tenant_id=change.tenant_id,
|
||||
kind=change.kind,
|
||||
state=change.state,
|
||||
profile=change.profile,
|
||||
function_id=change.function_id,
|
||||
organization_unit_id=change.organization_unit_id,
|
||||
candidate_identity_id=change.candidate_identity_id,
|
||||
candidate_account_id=change.candidate_account_id,
|
||||
initiator_account_id=change.initiator_account_id,
|
||||
initiator_identity_id=change.initiator_identity_id,
|
||||
represented_assignment_id=change.represented_assignment_id,
|
||||
justification=change.justification,
|
||||
evidence=list(change.evidence),
|
||||
requested_valid_from=change.requested_valid_from,
|
||||
requested_valid_until=change.requested_valid_until,
|
||||
applies_to_subunits=change.applies_to_subunits,
|
||||
assignment_source=change.assignment_source,
|
||||
required_steps=list(change.required_steps),
|
||||
completed_steps=list(change.completed_steps),
|
||||
policy_decision=dict(change.policy_decision),
|
||||
workflow_definition_id=change.workflow_definition_id,
|
||||
workflow_definition_revision_id=change.workflow_definition_revision_id,
|
||||
workflow_definition_revision=change.workflow_definition_revision,
|
||||
workflow_definition_hash=change.workflow_definition_hash,
|
||||
workflow_instance_id=change.workflow_instance_id,
|
||||
workflow_current_step_id=change.workflow_current_step_id,
|
||||
resulting_assignment_id=change.resulting_assignment_id,
|
||||
expires_at=change.expires_at,
|
||||
review_deadline_at=change.review_deadline_at,
|
||||
escalated_at=change.escalated_at,
|
||||
escalation_from_state=change.escalation_from_state,
|
||||
escalation_target_function_id=change.escalation_target_function_id,
|
||||
outcome_reason=change.outcome_reason,
|
||||
resource_revision=change.resource_revision,
|
||||
etag=change.strong_etag,
|
||||
metadata=dict(change.metadata_),
|
||||
events=(
|
||||
[
|
||||
_event_item(event)
|
||||
for event in change_events(session, change_id=change.id)
|
||||
]
|
||||
if include_events
|
||||
else []
|
||||
),
|
||||
available_actions=actions,
|
||||
availability_reason=reason,
|
||||
created_at=change.created_at,
|
||||
updated_at=change.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _set_etag(response: Response, change: IdmFunctionAssignmentChange) -> None:
|
||||
response.headers["ETag"] = change.strong_etag
|
||||
|
||||
|
||||
def _record_change_audit(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
change: IdmFunctionAssignmentChange,
|
||||
action: str,
|
||||
from_state: str | None,
|
||||
) -> None:
|
||||
registry = get_registry()
|
||||
if registry is None or not registry.has_capability(CAPABILITY_AUDIT_RECORDER):
|
||||
return
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action=f"idm.function_assignment_change.{action}",
|
||||
object_type="function_assignment_change",
|
||||
object_id=change.id,
|
||||
details={
|
||||
"kind": change.kind,
|
||||
"from_state": from_state,
|
||||
"to_state": change.state,
|
||||
"function_id": change.function_id,
|
||||
"candidate_identity_id": change.candidate_identity_id,
|
||||
"represented_assignment_id": change.represented_assignment_id,
|
||||
"policy_decision": dict(change.policy_decision),
|
||||
"workflow_definition_id": change.workflow_definition_id,
|
||||
"workflow_definition_revision_id": change.workflow_definition_revision_id,
|
||||
"workflow_instance_id": change.workflow_instance_id,
|
||||
"evidence": list(change.evidence),
|
||||
"resulting_assignment_id": change.resulting_assignment_id,
|
||||
"review_deadline_at": (
|
||||
change.review_deadline_at.isoformat()
|
||||
if change.review_deadline_at
|
||||
else None
|
||||
),
|
||||
"escalated_at": (
|
||||
change.escalated_at.isoformat() if change.escalated_at else None
|
||||
),
|
||||
"escalation_from_state": change.escalation_from_state,
|
||||
"escalation_target_function_id": (
|
||||
change.escalation_target_function_id
|
||||
),
|
||||
"resource_revision": change.resource_revision,
|
||||
},
|
||||
commit=False,
|
||||
)
|
||||
|
||||
|
||||
def _mutation_error(exc: Exception) -> HTTPException:
|
||||
if isinstance(exc, MissingPreconditionError):
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_428_PRECONDITION_REQUIRED,
|
||||
detail=exc.as_dict(),
|
||||
)
|
||||
if isinstance(exc, RevisionConflictError):
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_412_PRECONDITION_FAILED,
|
||||
detail=exc.as_dict(),
|
||||
)
|
||||
if isinstance(exc, ConcurrencyError):
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={"code": "concurrency_conflict", "message": str(exc)},
|
||||
)
|
||||
if isinstance(exc, FunctionAssignmentChangeConflict):
|
||||
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(exc),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/capability", response_model=FunctionAssignmentCapabilityItem)
|
||||
def get_function_assignment_capability(
|
||||
kind: FunctionAssignmentChangeKind,
|
||||
function_id: str = Query(min_length=1, max_length=36),
|
||||
candidate_identity_id: str | None = Query(default=None, max_length=36),
|
||||
candidate_account_id: str | None = Query(default=None, max_length=36),
|
||||
has_evidence: bool = False,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> FunctionAssignmentCapabilityItem:
|
||||
_require_any(principal, *READ_SCOPES)
|
||||
candidate_identity_id = candidate_identity_id or principal.identity_id
|
||||
if candidate_identity_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="A candidate identity is required.",
|
||||
)
|
||||
function = _function(function_id, principal.tenant_id)
|
||||
registry = _registry()
|
||||
decision, reason = resolve_submission_capability(
|
||||
session,
|
||||
principal=principal,
|
||||
registry=registry,
|
||||
kind=kind,
|
||||
function=function,
|
||||
candidate_identity_id=candidate_identity_id,
|
||||
candidate_account_id=candidate_account_id,
|
||||
has_evidence=has_evidence,
|
||||
)
|
||||
return FunctionAssignmentCapabilityItem(
|
||||
kind=kind,
|
||||
function_id=function_id,
|
||||
available=bool(decision and decision.allowed),
|
||||
reason=reason,
|
||||
profile=decision.profile if decision else "unavailable",
|
||||
required_steps=list(decision.required_steps) if decision else [],
|
||||
requirements=list(decision.requirements) if decision else [],
|
||||
authority_function_id=(decision.authority_function_id if decision else None),
|
||||
evidence_required=bool(decision and decision.evidence_required),
|
||||
recipient_acceptance_required=bool(
|
||||
decision and decision.recipient_acceptance_required
|
||||
),
|
||||
maximum_validity_days=(decision.maximum_validity_days if decision else None),
|
||||
delegation_allowed=bool(decision and decision.delegation_allowed),
|
||||
maximum_delegation_depth=(
|
||||
decision.maximum_delegation_depth if decision else 0
|
||||
),
|
||||
maximum_delegated_validity_days=(
|
||||
decision.maximum_delegated_validity_days if decision else None
|
||||
),
|
||||
escalation_rules=(
|
||||
[rule.to_dict() for rule in decision.escalation_rules]
|
||||
if decision
|
||||
else []
|
||||
),
|
||||
workflow_available=registry.has_capability(CAPABILITY_WORKFLOW_ORCHESTRATION),
|
||||
policy_available=registry.has_capability(
|
||||
CAPABILITY_POLICY_FUNCTION_ASSIGNMENT_GOVERNANCE
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=FunctionAssignmentChangeList)
|
||||
def list_function_assignment_changes(
|
||||
kind: FunctionAssignmentChangeKind | None = None,
|
||||
state_filter: str | None = Query(default=None, alias="state", max_length=40),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=50, ge=1, le=200),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> FunctionAssignmentChangeList:
|
||||
_require_any(principal, *READ_SCOPES)
|
||||
statement = _change_query(session, principal)
|
||||
if kind is not None:
|
||||
statement = statement.where(IdmFunctionAssignmentChange.kind == kind)
|
||||
if state_filter:
|
||||
statement = statement.where(IdmFunctionAssignmentChange.state == state_filter)
|
||||
total = int(
|
||||
session.scalar(select(func.count()).select_from(statement.subquery())) or 0
|
||||
)
|
||||
rows = list(
|
||||
session.scalars(
|
||||
statement.order_by(
|
||||
IdmFunctionAssignmentChange.updated_at.desc(),
|
||||
IdmFunctionAssignmentChange.id.desc(),
|
||||
)
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
)
|
||||
return FunctionAssignmentChangeList(
|
||||
changes=[
|
||||
_change_item(session, principal, change, include_events=False)
|
||||
for change in rows
|
||||
],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
pages=max(1, ceil(total / page_size)),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{change_id}", response_model=FunctionAssignmentChangeItem)
|
||||
def get_function_assignment_change(
|
||||
change_id: str,
|
||||
response: Response,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> FunctionAssignmentChangeItem:
|
||||
_require_any(principal, *READ_SCOPES)
|
||||
change = _get_change(session, principal, change_id)
|
||||
_set_etag(response, change)
|
||||
return _change_item(session, principal, change, include_events=True)
|
||||
|
||||
|
||||
@router.post("", response_model=FunctionAssignmentChangeItem, status_code=201)
|
||||
def create_governed_function_assignment_change(
|
||||
payload: FunctionAssignmentChangeCreateRequest,
|
||||
response: Response,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> FunctionAssignmentChangeItem:
|
||||
required = (
|
||||
"idm:function_request:create"
|
||||
if payload.kind == "request"
|
||||
else "idm:function_grant:create"
|
||||
)
|
||||
_require_any(
|
||||
principal,
|
||||
required,
|
||||
"idm:function_change:admin",
|
||||
"idm:organization_assignment:write",
|
||||
)
|
||||
_validate_candidate(payload.candidate_identity_id, payload.candidate_account_id)
|
||||
try:
|
||||
change, replayed = create_function_assignment_change(
|
||||
session,
|
||||
principal=principal,
|
||||
registry=_registry(),
|
||||
function=_function(payload.function_id, principal.tenant_id),
|
||||
payload=payload,
|
||||
)
|
||||
if not replayed:
|
||||
_record_change_audit(
|
||||
session,
|
||||
principal,
|
||||
change=change,
|
||||
action="created",
|
||||
from_state=None,
|
||||
)
|
||||
session.commit()
|
||||
except (
|
||||
FunctionAssignmentChangeConflict,
|
||||
FunctionAssignmentChangeUnavailable,
|
||||
) as exc:
|
||||
session.rollback()
|
||||
raise _mutation_error(exc) from exc
|
||||
except IntegrityError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="The function assignment change conflicts with existing data.",
|
||||
) from exc
|
||||
session.refresh(change)
|
||||
response.status_code = status.HTTP_200_OK if replayed else status.HTTP_201_CREATED
|
||||
_set_etag(response, change)
|
||||
return _change_item(session, principal, change, include_events=True)
|
||||
|
||||
|
||||
@router.post("/{change_id}/actions", response_model=FunctionAssignmentChangeItem)
|
||||
def act_on_function_assignment_change(
|
||||
change_id: str,
|
||||
payload: FunctionAssignmentChangeActionRequest,
|
||||
response: Response,
|
||||
if_match: str | None = Header(default=None, alias="If-Match"),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> FunctionAssignmentChangeItem:
|
||||
change = _get_change(session, principal, change_id)
|
||||
from_state = change.state
|
||||
if payload.action == "recover":
|
||||
_require_any(principal, "idm:function_change:admin")
|
||||
elif payload.action not in {"withdraw", "respond"}:
|
||||
_require_any(
|
||||
principal,
|
||||
"idm:function_change:decide",
|
||||
"idm:function_change:admin",
|
||||
"idm:organization_assignment:write",
|
||||
)
|
||||
try:
|
||||
assert_revision_precondition(
|
||||
if_match,
|
||||
resource_type="idm_function_assignment_change",
|
||||
resource_id=change.id,
|
||||
submitted_base_revision=payload.base_revision,
|
||||
)
|
||||
change = transition_function_assignment_change(
|
||||
session,
|
||||
principal=principal,
|
||||
registry=_registry(),
|
||||
change=change,
|
||||
function=_function(change.function_id, change.tenant_id),
|
||||
action=payload.action,
|
||||
base_revision=payload.base_revision,
|
||||
comment=payload.comment,
|
||||
evidence=payload.evidence,
|
||||
)
|
||||
_record_change_audit(
|
||||
session,
|
||||
principal,
|
||||
change=change,
|
||||
action=payload.action,
|
||||
from_state=from_state,
|
||||
)
|
||||
session.commit()
|
||||
except (
|
||||
ConcurrencyError,
|
||||
FunctionAssignmentChangeConflict,
|
||||
FunctionAssignmentChangeUnavailable,
|
||||
) as exc:
|
||||
session.rollback()
|
||||
raise _mutation_error(exc) from exc
|
||||
except IntegrityError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="The function assignment transition conflicts with existing data.",
|
||||
) from exc
|
||||
session.refresh(change)
|
||||
_set_etag(response, change)
|
||||
return _change_item(session, principal, change, include_events=True)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -0,0 +1,569 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal, require_any_scope
|
||||
from govoplan_core.core.events import (
|
||||
EventActorRef,
|
||||
EventObjectRef,
|
||||
EventTenantRef,
|
||||
PlatformEvent,
|
||||
emit_platform_event,
|
||||
)
|
||||
from govoplan_core.core.identity import (
|
||||
CAPABILITY_IDENTITY_DIRECTORY,
|
||||
IdentityDirectory,
|
||||
)
|
||||
from govoplan_core.core.idm import (
|
||||
CAPABILITY_IDM_RELATIONSHIPS,
|
||||
IdentityRelationshipDecisionRef,
|
||||
IdentityRelationshipRef,
|
||||
IdmRelationshipDirectory,
|
||||
TypedGroupRef,
|
||||
)
|
||||
from govoplan_core.core.principal_cache import invalidate_auth_principals
|
||||
from govoplan_core.core.runtime import get_registry
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_core.security.time import ensure_aware_utc, utc_now
|
||||
from govoplan_idm.backend.db.models import (
|
||||
IdmIdentityRelationship,
|
||||
IdmTypedGroup,
|
||||
)
|
||||
|
||||
from .schemas import (
|
||||
IdentityRelationshipCreateRequest,
|
||||
IdentityRelationshipDecisionItem,
|
||||
IdentityRelationshipItem,
|
||||
IdentityRelationshipList,
|
||||
IdentityRelationshipRevokeRequest,
|
||||
IdentityRelationshipUpdateRequest,
|
||||
TypedGroupCreateRequest,
|
||||
TypedGroupItem,
|
||||
TypedGroupList,
|
||||
TypedGroupMembershipResolutionItem,
|
||||
TypedGroupUpdateRequest,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
RELATIONSHIP_READ_SCOPES = (
|
||||
"idm:relationship:read",
|
||||
"idm:relationship:write",
|
||||
)
|
||||
RELATIONSHIP_WRITE_SCOPES = ("idm:relationship:write",)
|
||||
|
||||
|
||||
def _not_found(label: str) -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"{label} not found",
|
||||
)
|
||||
|
||||
|
||||
def _invalid(message: str) -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=message,
|
||||
)
|
||||
|
||||
|
||||
def _conflict(message: str) -> HTTPException:
|
||||
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=message)
|
||||
|
||||
|
||||
def _typed_group_item(item: IdmTypedGroup) -> TypedGroupItem:
|
||||
return TypedGroupItem.model_validate(
|
||||
{column.name: getattr(item, column.name) for column in item.__table__.columns}
|
||||
)
|
||||
|
||||
|
||||
def _relationship_item(item: IdmIdentityRelationship) -> IdentityRelationshipItem:
|
||||
return IdentityRelationshipItem.model_validate(
|
||||
{column.name: getattr(item, column.name) for column in item.__table__.columns}
|
||||
)
|
||||
|
||||
|
||||
def _typed_group_ref_item(item: TypedGroupRef) -> TypedGroupItem:
|
||||
return TypedGroupItem(
|
||||
id=item.id,
|
||||
tenant_id=item.tenant_id,
|
||||
key=item.key,
|
||||
name=item.name,
|
||||
group_type=item.group_type,
|
||||
description=item.description,
|
||||
status=item.status,
|
||||
source_provider=item.source_provider,
|
||||
source_resource_type=item.source_resource_type,
|
||||
source_resource_id=item.source_resource_id,
|
||||
source_revision=item.source_revision,
|
||||
properties=dict(item.properties),
|
||||
provenance=dict(item.provenance),
|
||||
revision=item.revision,
|
||||
)
|
||||
|
||||
|
||||
def _relationship_ref_item(
|
||||
item: IdentityRelationshipRef,
|
||||
) -> IdentityRelationshipItem:
|
||||
return IdentityRelationshipItem(
|
||||
id=item.id,
|
||||
tenant_id=item.tenant_id,
|
||||
relationship_kind=item.relationship_kind,
|
||||
subject_identity_id=item.subject_identity_id,
|
||||
target_group_id=item.target_group_id,
|
||||
related_identity_id=item.related_identity_id,
|
||||
role=item.role,
|
||||
valid_from=item.valid_from,
|
||||
valid_until=item.valid_until,
|
||||
status=item.status,
|
||||
revoked_at=item.revoked_at,
|
||||
revoked_by=item.revoked_by,
|
||||
revocation_reason=item.revocation_reason,
|
||||
source_provider=item.source_provider,
|
||||
source_resource_type=item.source_resource_type,
|
||||
source_resource_id=item.source_resource_id,
|
||||
source_revision=item.source_revision,
|
||||
properties=dict(item.properties),
|
||||
provenance=dict(item.provenance),
|
||||
revision=item.revision,
|
||||
)
|
||||
|
||||
|
||||
def _decision_item(
|
||||
item: IdentityRelationshipDecisionRef,
|
||||
) -> IdentityRelationshipDecisionItem:
|
||||
return IdentityRelationshipDecisionItem(
|
||||
relationship=_relationship_ref_item(item.relationship),
|
||||
included=item.included,
|
||||
code=item.code,
|
||||
explanation=item.explanation,
|
||||
identity_status=item.identity_status,
|
||||
)
|
||||
|
||||
|
||||
def _tenant_row(session: Session, model, item_id: str, tenant_id: str, label: str):
|
||||
item = session.get(model, item_id)
|
||||
if item is None or item.tenant_id != tenant_id:
|
||||
raise _not_found(label)
|
||||
return item
|
||||
|
||||
|
||||
def _identity_directory() -> IdentityDirectory:
|
||||
registry = get_registry()
|
||||
if registry is None or not registry.has_capability(CAPABILITY_IDENTITY_DIRECTORY):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Identity directory is unavailable",
|
||||
)
|
||||
capability = registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY)
|
||||
if not isinstance(capability, IdentityDirectory):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Invalid capability: {CAPABILITY_IDENTITY_DIRECTORY}",
|
||||
)
|
||||
return capability
|
||||
|
||||
|
||||
def _relationship_directory() -> IdmRelationshipDirectory:
|
||||
registry = get_registry()
|
||||
if registry is None or not registry.has_capability(CAPABILITY_IDM_RELATIONSHIPS):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="IDM relationship directory is unavailable",
|
||||
)
|
||||
capability = registry.require_capability(CAPABILITY_IDM_RELATIONSHIPS)
|
||||
if not isinstance(capability, IdmRelationshipDirectory):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Invalid capability: {CAPABILITY_IDM_RELATIONSHIPS}",
|
||||
)
|
||||
return capability
|
||||
|
||||
|
||||
def _ensure_identity(identity_id: str, *, label: str = "Identity") -> None:
|
||||
if _identity_directory().get_identity(identity_id) is None:
|
||||
raise _not_found(label)
|
||||
|
||||
|
||||
def _ensure_target_shape(
|
||||
target_group_id: str | None,
|
||||
related_identity_id: str | None,
|
||||
) -> None:
|
||||
if (target_group_id is None) == (related_identity_id is None):
|
||||
raise _invalid(
|
||||
"A relationship must target exactly one typed group or related identity."
|
||||
)
|
||||
|
||||
|
||||
def _ensure_window(valid_from: datetime | None, valid_until: datetime | None) -> None:
|
||||
start = ensure_aware_utc(valid_from)
|
||||
end = ensure_aware_utc(valid_until)
|
||||
if start is not None and end is not None and end <= start:
|
||||
raise _invalid("Relationship end must be after its start.")
|
||||
|
||||
|
||||
def _emit_change(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
event_type: str,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
payload: dict[str, object],
|
||||
subject_identity_id: str | None = None,
|
||||
) -> None:
|
||||
emit_platform_event(
|
||||
session,
|
||||
PlatformEvent(
|
||||
type=event_type,
|
||||
module_id="idm",
|
||||
payload=payload,
|
||||
actor=EventActorRef(type="account", id=principal.account_id),
|
||||
tenant=EventTenantRef(id=principal.tenant_id),
|
||||
subject=(
|
||||
EventObjectRef(type="identity", id=subject_identity_id)
|
||||
if subject_identity_id is not None
|
||||
else None
|
||||
),
|
||||
resource=EventObjectRef(type=resource_type, id=resource_id),
|
||||
classification="internal",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _commit(session: Session, principal: ApiPrincipal, item, *, resource_type: str):
|
||||
invalidate_auth_principals(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
source_module="idm",
|
||||
resource_type=resource_type,
|
||||
resource_id=item.id,
|
||||
)
|
||||
try:
|
||||
session.commit()
|
||||
except IntegrityError as exc:
|
||||
session.rollback()
|
||||
raise _conflict("The IDM relationship conflicts with existing data.") from exc
|
||||
session.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
@router.get("/typed-groups", response_model=TypedGroupList)
|
||||
def list_typed_groups(
|
||||
query: str | None = Query(default=None, max_length=255),
|
||||
group_type: str | None = Query(default=None, max_length=80),
|
||||
include_inactive: bool = False,
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope(*RELATIONSHIP_READ_SCOPES)),
|
||||
) -> TypedGroupList:
|
||||
statement = session.query(IdmTypedGroup).filter(
|
||||
IdmTypedGroup.tenant_id == principal.tenant_id
|
||||
)
|
||||
if not include_inactive:
|
||||
statement = statement.filter(IdmTypedGroup.status == "active")
|
||||
if group_type:
|
||||
statement = statement.filter(IdmTypedGroup.group_type == group_type)
|
||||
if query:
|
||||
statement = statement.filter(IdmTypedGroup.name.ilike(f"%{query.strip()}%"))
|
||||
total = statement.count()
|
||||
rows = (
|
||||
statement.order_by(IdmTypedGroup.name.asc(), IdmTypedGroup.id.asc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return TypedGroupList(groups=[_typed_group_item(item) for item in rows], total=total)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/typed-groups",
|
||||
response_model=TypedGroupItem,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_typed_group(
|
||||
payload: TypedGroupCreateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope(*RELATIONSHIP_WRITE_SCOPES)),
|
||||
) -> TypedGroupItem:
|
||||
item = IdmTypedGroup(
|
||||
tenant_id=principal.tenant_id,
|
||||
**payload.model_dump(),
|
||||
status="active",
|
||||
revision=1,
|
||||
)
|
||||
session.add(item)
|
||||
try:
|
||||
session.flush()
|
||||
except IntegrityError as exc:
|
||||
session.rollback()
|
||||
raise _conflict("A typed group with this type and key already exists.") from exc
|
||||
_emit_change(
|
||||
session,
|
||||
principal,
|
||||
event_type="idm.typed_group.created.v1",
|
||||
resource_type="typed_group",
|
||||
resource_id=item.id,
|
||||
payload={"group_type": item.group_type, "key": item.key, "revision": 1},
|
||||
)
|
||||
return _typed_group_item(
|
||||
_commit(session, principal, item, resource_type="typed_group")
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/typed-groups/{group_id}", response_model=TypedGroupItem)
|
||||
def update_typed_group(
|
||||
group_id: str,
|
||||
payload: TypedGroupUpdateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope(*RELATIONSHIP_WRITE_SCOPES)),
|
||||
) -> TypedGroupItem:
|
||||
item = _tenant_row(
|
||||
session, IdmTypedGroup, group_id, principal.tenant_id, "Typed group"
|
||||
)
|
||||
if item.revision != payload.base_revision:
|
||||
raise _conflict("The typed group changed since it was loaded.")
|
||||
values = payload.model_dump(exclude_unset=True, exclude={"base_revision"})
|
||||
for key, value in values.items():
|
||||
setattr(item, key, value)
|
||||
item.revision += 1
|
||||
session.flush()
|
||||
_emit_change(
|
||||
session,
|
||||
principal,
|
||||
event_type="idm.typed_group.changed.v1",
|
||||
resource_type="typed_group",
|
||||
resource_id=item.id,
|
||||
payload={"group_type": item.group_type, "key": item.key, "revision": item.revision},
|
||||
)
|
||||
return _typed_group_item(
|
||||
_commit(session, principal, item, resource_type="typed_group")
|
||||
)
|
||||
|
||||
|
||||
@router.get("/relationships", response_model=IdentityRelationshipList)
|
||||
def list_identity_relationships(
|
||||
identity_id: str | None = Query(default=None, max_length=36),
|
||||
group_id: str | None = Query(default=None, max_length=36),
|
||||
relationship_kind: str | None = Query(default=None, max_length=80),
|
||||
include_revoked: bool = False,
|
||||
limit: int = Query(default=500, ge=1, le=1000),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope(*RELATIONSHIP_READ_SCOPES)),
|
||||
) -> IdentityRelationshipList:
|
||||
statement = session.query(IdmIdentityRelationship).filter(
|
||||
IdmIdentityRelationship.tenant_id == principal.tenant_id
|
||||
)
|
||||
if identity_id:
|
||||
statement = statement.filter(
|
||||
IdmIdentityRelationship.subject_identity_id == identity_id
|
||||
)
|
||||
if group_id:
|
||||
statement = statement.filter(IdmIdentityRelationship.target_group_id == group_id)
|
||||
if relationship_kind:
|
||||
statement = statement.filter(
|
||||
IdmIdentityRelationship.relationship_kind == relationship_kind
|
||||
)
|
||||
if not include_revoked:
|
||||
statement = statement.filter(IdmIdentityRelationship.status == "active")
|
||||
total = statement.count()
|
||||
rows = (
|
||||
statement.order_by(
|
||||
IdmIdentityRelationship.created_at.asc(),
|
||||
IdmIdentityRelationship.id.asc(),
|
||||
)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return IdentityRelationshipList(
|
||||
relationships=[_relationship_item(item) for item in rows], total=total
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/relationships",
|
||||
response_model=IdentityRelationshipItem,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_identity_relationship(
|
||||
payload: IdentityRelationshipCreateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope(*RELATIONSHIP_WRITE_SCOPES)),
|
||||
) -> IdentityRelationshipItem:
|
||||
_ensure_target_shape(payload.target_group_id, payload.related_identity_id)
|
||||
_ensure_window(payload.valid_from, payload.valid_until)
|
||||
_ensure_identity(payload.subject_identity_id)
|
||||
if payload.related_identity_id:
|
||||
_ensure_identity(payload.related_identity_id, label="Related identity")
|
||||
if payload.target_group_id:
|
||||
_tenant_row(
|
||||
session,
|
||||
IdmTypedGroup,
|
||||
payload.target_group_id,
|
||||
principal.tenant_id,
|
||||
"Typed group",
|
||||
)
|
||||
item = IdmIdentityRelationship(
|
||||
tenant_id=principal.tenant_id,
|
||||
**payload.model_dump(),
|
||||
status="active",
|
||||
revision=1,
|
||||
)
|
||||
session.add(item)
|
||||
session.flush()
|
||||
_emit_change(
|
||||
session,
|
||||
principal,
|
||||
event_type="idm.relationship.created.v1",
|
||||
resource_type="identity_relationship",
|
||||
resource_id=item.id,
|
||||
subject_identity_id=item.subject_identity_id,
|
||||
payload={
|
||||
"relationship_kind": item.relationship_kind,
|
||||
"target_group_id": item.target_group_id,
|
||||
"related_identity_id": item.related_identity_id,
|
||||
"revision": 1,
|
||||
},
|
||||
)
|
||||
return _relationship_item(
|
||||
_commit(session, principal, item, resource_type="identity_relationship")
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/relationships/{relationship_id}", response_model=IdentityRelationshipItem)
|
||||
def update_identity_relationship(
|
||||
relationship_id: str,
|
||||
payload: IdentityRelationshipUpdateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope(*RELATIONSHIP_WRITE_SCOPES)),
|
||||
) -> IdentityRelationshipItem:
|
||||
item = _tenant_row(
|
||||
session,
|
||||
IdmIdentityRelationship,
|
||||
relationship_id,
|
||||
principal.tenant_id,
|
||||
"Identity relationship",
|
||||
)
|
||||
if item.status == "revoked":
|
||||
raise _conflict("A revoked relationship cannot be changed.")
|
||||
if item.revision != payload.base_revision:
|
||||
raise _conflict("The identity relationship changed since it was loaded.")
|
||||
values = payload.model_dump(exclude_unset=True, exclude={"base_revision"})
|
||||
if values.get("target_group_id") is not None:
|
||||
values["related_identity_id"] = None
|
||||
if values.get("related_identity_id") is not None:
|
||||
values["target_group_id"] = None
|
||||
target_group_id = values.get("target_group_id", item.target_group_id)
|
||||
related_identity_id = values.get("related_identity_id", item.related_identity_id)
|
||||
_ensure_target_shape(target_group_id, related_identity_id)
|
||||
valid_from = values.get("valid_from", item.valid_from)
|
||||
valid_until = values.get("valid_until", item.valid_until)
|
||||
_ensure_window(valid_from, valid_until)
|
||||
if target_group_id:
|
||||
_tenant_row(
|
||||
session,
|
||||
IdmTypedGroup,
|
||||
target_group_id,
|
||||
principal.tenant_id,
|
||||
"Typed group",
|
||||
)
|
||||
if related_identity_id:
|
||||
_ensure_identity(related_identity_id, label="Related identity")
|
||||
for key, value in values.items():
|
||||
setattr(item, key, value)
|
||||
item.revision += 1
|
||||
if item.valid_until is None or ensure_aware_utc(item.valid_until) > utc_now():
|
||||
item.expired_event_at = None
|
||||
session.flush()
|
||||
_emit_change(
|
||||
session,
|
||||
principal,
|
||||
event_type="idm.relationship.changed.v1",
|
||||
resource_type="identity_relationship",
|
||||
resource_id=item.id,
|
||||
subject_identity_id=item.subject_identity_id,
|
||||
payload={"relationship_kind": item.relationship_kind, "revision": item.revision},
|
||||
)
|
||||
return _relationship_item(
|
||||
_commit(session, principal, item, resource_type="identity_relationship")
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/relationships/{relationship_id}/revoke",
|
||||
response_model=IdentityRelationshipItem,
|
||||
)
|
||||
def revoke_identity_relationship(
|
||||
relationship_id: str,
|
||||
payload: IdentityRelationshipRevokeRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope(*RELATIONSHIP_WRITE_SCOPES)),
|
||||
) -> IdentityRelationshipItem:
|
||||
item = _tenant_row(
|
||||
session,
|
||||
IdmIdentityRelationship,
|
||||
relationship_id,
|
||||
principal.tenant_id,
|
||||
"Identity relationship",
|
||||
)
|
||||
if item.revision != payload.base_revision:
|
||||
raise _conflict("The identity relationship changed since it was loaded.")
|
||||
if item.status == "revoked":
|
||||
return _relationship_item(item)
|
||||
item.status = "revoked"
|
||||
item.revoked_at = utc_now()
|
||||
item.revoked_by = principal.account_id
|
||||
item.revocation_reason = payload.reason
|
||||
item.revision += 1
|
||||
session.flush()
|
||||
_emit_change(
|
||||
session,
|
||||
principal,
|
||||
event_type="idm.relationship.revoked.v1",
|
||||
resource_type="identity_relationship",
|
||||
resource_id=item.id,
|
||||
subject_identity_id=item.subject_identity_id,
|
||||
payload={
|
||||
"relationship_kind": item.relationship_kind,
|
||||
"reason": payload.reason,
|
||||
"revision": item.revision,
|
||||
},
|
||||
)
|
||||
return _relationship_item(
|
||||
_commit(session, principal, item, resource_type="identity_relationship")
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/typed-groups/{group_id}/memberships",
|
||||
response_model=TypedGroupMembershipResolutionItem,
|
||||
)
|
||||
def resolve_typed_group_memberships(
|
||||
group_id: str,
|
||||
effective_at: datetime | None = None,
|
||||
relationship_kind: list[str] = Query(default=["member"]),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope(*RELATIONSHIP_READ_SCOPES)),
|
||||
) -> TypedGroupMembershipResolutionItem:
|
||||
_tenant_row(
|
||||
session, IdmTypedGroup, group_id, principal.tenant_id, "Typed group"
|
||||
)
|
||||
resolved = _relationship_directory().resolve_typed_group_memberships(
|
||||
(group_id,),
|
||||
tenant_id=principal.tenant_id,
|
||||
effective_at=effective_at,
|
||||
relationship_kinds=tuple(relationship_kind),
|
||||
)[group_id]
|
||||
return TypedGroupMembershipResolutionItem(
|
||||
group=_typed_group_ref_item(resolved.group),
|
||||
effective_at=resolved.effective_at,
|
||||
decisions=[_decision_item(item) for item in resolved.decisions],
|
||||
identity_ids=list(resolved.identity_ids),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -4,7 +4,6 @@ from typing import Any, TypeVar
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -17,11 +16,33 @@ from govoplan_core.core.configuration_control import (
|
||||
ensure_configuration_change_allowed,
|
||||
record_configuration_change_applied,
|
||||
)
|
||||
from govoplan_core.core.principal_cache import invalidate_auth_principals
|
||||
from govoplan_core.core.identity import (
|
||||
CAPABILITY_IDENTITY_DIRECTORY,
|
||||
CAPABILITY_IDENTITY_SEARCH,
|
||||
IdentityDirectory,
|
||||
IdentityRef,
|
||||
IdentitySearchProvider,
|
||||
)
|
||||
from govoplan_core.core.organizations import (
|
||||
CAPABILITY_ORGANIZATION_DIRECTORY,
|
||||
OrganizationDirectory,
|
||||
OrganizationFunctionRef,
|
||||
)
|
||||
from govoplan_core.core.runtime import get_registry
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_identity.backend.db.models import Identity, IdentityAccountLink
|
||||
from govoplan_core.security.time import utc_now
|
||||
from govoplan_idm.backend.assignment_transitions import (
|
||||
AssignmentMutationPlan,
|
||||
AssignmentTransitionError,
|
||||
assignment_is_expired,
|
||||
lifecycle_event_types,
|
||||
plan_assignment_update,
|
||||
validate_assignment_shape,
|
||||
validate_assignment_source_rules,
|
||||
)
|
||||
from govoplan_idm.backend.assignment_events import emit_assignment_event
|
||||
from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment, IdmTenantSettings
|
||||
from govoplan_organizations.backend.db.models import OrganizationFunction
|
||||
|
||||
from .schemas import (
|
||||
IdmSettingsItem,
|
||||
@@ -40,6 +61,7 @@ router = APIRouter(prefix="/idm", tags=["idm"])
|
||||
ORGANIZATION_IDENTITY_READ_SCOPES = (
|
||||
"idm:organization_identity:read",
|
||||
"idm:organization_assignment:write",
|
||||
"idm:relationship:write",
|
||||
"organizations:function:assign",
|
||||
"admin:users:read",
|
||||
)
|
||||
@@ -54,8 +76,6 @@ IDM_SETTINGS_READ_SCOPES = (
|
||||
IDM_SETTINGS_WRITE_SCOPES = ("idm:settings:write",)
|
||||
IDM_ASSIGNMENT_CHANGE_CONTROL_KEY = "idm.organization_assignments"
|
||||
IDM_ASSIGNMENT_AUDIT_EVENT = "idm.organization_assignment.updated"
|
||||
ASSIGNMENT_SOURCES = {"direct", "delegated", "acting_for", "directory", "governance", "system"}
|
||||
|
||||
ModelT = TypeVar("ModelT")
|
||||
|
||||
|
||||
@@ -87,6 +107,13 @@ def _get_tenant_row(session: Session, model: type[ModelT], item_id: str, tenant_
|
||||
|
||||
|
||||
def _commit(session: Session, item: ModelT) -> ModelT:
|
||||
invalidate_auth_principals(
|
||||
session,
|
||||
tenant_id=getattr(item, "tenant_id", None),
|
||||
source_module="idm",
|
||||
resource_type=item.__class__.__name__,
|
||||
resource_id=str(getattr(item, "id", getattr(item, "tenant_id", "system"))),
|
||||
)
|
||||
try:
|
||||
session.commit()
|
||||
except IntegrityError as exc:
|
||||
@@ -96,6 +123,40 @@ def _commit(session: Session, item: ModelT) -> ModelT:
|
||||
return item
|
||||
|
||||
|
||||
def _flush_assignment(
|
||||
session: Session,
|
||||
item: IdmOrganizationFunctionAssignment,
|
||||
) -> None:
|
||||
try:
|
||||
session.flush()
|
||||
except IntegrityError as exc:
|
||||
session.rollback()
|
||||
raise _conflict(
|
||||
"The IDM assignment conflicts with an existing assignment."
|
||||
) from exc
|
||||
|
||||
|
||||
def _commit_assignment_transaction(
|
||||
session: Session,
|
||||
item: IdmOrganizationFunctionAssignment,
|
||||
) -> None:
|
||||
invalidate_auth_principals(
|
||||
session,
|
||||
tenant_id=item.tenant_id,
|
||||
source_module="idm",
|
||||
resource_type="organization_function_assignment",
|
||||
resource_id=item.id,
|
||||
)
|
||||
try:
|
||||
session.commit()
|
||||
except IntegrityError as exc:
|
||||
session.rollback()
|
||||
raise _conflict(
|
||||
"The IDM assignment conflicts with an existing assignment."
|
||||
) from exc
|
||||
session.refresh(item)
|
||||
|
||||
|
||||
def _row_fields(item: object) -> dict[str, Any]:
|
||||
keys = [column.name for column in item.__table__.columns] # type: ignore[attr-defined]
|
||||
return {key: getattr(item, key) for key in keys}
|
||||
@@ -125,31 +186,76 @@ def _default_settings(tenant_id: str) -> IdmSettingsItem:
|
||||
)
|
||||
|
||||
|
||||
def _ensure_identity(session: Session, identity_id: str) -> None:
|
||||
identity = session.get(Identity, identity_id)
|
||||
if identity is None:
|
||||
def _identity_directory() -> IdentityDirectory:
|
||||
registry = get_registry()
|
||||
if registry is None or not registry.has_capability(CAPABILITY_IDENTITY_DIRECTORY):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Identity directory is unavailable",
|
||||
)
|
||||
capability = registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY)
|
||||
if not isinstance(capability, IdentityDirectory):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Invalid capability: {CAPABILITY_IDENTITY_DIRECTORY}",
|
||||
)
|
||||
return capability
|
||||
|
||||
|
||||
def _identity_search() -> IdentitySearchProvider:
|
||||
registry = get_registry()
|
||||
if registry is None or not registry.has_capability(CAPABILITY_IDENTITY_SEARCH):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Identity search is unavailable",
|
||||
)
|
||||
capability = registry.require_capability(CAPABILITY_IDENTITY_SEARCH)
|
||||
if not isinstance(capability, IdentitySearchProvider):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Invalid capability: {CAPABILITY_IDENTITY_SEARCH}",
|
||||
)
|
||||
return capability
|
||||
|
||||
|
||||
def _organization_directory() -> OrganizationDirectory:
|
||||
registry = get_registry()
|
||||
if registry is None or not registry.has_capability(CAPABILITY_ORGANIZATION_DIRECTORY):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Organization directory is unavailable",
|
||||
)
|
||||
capability = registry.require_capability(CAPABILITY_ORGANIZATION_DIRECTORY)
|
||||
if not isinstance(capability, OrganizationDirectory):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Invalid capability: {CAPABILITY_ORGANIZATION_DIRECTORY}",
|
||||
)
|
||||
return capability
|
||||
|
||||
|
||||
def _organization_function(function_id: str, tenant_id: str) -> OrganizationFunctionRef:
|
||||
function = _organization_directory().get_function(function_id)
|
||||
if function is None or function.tenant_id != tenant_id:
|
||||
raise _not_found("Organization function")
|
||||
return function
|
||||
|
||||
|
||||
def _ensure_identity(identity_id: str) -> None:
|
||||
if _identity_directory().get_identity(identity_id) is None:
|
||||
raise _not_found("Identity")
|
||||
|
||||
|
||||
def _ensure_account_link(session: Session, identity_id: str, account_id: str | None) -> None:
|
||||
def _ensure_account_link(identity_id: str, account_id: str | None) -> None:
|
||||
if account_id is None:
|
||||
return
|
||||
exists = (
|
||||
session.query(IdentityAccountLink)
|
||||
.filter(IdentityAccountLink.identity_id == identity_id, IdentityAccountLink.account_id == account_id)
|
||||
.first()
|
||||
)
|
||||
if exists is None:
|
||||
links = _identity_directory().accounts_for_identity(identity_id)
|
||||
if not any(link.account_id == account_id for link in links):
|
||||
raise _invalid("Account is not linked to the selected identity.")
|
||||
|
||||
|
||||
def _account_linked_to_identity(session: Session, identity_id: str, account_id: str) -> bool:
|
||||
return (
|
||||
session.query(IdentityAccountLink)
|
||||
.filter(IdentityAccountLink.identity_id == identity_id, IdentityAccountLink.account_id == account_id)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
def _account_linked_to_identity(identity_id: str, account_id: str) -> bool:
|
||||
return any(link.account_id == account_id for link in _identity_directory().accounts_for_identity(identity_id))
|
||||
|
||||
|
||||
def _ensure_assignment_workflow(
|
||||
@@ -158,50 +264,69 @@ def _ensure_assignment_workflow(
|
||||
*,
|
||||
tenant_id: str,
|
||||
) -> None:
|
||||
if item.source not in ASSIGNMENT_SOURCES:
|
||||
raise _invalid("Assignment source is not supported.")
|
||||
if item.valid_from is not None and item.valid_until is not None and item.valid_until <= item.valid_from:
|
||||
raise _invalid("Valid until must be after valid from.")
|
||||
if item.delegated_from_assignment_id is not None and item.delegated_from_assignment_id == item.id:
|
||||
raise _invalid("A function assignment cannot delegate from itself.")
|
||||
try:
|
||||
validate_assignment_shape(item)
|
||||
function = _organization_function(item.function_id, tenant_id)
|
||||
base = _assignment_source_assignment(session, item, tenant_id=tenant_id)
|
||||
validate_assignment_source_rules(
|
||||
item,
|
||||
function=function,
|
||||
base=base,
|
||||
account_linked_to_identity=_account_linked_to_identity,
|
||||
)
|
||||
except AssignmentTransitionError as exc:
|
||||
raise _invalid(str(exc)) from exc
|
||||
|
||||
function = _get_tenant_row(session, OrganizationFunction, item.function_id, tenant_id, "Organization function")
|
||||
base: IdmOrganizationFunctionAssignment | None = None
|
||||
if item.delegated_from_assignment_id is not None:
|
||||
|
||||
def _plan_assignment_update(
|
||||
session: Session,
|
||||
item: IdmOrganizationFunctionAssignment,
|
||||
payload: OrganizationFunctionAssignmentUpdateRequest,
|
||||
*,
|
||||
tenant_id: str,
|
||||
) -> AssignmentMutationPlan:
|
||||
values = payload.model_dump(
|
||||
exclude_unset=True,
|
||||
exclude={
|
||||
"change_request_id",
|
||||
"governance_override_reason",
|
||||
"governance_override_evidence",
|
||||
},
|
||||
)
|
||||
organization_unit_id: str | None = None
|
||||
if "function_id" in values and values["function_id"] is not None:
|
||||
function = _organization_function(str(values["function_id"]), tenant_id)
|
||||
organization_unit_id = function.organization_unit_id
|
||||
try:
|
||||
plan = plan_assignment_update(
|
||||
item,
|
||||
values,
|
||||
organization_unit_id=organization_unit_id,
|
||||
)
|
||||
except AssignmentTransitionError as exc:
|
||||
raise _invalid(str(exc)) from exc
|
||||
|
||||
if plan.after.identity_id != plan.before.identity_id:
|
||||
_ensure_identity(plan.after.identity_id)
|
||||
_ensure_account_link(plan.after.identity_id, plan.after.account_id)
|
||||
_ensure_assignment_workflow(session, plan.after, tenant_id=tenant_id) # type: ignore[arg-type]
|
||||
return plan
|
||||
|
||||
|
||||
def _assignment_source_assignment(
|
||||
session: Session,
|
||||
item: IdmOrganizationFunctionAssignment,
|
||||
*,
|
||||
tenant_id: str,
|
||||
) -> IdmOrganizationFunctionAssignment | None:
|
||||
if item.delegated_from_assignment_id is None:
|
||||
return None
|
||||
base = _get_tenant_row(session, IdmOrganizationFunctionAssignment, item.delegated_from_assignment_id, tenant_id, "Delegated function assignment")
|
||||
if not base.is_active:
|
||||
raise _invalid("Delegation source assignment must be active.")
|
||||
if base.function_id != item.function_id:
|
||||
raise _invalid("Delegated and acting-for assignments must use the same organization function as the source assignment.")
|
||||
|
||||
if item.source == "delegated":
|
||||
if base is None:
|
||||
raise _invalid("Delegated assignments require a source assignment.")
|
||||
if not function.delegable:
|
||||
raise _invalid("This organization function does not allow delegation.")
|
||||
if item.acting_for_account_id is not None:
|
||||
raise _invalid("Delegated assignments cannot set an acting-for account.")
|
||||
if item.identity_id == base.identity_id and (item.account_id or "") == (base.account_id or ""):
|
||||
raise _invalid("A delegated assignment must target another identity or account.")
|
||||
elif item.source == "acting_for":
|
||||
if base is None:
|
||||
raise _invalid("Acting-for assignments require a source assignment.")
|
||||
if not function.act_in_place_allowed:
|
||||
raise _invalid("This organization function does not allow acting in place.")
|
||||
if item.acting_for_account_id is None:
|
||||
raise _invalid("Acting-for assignments require an acting-for account.")
|
||||
if base.account_id is not None:
|
||||
if item.acting_for_account_id != base.account_id:
|
||||
raise _invalid("Acting-for account must match the source assignment account.")
|
||||
elif not _account_linked_to_identity(session, base.identity_id, item.acting_for_account_id):
|
||||
raise _invalid("Acting-for account must belong to the source assignment identity.")
|
||||
if item.account_id == item.acting_for_account_id:
|
||||
raise _invalid("The acting account and acting-for account must be different.")
|
||||
else:
|
||||
if item.delegated_from_assignment_id is not None:
|
||||
raise _invalid("Only delegated or acting-for assignments can reference a source assignment.")
|
||||
if item.acting_for_account_id is not None:
|
||||
raise _invalid("Only acting-for assignments can set an acting-for account.")
|
||||
return base
|
||||
|
||||
|
||||
def _requires_assignment_change_request(session: Session, tenant_id: str) -> bool:
|
||||
@@ -213,16 +338,32 @@ def _actor_id(principal: ApiPrincipal) -> str:
|
||||
return principal.membership_id or principal.account_id
|
||||
|
||||
|
||||
def _payload_for_control(resource_type: str, operation: str, payload: object) -> dict[str, Any]:
|
||||
def _payload_for_control(
|
||||
resource_type: str, operation: str, payload: object
|
||||
) -> dict[str, Any]:
|
||||
if hasattr(payload, "model_dump"):
|
||||
values = payload.model_dump(mode="json", exclude={"change_request_id"}, exclude_unset=True) # type: ignore[attr-defined]
|
||||
values = payload.model_dump( # type: ignore[attr-defined]
|
||||
mode="json",
|
||||
exclude={
|
||||
"change_request_id",
|
||||
"governance_override_reason",
|
||||
"governance_override_evidence",
|
||||
},
|
||||
exclude_unset=True,
|
||||
)
|
||||
else:
|
||||
values = {}
|
||||
return {"resource_type": resource_type, "operation": operation, "payload": values}
|
||||
|
||||
|
||||
def _target_for_control(tenant_id: str, resource_type: str, operation: str, resource_id: str | None = None) -> dict[str, Any]:
|
||||
target: dict[str, Any] = {"tenant_id": tenant_id, "resource_type": resource_type, "operation": operation}
|
||||
def _target_for_control(
|
||||
tenant_id: str, resource_type: str, operation: str, resource_id: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
target: dict[str, Any] = {
|
||||
"tenant_id": tenant_id,
|
||||
"resource_type": resource_type,
|
||||
"operation": operation,
|
||||
}
|
||||
if resource_id is not None:
|
||||
target["resource_id"] = resource_id
|
||||
return target
|
||||
@@ -277,7 +418,6 @@ def _record_assignment_change_applied(
|
||||
target=target,
|
||||
audit_event=IDM_ASSIGNMENT_AUDIT_EVENT,
|
||||
)
|
||||
session.commit()
|
||||
|
||||
|
||||
def _audit_capability_available() -> bool:
|
||||
@@ -304,10 +444,68 @@ def _record_assignment_audit(
|
||||
object_type=resource_type,
|
||||
object_id=resource_id,
|
||||
details={"before": before, "after": after},
|
||||
commit=True,
|
||||
commit=False,
|
||||
)
|
||||
|
||||
|
||||
def _publish_assignment_event(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
item: OrganizationFunctionAssignmentItem,
|
||||
*,
|
||||
event_type: str,
|
||||
) -> None:
|
||||
emit_assignment_event(
|
||||
session,
|
||||
item,
|
||||
event_type=event_type,
|
||||
actor_type="account",
|
||||
actor_id=principal.account_id,
|
||||
)
|
||||
|
||||
|
||||
def _apply_governance_override(
|
||||
principal: ApiPrincipal,
|
||||
function: OrganizationFunctionRef,
|
||||
payload: object,
|
||||
settings: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
governance = function.settings.get("assignment_governance")
|
||||
if not isinstance(governance, dict) or not any(
|
||||
str(governance.get(key) or "unavailable").strip().casefold() != "unavailable"
|
||||
for key in ("request_profile", "grant_profile")
|
||||
):
|
||||
return settings
|
||||
if not principal.has("idm:function_change:admin"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=(
|
||||
"Direct assignment to a governed function requires function "
|
||||
"change recovery permission."
|
||||
),
|
||||
)
|
||||
reason = str(getattr(payload, "governance_override_reason", None) or "").strip()
|
||||
if not reason:
|
||||
raise _invalid(
|
||||
"Direct assignment to a governed function requires an emergency override reason."
|
||||
)
|
||||
evidence = [
|
||||
str(item).strip()
|
||||
for item in getattr(payload, "governance_override_evidence", ())
|
||||
if str(item).strip()
|
||||
]
|
||||
return {
|
||||
**settings,
|
||||
"governance_override": {
|
||||
"reason": reason,
|
||||
"evidence": evidence,
|
||||
"actor_account_id": principal.account_id,
|
||||
"recorded_at": utc_now().isoformat(),
|
||||
"function_id": function.id,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/settings", response_model=IdmSettingsItem)
|
||||
def get_idm_settings(
|
||||
session: Session = Depends(get_session),
|
||||
@@ -318,6 +516,50 @@ def get_idm_settings(
|
||||
return _settings_item(item) if item is not None else _default_settings(tenant_id)
|
||||
|
||||
|
||||
def _validate_function_governance_defaults(settings: dict[str, Any]) -> None:
|
||||
raw = settings.get("function_assignment_governance_defaults")
|
||||
if raw is None:
|
||||
return
|
||||
if not isinstance(raw, dict):
|
||||
raise _invalid("Function assignment governance defaults must be an object.")
|
||||
if "delegation_allowed" in raw and not isinstance(
|
||||
raw.get("delegation_allowed"), bool
|
||||
):
|
||||
raise _invalid("delegation_allowed must be true or false.")
|
||||
for key, minimum, maximum in (
|
||||
("maximum_delegation_depth", 1, 20),
|
||||
("maximum_delegated_validity_days", 1, 3650),
|
||||
):
|
||||
value = raw.get(key)
|
||||
if value is None:
|
||||
continue
|
||||
if not isinstance(value, int) or isinstance(value, bool) or not minimum <= value <= maximum:
|
||||
raise _invalid(f"{key} must be between {minimum} and {maximum}.")
|
||||
escalation = raw.get("escalation")
|
||||
if escalation is None:
|
||||
return
|
||||
if not isinstance(escalation, dict):
|
||||
raise _invalid("Escalation defaults must be an object.")
|
||||
unsupported = set(escalation) - {"holder", "authority", "recipient"}
|
||||
if unsupported:
|
||||
raise _invalid(
|
||||
"Unsupported escalation review step: " + ", ".join(sorted(unsupported))
|
||||
)
|
||||
for step, value in escalation.items():
|
||||
if not isinstance(value, dict):
|
||||
raise _invalid(f"The {step} escalation rule must be an object.")
|
||||
target = value.get("target_function_id")
|
||||
timeout = value.get("timeout_hours")
|
||||
if not isinstance(target, str) or not target.strip():
|
||||
raise _invalid(f"The {step} escalation target function is required.")
|
||||
if (
|
||||
not isinstance(timeout, int)
|
||||
or isinstance(timeout, bool)
|
||||
or not 1 <= timeout <= 8760
|
||||
):
|
||||
raise _invalid(f"The {step} escalation timeout must be 1 to 8760 hours.")
|
||||
|
||||
|
||||
@router.patch("/settings", response_model=IdmSettingsItem)
|
||||
def update_idm_settings(
|
||||
payload: IdmSettingsUpdateRequest,
|
||||
@@ -346,8 +588,19 @@ def update_idm_settings(
|
||||
if "settings" in fields:
|
||||
if payload.settings is None:
|
||||
raise _invalid("Settings cannot be empty.")
|
||||
_validate_function_governance_defaults(payload.settings)
|
||||
defaults = payload.settings.get("function_assignment_governance_defaults")
|
||||
escalation = defaults.get("escalation") if isinstance(defaults, dict) else None
|
||||
if isinstance(escalation, dict):
|
||||
for rule in escalation.values():
|
||||
if isinstance(rule, dict):
|
||||
_organization_function(
|
||||
str(rule.get("target_function_id")),
|
||||
tenant_id,
|
||||
)
|
||||
item.settings = payload.settings
|
||||
result = _settings_item(_commit(session, item))
|
||||
session.flush()
|
||||
result = _settings_item(item)
|
||||
_record_assignment_audit(
|
||||
session,
|
||||
principal,
|
||||
@@ -357,22 +610,48 @@ def update_idm_settings(
|
||||
before=before,
|
||||
after=result.model_dump(mode="json"),
|
||||
)
|
||||
invalidate_auth_principals(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
source_module="idm",
|
||||
resource_type="tenant_settings",
|
||||
resource_id=tenant_id,
|
||||
)
|
||||
session.commit()
|
||||
session.refresh(item)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/organization-function-assignments", response_model=OrganizationFunctionAssignmentList)
|
||||
def list_organization_function_assignments(
|
||||
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(*IDM_ASSIGNMENT_READ_SCOPES)),
|
||||
) -> OrganizationFunctionAssignmentList:
|
||||
tenant_id = _tenant_id(principal)
|
||||
assignments = (
|
||||
query = (
|
||||
session.query(IdmOrganizationFunctionAssignment)
|
||||
.filter(IdmOrganizationFunctionAssignment.tenant_id == tenant_id)
|
||||
.order_by(IdmOrganizationFunctionAssignment.created_at.asc())
|
||||
)
|
||||
total = query.order_by(None).count()
|
||||
pages = max(1, (total + page_size - 1) // page_size)
|
||||
assignments = (
|
||||
query.order_by(
|
||||
IdmOrganizationFunctionAssignment.created_at.asc(),
|
||||
IdmOrganizationFunctionAssignment.id.asc(),
|
||||
)
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
return OrganizationFunctionAssignmentList(assignments=[_assignment_item(item) for item in assignments])
|
||||
return OrganizationFunctionAssignmentList(
|
||||
assignments=[_assignment_item(item) for item in assignments],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
pages=pages,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/organization-function-assignments", response_model=OrganizationFunctionAssignmentItem, status_code=status.HTTP_201_CREATED)
|
||||
@@ -383,9 +662,15 @@ def create_organization_function_assignment(
|
||||
) -> OrganizationFunctionAssignmentItem:
|
||||
tenant_id = _tenant_id(principal)
|
||||
approval, target, _value = _ensure_assignment_change_allowed(session, principal, tenant_id=tenant_id, operation="created", payload=payload)
|
||||
_ensure_identity(session, payload.identity_id)
|
||||
_ensure_account_link(session, payload.identity_id, payload.account_id)
|
||||
function = _get_tenant_row(session, OrganizationFunction, payload.function_id, tenant_id, "Organization function")
|
||||
_ensure_identity(payload.identity_id)
|
||||
_ensure_account_link(payload.identity_id, payload.account_id)
|
||||
function = _organization_function(payload.function_id, tenant_id)
|
||||
item_settings = _apply_governance_override(
|
||||
principal,
|
||||
function,
|
||||
payload,
|
||||
dict(payload.settings),
|
||||
)
|
||||
item = IdmOrganizationFunctionAssignment(
|
||||
tenant_id=tenant_id,
|
||||
identity_id=payload.identity_id,
|
||||
@@ -399,26 +684,42 @@ def create_organization_function_assignment(
|
||||
valid_from=payload.valid_from,
|
||||
valid_until=payload.valid_until,
|
||||
is_active=payload.is_active,
|
||||
settings=payload.settings,
|
||||
settings=item_settings,
|
||||
)
|
||||
if item.delegated_from_assignment_id is not None:
|
||||
_get_tenant_row(session, IdmOrganizationFunctionAssignment, item.delegated_from_assignment_id, tenant_id, "Delegated function assignment")
|
||||
_ensure_assignment_workflow(session, item, tenant_id=tenant_id)
|
||||
session.add(item)
|
||||
saved = _commit(session, item)
|
||||
result = _assignment_item(saved)
|
||||
_flush_assignment(session, item)
|
||||
result = _assignment_item(item)
|
||||
after = result.model_dump(mode="json")
|
||||
_record_assignment_change_applied(session, principal, approval=approval, target={**target, "resource_id": saved.id}, before=None, after=after)
|
||||
_record_assignment_change_applied(session, principal, approval=approval, target={**target, "resource_id": item.id}, before=None, after=after)
|
||||
_record_assignment_audit(
|
||||
session,
|
||||
principal,
|
||||
action="idm.organization_assignment.created",
|
||||
resource_type="idm_organization_function_assignment",
|
||||
resource_id=saved.id,
|
||||
resource_id=item.id,
|
||||
before=None,
|
||||
after=after,
|
||||
)
|
||||
return result
|
||||
_publish_assignment_event(
|
||||
session,
|
||||
principal,
|
||||
result,
|
||||
event_type="idm.function_assignment.created.v1",
|
||||
)
|
||||
now = utc_now()
|
||||
if assignment_is_expired(item, now=now):
|
||||
item.expired_event_at = now
|
||||
_publish_assignment_event(
|
||||
session,
|
||||
principal,
|
||||
_assignment_item(item),
|
||||
event_type="idm.function_assignment.expired.v1",
|
||||
)
|
||||
_commit_assignment_transaction(session, item)
|
||||
return _assignment_item(item)
|
||||
|
||||
|
||||
@router.patch("/organization-function-assignments/{assignment_id}", response_model=OrganizationFunctionAssignmentItem)
|
||||
@@ -432,44 +733,32 @@ def update_organization_function_assignment(
|
||||
item = _get_tenant_row(session, IdmOrganizationFunctionAssignment, assignment_id, tenant_id, "Organization function assignment")
|
||||
before = _jsonable(_row_fields(item))
|
||||
approval, target, _value = _ensure_assignment_change_allowed(session, principal, tenant_id=tenant_id, operation="updated", payload=payload, resource_id=assignment_id)
|
||||
if "function_id" in payload.model_fields_set:
|
||||
if payload.function_id is None:
|
||||
raise _invalid("Function is required.")
|
||||
function = _get_tenant_row(session, OrganizationFunction, payload.function_id, tenant_id, "Organization function")
|
||||
item.function_id = function.id
|
||||
item.organization_unit_id = function.organization_unit_id
|
||||
identity_id = payload.identity_id if "identity_id" in payload.model_fields_set else item.identity_id
|
||||
account_id = payload.account_id if "account_id" in payload.model_fields_set else item.account_id
|
||||
if "identity_id" in payload.model_fields_set:
|
||||
if payload.identity_id is None:
|
||||
raise _invalid("Identity is required.")
|
||||
_ensure_identity(session, payload.identity_id)
|
||||
item.identity_id = payload.identity_id
|
||||
_ensure_account_link(session, identity_id, account_id)
|
||||
if "source" in payload.model_fields_set and payload.source is None:
|
||||
raise _invalid("Assignment source is required.")
|
||||
if "applies_to_subunits" in payload.model_fields_set and payload.applies_to_subunits is None:
|
||||
raise _invalid("Subunit applicability cannot be empty.")
|
||||
if "is_active" in payload.model_fields_set and payload.is_active is None:
|
||||
raise _invalid("Active state cannot be empty.")
|
||||
if "settings" in payload.model_fields_set and payload.settings is None:
|
||||
raise _invalid("Settings cannot be empty.")
|
||||
for field in (
|
||||
"account_id",
|
||||
"applies_to_subunits",
|
||||
"source",
|
||||
"delegated_from_assignment_id",
|
||||
"acting_for_account_id",
|
||||
"valid_from",
|
||||
"valid_until",
|
||||
"is_active",
|
||||
"settings",
|
||||
):
|
||||
if field in payload.model_fields_set:
|
||||
setattr(item, field, getattr(payload, field))
|
||||
_ensure_assignment_workflow(session, item, tenant_id=tenant_id)
|
||||
saved = _commit(session, item)
|
||||
result = _assignment_item(saved)
|
||||
plan = _plan_assignment_update(
|
||||
session,
|
||||
item,
|
||||
payload,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
plan.apply(item)
|
||||
function = _organization_function(item.function_id, tenant_id)
|
||||
item.settings = _apply_governance_override(
|
||||
principal,
|
||||
function,
|
||||
payload,
|
||||
dict(item.settings),
|
||||
)
|
||||
now = utc_now()
|
||||
event_types = lifecycle_event_types(
|
||||
plan.before,
|
||||
plan.after,
|
||||
now=now,
|
||||
)
|
||||
if "idm.function_assignment.expired.v1" in event_types:
|
||||
item.expired_event_at = now
|
||||
elif not assignment_is_expired(item, now=now):
|
||||
item.expired_event_at = None
|
||||
_flush_assignment(session, item)
|
||||
result = _assignment_item(item)
|
||||
after = result.model_dump(mode="json")
|
||||
_record_assignment_change_applied(session, principal, approval=approval, target=target, before=before, after=after)
|
||||
_record_assignment_audit(
|
||||
@@ -477,11 +766,19 @@ def update_organization_function_assignment(
|
||||
principal,
|
||||
action="idm.organization_assignment.updated",
|
||||
resource_type="idm_organization_function_assignment",
|
||||
resource_id=saved.id,
|
||||
resource_id=item.id,
|
||||
before=before,
|
||||
after=after,
|
||||
)
|
||||
return result
|
||||
for event_type in event_types:
|
||||
_publish_assignment_event(
|
||||
session,
|
||||
principal,
|
||||
result,
|
||||
event_type=event_type,
|
||||
)
|
||||
_commit_assignment_transaction(session, item)
|
||||
return _assignment_item(item)
|
||||
|
||||
|
||||
@router.get("/organization-identities", response_model=OrganizationIdentityCandidateList)
|
||||
@@ -492,53 +789,33 @@ def list_organization_identity_candidates(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope(*ORGANIZATION_IDENTITY_READ_SCOPES)),
|
||||
) -> OrganizationIdentityCandidateList:
|
||||
del principal
|
||||
identity_query = session.query(Identity)
|
||||
if not include_inactive:
|
||||
identity_query = identity_query.filter(Identity.is_active.is_(True))
|
||||
if query:
|
||||
pattern = f"%{query.strip().casefold()}%"
|
||||
matching_account_links = session.query(IdentityAccountLink.identity_id).filter(
|
||||
func.lower(IdentityAccountLink.account_id).like(pattern)
|
||||
del session, principal
|
||||
identities = _identity_search().search_identities(
|
||||
query,
|
||||
include_inactive=include_inactive,
|
||||
limit=limit,
|
||||
)
|
||||
identity_query = identity_query.filter(
|
||||
or_(
|
||||
func.lower(Identity.id).like(pattern),
|
||||
func.lower(Identity.display_name).like(pattern),
|
||||
func.lower(Identity.external_subject).like(pattern),
|
||||
Identity.id.in_(matching_account_links),
|
||||
)
|
||||
)
|
||||
|
||||
identities = identity_query.order_by(Identity.display_name.asc(), Identity.id.asc()).limit(limit).all()
|
||||
identity_ids = [identity.id for identity in identities]
|
||||
links_by_identity: dict[str, list[IdentityAccountLink]] = {identity_id: [] for identity_id in identity_ids}
|
||||
if identity_ids:
|
||||
links = (
|
||||
session.query(IdentityAccountLink)
|
||||
.filter(IdentityAccountLink.identity_id.in_(identity_ids))
|
||||
.order_by(IdentityAccountLink.is_primary.desc(), IdentityAccountLink.account_id.asc())
|
||||
.all()
|
||||
)
|
||||
for link in links:
|
||||
links_by_identity.setdefault(link.identity_id, []).append(link)
|
||||
|
||||
return OrganizationIdentityCandidateList(
|
||||
identities=[
|
||||
_identity_candidate(identity, links_by_identity.get(identity.id, []))
|
||||
for identity in identities
|
||||
]
|
||||
identities=[_identity_candidate(identity) for identity in identities]
|
||||
)
|
||||
|
||||
|
||||
def _identity_candidate(identity: Identity, links: list[IdentityAccountLink]) -> OrganizationIdentityCandidate:
|
||||
primary_link = next((link for link in links if link.is_primary), None)
|
||||
def _identity_candidate(identity: IdentityRef) -> OrganizationIdentityCandidate:
|
||||
return OrganizationIdentityCandidate(
|
||||
id=identity.id,
|
||||
display_name=identity.display_name,
|
||||
external_subject=identity.external_subject,
|
||||
source=identity.source,
|
||||
primary_account_id=primary_link.account_id if primary_link is not None else None,
|
||||
account_ids=[link.account_id for link in links],
|
||||
status="active" if identity.is_active else "inactive",
|
||||
primary_account_id=identity.primary_account_id,
|
||||
account_ids=list(identity.account_ids),
|
||||
status=identity.status,
|
||||
)
|
||||
|
||||
|
||||
from .function_changes import router as function_changes_router # noqa: E402
|
||||
from .relationships import router as relationships_router # noqa: E402
|
||||
|
||||
|
||||
router.include_router(function_changes_router)
|
||||
router.include_router(relationships_router)
|
||||
|
||||
@@ -37,6 +37,7 @@ class OrganizationFunctionAssignmentItem(BaseModel):
|
||||
acting_for_account_id: str | None = None
|
||||
valid_from: datetime | None = None
|
||||
valid_until: datetime | None = None
|
||||
expired_event_at: datetime | None = None
|
||||
is_active: bool
|
||||
settings: dict[str, Any]
|
||||
created_at: datetime
|
||||
@@ -45,6 +46,10 @@ class OrganizationFunctionAssignmentItem(BaseModel):
|
||||
|
||||
class OrganizationFunctionAssignmentList(BaseModel):
|
||||
assignments: list[OrganizationFunctionAssignmentItem]
|
||||
total: int = 0
|
||||
page: int = 1
|
||||
page_size: int = 500
|
||||
pages: int = 1
|
||||
|
||||
|
||||
class OrganizationFunctionAssignmentCreateRequest(BaseModel):
|
||||
@@ -60,6 +65,10 @@ class OrganizationFunctionAssignmentCreateRequest(BaseModel):
|
||||
is_active: bool = True
|
||||
settings: dict[str, Any] = Field(default_factory=dict)
|
||||
change_request_id: str | None = None
|
||||
governance_override_reason: str | None = Field(default=None, max_length=4_000)
|
||||
governance_override_evidence: list[str] = Field(
|
||||
default_factory=list, max_length=100
|
||||
)
|
||||
|
||||
|
||||
class OrganizationFunctionAssignmentUpdateRequest(BaseModel):
|
||||
@@ -75,6 +84,10 @@ class OrganizationFunctionAssignmentUpdateRequest(BaseModel):
|
||||
is_active: bool | None = None
|
||||
settings: dict[str, Any] | None = None
|
||||
change_request_id: str | None = None
|
||||
governance_override_reason: str | None = Field(default=None, max_length=4_000)
|
||||
governance_override_evidence: list[str] = Field(
|
||||
default_factory=list, max_length=100
|
||||
)
|
||||
|
||||
|
||||
class IdmSettingsItem(BaseModel):
|
||||
@@ -92,3 +105,263 @@ class IdmSettingsUpdateRequest(BaseModel):
|
||||
audit_detail_level: AuditDetailLevel | None = None
|
||||
change_retention_days: int | None = Field(default=None, ge=0)
|
||||
settings: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class TypedGroupItem(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
key: str
|
||||
name: str
|
||||
group_type: str
|
||||
description: str | None = None
|
||||
status: Literal["active", "inactive"]
|
||||
source_provider: str
|
||||
source_resource_type: str | None = None
|
||||
source_resource_id: str | None = None
|
||||
source_revision: str | None = None
|
||||
properties: dict[str, Any]
|
||||
provenance: dict[str, Any]
|
||||
revision: int
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class TypedGroupList(BaseModel):
|
||||
groups: list[TypedGroupItem]
|
||||
total: int
|
||||
|
||||
|
||||
class TypedGroupCreateRequest(BaseModel):
|
||||
key: str = Field(min_length=1, max_length=120)
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
group_type: str = Field(min_length=1, max_length=80)
|
||||
description: str | None = Field(default=None, max_length=8_000)
|
||||
source_provider: str = Field(default="local", min_length=1, max_length=80)
|
||||
source_resource_type: str | None = Field(default=None, max_length=120)
|
||||
source_resource_id: str | None = Field(default=None, max_length=255)
|
||||
source_revision: str | None = Field(default=None, max_length=255)
|
||||
properties: dict[str, Any] = Field(default_factory=dict)
|
||||
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class TypedGroupUpdateRequest(BaseModel):
|
||||
base_revision: int = Field(ge=1)
|
||||
key: str | None = Field(default=None, min_length=1, max_length=120)
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
group_type: str | None = Field(default=None, min_length=1, max_length=80)
|
||||
description: str | None = Field(default=None, max_length=8_000)
|
||||
status: Literal["active", "inactive"] | None = None
|
||||
source_provider: str | None = Field(default=None, min_length=1, max_length=80)
|
||||
source_resource_type: str | None = Field(default=None, max_length=120)
|
||||
source_resource_id: str | None = Field(default=None, max_length=255)
|
||||
source_revision: str | None = Field(default=None, max_length=255)
|
||||
properties: dict[str, Any] | None = None
|
||||
provenance: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class IdentityRelationshipItem(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
relationship_kind: str
|
||||
subject_identity_id: str
|
||||
target_group_id: str | None = None
|
||||
related_identity_id: str | None = None
|
||||
role: str | None = None
|
||||
valid_from: datetime | None = None
|
||||
valid_until: datetime | None = None
|
||||
status: Literal["active", "revoked"]
|
||||
revoked_at: datetime | None = None
|
||||
revoked_by: str | None = None
|
||||
revocation_reason: str | None = None
|
||||
expired_event_at: datetime | None = None
|
||||
source_provider: str
|
||||
source_resource_type: str | None = None
|
||||
source_resource_id: str | None = None
|
||||
source_revision: str | None = None
|
||||
properties: dict[str, Any]
|
||||
provenance: dict[str, Any]
|
||||
revision: int
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class IdentityRelationshipList(BaseModel):
|
||||
relationships: list[IdentityRelationshipItem]
|
||||
total: int
|
||||
|
||||
|
||||
class IdentityRelationshipCreateRequest(BaseModel):
|
||||
relationship_kind: str = Field(min_length=1, max_length=80)
|
||||
subject_identity_id: str = Field(min_length=1, max_length=36)
|
||||
target_group_id: str | None = Field(default=None, max_length=36)
|
||||
related_identity_id: str | None = Field(default=None, max_length=36)
|
||||
role: str | None = Field(default=None, max_length=120)
|
||||
valid_from: datetime | None = None
|
||||
valid_until: datetime | None = None
|
||||
source_provider: str = Field(default="local", min_length=1, max_length=80)
|
||||
source_resource_type: str | None = Field(default=None, max_length=120)
|
||||
source_resource_id: str | None = Field(default=None, max_length=255)
|
||||
source_revision: str | None = Field(default=None, max_length=255)
|
||||
properties: dict[str, Any] = Field(default_factory=dict)
|
||||
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class IdentityRelationshipUpdateRequest(BaseModel):
|
||||
base_revision: int = Field(ge=1)
|
||||
relationship_kind: str | None = Field(default=None, min_length=1, max_length=80)
|
||||
target_group_id: str | None = Field(default=None, max_length=36)
|
||||
related_identity_id: str | None = Field(default=None, max_length=36)
|
||||
role: str | None = Field(default=None, max_length=120)
|
||||
valid_from: datetime | None = None
|
||||
valid_until: datetime | None = None
|
||||
source_provider: str | None = Field(default=None, min_length=1, max_length=80)
|
||||
source_resource_type: str | None = Field(default=None, max_length=120)
|
||||
source_resource_id: str | None = Field(default=None, max_length=255)
|
||||
source_revision: str | None = Field(default=None, max_length=255)
|
||||
properties: dict[str, Any] | None = None
|
||||
provenance: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class IdentityRelationshipRevokeRequest(BaseModel):
|
||||
base_revision: int = Field(ge=1)
|
||||
reason: str = Field(min_length=1, max_length=8_000)
|
||||
|
||||
|
||||
class IdentityRelationshipDecisionItem(BaseModel):
|
||||
relationship: IdentityRelationshipItem
|
||||
included: bool
|
||||
code: str
|
||||
explanation: str
|
||||
identity_status: str | None = None
|
||||
|
||||
|
||||
class TypedGroupMembershipResolutionItem(BaseModel):
|
||||
group: TypedGroupItem
|
||||
effective_at: datetime
|
||||
decisions: list[IdentityRelationshipDecisionItem]
|
||||
identity_ids: list[str]
|
||||
|
||||
|
||||
FunctionAssignmentChangeKind = Literal["request", "grant"]
|
||||
FunctionAssignmentChangeAction = Literal[
|
||||
"approve",
|
||||
"reject",
|
||||
"accept",
|
||||
"request_changes",
|
||||
"respond",
|
||||
"withdraw",
|
||||
"recover",
|
||||
]
|
||||
|
||||
|
||||
class FunctionAssignmentChangeCreateRequest(BaseModel):
|
||||
kind: FunctionAssignmentChangeKind
|
||||
function_id: str = Field(min_length=1, max_length=36)
|
||||
candidate_identity_id: str = Field(min_length=1, max_length=36)
|
||||
candidate_account_id: str | None = Field(default=None, max_length=36)
|
||||
justification: str = Field(min_length=1, max_length=8_000)
|
||||
evidence: list[str] = Field(default_factory=list, max_length=100)
|
||||
requested_valid_from: datetime | None = None
|
||||
requested_valid_until: datetime | None = None
|
||||
applies_to_subunits: bool = False
|
||||
assignment_source: Literal["governance", "delegated"] = "governance"
|
||||
represented_assignment_id: str | None = Field(default=None, max_length=36)
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class FunctionAssignmentChangeActionRequest(BaseModel):
|
||||
action: FunctionAssignmentChangeAction
|
||||
base_revision: int = Field(ge=1)
|
||||
comment: str | None = Field(default=None, max_length=4_000)
|
||||
evidence: list[str] = Field(default_factory=list, max_length=100)
|
||||
|
||||
|
||||
class FunctionAssignmentChangeEventItem(BaseModel):
|
||||
id: str
|
||||
sequence: int
|
||||
action: str
|
||||
from_state: str | None = None
|
||||
to_state: str
|
||||
actor_account_id: str | None = None
|
||||
actor_identity_id: str | None = None
|
||||
actor_assignment_id: str | None = None
|
||||
comment: str | None = None
|
||||
evidence: list[str]
|
||||
policy_decision: dict[str, Any]
|
||||
workflow_step_id: str | None = None
|
||||
details: dict[str, Any]
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class FunctionAssignmentChangeItem(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
kind: FunctionAssignmentChangeKind
|
||||
state: str
|
||||
profile: str
|
||||
function_id: str
|
||||
organization_unit_id: str
|
||||
candidate_identity_id: str
|
||||
candidate_account_id: str | None = None
|
||||
initiator_account_id: str
|
||||
initiator_identity_id: str | None = None
|
||||
represented_assignment_id: str | None = None
|
||||
justification: str
|
||||
evidence: list[str]
|
||||
requested_valid_from: datetime | None = None
|
||||
requested_valid_until: datetime | None = None
|
||||
applies_to_subunits: bool
|
||||
assignment_source: str
|
||||
required_steps: list[str]
|
||||
completed_steps: list[str]
|
||||
policy_decision: dict[str, Any]
|
||||
workflow_definition_id: str | None = None
|
||||
workflow_definition_revision_id: str | None = None
|
||||
workflow_definition_revision: int | None = None
|
||||
workflow_definition_hash: str | None = None
|
||||
workflow_instance_id: str | None = None
|
||||
workflow_current_step_id: str | None = None
|
||||
resulting_assignment_id: str | None = None
|
||||
expires_at: datetime | None = None
|
||||
review_deadline_at: datetime | None = None
|
||||
escalated_at: datetime | None = None
|
||||
escalation_from_state: str | None = None
|
||||
escalation_target_function_id: str | None = None
|
||||
outcome_reason: str | None = None
|
||||
resource_revision: int
|
||||
etag: str
|
||||
metadata: dict[str, Any]
|
||||
events: list[FunctionAssignmentChangeEventItem] = Field(default_factory=list)
|
||||
available_actions: list[str] = Field(default_factory=list)
|
||||
availability_reason: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class FunctionAssignmentChangeList(BaseModel):
|
||||
changes: list[FunctionAssignmentChangeItem]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
pages: int
|
||||
|
||||
|
||||
class FunctionAssignmentCapabilityItem(BaseModel):
|
||||
kind: FunctionAssignmentChangeKind
|
||||
function_id: str
|
||||
available: bool
|
||||
reason: str | None = None
|
||||
profile: str = "unavailable"
|
||||
required_steps: list[str] = Field(default_factory=list)
|
||||
requirements: list[str] = Field(default_factory=list)
|
||||
authority_function_id: str | None = None
|
||||
evidence_required: bool = False
|
||||
recipient_acceptance_required: bool = False
|
||||
maximum_validity_days: int | None = None
|
||||
delegation_allowed: bool = False
|
||||
maximum_delegation_depth: int = 0
|
||||
maximum_delegated_validity_days: int | None = None
|
||||
escalation_rules: list[dict[str, Any]] = Field(default_factory=list)
|
||||
workflow_available: bool = False
|
||||
policy_available: bool = False
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Protocol
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.events import (
|
||||
EventActorRef,
|
||||
EventObjectRef,
|
||||
EventTenantRef,
|
||||
PlatformEvent,
|
||||
emit_platform_event,
|
||||
)
|
||||
|
||||
|
||||
class AssignmentEventSource(Protocol):
|
||||
id: str
|
||||
tenant_id: str
|
||||
identity_id: str
|
||||
account_id: str | None
|
||||
function_id: str
|
||||
organization_unit_id: str
|
||||
applies_to_subunits: bool
|
||||
source: str
|
||||
delegated_from_assignment_id: str | None
|
||||
acting_for_account_id: str | None
|
||||
valid_from: datetime | None
|
||||
valid_until: datetime | None
|
||||
is_active: bool
|
||||
|
||||
|
||||
def emit_assignment_event(
|
||||
session: Session,
|
||||
item: AssignmentEventSource,
|
||||
*,
|
||||
event_type: str,
|
||||
actor_type: str,
|
||||
actor_id: str | None = None,
|
||||
occurred_at: datetime | None = None,
|
||||
) -> None:
|
||||
event_options = {"occurred_at": occurred_at} if occurred_at else {}
|
||||
emit_platform_event(
|
||||
session,
|
||||
PlatformEvent(
|
||||
type=event_type,
|
||||
module_id="idm",
|
||||
payload={
|
||||
"identity_id": item.identity_id,
|
||||
"account_id": item.account_id,
|
||||
"function_id": item.function_id,
|
||||
"organization_unit_id": item.organization_unit_id,
|
||||
"applies_to_subunits": item.applies_to_subunits,
|
||||
"source": item.source,
|
||||
"delegated_from_assignment_id": (
|
||||
item.delegated_from_assignment_id
|
||||
),
|
||||
"acting_for_account_id": item.acting_for_account_id,
|
||||
"valid_from": (
|
||||
item.valid_from.isoformat()
|
||||
if item.valid_from is not None
|
||||
else None
|
||||
),
|
||||
"valid_until": (
|
||||
item.valid_until.isoformat()
|
||||
if item.valid_until is not None
|
||||
else None
|
||||
),
|
||||
"is_active": item.is_active,
|
||||
},
|
||||
actor=EventActorRef(type=actor_type, id=actor_id),
|
||||
tenant=EventTenantRef(id=item.tenant_id),
|
||||
subject=EventObjectRef(
|
||||
type="organization_function",
|
||||
id=item.function_id,
|
||||
),
|
||||
resource=EventObjectRef(
|
||||
type="organization_function_assignment",
|
||||
id=item.id,
|
||||
),
|
||||
classification="internal",
|
||||
**event_options,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["emit_assignment_event"]
|
||||
@@ -0,0 +1,567 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.events import (
|
||||
EventActorRef,
|
||||
EventObjectRef,
|
||||
EventTenantRef,
|
||||
PlatformEvent,
|
||||
emit_platform_event,
|
||||
)
|
||||
from govoplan_core.audit.logging import audit_event
|
||||
from govoplan_core.core.principal_cache import invalidate_auth_principals
|
||||
from govoplan_core.core.notifications import (
|
||||
NotificationDispatchRequest,
|
||||
notification_dispatch_provider,
|
||||
)
|
||||
from govoplan_core.security.time import ensure_aware_utc, utc_now
|
||||
from govoplan_idm.backend.assignment_events import emit_assignment_event
|
||||
from govoplan_idm.backend.db.models import (
|
||||
IdmFunctionAssignmentChange,
|
||||
IdmFunctionAssignmentChangeEvent,
|
||||
IdmIdentityRelationship,
|
||||
IdmOrganizationFunctionAssignment,
|
||||
)
|
||||
from govoplan_idm.backend.function_assignment_changes import OPEN_STATES
|
||||
|
||||
|
||||
class SqlIdmAssignmentLifecycle:
|
||||
"""Claim and publish elapsed assignments exactly once per validity window."""
|
||||
|
||||
def __init__(self, *, registry: object | None = None) -> None:
|
||||
self._registry = registry
|
||||
|
||||
def process_expired(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
effective_at: datetime | None = None,
|
||||
limit: int = 100,
|
||||
) -> dict[str, object]:
|
||||
if not isinstance(session, Session):
|
||||
raise TypeError("IDM assignment lifecycle requires a SQLAlchemy session")
|
||||
if limit < 1 or limit > 1000:
|
||||
raise ValueError("IDM assignment expiry limit must be between 1 and 1000")
|
||||
now = ensure_aware_utc(effective_at) or utc_now()
|
||||
query = session.query(IdmOrganizationFunctionAssignment).filter(
|
||||
IdmOrganizationFunctionAssignment.is_active.is_(True),
|
||||
IdmOrganizationFunctionAssignment.valid_until.is_not(None),
|
||||
IdmOrganizationFunctionAssignment.valid_until <= now,
|
||||
IdmOrganizationFunctionAssignment.expired_event_at.is_(None),
|
||||
)
|
||||
if tenant_id is not None:
|
||||
query = query.filter(
|
||||
IdmOrganizationFunctionAssignment.tenant_id == tenant_id
|
||||
)
|
||||
candidates = (
|
||||
query.order_by(
|
||||
IdmOrganizationFunctionAssignment.valid_until.asc(),
|
||||
IdmOrganizationFunctionAssignment.id.asc(),
|
||||
)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
expired_ids: list[str] = []
|
||||
touched_tenants: set[str] = set()
|
||||
for item in candidates:
|
||||
claimed = (
|
||||
session.query(IdmOrganizationFunctionAssignment)
|
||||
.filter(
|
||||
IdmOrganizationFunctionAssignment.id == item.id,
|
||||
IdmOrganizationFunctionAssignment.is_active.is_(True),
|
||||
IdmOrganizationFunctionAssignment.valid_until.is_not(None),
|
||||
IdmOrganizationFunctionAssignment.valid_until <= now,
|
||||
IdmOrganizationFunctionAssignment.expired_event_at.is_(None),
|
||||
)
|
||||
.update(
|
||||
{IdmOrganizationFunctionAssignment.expired_event_at: now},
|
||||
synchronize_session=False,
|
||||
)
|
||||
)
|
||||
if claimed != 1:
|
||||
continue
|
||||
session.refresh(item)
|
||||
emit_assignment_event(
|
||||
session,
|
||||
item,
|
||||
event_type="idm.function_assignment.expired.v1",
|
||||
actor_type="system",
|
||||
occurred_at=now,
|
||||
)
|
||||
expired_ids.append(item.id)
|
||||
touched_tenants.add(item.tenant_id)
|
||||
|
||||
for touched_tenant_id in sorted(touched_tenants):
|
||||
invalidate_auth_principals(
|
||||
session,
|
||||
tenant_id=touched_tenant_id,
|
||||
source_module="idm",
|
||||
resource_type="organization_function_assignment_expiry",
|
||||
resource_id=touched_tenant_id,
|
||||
)
|
||||
escalated_change_ids = self._escalate_due_changes(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
effective_at=now,
|
||||
limit=limit,
|
||||
)
|
||||
expired_change_ids = self._expire_open_changes(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
effective_at=now,
|
||||
limit=limit,
|
||||
)
|
||||
expired_relationship_ids = self._expire_relationships(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
effective_at=now,
|
||||
limit=limit,
|
||||
)
|
||||
return {
|
||||
"selected": len(candidates),
|
||||
"expired": len(expired_ids),
|
||||
"assignment_ids": expired_ids,
|
||||
"expired_changes": len(expired_change_ids),
|
||||
"change_ids": expired_change_ids,
|
||||
"escalated_changes": len(escalated_change_ids),
|
||||
"escalated_change_ids": escalated_change_ids,
|
||||
"expired_relationships": len(expired_relationship_ids),
|
||||
"relationship_ids": expired_relationship_ids,
|
||||
}
|
||||
|
||||
def _escalate_due_changes(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str | None,
|
||||
effective_at: datetime,
|
||||
limit: int,
|
||||
) -> list[str]:
|
||||
review_states = (
|
||||
"awaiting_holder",
|
||||
"awaiting_authority",
|
||||
"awaiting_recipient",
|
||||
)
|
||||
query = session.query(IdmFunctionAssignmentChange).filter(
|
||||
IdmFunctionAssignmentChange.state.in_(review_states),
|
||||
IdmFunctionAssignmentChange.review_deadline_at.is_not(None),
|
||||
IdmFunctionAssignmentChange.review_deadline_at <= effective_at,
|
||||
IdmFunctionAssignmentChange.escalated_at.is_(None),
|
||||
IdmFunctionAssignmentChange.escalation_target_function_id.is_not(None),
|
||||
)
|
||||
if tenant_id is not None:
|
||||
query = query.filter(IdmFunctionAssignmentChange.tenant_id == tenant_id)
|
||||
candidates = (
|
||||
query.order_by(
|
||||
IdmFunctionAssignmentChange.review_deadline_at.asc(),
|
||||
IdmFunctionAssignmentChange.id.asc(),
|
||||
)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
escalated_ids: list[str] = []
|
||||
for change in candidates:
|
||||
previous_state = change.state
|
||||
deadline = change.review_deadline_at
|
||||
target_function_id = change.escalation_target_function_id
|
||||
claimed = (
|
||||
session.query(IdmFunctionAssignmentChange)
|
||||
.filter(
|
||||
IdmFunctionAssignmentChange.id == change.id,
|
||||
IdmFunctionAssignmentChange.state == previous_state,
|
||||
IdmFunctionAssignmentChange.review_deadline_at == deadline,
|
||||
IdmFunctionAssignmentChange.review_deadline_at <= effective_at,
|
||||
IdmFunctionAssignmentChange.escalated_at.is_(None),
|
||||
IdmFunctionAssignmentChange.escalation_target_function_id
|
||||
== target_function_id,
|
||||
)
|
||||
.update(
|
||||
{
|
||||
IdmFunctionAssignmentChange.state: "escalated",
|
||||
IdmFunctionAssignmentChange.escalated_at: effective_at,
|
||||
IdmFunctionAssignmentChange.escalation_from_state: previous_state,
|
||||
IdmFunctionAssignmentChange.review_deadline_at: None,
|
||||
IdmFunctionAssignmentChange.outcome_reason: (
|
||||
"The review deadline elapsed; the change is explicitly "
|
||||
"escalated to the configured target function."
|
||||
),
|
||||
IdmFunctionAssignmentChange.resource_revision: (
|
||||
IdmFunctionAssignmentChange.resource_revision + 1
|
||||
),
|
||||
},
|
||||
synchronize_session=False,
|
||||
)
|
||||
)
|
||||
if claimed != 1:
|
||||
continue
|
||||
session.refresh(change)
|
||||
sequence = (
|
||||
int(
|
||||
session.scalar(
|
||||
select(func.max(IdmFunctionAssignmentChangeEvent.sequence)).where(
|
||||
IdmFunctionAssignmentChangeEvent.change_id == change.id
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
+ 1
|
||||
)
|
||||
session.add(
|
||||
IdmFunctionAssignmentChangeEvent(
|
||||
tenant_id=change.tenant_id,
|
||||
change_id=change.id,
|
||||
sequence=sequence,
|
||||
action="escalated",
|
||||
from_state=previous_state,
|
||||
to_state="escalated",
|
||||
policy_decision=dict(change.policy_decision),
|
||||
workflow_step_id=change.workflow_current_step_id,
|
||||
details={
|
||||
"review_deadline_at": (
|
||||
deadline.isoformat() if deadline is not None else None
|
||||
),
|
||||
"effective_at": effective_at.isoformat(),
|
||||
"target_function_id": target_function_id,
|
||||
"automatic_approver_substitution": False,
|
||||
},
|
||||
created_at=effective_at,
|
||||
)
|
||||
)
|
||||
emit_platform_event(
|
||||
session,
|
||||
PlatformEvent(
|
||||
type="idm.function_change.escalated.v1",
|
||||
module_id="idm",
|
||||
payload={
|
||||
"kind": change.kind,
|
||||
"state": change.state,
|
||||
"from_state": previous_state,
|
||||
"function_id": change.function_id,
|
||||
"target_function_id": target_function_id,
|
||||
"resource_revision": change.resource_revision,
|
||||
},
|
||||
actor=EventActorRef(type="system"),
|
||||
tenant=EventTenantRef(id=change.tenant_id),
|
||||
subject=EventObjectRef(
|
||||
type="organization_function",
|
||||
id=change.function_id,
|
||||
),
|
||||
resource=EventObjectRef(
|
||||
type="function_assignment_change",
|
||||
id=change.id,
|
||||
),
|
||||
classification="internal",
|
||||
),
|
||||
)
|
||||
audit_event(
|
||||
session,
|
||||
tenant_id=change.tenant_id,
|
||||
action="idm.function_assignment_change.escalated",
|
||||
object_type="function_assignment_change",
|
||||
object_id=change.id,
|
||||
details={
|
||||
"from_state": previous_state,
|
||||
"to_state": "escalated",
|
||||
"review_deadline_at": (
|
||||
deadline.isoformat() if deadline is not None else None
|
||||
),
|
||||
"target_function_id": target_function_id,
|
||||
"resource_revision": change.resource_revision,
|
||||
"automatic_approver_substitution": False,
|
||||
},
|
||||
correlation_id=change.id,
|
||||
commit=False,
|
||||
)
|
||||
self._notify_escalation(session, change)
|
||||
escalated_ids.append(change.id)
|
||||
return escalated_ids
|
||||
|
||||
@staticmethod
|
||||
def _expire_relationships(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str | None,
|
||||
effective_at: datetime,
|
||||
limit: int,
|
||||
) -> list[str]:
|
||||
query = session.query(IdmIdentityRelationship).filter(
|
||||
IdmIdentityRelationship.status == "active",
|
||||
IdmIdentityRelationship.valid_until.is_not(None),
|
||||
IdmIdentityRelationship.valid_until <= effective_at,
|
||||
IdmIdentityRelationship.expired_event_at.is_(None),
|
||||
)
|
||||
if tenant_id is not None:
|
||||
query = query.filter(IdmIdentityRelationship.tenant_id == tenant_id)
|
||||
candidates = (
|
||||
query.order_by(
|
||||
IdmIdentityRelationship.valid_until.asc(),
|
||||
IdmIdentityRelationship.id.asc(),
|
||||
)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
expired_ids: list[str] = []
|
||||
for item in candidates:
|
||||
claimed = (
|
||||
session.query(IdmIdentityRelationship)
|
||||
.filter(
|
||||
IdmIdentityRelationship.id == item.id,
|
||||
IdmIdentityRelationship.status == "active",
|
||||
IdmIdentityRelationship.valid_until.is_not(None),
|
||||
IdmIdentityRelationship.valid_until <= effective_at,
|
||||
IdmIdentityRelationship.expired_event_at.is_(None),
|
||||
)
|
||||
.update(
|
||||
{
|
||||
IdmIdentityRelationship.expired_event_at: effective_at,
|
||||
IdmIdentityRelationship.revision: (
|
||||
IdmIdentityRelationship.revision + 1
|
||||
),
|
||||
},
|
||||
synchronize_session=False,
|
||||
)
|
||||
)
|
||||
if claimed != 1:
|
||||
continue
|
||||
session.refresh(item)
|
||||
emit_platform_event(
|
||||
session,
|
||||
PlatformEvent(
|
||||
type="idm.relationship.expired.v1",
|
||||
module_id="idm",
|
||||
payload={
|
||||
"relationship_kind": item.relationship_kind,
|
||||
"target_group_id": item.target_group_id,
|
||||
"related_identity_id": item.related_identity_id,
|
||||
"revision": item.revision,
|
||||
},
|
||||
actor=EventActorRef(type="system"),
|
||||
tenant=EventTenantRef(id=item.tenant_id),
|
||||
subject=EventObjectRef(
|
||||
type="identity", id=item.subject_identity_id
|
||||
),
|
||||
resource=EventObjectRef(
|
||||
type="identity_relationship", id=item.id
|
||||
),
|
||||
classification="internal",
|
||||
),
|
||||
)
|
||||
invalidate_auth_principals(
|
||||
session,
|
||||
tenant_id=item.tenant_id,
|
||||
source_module="idm",
|
||||
resource_type="identity_relationship_expiry",
|
||||
resource_id=item.id,
|
||||
)
|
||||
expired_ids.append(item.id)
|
||||
return expired_ids
|
||||
|
||||
def _expire_open_changes(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str | None,
|
||||
effective_at: datetime,
|
||||
limit: int,
|
||||
) -> list[str]:
|
||||
query = session.query(IdmFunctionAssignmentChange).filter(
|
||||
IdmFunctionAssignmentChange.state.in_(OPEN_STATES),
|
||||
IdmFunctionAssignmentChange.expires_at.is_not(None),
|
||||
IdmFunctionAssignmentChange.expires_at <= effective_at,
|
||||
)
|
||||
if tenant_id is not None:
|
||||
query = query.filter(IdmFunctionAssignmentChange.tenant_id == tenant_id)
|
||||
candidates = (
|
||||
query.order_by(
|
||||
IdmFunctionAssignmentChange.expires_at.asc(),
|
||||
IdmFunctionAssignmentChange.id.asc(),
|
||||
)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
expired_ids: list[str] = []
|
||||
for change in candidates:
|
||||
previous_state = change.state
|
||||
claimed = (
|
||||
session.query(IdmFunctionAssignmentChange)
|
||||
.filter(
|
||||
IdmFunctionAssignmentChange.id == change.id,
|
||||
IdmFunctionAssignmentChange.state == previous_state,
|
||||
IdmFunctionAssignmentChange.expires_at.is_not(None),
|
||||
IdmFunctionAssignmentChange.expires_at <= effective_at,
|
||||
)
|
||||
.update(
|
||||
{
|
||||
IdmFunctionAssignmentChange.state: "expired",
|
||||
IdmFunctionAssignmentChange.outcome_reason: (
|
||||
"The governed function assignment change expired."
|
||||
),
|
||||
IdmFunctionAssignmentChange.resource_revision: (
|
||||
IdmFunctionAssignmentChange.resource_revision + 1
|
||||
),
|
||||
},
|
||||
synchronize_session=False,
|
||||
)
|
||||
)
|
||||
if claimed != 1:
|
||||
continue
|
||||
session.refresh(change)
|
||||
sequence = (
|
||||
int(
|
||||
session.scalar(
|
||||
session.query(
|
||||
func.max(IdmFunctionAssignmentChangeEvent.sequence)
|
||||
)
|
||||
.filter(IdmFunctionAssignmentChangeEvent.change_id == change.id)
|
||||
.statement
|
||||
)
|
||||
or 0
|
||||
)
|
||||
+ 1
|
||||
)
|
||||
session.add(
|
||||
IdmFunctionAssignmentChangeEvent(
|
||||
tenant_id=change.tenant_id,
|
||||
change_id=change.id,
|
||||
sequence=sequence,
|
||||
action="expired",
|
||||
from_state=previous_state,
|
||||
to_state="expired",
|
||||
policy_decision=dict(change.policy_decision),
|
||||
details={"effective_at": effective_at.isoformat()},
|
||||
created_at=effective_at,
|
||||
)
|
||||
)
|
||||
emit_platform_event(
|
||||
session,
|
||||
PlatformEvent(
|
||||
type="idm.function_change.expired.v1",
|
||||
module_id="idm",
|
||||
payload={
|
||||
"kind": change.kind,
|
||||
"state": change.state,
|
||||
"function_id": change.function_id,
|
||||
"candidate_identity_id": change.candidate_identity_id,
|
||||
"resource_revision": change.resource_revision,
|
||||
},
|
||||
actor=EventActorRef(type="system"),
|
||||
tenant=EventTenantRef(id=change.tenant_id),
|
||||
subject=EventObjectRef(
|
||||
type="organization_function",
|
||||
id=change.function_id,
|
||||
),
|
||||
resource=EventObjectRef(
|
||||
type="function_assignment_change",
|
||||
id=change.id,
|
||||
),
|
||||
classification="internal",
|
||||
),
|
||||
)
|
||||
self._notify_expiry(session, change)
|
||||
expired_ids.append(change.id)
|
||||
return expired_ids
|
||||
|
||||
def _notify_expiry(
|
||||
self,
|
||||
session: Session,
|
||||
change: IdmFunctionAssignmentChange,
|
||||
) -> None:
|
||||
provider = notification_dispatch_provider(self._registry)
|
||||
if provider is None:
|
||||
return
|
||||
recipient_ids = {
|
||||
change.initiator_account_id,
|
||||
change.candidate_account_id,
|
||||
}
|
||||
for account_id in sorted(item for item in recipient_ids if item):
|
||||
provider.enqueue_notification(
|
||||
session,
|
||||
NotificationDispatchRequest(
|
||||
tenant_id=change.tenant_id,
|
||||
source_module="idm",
|
||||
source_resource_type="function_assignment_change",
|
||||
source_resource_id=change.id,
|
||||
event_kind="function_assignment_change.expired",
|
||||
recipient_type="account",
|
||||
recipient_id=account_id,
|
||||
subject="Function assignment change expired",
|
||||
body_text=(
|
||||
"The governed function assignment change expired "
|
||||
"before all required decisions were completed."
|
||||
),
|
||||
action_url=f"/idm?change={change.id}",
|
||||
payload={"change_id": change.id, "state": "expired"},
|
||||
),
|
||||
)
|
||||
|
||||
def _notify_escalation(
|
||||
self,
|
||||
session: Session,
|
||||
change: IdmFunctionAssignmentChange,
|
||||
) -> None:
|
||||
provider = notification_dispatch_provider(self._registry)
|
||||
if provider is None:
|
||||
return
|
||||
recipients = {
|
||||
change.initiator_account_id,
|
||||
change.candidate_account_id,
|
||||
}
|
||||
target = change.escalation_target_function_id
|
||||
if target:
|
||||
effective_at = change.escalated_at or utc_now()
|
||||
recipients.update(
|
||||
item
|
||||
for item in session.scalars(
|
||||
select(IdmOrganizationFunctionAssignment.account_id).where(
|
||||
IdmOrganizationFunctionAssignment.tenant_id
|
||||
== change.tenant_id,
|
||||
IdmOrganizationFunctionAssignment.function_id == target,
|
||||
IdmOrganizationFunctionAssignment.account_id.is_not(None),
|
||||
IdmOrganizationFunctionAssignment.is_active.is_(True),
|
||||
or_(
|
||||
IdmOrganizationFunctionAssignment.valid_from.is_(None),
|
||||
IdmOrganizationFunctionAssignment.valid_from
|
||||
<= effective_at,
|
||||
),
|
||||
or_(
|
||||
IdmOrganizationFunctionAssignment.valid_until.is_(None),
|
||||
IdmOrganizationFunctionAssignment.valid_until
|
||||
> effective_at,
|
||||
),
|
||||
)
|
||||
)
|
||||
if item
|
||||
)
|
||||
for account_id in sorted(item for item in recipients if item):
|
||||
provider.enqueue_notification(
|
||||
session,
|
||||
NotificationDispatchRequest(
|
||||
tenant_id=change.tenant_id,
|
||||
source_module="idm",
|
||||
source_resource_type="function_assignment_change",
|
||||
source_resource_id=change.id,
|
||||
event_kind="function_assignment_change.escalated",
|
||||
recipient_type="account",
|
||||
recipient_id=account_id,
|
||||
subject="Function assignment review escalated",
|
||||
body_text=(
|
||||
"The configured review deadline elapsed. The change is "
|
||||
"visibly escalated and still requires an explicit decision."
|
||||
),
|
||||
action_url=f"/idm?change={change.id}",
|
||||
payload={
|
||||
"change_id": change.id,
|
||||
"state": "escalated",
|
||||
"target_function_id": target,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["SqlIdmAssignmentLifecycle"]
|
||||
@@ -0,0 +1,316 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Protocol
|
||||
|
||||
from govoplan_core.core.organizations import OrganizationFunctionRef
|
||||
|
||||
|
||||
ASSIGNMENT_SOURCES = {
|
||||
"direct",
|
||||
"delegated",
|
||||
"acting_for",
|
||||
"directory",
|
||||
"governance",
|
||||
"system",
|
||||
}
|
||||
MUTABLE_ASSIGNMENT_FIELDS = (
|
||||
"identity_id",
|
||||
"account_id",
|
||||
"function_id",
|
||||
"applies_to_subunits",
|
||||
"source",
|
||||
"delegated_from_assignment_id",
|
||||
"acting_for_account_id",
|
||||
"valid_from",
|
||||
"valid_until",
|
||||
"is_active",
|
||||
"settings",
|
||||
)
|
||||
REQUIRED_UPDATE_FIELDS = {
|
||||
"identity_id": "Identity is required.",
|
||||
"function_id": "Function is required.",
|
||||
"applies_to_subunits": "Subunit applicability cannot be empty.",
|
||||
"source": "Assignment source is required.",
|
||||
"is_active": "Active state cannot be empty.",
|
||||
"settings": "Settings cannot be empty.",
|
||||
}
|
||||
|
||||
|
||||
class AssignmentTransitionError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class AssignmentLike(Protocol):
|
||||
id: str
|
||||
identity_id: str
|
||||
account_id: str | None
|
||||
function_id: str
|
||||
organization_unit_id: str
|
||||
applies_to_subunits: bool
|
||||
source: str
|
||||
delegated_from_assignment_id: str | None
|
||||
acting_for_account_id: str | None
|
||||
valid_from: datetime | None
|
||||
valid_until: datetime | None
|
||||
is_active: bool
|
||||
settings: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AssignmentSnapshot:
|
||||
id: str
|
||||
identity_id: str
|
||||
account_id: str | None
|
||||
function_id: str
|
||||
organization_unit_id: str
|
||||
applies_to_subunits: bool
|
||||
source: str
|
||||
delegated_from_assignment_id: str | None
|
||||
acting_for_account_id: str | None
|
||||
valid_from: datetime | None
|
||||
valid_until: datetime | None
|
||||
is_active: bool
|
||||
settings: dict[str, Any]
|
||||
|
||||
@classmethod
|
||||
def from_assignment(cls, item: AssignmentLike) -> AssignmentSnapshot:
|
||||
return cls(
|
||||
id=item.id,
|
||||
identity_id=item.identity_id,
|
||||
account_id=item.account_id,
|
||||
function_id=item.function_id,
|
||||
organization_unit_id=item.organization_unit_id,
|
||||
applies_to_subunits=item.applies_to_subunits,
|
||||
source=item.source,
|
||||
delegated_from_assignment_id=item.delegated_from_assignment_id,
|
||||
acting_for_account_id=item.acting_for_account_id,
|
||||
valid_from=item.valid_from,
|
||||
valid_until=item.valid_until,
|
||||
is_active=item.is_active,
|
||||
settings=dict(item.settings or {}),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AssignmentMutationPlan:
|
||||
before: AssignmentSnapshot
|
||||
after: AssignmentSnapshot
|
||||
changed_fields: tuple[str, ...]
|
||||
|
||||
def apply(self, item: AssignmentLike) -> None:
|
||||
for field in self.changed_fields:
|
||||
setattr(item, field, getattr(self.after, field))
|
||||
|
||||
|
||||
def plan_assignment_update(
|
||||
item: AssignmentLike,
|
||||
values: Mapping[str, object],
|
||||
*,
|
||||
organization_unit_id: str | None = None,
|
||||
) -> AssignmentMutationPlan:
|
||||
before = AssignmentSnapshot.from_assignment(item)
|
||||
updates: dict[str, object] = {}
|
||||
for field in MUTABLE_ASSIGNMENT_FIELDS:
|
||||
if field not in values:
|
||||
continue
|
||||
value = values[field]
|
||||
message = REQUIRED_UPDATE_FIELDS.get(field)
|
||||
if value is None and message is not None:
|
||||
raise AssignmentTransitionError(message)
|
||||
updates[field] = value
|
||||
if "function_id" in updates:
|
||||
if organization_unit_id is None:
|
||||
raise AssignmentTransitionError(
|
||||
"The organization unit for the selected function is required."
|
||||
)
|
||||
updates["organization_unit_id"] = organization_unit_id
|
||||
if "settings" in updates:
|
||||
updates["settings"] = dict(updates["settings"] or {}) # type: ignore[arg-type]
|
||||
|
||||
after = replace(before, **updates)
|
||||
validate_assignment_shape(after)
|
||||
changed_fields = tuple(
|
||||
field
|
||||
for field in (*MUTABLE_ASSIGNMENT_FIELDS, "organization_unit_id")
|
||||
if getattr(before, field) != getattr(after, field)
|
||||
)
|
||||
return AssignmentMutationPlan(
|
||||
before=before,
|
||||
after=after,
|
||||
changed_fields=changed_fields,
|
||||
)
|
||||
|
||||
|
||||
def validate_assignment_shape(item: AssignmentLike) -> None:
|
||||
if item.source not in ASSIGNMENT_SOURCES:
|
||||
raise AssignmentTransitionError("Assignment source is not supported.")
|
||||
if (
|
||||
item.valid_from is not None
|
||||
and item.valid_until is not None
|
||||
and _comparable_datetime(item.valid_until)
|
||||
<= _comparable_datetime(item.valid_from)
|
||||
):
|
||||
raise AssignmentTransitionError("Valid until must be after valid from.")
|
||||
if (
|
||||
item.delegated_from_assignment_id is not None
|
||||
and item.delegated_from_assignment_id == item.id
|
||||
):
|
||||
raise AssignmentTransitionError(
|
||||
"A function assignment cannot delegate from itself."
|
||||
)
|
||||
|
||||
|
||||
def validate_assignment_source_rules(
|
||||
item: AssignmentLike,
|
||||
*,
|
||||
function: OrganizationFunctionRef,
|
||||
base: AssignmentLike | None,
|
||||
account_linked_to_identity: Callable[[str, str], bool],
|
||||
) -> None:
|
||||
if item.source == "delegated":
|
||||
_validate_delegated_assignment(item, function=function, base=base)
|
||||
return
|
||||
if item.source == "acting_for":
|
||||
_validate_acting_for_assignment(
|
||||
item,
|
||||
function=function,
|
||||
base=base,
|
||||
account_linked_to_identity=account_linked_to_identity,
|
||||
)
|
||||
return
|
||||
if item.delegated_from_assignment_id is not None:
|
||||
raise AssignmentTransitionError(
|
||||
"Only delegated or acting-for assignments can reference a source assignment."
|
||||
)
|
||||
if item.acting_for_account_id is not None:
|
||||
raise AssignmentTransitionError(
|
||||
"Only acting-for assignments can set an acting-for account."
|
||||
)
|
||||
|
||||
|
||||
def lifecycle_event_types(
|
||||
before: AssignmentSnapshot,
|
||||
after: AssignmentSnapshot,
|
||||
*,
|
||||
now: datetime,
|
||||
) -> tuple[str, ...]:
|
||||
events = ["idm.function_assignment.changed.v1"]
|
||||
if before.is_active and not after.is_active:
|
||||
events.append("idm.function_assignment.revoked.v1")
|
||||
before_expired = _is_expired(before, now=now)
|
||||
after_expired = _is_expired(after, now=now)
|
||||
if after_expired and (
|
||||
not before_expired or before.valid_until != after.valid_until
|
||||
):
|
||||
events.append("idm.function_assignment.expired.v1")
|
||||
return tuple(events)
|
||||
|
||||
|
||||
def assignment_is_expired(
|
||||
item: AssignmentLike | AssignmentSnapshot,
|
||||
*,
|
||||
now: datetime,
|
||||
) -> bool:
|
||||
return _is_expired(item, now=now)
|
||||
|
||||
|
||||
def _is_expired(
|
||||
item: AssignmentLike | AssignmentSnapshot,
|
||||
*,
|
||||
now: datetime,
|
||||
) -> bool:
|
||||
return bool(
|
||||
item.is_active
|
||||
and item.valid_until is not None
|
||||
and _comparable_datetime(item.valid_until) <= _comparable_datetime(now)
|
||||
)
|
||||
|
||||
|
||||
def _validate_delegated_assignment(
|
||||
item: AssignmentLike,
|
||||
*,
|
||||
function: OrganizationFunctionRef,
|
||||
base: AssignmentLike | None,
|
||||
) -> None:
|
||||
if base is None:
|
||||
raise AssignmentTransitionError(
|
||||
"Delegated assignments require a source assignment."
|
||||
)
|
||||
if not function.delegable:
|
||||
raise AssignmentTransitionError(
|
||||
"This organization function does not allow delegation."
|
||||
)
|
||||
if item.acting_for_account_id is not None:
|
||||
raise AssignmentTransitionError(
|
||||
"Delegated assignments cannot set an acting-for account."
|
||||
)
|
||||
if item.identity_id == base.identity_id and (item.account_id or "") == (
|
||||
base.account_id or ""
|
||||
):
|
||||
raise AssignmentTransitionError(
|
||||
"A delegated assignment must target another identity or account."
|
||||
)
|
||||
|
||||
|
||||
def _validate_acting_for_assignment(
|
||||
item: AssignmentLike,
|
||||
*,
|
||||
function: OrganizationFunctionRef,
|
||||
base: AssignmentLike | None,
|
||||
account_linked_to_identity: Callable[[str, str], bool],
|
||||
) -> None:
|
||||
if base is None:
|
||||
raise AssignmentTransitionError(
|
||||
"Acting-for assignments require a source assignment."
|
||||
)
|
||||
if not function.act_in_place_allowed:
|
||||
raise AssignmentTransitionError(
|
||||
"This organization function does not allow acting in place."
|
||||
)
|
||||
if item.acting_for_account_id is None:
|
||||
raise AssignmentTransitionError(
|
||||
"Acting-for assignments require an acting-for account."
|
||||
)
|
||||
if (
|
||||
base.account_id is not None
|
||||
and item.acting_for_account_id != base.account_id
|
||||
):
|
||||
raise AssignmentTransitionError(
|
||||
"Acting-for account must match the source assignment account."
|
||||
)
|
||||
if (
|
||||
base.account_id is None
|
||||
and not account_linked_to_identity(
|
||||
base.identity_id,
|
||||
item.acting_for_account_id,
|
||||
)
|
||||
):
|
||||
raise AssignmentTransitionError(
|
||||
"Acting-for account must belong to the source assignment identity."
|
||||
)
|
||||
if item.account_id == item.acting_for_account_id:
|
||||
raise AssignmentTransitionError(
|
||||
"The acting account and acting-for account must be different."
|
||||
)
|
||||
|
||||
|
||||
def _comparable_datetime(value: datetime) -> datetime:
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ASSIGNMENT_SOURCES",
|
||||
"AssignmentMutationPlan",
|
||||
"AssignmentSnapshot",
|
||||
"AssignmentTransitionError",
|
||||
"assignment_is_expired",
|
||||
"lifecycle_event_types",
|
||||
"plan_assignment_update",
|
||||
"validate_assignment_shape",
|
||||
"validate_assignment_source_rules",
|
||||
]
|
||||
@@ -4,9 +4,21 @@ import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, JSON, String, UniqueConstraint
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
JSON,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from govoplan_core.core.concurrency import strong_resource_etag
|
||||
from govoplan_core.db.base import Base, TimestampMixin
|
||||
|
||||
|
||||
@@ -24,6 +36,12 @@ class IdmOrganizationFunctionAssignment(Base, TimestampMixin):
|
||||
"organization_unit_id",
|
||||
name="uq_idm_org_function_assignments_identity_scope",
|
||||
),
|
||||
Index(
|
||||
"ix_idm_org_function_assignments_expiry_due",
|
||||
"is_active",
|
||||
"expired_event_at",
|
||||
"valid_until",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
@@ -38,10 +56,123 @@ class IdmOrganizationFunctionAssignment(Base, TimestampMixin):
|
||||
acting_for_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
valid_from: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
valid_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
expired_event_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
|
||||
class IdmTypedGroup(Base, TimestampMixin):
|
||||
__tablename__ = "idm_typed_groups"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"group_type",
|
||||
"key",
|
||||
name="uq_idm_typed_groups_tenant_type_key",
|
||||
),
|
||||
Index(
|
||||
"ix_idm_typed_groups_tenant_status_name",
|
||||
"tenant_id",
|
||||
"status",
|
||||
"name",
|
||||
),
|
||||
)
|
||||
|
||||
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)
|
||||
key: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
group_type: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), default="active", nullable=False)
|
||||
source_provider: Mapped[str] = mapped_column(String(80), default="local", nullable=False)
|
||||
source_resource_type: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
source_resource_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
source_revision: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
properties: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
provenance: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
|
||||
@property
|
||||
def strong_etag(self) -> str:
|
||||
return strong_resource_etag("idm_typed_group", self.id, self.revision)
|
||||
|
||||
|
||||
class IdmIdentityRelationship(Base, TimestampMixin):
|
||||
__tablename__ = "idm_identity_relationships"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"((target_group_id IS NOT NULL AND related_identity_id IS NULL) OR "
|
||||
"(target_group_id IS NULL AND related_identity_id IS NOT NULL))",
|
||||
name="ck_idm_relationship_exactly_one_target",
|
||||
),
|
||||
CheckConstraint(
|
||||
"valid_until IS NULL OR valid_from IS NULL OR valid_until > valid_from",
|
||||
name="ck_idm_relationship_valid_window",
|
||||
),
|
||||
Index(
|
||||
"ix_idm_relationships_tenant_subject_effective",
|
||||
"tenant_id",
|
||||
"subject_identity_id",
|
||||
"status",
|
||||
"valid_from",
|
||||
"valid_until",
|
||||
),
|
||||
Index(
|
||||
"ix_idm_relationships_tenant_group_effective",
|
||||
"tenant_id",
|
||||
"target_group_id",
|
||||
"status",
|
||||
"valid_from",
|
||||
"valid_until",
|
||||
),
|
||||
Index(
|
||||
"ix_idm_relationships_expiry_due",
|
||||
"status",
|
||||
"expired_event_at",
|
||||
"valid_until",
|
||||
),
|
||||
)
|
||||
|
||||
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)
|
||||
relationship_kind: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
|
||||
subject_identity_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("identity_identities.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
target_group_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("idm_typed_groups.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
related_identity_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("identity_identities.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
role: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
valid_from: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
valid_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), default="active", nullable=False)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
revoked_by: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
revocation_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
expired_event_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
source_provider: Mapped[str] = mapped_column(String(80), default="local", nullable=False)
|
||||
source_resource_type: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
source_resource_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
source_revision: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
properties: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
provenance: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
|
||||
@property
|
||||
def strong_etag(self) -> str:
|
||||
return strong_resource_etag("idm_identity_relationship", self.id, self.revision)
|
||||
|
||||
|
||||
class IdmTenantSettings(Base, TimestampMixin):
|
||||
__tablename__ = "idm_tenant_settings"
|
||||
|
||||
@@ -52,4 +183,194 @@ class IdmTenantSettings(Base, TimestampMixin):
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
|
||||
__all__ = ["IdmOrganizationFunctionAssignment", "IdmTenantSettings", "new_uuid"]
|
||||
class IdmFunctionAssignmentChange(Base, TimestampMixin):
|
||||
__tablename__ = "idm_function_assignment_changes"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"kind",
|
||||
"initiator_account_id",
|
||||
"idempotency_key",
|
||||
name="uq_idm_function_assignment_change_idempotency",
|
||||
),
|
||||
Index(
|
||||
"ix_idm_function_assignment_changes_tenant_state",
|
||||
"tenant_id",
|
||||
"state",
|
||||
"updated_at",
|
||||
),
|
||||
Index(
|
||||
"ix_idm_function_assignment_changes_candidate",
|
||||
"tenant_id",
|
||||
"candidate_identity_id",
|
||||
"state",
|
||||
),
|
||||
Index(
|
||||
"ix_idm_function_assignment_changes_expiry",
|
||||
"state",
|
||||
"expires_at",
|
||||
),
|
||||
)
|
||||
|
||||
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)
|
||||
kind: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||
state: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||
profile: Mapped[str] = mapped_column(String(60), nullable=False)
|
||||
function_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations_functions.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
organization_unit_id: Mapped[str] = mapped_column(
|
||||
String(36), nullable=False, index=True
|
||||
)
|
||||
candidate_identity_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("identity_identities.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
candidate_account_id: Mapped[str | None] = mapped_column(
|
||||
String(36), nullable=True, index=True
|
||||
)
|
||||
initiator_account_id: Mapped[str] = mapped_column(
|
||||
String(36), nullable=False, index=True
|
||||
)
|
||||
initiator_identity_id: Mapped[str | None] = mapped_column(
|
||||
String(36), nullable=True, index=True
|
||||
)
|
||||
represented_assignment_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("idm_organization_function_assignments.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
justification: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
evidence: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||
requested_valid_from: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
requested_valid_until: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
applies_to_subunits: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, nullable=False
|
||||
)
|
||||
assignment_source: Mapped[str] = mapped_column(
|
||||
String(50), default="governance", nullable=False
|
||||
)
|
||||
required_steps: Mapped[list[str]] = mapped_column(
|
||||
JSON, default=list, nullable=False
|
||||
)
|
||||
completed_steps: Mapped[list[str]] = mapped_column(
|
||||
JSON, default=list, nullable=False
|
||||
)
|
||||
policy_decision: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
workflow_definition_id: Mapped[str | None] = mapped_column(
|
||||
String(36), nullable=True, index=True
|
||||
)
|
||||
workflow_definition_revision_id: Mapped[str | None] = mapped_column(
|
||||
String(36), nullable=True
|
||||
)
|
||||
workflow_definition_revision: Mapped[int | None] = mapped_column(
|
||||
Integer, nullable=True
|
||||
)
|
||||
workflow_definition_hash: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True
|
||||
)
|
||||
workflow_instance_id: Mapped[str | None] = mapped_column(
|
||||
String(36), nullable=True, unique=True
|
||||
)
|
||||
workflow_current_step_id: Mapped[str | None] = mapped_column(
|
||||
String(36), nullable=True
|
||||
)
|
||||
resulting_assignment_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("idm_organization_function_assignments.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
review_deadline_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, index=True
|
||||
)
|
||||
escalated_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
escalation_from_state: Mapped[str | None] = mapped_column(
|
||||
String(40), nullable=True
|
||||
)
|
||||
escalation_target_function_id: Mapped[str | None] = mapped_column(
|
||||
String(36), nullable=True, index=True
|
||||
)
|
||||
outcome_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
resource_revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
metadata_: Mapped[dict[str, Any]] = mapped_column(
|
||||
"metadata", JSON, default=dict, nullable=False
|
||||
)
|
||||
|
||||
@property
|
||||
def strong_etag(self) -> str:
|
||||
return strong_resource_etag(
|
||||
"idm_function_assignment_change",
|
||||
self.id,
|
||||
self.resource_revision,
|
||||
)
|
||||
|
||||
|
||||
class IdmFunctionAssignmentChangeEvent(Base):
|
||||
__tablename__ = "idm_function_assignment_change_events"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"change_id",
|
||||
"sequence",
|
||||
name="uq_idm_function_assignment_change_event_sequence",
|
||||
),
|
||||
Index(
|
||||
"ix_idm_function_assignment_change_events_tenant_change",
|
||||
"tenant_id",
|
||||
"change_id",
|
||||
"sequence",
|
||||
),
|
||||
)
|
||||
|
||||
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)
|
||||
change_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("idm_function_assignment_changes.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
sequence: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
action: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
from_state: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
||||
to_state: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
actor_account_id: Mapped[str | None] = mapped_column(
|
||||
String(36), nullable=True, index=True
|
||||
)
|
||||
actor_identity_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
actor_assignment_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
comment: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
evidence: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||
policy_decision: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
workflow_step_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
details: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"IdmFunctionAssignmentChange",
|
||||
"IdmFunctionAssignmentChangeEvent",
|
||||
"IdmIdentityRelationship",
|
||||
"IdmOrganizationFunctionAssignment",
|
||||
"IdmTenantSettings",
|
||||
"IdmTypedGroup",
|
||||
"new_uuid",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.policy import FunctionAssignmentGovernanceDecision
|
||||
from govoplan_core.security.time import utc_now
|
||||
from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DelegationRoute:
|
||||
effective: bool
|
||||
code: str
|
||||
reason: str | None = None
|
||||
assignment_id: str | None = None
|
||||
chain_assignment_ids: tuple[str, ...] = ()
|
||||
delegation_depth: int = 0
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"effective": self.effective,
|
||||
"code": self.code,
|
||||
"reason": self.reason,
|
||||
"assignment_id": self.assignment_id,
|
||||
"chain_assignment_ids": list(self.chain_assignment_ids),
|
||||
"delegation_depth": self.delegation_depth,
|
||||
}
|
||||
|
||||
|
||||
def resolve_actor_function_route(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
function_id: str,
|
||||
account_id: str | None,
|
||||
identity_id: str | None,
|
||||
decision: FunctionAssignmentGovernanceDecision,
|
||||
effective_at: datetime | None = None,
|
||||
) -> DelegationRoute:
|
||||
clauses = []
|
||||
if account_id:
|
||||
clauses.append(IdmOrganizationFunctionAssignment.account_id == account_id)
|
||||
if identity_id:
|
||||
clauses.append(IdmOrganizationFunctionAssignment.identity_id == identity_id)
|
||||
if not clauses:
|
||||
return DelegationRoute(
|
||||
False,
|
||||
"identity_unavailable",
|
||||
"The actor has no resolvable account or identity for this route.",
|
||||
)
|
||||
candidates = list(
|
||||
session.scalars(
|
||||
select(IdmOrganizationFunctionAssignment)
|
||||
.where(
|
||||
IdmOrganizationFunctionAssignment.tenant_id == tenant_id,
|
||||
IdmOrganizationFunctionAssignment.function_id == function_id,
|
||||
or_(*clauses),
|
||||
)
|
||||
.order_by(
|
||||
IdmOrganizationFunctionAssignment.is_active.desc(),
|
||||
IdmOrganizationFunctionAssignment.updated_at.desc(),
|
||||
IdmOrganizationFunctionAssignment.id.asc(),
|
||||
)
|
||||
)
|
||||
)
|
||||
if not candidates:
|
||||
return DelegationRoute(
|
||||
False,
|
||||
"vacant",
|
||||
"No function assignment connects the actor to this review route.",
|
||||
)
|
||||
failures: list[DelegationRoute] = []
|
||||
for candidate in candidates:
|
||||
route = validate_delegation_chain(
|
||||
session,
|
||||
assignment=candidate,
|
||||
tenant_id=tenant_id,
|
||||
function_id=function_id,
|
||||
decision=decision,
|
||||
effective_at=effective_at,
|
||||
)
|
||||
if route.effective:
|
||||
return route
|
||||
failures.append(route)
|
||||
return _preferred_failure(failures)
|
||||
|
||||
|
||||
def resolve_function_route_availability(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
function_id: str,
|
||||
decision: FunctionAssignmentGovernanceDecision,
|
||||
effective_at: datetime | None = None,
|
||||
) -> DelegationRoute:
|
||||
candidates = list(
|
||||
session.scalars(
|
||||
select(IdmOrganizationFunctionAssignment)
|
||||
.where(
|
||||
IdmOrganizationFunctionAssignment.tenant_id == tenant_id,
|
||||
IdmOrganizationFunctionAssignment.function_id == function_id,
|
||||
)
|
||||
.order_by(
|
||||
IdmOrganizationFunctionAssignment.is_active.desc(),
|
||||
IdmOrganizationFunctionAssignment.updated_at.desc(),
|
||||
IdmOrganizationFunctionAssignment.id.asc(),
|
||||
)
|
||||
)
|
||||
)
|
||||
if not candidates:
|
||||
return DelegationRoute(
|
||||
False,
|
||||
"vacant",
|
||||
"The designated function is vacant.",
|
||||
)
|
||||
failures: list[DelegationRoute] = []
|
||||
for candidate in candidates:
|
||||
route = validate_delegation_chain(
|
||||
session,
|
||||
assignment=candidate,
|
||||
tenant_id=tenant_id,
|
||||
function_id=function_id,
|
||||
decision=decision,
|
||||
effective_at=effective_at,
|
||||
)
|
||||
if route.effective:
|
||||
return route
|
||||
failures.append(route)
|
||||
return _preferred_failure(failures)
|
||||
|
||||
|
||||
def validate_delegation_chain(
|
||||
session: Session,
|
||||
*,
|
||||
assignment: IdmOrganizationFunctionAssignment,
|
||||
tenant_id: str,
|
||||
function_id: str,
|
||||
decision: FunctionAssignmentGovernanceDecision,
|
||||
effective_at: datetime | None = None,
|
||||
) -> DelegationRoute:
|
||||
now = _aware(effective_at or utc_now())
|
||||
current = assignment
|
||||
visited: set[str] = set()
|
||||
chain: list[str] = []
|
||||
delegation_depth = 0
|
||||
while True:
|
||||
if current.id in visited:
|
||||
return _failure(
|
||||
"cyclic",
|
||||
"The effective delegation route is cyclic and cannot authorize this action.",
|
||||
assignment,
|
||||
chain,
|
||||
delegation_depth,
|
||||
)
|
||||
visited.add(current.id)
|
||||
chain.append(current.id)
|
||||
if current.tenant_id != tenant_id:
|
||||
return _failure(
|
||||
"tenant_mismatch",
|
||||
"The delegation route crosses a tenant boundary.",
|
||||
assignment,
|
||||
chain,
|
||||
delegation_depth,
|
||||
)
|
||||
if current.function_id != function_id:
|
||||
return _failure(
|
||||
"function_mismatch",
|
||||
"The delegation route changes organization function.",
|
||||
assignment,
|
||||
chain,
|
||||
delegation_depth,
|
||||
)
|
||||
if not current.is_active:
|
||||
return _failure(
|
||||
"unavailable",
|
||||
"A function assignment in the delegation route is no longer active.",
|
||||
assignment,
|
||||
chain,
|
||||
delegation_depth,
|
||||
)
|
||||
if current.valid_from is not None and _aware(current.valid_from) > now:
|
||||
return _failure(
|
||||
"not_yet_effective",
|
||||
"A function assignment in the delegation route is not yet effective.",
|
||||
assignment,
|
||||
chain,
|
||||
delegation_depth,
|
||||
)
|
||||
if current.valid_until is not None and _aware(current.valid_until) <= now:
|
||||
return _failure(
|
||||
"expired",
|
||||
"A function assignment in the delegation route has expired.",
|
||||
assignment,
|
||||
chain,
|
||||
delegation_depth,
|
||||
)
|
||||
|
||||
source_id = current.delegated_from_assignment_id
|
||||
if source_id is None:
|
||||
if current.source in {"delegated", "acting_for"}:
|
||||
return _failure(
|
||||
"source_unavailable",
|
||||
"A derived function assignment has no available source assignment.",
|
||||
assignment,
|
||||
chain,
|
||||
delegation_depth,
|
||||
)
|
||||
return DelegationRoute(
|
||||
True,
|
||||
"effective",
|
||||
assignment_id=assignment.id,
|
||||
chain_assignment_ids=tuple(chain),
|
||||
delegation_depth=delegation_depth,
|
||||
)
|
||||
|
||||
if current.source not in {"delegated", "acting_for"}:
|
||||
return _failure(
|
||||
"source_mismatch",
|
||||
"Only delegated or acting-for assignments may extend an assignment route.",
|
||||
assignment,
|
||||
chain,
|
||||
delegation_depth,
|
||||
)
|
||||
if current.source == "delegated":
|
||||
delegation_depth += 1
|
||||
if not decision.delegation_allowed:
|
||||
return _failure(
|
||||
"policy_tightened",
|
||||
"The current Policy no longer permits delegated authority.",
|
||||
assignment,
|
||||
chain,
|
||||
delegation_depth,
|
||||
)
|
||||
if delegation_depth > decision.maximum_delegation_depth:
|
||||
return _failure(
|
||||
"over_depth",
|
||||
"The delegation route exceeds the current Policy depth ceiling.",
|
||||
assignment,
|
||||
chain,
|
||||
delegation_depth,
|
||||
)
|
||||
if (
|
||||
decision.maximum_delegated_validity_days is not None
|
||||
and current.valid_until is not None
|
||||
):
|
||||
start = _aware(current.valid_from) if current.valid_from else now
|
||||
ceiling = start + timedelta(
|
||||
days=decision.maximum_delegated_validity_days
|
||||
)
|
||||
if _aware(current.valid_until) > ceiling:
|
||||
return _failure(
|
||||
"policy_tightened",
|
||||
"The delegated validity window exceeds the current Policy ceiling.",
|
||||
assignment,
|
||||
chain,
|
||||
delegation_depth,
|
||||
)
|
||||
|
||||
parent = session.get(IdmOrganizationFunctionAssignment, source_id)
|
||||
if parent is None:
|
||||
return _failure(
|
||||
"source_unavailable",
|
||||
"A source assignment in the delegation route is unavailable.",
|
||||
assignment,
|
||||
chain,
|
||||
delegation_depth,
|
||||
)
|
||||
if parent.organization_unit_id != current.organization_unit_id:
|
||||
return _failure(
|
||||
"scope_mismatch",
|
||||
"The delegation route changes organization-unit scope.",
|
||||
assignment,
|
||||
chain,
|
||||
delegation_depth,
|
||||
)
|
||||
if (
|
||||
parent.valid_from is not None
|
||||
and (
|
||||
current.valid_from is None
|
||||
or _aware(current.valid_from) < _aware(parent.valid_from)
|
||||
)
|
||||
):
|
||||
return _failure(
|
||||
"validity_outside_source",
|
||||
"A derived assignment starts before its source assignment.",
|
||||
assignment,
|
||||
chain,
|
||||
delegation_depth,
|
||||
)
|
||||
if (
|
||||
parent.valid_until is not None
|
||||
and (
|
||||
current.valid_until is None
|
||||
or _aware(current.valid_until) > _aware(parent.valid_until)
|
||||
)
|
||||
):
|
||||
return _failure(
|
||||
"validity_outside_source",
|
||||
"A derived assignment outlives its source assignment.",
|
||||
assignment,
|
||||
chain,
|
||||
delegation_depth,
|
||||
)
|
||||
current = parent
|
||||
|
||||
|
||||
def _preferred_failure(failures: list[DelegationRoute]) -> DelegationRoute:
|
||||
priority = {
|
||||
"cyclic": 0,
|
||||
"over_depth": 1,
|
||||
"policy_tightened": 2,
|
||||
"validity_outside_source": 3,
|
||||
"expired": 4,
|
||||
"unavailable": 5,
|
||||
"not_yet_effective": 6,
|
||||
}
|
||||
return min(failures, key=lambda item: priority.get(item.code, 20))
|
||||
|
||||
|
||||
def _failure(
|
||||
code: str,
|
||||
reason: str,
|
||||
assignment: IdmOrganizationFunctionAssignment,
|
||||
chain: list[str],
|
||||
depth: int,
|
||||
) -> DelegationRoute:
|
||||
return DelegationRoute(
|
||||
False,
|
||||
code,
|
||||
reason,
|
||||
assignment.id,
|
||||
tuple(chain),
|
||||
depth,
|
||||
)
|
||||
|
||||
|
||||
def _aware(value: datetime) -> datetime:
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DelegationRoute",
|
||||
"resolve_actor_function_route",
|
||||
"resolve_function_route_availability",
|
||||
"validate_delegation_chain",
|
||||
]
|
||||
@@ -1,13 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import or_
|
||||
|
||||
from govoplan_core.core.idm import IdmDirectory, OrganizationFunctionAssignmentRef
|
||||
from govoplan_core.core.identity import IdentityDirectory
|
||||
from govoplan_core.core.idm import (
|
||||
IdmDirectory,
|
||||
OrganizationFunctionAssignmentRef,
|
||||
OrganizationFunctionIncumbencyRef,
|
||||
)
|
||||
from govoplan_core.core.organizations import OrganizationDirectory
|
||||
from govoplan_core.db.session import get_database
|
||||
from govoplan_core.security.time import utc_now
|
||||
from govoplan_identity.backend.db.models import IdentityAccountLink
|
||||
from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment
|
||||
from govoplan_organizations.backend.db.models import OrganizationFunction
|
||||
|
||||
|
||||
def _status(active: bool) -> str:
|
||||
@@ -33,6 +40,10 @@ def _assignment_ref(item: IdmOrganizationFunctionAssignment) -> OrganizationFunc
|
||||
|
||||
|
||||
class SqlIdmDirectory(IdmDirectory):
|
||||
def __init__(self, *, identities: IdentityDirectory, organizations: OrganizationDirectory) -> None:
|
||||
self._identities = identities
|
||||
self._organizations = organizations
|
||||
|
||||
def get_organization_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None:
|
||||
with get_database().session() as session:
|
||||
item = session.get(IdmOrganizationFunctionAssignment, assignment_id)
|
||||
@@ -43,51 +54,263 @@ class SqlIdmDirectory(IdmDirectory):
|
||||
identity_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
effective_at: datetime | None = None,
|
||||
) -> tuple[OrganizationFunctionAssignmentRef, ...]:
|
||||
return self._assignments_for_identity_ids(identity_ids=(identity_id,), tenant_id=tenant_id)
|
||||
return tuple(
|
||||
self.organization_function_assignments_for_identities(
|
||||
(identity_id,),
|
||||
tenant_id=tenant_id,
|
||||
effective_at=effective_at,
|
||||
).get(identity_id, ())
|
||||
)
|
||||
|
||||
def organization_function_assignments_for_account(
|
||||
self,
|
||||
account_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
effective_at: datetime | None = None,
|
||||
) -> tuple[OrganizationFunctionAssignmentRef, ...]:
|
||||
with get_database().session() as session:
|
||||
identity_ids = [
|
||||
row[0]
|
||||
for row in session.query(IdentityAccountLink.identity_id)
|
||||
.filter(IdentityAccountLink.account_id == account_id)
|
||||
.all()
|
||||
]
|
||||
if not identity_ids:
|
||||
return ()
|
||||
return self._assignments_for_identity_ids(identity_ids=tuple(identity_ids), tenant_id=tenant_id, account_id=account_id)
|
||||
return tuple(
|
||||
self.organization_function_assignments_for_accounts(
|
||||
(account_id,),
|
||||
tenant_id=tenant_id,
|
||||
effective_at=effective_at,
|
||||
).get(account_id, ())
|
||||
)
|
||||
|
||||
def _assignments_for_identity_ids(
|
||||
def organization_function_assignments_for_identities(
|
||||
self,
|
||||
identity_ids: Sequence[str],
|
||||
*,
|
||||
identity_ids: tuple[str, ...],
|
||||
tenant_id: str | None = None,
|
||||
account_id: str | None = None,
|
||||
) -> tuple[OrganizationFunctionAssignmentRef, ...]:
|
||||
if not identity_ids:
|
||||
return ()
|
||||
now = utc_now()
|
||||
effective_at: datetime | None = None,
|
||||
) -> dict[str, tuple[OrganizationFunctionAssignmentRef, ...]]:
|
||||
requested = tuple(dict.fromkeys(identity_ids))
|
||||
result: dict[str, list[OrganizationFunctionAssignmentRef]] = {
|
||||
identity_id: [] for identity_id in requested
|
||||
}
|
||||
if not requested:
|
||||
return {}
|
||||
with get_database().session() as session:
|
||||
items = self._effective_assignment_items(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
effective_at=effective_at or utc_now(),
|
||||
identity_ids=requested,
|
||||
)
|
||||
for item in items:
|
||||
result[item.identity_id].append(_assignment_ref(item))
|
||||
return {
|
||||
identity_id: tuple(assignments)
|
||||
for identity_id, assignments in result.items()
|
||||
}
|
||||
|
||||
def organization_function_assignments_for_accounts(
|
||||
self,
|
||||
account_ids: Sequence[str],
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
effective_at: datetime | None = None,
|
||||
) -> dict[str, tuple[OrganizationFunctionAssignmentRef, ...]]:
|
||||
requested = tuple(dict.fromkeys(account_ids))
|
||||
result: dict[str, list[OrganizationFunctionAssignmentRef]] = {
|
||||
account_id: [] for account_id in requested
|
||||
}
|
||||
if not requested:
|
||||
return {}
|
||||
identities = self._identities.identities_for_accounts(requested)
|
||||
identity_ids_by_account: dict[str, set[str]] = {
|
||||
account_id: set() for account_id in requested
|
||||
}
|
||||
requested_set = set(requested)
|
||||
for identity in identities:
|
||||
linked_accounts = set(identity.account_ids)
|
||||
if identity.primary_account_id:
|
||||
linked_accounts.add(identity.primary_account_id)
|
||||
for account_id in linked_accounts & requested_set:
|
||||
identity_ids_by_account[account_id].add(identity.id)
|
||||
identity_ids = tuple(
|
||||
dict.fromkeys(
|
||||
identity_id
|
||||
for values in identity_ids_by_account.values()
|
||||
for identity_id in values
|
||||
)
|
||||
)
|
||||
if not identity_ids:
|
||||
return {
|
||||
account_id: tuple(assignments)
|
||||
for account_id, assignments in result.items()
|
||||
}
|
||||
with get_database().session() as session:
|
||||
items = self._effective_assignment_items(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
effective_at=effective_at or utc_now(),
|
||||
identity_ids=identity_ids,
|
||||
)
|
||||
for account_id, linked_identity_ids in identity_ids_by_account.items():
|
||||
result[account_id].extend(
|
||||
_assignment_ref(item)
|
||||
for item in items
|
||||
if item.identity_id in linked_identity_ids
|
||||
and item.account_id in {None, account_id}
|
||||
)
|
||||
return {
|
||||
account_id: tuple(assignments)
|
||||
for account_id, assignments in result.items()
|
||||
}
|
||||
|
||||
def organization_function_assignments_for_function(
|
||||
self,
|
||||
function_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
effective_at: datetime | None = None,
|
||||
) -> tuple[OrganizationFunctionAssignmentRef, ...]:
|
||||
function = self._organizations.get_function(function_id)
|
||||
if function is None:
|
||||
return ()
|
||||
if tenant_id is not None and function.tenant_id != tenant_id:
|
||||
raise ValueError("Organization function belongs to another tenant.")
|
||||
resolved_tenant_id = tenant_id or function.tenant_id
|
||||
return self.organization_function_incumbencies(
|
||||
(function_id,),
|
||||
tenant_id=resolved_tenant_id,
|
||||
effective_at=effective_at,
|
||||
)[function_id].assignments
|
||||
|
||||
def organization_function_incumbencies(
|
||||
self,
|
||||
function_ids: Sequence[str],
|
||||
*,
|
||||
tenant_id: str,
|
||||
effective_at: datetime | None = None,
|
||||
) -> dict[str, OrganizationFunctionIncumbencyRef]:
|
||||
requested = tuple(dict.fromkeys(function_ids))
|
||||
functions = {}
|
||||
for function_id in requested:
|
||||
function = self._organizations.get_function(function_id)
|
||||
if function is None:
|
||||
raise ValueError(
|
||||
f"Organization function does not exist: {function_id}"
|
||||
)
|
||||
if function.tenant_id != tenant_id:
|
||||
raise ValueError(
|
||||
"Organization function belongs to another tenant."
|
||||
)
|
||||
functions[function_id] = function
|
||||
if not requested:
|
||||
return {}
|
||||
with get_database().session() as session:
|
||||
items = self._effective_assignment_items(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
effective_at=effective_at or utc_now(),
|
||||
function_ids=requested,
|
||||
)
|
||||
assignments: dict[str, list[OrganizationFunctionAssignmentRef]] = {
|
||||
function_id: [] for function_id in requested
|
||||
}
|
||||
for item in items:
|
||||
assignments[item.function_id].append(_assignment_ref(item))
|
||||
return {
|
||||
function_id: OrganizationFunctionIncumbencyRef(
|
||||
tenant_id=tenant_id,
|
||||
function_id=function_id,
|
||||
assignments=tuple(assignments[function_id]),
|
||||
function_active=functions[function_id].status == "active",
|
||||
)
|
||||
for function_id in requested
|
||||
}
|
||||
|
||||
def _effective_assignment_items(
|
||||
self,
|
||||
session,
|
||||
*,
|
||||
effective_at: datetime,
|
||||
tenant_id: str | None = None,
|
||||
identity_ids: Sequence[str] = (),
|
||||
function_ids: Sequence[str] = (),
|
||||
) -> tuple[IdmOrganizationFunctionAssignment, ...]:
|
||||
query = (
|
||||
session.query(IdmOrganizationFunctionAssignment)
|
||||
.join(OrganizationFunction, OrganizationFunction.id == IdmOrganizationFunctionAssignment.function_id)
|
||||
.filter(
|
||||
IdmOrganizationFunctionAssignment.identity_id.in_(identity_ids),
|
||||
IdmOrganizationFunctionAssignment.is_active.is_(True),
|
||||
OrganizationFunction.is_active.is_(True),
|
||||
or_(IdmOrganizationFunctionAssignment.valid_from.is_(None), IdmOrganizationFunctionAssignment.valid_from <= now),
|
||||
or_(IdmOrganizationFunctionAssignment.valid_until.is_(None), IdmOrganizationFunctionAssignment.valid_until > now),
|
||||
or_(
|
||||
IdmOrganizationFunctionAssignment.valid_from.is_(None),
|
||||
IdmOrganizationFunctionAssignment.valid_from <= effective_at,
|
||||
),
|
||||
or_(
|
||||
IdmOrganizationFunctionAssignment.valid_until.is_(None),
|
||||
IdmOrganizationFunctionAssignment.valid_until > effective_at,
|
||||
),
|
||||
)
|
||||
.order_by(IdmOrganizationFunctionAssignment.created_at.asc())
|
||||
)
|
||||
if account_id is not None:
|
||||
query = query.filter(or_(IdmOrganizationFunctionAssignment.account_id.is_(None), IdmOrganizationFunctionAssignment.account_id == account_id))
|
||||
if identity_ids:
|
||||
query = query.filter(
|
||||
IdmOrganizationFunctionAssignment.identity_id.in_(identity_ids),
|
||||
)
|
||||
if function_ids:
|
||||
query = query.filter(
|
||||
IdmOrganizationFunctionAssignment.function_id.in_(function_ids),
|
||||
)
|
||||
if tenant_id is not None:
|
||||
query = query.filter(IdmOrganizationFunctionAssignment.tenant_id == tenant_id)
|
||||
return tuple(_assignment_ref(item) for item in query.all())
|
||||
query = query.filter(
|
||||
IdmOrganizationFunctionAssignment.tenant_id == tenant_id
|
||||
)
|
||||
items = query.all()
|
||||
source_ids = {
|
||||
item.delegated_from_assignment_id
|
||||
for item in items
|
||||
if item.source in {"delegated", "acting_for"}
|
||||
and item.delegated_from_assignment_id is not None
|
||||
}
|
||||
effective_sources = (
|
||||
session.query(IdmOrganizationFunctionAssignment)
|
||||
.filter(
|
||||
IdmOrganizationFunctionAssignment.id.in_(source_ids),
|
||||
IdmOrganizationFunctionAssignment.is_active.is_(True),
|
||||
or_(
|
||||
IdmOrganizationFunctionAssignment.valid_from.is_(None),
|
||||
IdmOrganizationFunctionAssignment.valid_from <= effective_at,
|
||||
),
|
||||
or_(
|
||||
IdmOrganizationFunctionAssignment.valid_until.is_(None),
|
||||
IdmOrganizationFunctionAssignment.valid_until > effective_at,
|
||||
),
|
||||
)
|
||||
.all()
|
||||
if source_ids
|
||||
else ()
|
||||
)
|
||||
effective_sources_by_id = {
|
||||
item.id: item for item in effective_sources
|
||||
}
|
||||
function_cache = {}
|
||||
active_items: list[IdmOrganizationFunctionAssignment] = []
|
||||
for item in items:
|
||||
if item.source in {"delegated", "acting_for"}:
|
||||
source = effective_sources_by_id.get(
|
||||
item.delegated_from_assignment_id or ""
|
||||
)
|
||||
if (
|
||||
source is None
|
||||
or source.tenant_id != item.tenant_id
|
||||
or source.function_id != item.function_id
|
||||
):
|
||||
continue
|
||||
if item.function_id not in function_cache:
|
||||
function_cache[item.function_id] = (
|
||||
self._organizations.get_function(item.function_id)
|
||||
)
|
||||
function = function_cache[item.function_id]
|
||||
if (
|
||||
function is None
|
||||
or function.status != "active"
|
||||
or function.tenant_id != item.tenant_id
|
||||
):
|
||||
continue
|
||||
active_items.append(item)
|
||||
return tuple(active_items)
|
||||
|
||||
@@ -0,0 +1,749 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.dsar import (
|
||||
DsarErasureActionRef,
|
||||
DsarExecutionResultRef,
|
||||
DsarRecordRef,
|
||||
DsarSubjectRef,
|
||||
dsar_capability_name,
|
||||
)
|
||||
from govoplan_idm.backend.db.models import (
|
||||
IdmFunctionAssignmentChange,
|
||||
IdmFunctionAssignmentChangeEvent,
|
||||
IdmIdentityRelationship,
|
||||
IdmOrganizationFunctionAssignment,
|
||||
IdmTypedGroup,
|
||||
)
|
||||
|
||||
|
||||
IDM_DSAR_CAPABILITY = dsar_capability_name("idm")
|
||||
_MAX_RECORDS = 5_000
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _SubjectSelectors:
|
||||
account_id: str | None
|
||||
identity_id: str | None
|
||||
references: Mapping[str, str]
|
||||
|
||||
@property
|
||||
def has_canonical_selector(self) -> bool:
|
||||
return bool(self.account_id or self.identity_id)
|
||||
|
||||
|
||||
class IdmDsarProvider:
|
||||
provider_id = "idm"
|
||||
module_id = "idm"
|
||||
|
||||
def search_subject(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
) -> Sequence[DsarRecordRef]:
|
||||
db = _session(session)
|
||||
selectors = _subject_selectors(subject)
|
||||
if selectors is None:
|
||||
return ()
|
||||
|
||||
assignments = _matching_assignments(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
selectors=selectors,
|
||||
)
|
||||
relationships = _matching_relationships(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
selectors=selectors,
|
||||
)
|
||||
changes = _matching_changes(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
selectors=selectors,
|
||||
)
|
||||
if _direct_reference_conflicts(
|
||||
selectors,
|
||||
assignments=assignments,
|
||||
relationships=relationships,
|
||||
changes=changes,
|
||||
):
|
||||
return ()
|
||||
|
||||
records: list[DsarRecordRef] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
|
||||
def append(record: DsarRecordRef) -> None:
|
||||
key = (record.resource_type, record.resource_id)
|
||||
if key in seen:
|
||||
return
|
||||
if len(records) >= _MAX_RECORDS:
|
||||
raise ValueError(
|
||||
"IDM DSAR result limit exceeded; narrow the subject selectors."
|
||||
)
|
||||
seen.add(key)
|
||||
records.append(record)
|
||||
|
||||
for assignment in assignments:
|
||||
match_fields = _assignment_match_fields(assignment, selectors)
|
||||
append(
|
||||
_record(
|
||||
"idm_function_assignment",
|
||||
assignment.id,
|
||||
"institutional_function_fact",
|
||||
"IDM organization-function assignment",
|
||||
{
|
||||
"match_fields": match_fields,
|
||||
"identity_id": (
|
||||
assignment.identity_id
|
||||
if "identity_id" in match_fields
|
||||
else None
|
||||
),
|
||||
"account_id": (
|
||||
assignment.account_id
|
||||
if "account_id" in match_fields
|
||||
else None
|
||||
),
|
||||
"function_id": assignment.function_id,
|
||||
"organization_unit_id": assignment.organization_unit_id,
|
||||
"applies_to_subunits": assignment.applies_to_subunits,
|
||||
"source": assignment.source,
|
||||
"has_delegated_source": bool(
|
||||
assignment.delegated_from_assignment_id
|
||||
),
|
||||
"acting_for_account_id": (
|
||||
assignment.acting_for_account_id
|
||||
if "acting_for_account_id" in match_fields
|
||||
else None
|
||||
),
|
||||
"valid_from": _iso(assignment.valid_from),
|
||||
"valid_until": _iso(assignment.valid_until),
|
||||
"expired_event_at": _iso(assignment.expired_event_at),
|
||||
"is_active": assignment.is_active,
|
||||
},
|
||||
observed_at=assignment.updated_at,
|
||||
)
|
||||
)
|
||||
|
||||
group_ids = {
|
||||
row.target_group_id
|
||||
for row in relationships
|
||||
if row.target_group_id is not None
|
||||
}
|
||||
groups = {
|
||||
row.id: row
|
||||
for row in _rows_by_ids(
|
||||
db,
|
||||
IdmTypedGroup,
|
||||
tenant_id=tenant_id,
|
||||
ids=group_ids,
|
||||
)
|
||||
}
|
||||
for group in groups.values():
|
||||
append(
|
||||
_record(
|
||||
"idm_typed_group_context",
|
||||
group.id,
|
||||
"typed_relationship_context",
|
||||
"IDM typed-group relationship context",
|
||||
{
|
||||
"key": group.key,
|
||||
"name": _bounded_text(group.name, 255),
|
||||
"group_type": group.group_type,
|
||||
"status": group.status,
|
||||
"source_provider": group.source_provider,
|
||||
"revision": group.revision,
|
||||
},
|
||||
observed_at=group.updated_at,
|
||||
immutable=True,
|
||||
retention_reason=(
|
||||
"The minimized typed-group definition is retained as context "
|
||||
"for the subject's effective-dated relationship evidence."
|
||||
),
|
||||
)
|
||||
)
|
||||
for relationship in relationships:
|
||||
match_fields = _relationship_match_fields(relationship, selectors)
|
||||
group = groups.get(relationship.target_group_id or "")
|
||||
append(
|
||||
_record(
|
||||
"idm_identity_relationship",
|
||||
relationship.id,
|
||||
"typed_identity_relationship",
|
||||
"IDM typed identity relationship",
|
||||
{
|
||||
"match_fields": match_fields,
|
||||
"relationship_kind": relationship.relationship_kind,
|
||||
"subject_identity_id": (
|
||||
relationship.subject_identity_id
|
||||
if "subject_identity_id" in match_fields
|
||||
else None
|
||||
),
|
||||
"target_group_id": relationship.target_group_id,
|
||||
"target_group_key": group.key if group else None,
|
||||
"target_group_name": _bounded_text(
|
||||
group.name if group else None,
|
||||
255,
|
||||
),
|
||||
"target_group_type": group.group_type if group else None,
|
||||
"related_identity_id": (
|
||||
relationship.related_identity_id
|
||||
if "related_identity_id" in match_fields
|
||||
else None
|
||||
),
|
||||
"role": _bounded_text(relationship.role, 120),
|
||||
"valid_from": _iso(relationship.valid_from),
|
||||
"valid_until": _iso(relationship.valid_until),
|
||||
"status": relationship.status,
|
||||
"revoked_at": _iso(relationship.revoked_at),
|
||||
"revoked_by": (
|
||||
relationship.revoked_by
|
||||
if relationship.revoked_by == selectors.account_id
|
||||
else None
|
||||
),
|
||||
"revocation_reason": _bounded_text(
|
||||
relationship.revocation_reason,
|
||||
2_000,
|
||||
),
|
||||
"expired_event_at": _iso(relationship.expired_event_at),
|
||||
"source_provider": relationship.source_provider,
|
||||
"revision": relationship.revision,
|
||||
},
|
||||
observed_at=relationship.updated_at,
|
||||
)
|
||||
)
|
||||
|
||||
change_ids: set[str] = set()
|
||||
for change in changes:
|
||||
change_ids.add(change.id)
|
||||
match_fields = _change_match_fields(change, selectors)
|
||||
append(
|
||||
_record(
|
||||
"idm_function_assignment_change",
|
||||
change.id,
|
||||
"function_assignment_governance_evidence",
|
||||
"IDM governed function-assignment change",
|
||||
{
|
||||
"match_fields": match_fields,
|
||||
"kind": change.kind,
|
||||
"state": change.state,
|
||||
"profile": change.profile,
|
||||
"function_id": change.function_id,
|
||||
"organization_unit_id": change.organization_unit_id,
|
||||
"candidate_identity_id": (
|
||||
change.candidate_identity_id
|
||||
if "candidate_identity_id" in match_fields
|
||||
else None
|
||||
),
|
||||
"candidate_account_id": (
|
||||
change.candidate_account_id
|
||||
if "candidate_account_id" in match_fields
|
||||
else None
|
||||
),
|
||||
"initiator_account_id": (
|
||||
change.initiator_account_id
|
||||
if "initiator_account_id" in match_fields
|
||||
else None
|
||||
),
|
||||
"initiator_identity_id": (
|
||||
change.initiator_identity_id
|
||||
if "initiator_identity_id" in match_fields
|
||||
else None
|
||||
),
|
||||
"has_represented_assignment": bool(
|
||||
change.represented_assignment_id
|
||||
),
|
||||
"requested_valid_from": _iso(change.requested_valid_from),
|
||||
"requested_valid_until": _iso(change.requested_valid_until),
|
||||
"applies_to_subunits": change.applies_to_subunits,
|
||||
"assignment_source": change.assignment_source,
|
||||
"resulting_assignment_id": change.resulting_assignment_id,
|
||||
"expires_at": _iso(change.expires_at),
|
||||
"resource_revision": change.resource_revision,
|
||||
},
|
||||
observed_at=change.updated_at,
|
||||
immutable=True,
|
||||
retention_reason=(
|
||||
"Governed assignment requests and grants retain their state, "
|
||||
"subject linkage, and outcome as institutional decision evidence."
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
for event in _matching_change_events(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
selectors=selectors,
|
||||
change_ids=change_ids,
|
||||
):
|
||||
match_fields = []
|
||||
if event.change_id in change_ids:
|
||||
match_fields.append("change_id")
|
||||
if event.actor_account_id == selectors.account_id:
|
||||
match_fields.append("actor_account_id")
|
||||
if event.actor_identity_id == selectors.identity_id:
|
||||
match_fields.append("actor_identity_id")
|
||||
append(
|
||||
_record(
|
||||
"idm_function_assignment_change_event",
|
||||
event.id,
|
||||
"function_assignment_governance_evidence",
|
||||
"IDM function-assignment lifecycle event",
|
||||
{
|
||||
"match_fields": match_fields,
|
||||
"change_id": event.change_id,
|
||||
"sequence": event.sequence,
|
||||
"action": event.action,
|
||||
"from_state": event.from_state,
|
||||
"to_state": event.to_state,
|
||||
"actor_account_id": (
|
||||
event.actor_account_id
|
||||
if "actor_account_id" in match_fields
|
||||
else None
|
||||
),
|
||||
"actor_identity_id": (
|
||||
event.actor_identity_id
|
||||
if "actor_identity_id" in match_fields
|
||||
else None
|
||||
),
|
||||
"actor_assignment_id": (
|
||||
event.actor_assignment_id
|
||||
if (
|
||||
"actor_account_id" in match_fields
|
||||
or "actor_identity_id" in match_fields
|
||||
)
|
||||
else None
|
||||
),
|
||||
"created_at": _iso(event.created_at),
|
||||
},
|
||||
observed_at=event.created_at,
|
||||
immutable=True,
|
||||
retention_reason=(
|
||||
"Assignment lifecycle events are immutable decision and "
|
||||
"accountability evidence."
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return tuple(records)
|
||||
|
||||
def plan_erasure(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
records: Sequence[DsarRecordRef],
|
||||
) -> Sequence[DsarErasureActionRef]:
|
||||
del tenant_id
|
||||
_session(session)
|
||||
if _subject_selectors(subject) is None:
|
||||
raise ValueError("IDM DSAR subject selectors conflict.")
|
||||
actions: list[DsarErasureActionRef] = []
|
||||
for record in records:
|
||||
_validate_record(record)
|
||||
if record.immutable_evidence:
|
||||
kind = "retain"
|
||||
rationale = record.retention_reason or (
|
||||
"IDM governance evidence must be retained."
|
||||
)
|
||||
title = f"Retain {record.title}"
|
||||
else:
|
||||
kind = "manual_review"
|
||||
rationale = (
|
||||
"Function assignments and typed relationships are effective-dated "
|
||||
"institutional facts. An authorized IDM operator must correct, "
|
||||
"revoke, deactivate, or expire them through the governed lifecycle "
|
||||
"after reviewing organizational and third-party consequences."
|
||||
)
|
||||
title = f"Review {record.title}"
|
||||
actions.append(
|
||||
DsarErasureActionRef(
|
||||
action_id=f"idm:{kind}:{record.resource_type}:{record.resource_id}",
|
||||
provider_id=self.provider_id,
|
||||
module_id=self.module_id,
|
||||
kind=kind,
|
||||
resource_type=record.resource_type,
|
||||
resource_id=record.resource_id,
|
||||
title=title,
|
||||
rationale=rationale,
|
||||
executable=False,
|
||||
)
|
||||
)
|
||||
return tuple(actions)
|
||||
|
||||
def execute_erasure(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
actions: Sequence[DsarErasureActionRef],
|
||||
request_id: str,
|
||||
) -> Sequence[DsarExecutionResultRef]:
|
||||
del tenant_id
|
||||
_session(session)
|
||||
if _subject_selectors(subject) is None:
|
||||
raise ValueError("IDM DSAR subject selectors conflict.")
|
||||
results: list[DsarExecutionResultRef] = []
|
||||
for action in actions:
|
||||
_validate_action(action)
|
||||
if action.executable:
|
||||
raise ValueError(
|
||||
"IDM DSAR does not publish executable erasure actions."
|
||||
)
|
||||
results.append(
|
||||
DsarExecutionResultRef(
|
||||
action_id=action.action_id,
|
||||
status="blocked",
|
||||
summary=(
|
||||
"Use the governed IDM assignment or relationship lifecycle "
|
||||
"after organizational, evidence, and third-party review."
|
||||
),
|
||||
evidence={"request_id": request_id},
|
||||
)
|
||||
)
|
||||
return tuple(results)
|
||||
|
||||
|
||||
def _matching_assignments(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
selectors: _SubjectSelectors,
|
||||
) -> list[IdmOrganizationFunctionAssignment]:
|
||||
conditions = []
|
||||
if selectors.identity_id:
|
||||
conditions.append(
|
||||
IdmOrganizationFunctionAssignment.identity_id == selectors.identity_id
|
||||
)
|
||||
if selectors.account_id:
|
||||
conditions.extend(
|
||||
(
|
||||
IdmOrganizationFunctionAssignment.account_id == selectors.account_id,
|
||||
IdmOrganizationFunctionAssignment.acting_for_account_id
|
||||
== selectors.account_id,
|
||||
)
|
||||
)
|
||||
if reference := selectors.references.get("assignment"):
|
||||
conditions.append(IdmOrganizationFunctionAssignment.id == reference)
|
||||
return _query_conditions(
|
||||
session,
|
||||
IdmOrganizationFunctionAssignment,
|
||||
tenant_id=tenant_id,
|
||||
conditions=conditions,
|
||||
)
|
||||
|
||||
|
||||
def _matching_relationships(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
selectors: _SubjectSelectors,
|
||||
) -> list[IdmIdentityRelationship]:
|
||||
conditions = []
|
||||
if selectors.identity_id:
|
||||
conditions.extend(
|
||||
(
|
||||
IdmIdentityRelationship.subject_identity_id == selectors.identity_id,
|
||||
IdmIdentityRelationship.related_identity_id == selectors.identity_id,
|
||||
)
|
||||
)
|
||||
if reference := selectors.references.get("relationship"):
|
||||
conditions.append(IdmIdentityRelationship.id == reference)
|
||||
return _query_conditions(
|
||||
session,
|
||||
IdmIdentityRelationship,
|
||||
tenant_id=tenant_id,
|
||||
conditions=conditions,
|
||||
)
|
||||
|
||||
|
||||
def _matching_changes(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
selectors: _SubjectSelectors,
|
||||
) -> list[IdmFunctionAssignmentChange]:
|
||||
conditions = []
|
||||
if selectors.identity_id:
|
||||
conditions.extend(
|
||||
(
|
||||
IdmFunctionAssignmentChange.candidate_identity_id
|
||||
== selectors.identity_id,
|
||||
IdmFunctionAssignmentChange.initiator_identity_id
|
||||
== selectors.identity_id,
|
||||
)
|
||||
)
|
||||
if selectors.account_id:
|
||||
conditions.extend(
|
||||
(
|
||||
IdmFunctionAssignmentChange.candidate_account_id
|
||||
== selectors.account_id,
|
||||
IdmFunctionAssignmentChange.initiator_account_id
|
||||
== selectors.account_id,
|
||||
)
|
||||
)
|
||||
if reference := selectors.references.get("assignment_change"):
|
||||
conditions.append(IdmFunctionAssignmentChange.id == reference)
|
||||
return _query_conditions(
|
||||
session,
|
||||
IdmFunctionAssignmentChange,
|
||||
tenant_id=tenant_id,
|
||||
conditions=conditions,
|
||||
)
|
||||
|
||||
|
||||
def _matching_change_events(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
selectors: _SubjectSelectors,
|
||||
change_ids: set[str],
|
||||
) -> list[IdmFunctionAssignmentChangeEvent]:
|
||||
conditions = []
|
||||
if change_ids:
|
||||
conditions.append(IdmFunctionAssignmentChangeEvent.change_id.in_(change_ids))
|
||||
if selectors.account_id:
|
||||
conditions.append(
|
||||
IdmFunctionAssignmentChangeEvent.actor_account_id == selectors.account_id
|
||||
)
|
||||
if selectors.identity_id:
|
||||
conditions.append(
|
||||
IdmFunctionAssignmentChangeEvent.actor_identity_id == selectors.identity_id
|
||||
)
|
||||
return _query_conditions(
|
||||
session,
|
||||
IdmFunctionAssignmentChangeEvent,
|
||||
tenant_id=tenant_id,
|
||||
conditions=conditions,
|
||||
)
|
||||
|
||||
|
||||
def _direct_reference_conflicts(
|
||||
selectors: _SubjectSelectors,
|
||||
*,
|
||||
assignments: Sequence[IdmOrganizationFunctionAssignment],
|
||||
relationships: Sequence[IdmIdentityRelationship],
|
||||
changes: Sequence[IdmFunctionAssignmentChange],
|
||||
) -> bool:
|
||||
if not selectors.has_canonical_selector:
|
||||
return False
|
||||
checks = (
|
||||
(
|
||||
"assignment",
|
||||
assignments,
|
||||
lambda row: _assignment_match_fields(row, selectors),
|
||||
),
|
||||
(
|
||||
"relationship",
|
||||
relationships,
|
||||
lambda row: _relationship_match_fields(row, selectors),
|
||||
),
|
||||
(
|
||||
"assignment_change",
|
||||
changes,
|
||||
lambda row: _change_match_fields(row, selectors),
|
||||
),
|
||||
)
|
||||
for kind, rows, match in checks:
|
||||
reference = selectors.references.get(kind)
|
||||
if not reference:
|
||||
continue
|
||||
row = next((item for item in rows if item.id == reference), None)
|
||||
if row is None or not [field for field in match(row) if field != "reference"]:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _assignment_match_fields(
|
||||
row: IdmOrganizationFunctionAssignment,
|
||||
selectors: _SubjectSelectors,
|
||||
) -> list[str]:
|
||||
fields = []
|
||||
if row.identity_id == selectors.identity_id:
|
||||
fields.append("identity_id")
|
||||
if selectors.account_id and row.account_id == selectors.account_id:
|
||||
fields.append("account_id")
|
||||
if selectors.account_id and row.acting_for_account_id == selectors.account_id:
|
||||
fields.append("acting_for_account_id")
|
||||
if row.id == selectors.references.get("assignment"):
|
||||
fields.append("reference")
|
||||
return fields
|
||||
|
||||
|
||||
def _relationship_match_fields(
|
||||
row: IdmIdentityRelationship,
|
||||
selectors: _SubjectSelectors,
|
||||
) -> list[str]:
|
||||
fields = []
|
||||
if row.subject_identity_id == selectors.identity_id:
|
||||
fields.append("subject_identity_id")
|
||||
if selectors.identity_id and row.related_identity_id == selectors.identity_id:
|
||||
fields.append("related_identity_id")
|
||||
if row.revoked_by == selectors.account_id:
|
||||
fields.append("revoked_by")
|
||||
if row.id == selectors.references.get("relationship"):
|
||||
fields.append("reference")
|
||||
return fields
|
||||
|
||||
|
||||
def _change_match_fields(
|
||||
row: IdmFunctionAssignmentChange,
|
||||
selectors: _SubjectSelectors,
|
||||
) -> list[str]:
|
||||
fields = []
|
||||
for field, expected in (
|
||||
("candidate_identity_id", selectors.identity_id),
|
||||
("candidate_account_id", selectors.account_id),
|
||||
("initiator_identity_id", selectors.identity_id),
|
||||
("initiator_account_id", selectors.account_id),
|
||||
):
|
||||
if expected and getattr(row, field) == expected:
|
||||
fields.append(field)
|
||||
if row.id == selectors.references.get("assignment_change"):
|
||||
fields.append("reference")
|
||||
return fields
|
||||
|
||||
|
||||
def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None:
|
||||
groups = {
|
||||
"account_id": (
|
||||
subject.account_id,
|
||||
subject.external_references.get("idm.account"),
|
||||
subject.external_references.get("access.account"),
|
||||
),
|
||||
"identity_id": (
|
||||
subject.identity_id,
|
||||
subject.external_references.get("idm.identity"),
|
||||
subject.external_references.get("identity.id"),
|
||||
),
|
||||
}
|
||||
normalized: dict[str, str | None] = {}
|
||||
for key, values in groups.items():
|
||||
distinct = {value for item in values if (value := _normalized_id(item))}
|
||||
if len(distinct) > 1:
|
||||
return None
|
||||
normalized[key] = next(iter(distinct), None)
|
||||
|
||||
aliases = {
|
||||
"idm.assignment": "assignment",
|
||||
"idm.relationship": "relationship",
|
||||
"idm.assignment_change": "assignment_change",
|
||||
}
|
||||
references = {
|
||||
target: value
|
||||
for source, target in aliases.items()
|
||||
if (value := _normalized_id(subject.external_references.get(source)))
|
||||
}
|
||||
return _SubjectSelectors(references=references, **normalized)
|
||||
|
||||
|
||||
def _rows_by_ids(
|
||||
session: Session,
|
||||
model: type,
|
||||
*,
|
||||
tenant_id: str,
|
||||
ids: set[str],
|
||||
) -> list[object]:
|
||||
if not ids:
|
||||
return []
|
||||
return _bounded_rows(
|
||||
session.query(model)
|
||||
.filter(model.tenant_id == tenant_id, model.id.in_(ids))
|
||||
.order_by(model.id)
|
||||
)
|
||||
|
||||
|
||||
def _query_conditions(
|
||||
session: Session,
|
||||
model: type,
|
||||
*,
|
||||
tenant_id: str,
|
||||
conditions: Sequence[object],
|
||||
) -> list[object]:
|
||||
if not conditions:
|
||||
return []
|
||||
return _bounded_rows(
|
||||
session.query(model)
|
||||
.filter(model.tenant_id == tenant_id, or_(*conditions))
|
||||
.order_by(model.id)
|
||||
)
|
||||
|
||||
|
||||
def _validate_record(record: DsarRecordRef) -> None:
|
||||
if record.provider_id != "idm" or record.module_id != "idm":
|
||||
raise ValueError("IDM DSAR received a foreign provider record.")
|
||||
|
||||
|
||||
def _validate_action(action: DsarErasureActionRef) -> None:
|
||||
if action.provider_id != "idm" or action.module_id != "idm":
|
||||
raise ValueError("IDM DSAR received a foreign provider action.")
|
||||
|
||||
|
||||
def _record(
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
category: str,
|
||||
title: str,
|
||||
data: Mapping[str, object],
|
||||
*,
|
||||
observed_at: datetime | None,
|
||||
immutable: bool = False,
|
||||
retention_reason: str | None = None,
|
||||
) -> DsarRecordRef:
|
||||
return DsarRecordRef(
|
||||
provider_id="idm",
|
||||
module_id="idm",
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
category=category,
|
||||
title=title,
|
||||
data=data,
|
||||
observed_at=observed_at,
|
||||
immutable_evidence=immutable,
|
||||
retention_reason=retention_reason,
|
||||
source_path="/idm",
|
||||
)
|
||||
|
||||
|
||||
def _session(value: object) -> Session:
|
||||
if not isinstance(value, Session):
|
||||
raise TypeError("IDM DSAR provider requires a SQLAlchemy session.")
|
||||
return value
|
||||
|
||||
|
||||
def _bounded_rows(query: object) -> list[object]:
|
||||
rows = query.limit(_MAX_RECORDS + 1).all() # type: ignore[attr-defined]
|
||||
if len(rows) > _MAX_RECORDS:
|
||||
raise ValueError("IDM DSAR match limit exceeded; narrow the subject selectors.")
|
||||
return rows
|
||||
|
||||
|
||||
def _bounded_text(value: str | None, limit: int) -> str | None:
|
||||
return value[:limit] if value else None
|
||||
|
||||
|
||||
def _normalized_id(value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
value = str(value).strip()
|
||||
return value or None
|
||||
|
||||
|
||||
def _iso(value: datetime | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=timezone.utc)
|
||||
return value.isoformat()
|
||||
|
||||
|
||||
__all__ = ["IDM_DSAR_CAPABILITY", "IdmDsarProvider"]
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+40
@@ -0,0 +1,40 @@
|
||||
"""Track emitted assignment expiry events.
|
||||
|
||||
Revision ID: 9a0b1c2d3e4f
|
||||
Revises: 8f9a0b1c2d3e
|
||||
Create Date: 2026-07-31 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "9a0b1c2d3e4f"
|
||||
down_revision = "8f9a0b1c2d3e"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"idm_organization_function_assignments",
|
||||
sa.Column("expired_event_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_idm_org_function_assignments_expiry_due",
|
||||
"idm_organization_function_assignments",
|
||||
["is_active", "expired_event_at", "valid_until"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
"ix_idm_org_function_assignments_expiry_due",
|
||||
table_name="idm_organization_function_assignments",
|
||||
)
|
||||
op.drop_column(
|
||||
"idm_organization_function_assignments",
|
||||
"expired_event_at",
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""IDM migration revisions."""
|
||||
@@ -0,0 +1,65 @@
|
||||
"""v0.1.7 idm baseline
|
||||
|
||||
Revision ID: 8f9a0b1c2d3e
|
||||
Revises: None
|
||||
Create Date: 2026-07-11 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = '8f9a0b1c2d3e'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = ('5c6d7e8f9a10', '6d7e8f9a0b1c')
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table('idm_tenant_settings',
|
||||
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('require_assignment_change_requests', sa.Boolean(), nullable=False),
|
||||
sa.Column('audit_detail_level', sa.String(length=20), nullable=False),
|
||||
sa.Column('change_retention_days', sa.Integer(), 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.PrimaryKeyConstraint('tenant_id', name=op.f('pk_idm_tenant_settings'))
|
||||
)
|
||||
op.create_table('idm_organization_function_assignments',
|
||||
sa.Column('id', sa.String(length=36), nullable=False),
|
||||
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('identity_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('account_id', sa.String(length=36), nullable=True),
|
||||
sa.Column('function_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('organization_unit_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('applies_to_subunits', sa.Boolean(), nullable=False),
|
||||
sa.Column('source', sa.String(length=50), nullable=False),
|
||||
sa.Column('delegated_from_assignment_id', sa.String(length=36), nullable=True),
|
||||
sa.Column('acting_for_account_id', sa.String(length=36), nullable=True),
|
||||
sa.Column('valid_from', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('valid_until', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('settings', sa.JSON(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['delegated_from_assignment_id'], ['idm_organization_function_assignments.id'], name=op.f('fk_idm_organization_function_assignments_delegated_from_assignment_id_idm_organization_function_assignments'), ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['function_id'], ['organizations_functions.id'], name=op.f('fk_idm_organization_function_assignments_function_id_organizations_functions'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['identity_id'], ['identity_identities.id'], name=op.f('fk_idm_organization_function_assignments_identity_id_identity_identities'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['organization_unit_id'], ['organizations_units.id'], name=op.f('fk_idm_organization_function_assignments_organization_unit_id_organizations_units'), ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_idm_organization_function_assignments')),
|
||||
sa.UniqueConstraint('tenant_id', 'identity_id', 'function_id', 'organization_unit_id', name='uq_idm_org_function_assignments_identity_scope')
|
||||
)
|
||||
op.create_index(op.f('ix_idm_organization_function_assignments_account_id'), 'idm_organization_function_assignments', ['account_id'], unique=False)
|
||||
op.create_index(op.f('ix_idm_organization_function_assignments_acting_for_account_id'), 'idm_organization_function_assignments', ['acting_for_account_id'], unique=False)
|
||||
op.create_index(op.f('ix_idm_organization_function_assignments_delegated_from_assignment_id'), 'idm_organization_function_assignments', ['delegated_from_assignment_id'], unique=False)
|
||||
op.create_index(op.f('ix_idm_organization_function_assignments_function_id'), 'idm_organization_function_assignments', ['function_id'], unique=False)
|
||||
op.create_index(op.f('ix_idm_organization_function_assignments_identity_id'), 'idm_organization_function_assignments', ['identity_id'], unique=False)
|
||||
op.create_index(op.f('ix_idm_organization_function_assignments_organization_unit_id'), 'idm_organization_function_assignments', ['organization_unit_id'], unique=False)
|
||||
op.create_index(op.f('ix_idm_organization_function_assignments_tenant_id'), 'idm_organization_function_assignments', ['tenant_id'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('idm_organization_function_assignments')
|
||||
op.drop_table('idm_tenant_settings')
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
"""Track emitted assignment expiry events.
|
||||
|
||||
Revision ID: 9a0b1c2d3e4f
|
||||
Revises: 8f9a0b1c2d3e
|
||||
Create Date: 2026-07-31 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "9a0b1c2d3e4f"
|
||||
down_revision = "8f9a0b1c2d3e"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"idm_organization_function_assignments",
|
||||
sa.Column("expired_event_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_idm_org_function_assignments_expiry_due",
|
||||
"idm_organization_function_assignments",
|
||||
["is_active", "expired_event_at", "valid_until"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
"ix_idm_org_function_assignments_expiry_due",
|
||||
table_name="idm_organization_function_assignments",
|
||||
)
|
||||
op.drop_column(
|
||||
"idm_organization_function_assignments",
|
||||
"expired_event_at",
|
||||
)
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
"""Add governed function assignment change aggregates.
|
||||
|
||||
Revision ID: a0b1c2d3e4f5
|
||||
Revises: 9a0b1c2d3e4f
|
||||
Create Date: 2026-07-31 00:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "a0b1c2d3e4f5"
|
||||
down_revision = "9a0b1c2d3e4f"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"idm_function_assignment_changes",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("kind", sa.String(length=20), nullable=False),
|
||||
sa.Column("state", sa.String(length=40), nullable=False),
|
||||
sa.Column("profile", sa.String(length=60), nullable=False),
|
||||
sa.Column("function_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("organization_unit_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("candidate_identity_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("candidate_account_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("initiator_account_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("initiator_identity_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("represented_assignment_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("justification", sa.Text(), nullable=False),
|
||||
sa.Column("evidence", sa.JSON(), nullable=False),
|
||||
sa.Column("requested_valid_from", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("requested_valid_until", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("applies_to_subunits", sa.Boolean(), nullable=False),
|
||||
sa.Column("assignment_source", sa.String(length=50), nullable=False),
|
||||
sa.Column("required_steps", sa.JSON(), nullable=False),
|
||||
sa.Column("completed_steps", sa.JSON(), nullable=False),
|
||||
sa.Column("policy_decision", sa.JSON(), nullable=False),
|
||||
sa.Column("workflow_definition_id", sa.String(length=36), nullable=True),
|
||||
sa.Column(
|
||||
"workflow_definition_revision_id", sa.String(length=36), nullable=True
|
||||
),
|
||||
sa.Column("workflow_definition_revision", sa.Integer(), nullable=True),
|
||||
sa.Column("workflow_definition_hash", sa.String(length=64), nullable=True),
|
||||
sa.Column("workflow_instance_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("workflow_current_step_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("resulting_assignment_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("outcome_reason", sa.Text(), nullable=True),
|
||||
sa.Column("resource_revision", sa.Integer(), nullable=False),
|
||||
sa.Column("metadata", 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(
|
||||
["candidate_identity_id"],
|
||||
["identity_identities.id"],
|
||||
name=op.f(
|
||||
"fk_idm_function_assignment_changes_candidate_identity_id_identity_identities"
|
||||
),
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["function_id"],
|
||||
["organizations_functions.id"],
|
||||
name=op.f(
|
||||
"fk_idm_function_assignment_changes_function_id_organizations_functions"
|
||||
),
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["represented_assignment_id"],
|
||||
["idm_organization_function_assignments.id"],
|
||||
name=op.f(
|
||||
"fk_idm_function_assignment_changes_represented_assignment_id_idm_assignments"
|
||||
),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["resulting_assignment_id"],
|
||||
["idm_organization_function_assignments.id"],
|
||||
name=op.f(
|
||||
"fk_idm_function_assignment_changes_resulting_assignment_id_idm_assignments"
|
||||
),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_idm_function_assignment_changes")),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"kind",
|
||||
"initiator_account_id",
|
||||
"idempotency_key",
|
||||
name="uq_idm_function_assignment_change_idempotency",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"workflow_instance_id",
|
||||
name=op.f("uq_idm_function_assignment_changes_workflow_instance_id"),
|
||||
),
|
||||
)
|
||||
for name, columns in (
|
||||
("ix_idm_function_assignment_changes_tenant_id", ["tenant_id"]),
|
||||
("ix_idm_function_assignment_changes_kind", ["kind"]),
|
||||
("ix_idm_function_assignment_changes_state", ["state"]),
|
||||
("ix_idm_function_assignment_changes_function_id", ["function_id"]),
|
||||
(
|
||||
"ix_idm_function_assignment_changes_organization_unit_id",
|
||||
["organization_unit_id"],
|
||||
),
|
||||
(
|
||||
"ix_idm_function_assignment_changes_candidate_identity_id",
|
||||
["candidate_identity_id"],
|
||||
),
|
||||
(
|
||||
"ix_idm_function_assignment_changes_candidate_account_id",
|
||||
["candidate_account_id"],
|
||||
),
|
||||
(
|
||||
"ix_idm_function_assignment_changes_initiator_account_id",
|
||||
["initiator_account_id"],
|
||||
),
|
||||
(
|
||||
"ix_idm_function_assignment_changes_initiator_identity_id",
|
||||
["initiator_identity_id"],
|
||||
),
|
||||
(
|
||||
"ix_idm_function_assignment_changes_represented_assignment_id",
|
||||
["represented_assignment_id"],
|
||||
),
|
||||
(
|
||||
"ix_idm_function_assignment_changes_workflow_definition_id",
|
||||
["workflow_definition_id"],
|
||||
),
|
||||
(
|
||||
"ix_idm_function_assignment_changes_resulting_assignment_id",
|
||||
["resulting_assignment_id"],
|
||||
),
|
||||
(
|
||||
"ix_idm_function_assignment_changes_tenant_state",
|
||||
["tenant_id", "state", "updated_at"],
|
||||
),
|
||||
(
|
||||
"ix_idm_function_assignment_changes_candidate",
|
||||
["tenant_id", "candidate_identity_id", "state"],
|
||||
),
|
||||
(
|
||||
"ix_idm_function_assignment_changes_expiry",
|
||||
["state", "expires_at"],
|
||||
),
|
||||
):
|
||||
op.create_index(name, "idm_function_assignment_changes", columns, unique=False)
|
||||
|
||||
op.create_table(
|
||||
"idm_function_assignment_change_events",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("change_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("sequence", sa.Integer(), nullable=False),
|
||||
sa.Column("action", sa.String(length=50), nullable=False),
|
||||
sa.Column("from_state", sa.String(length=40), nullable=True),
|
||||
sa.Column("to_state", sa.String(length=40), nullable=False),
|
||||
sa.Column("actor_account_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("actor_identity_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("actor_assignment_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("comment", sa.Text(), nullable=True),
|
||||
sa.Column("evidence", sa.JSON(), nullable=False),
|
||||
sa.Column("policy_decision", sa.JSON(), nullable=False),
|
||||
sa.Column("workflow_step_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("details", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["change_id"],
|
||||
["idm_function_assignment_changes.id"],
|
||||
name=op.f(
|
||||
"fk_idm_function_assignment_change_events_change_id_idm_function_assignment_changes"
|
||||
),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint(
|
||||
"id", name=op.f("pk_idm_function_assignment_change_events")
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"change_id",
|
||||
"sequence",
|
||||
name="uq_idm_function_assignment_change_event_sequence",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_idm_function_assignment_change_events_tenant_id",
|
||||
"idm_function_assignment_change_events",
|
||||
["tenant_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_idm_function_assignment_change_events_change_id",
|
||||
"idm_function_assignment_change_events",
|
||||
["change_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_idm_function_assignment_change_events_action",
|
||||
"idm_function_assignment_change_events",
|
||||
["action"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_idm_function_assignment_change_events_actor_account_id",
|
||||
"idm_function_assignment_change_events",
|
||||
["actor_account_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_idm_function_assignment_change_events_tenant_change",
|
||||
"idm_function_assignment_change_events",
|
||||
["tenant_id", "change_id", "sequence"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("idm_function_assignment_change_events")
|
||||
op.drop_table("idm_function_assignment_changes")
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
"""Add typed IDM groups and effective-dated relationships.
|
||||
|
||||
Revision ID: b1c2d3e4f5a6
|
||||
Revises: a0b1c2d3e4f5
|
||||
Create Date: 2026-08-02 00:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "b1c2d3e4f5a6"
|
||||
down_revision = "a0b1c2d3e4f5"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"idm_typed_groups",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("key", sa.String(length=120), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("group_type", sa.String(length=80), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("status", sa.String(length=20), nullable=False),
|
||||
sa.Column("source_provider", sa.String(length=80), nullable=False),
|
||||
sa.Column("source_resource_type", sa.String(length=120), nullable=True),
|
||||
sa.Column("source_resource_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("source_revision", sa.String(length=255), nullable=True),
|
||||
sa.Column("properties", sa.JSON(), nullable=False),
|
||||
sa.Column("provenance", sa.JSON(), nullable=False),
|
||||
sa.Column("revision", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_idm_typed_groups")),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"group_type",
|
||||
"key",
|
||||
name="uq_idm_typed_groups_tenant_type_key",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_idm_typed_groups_tenant_id"),
|
||||
"idm_typed_groups",
|
||||
["tenant_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_idm_typed_groups_group_type"),
|
||||
"idm_typed_groups",
|
||||
["group_type"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_idm_typed_groups_tenant_status_name",
|
||||
"idm_typed_groups",
|
||||
["tenant_id", "status", "name"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"idm_identity_relationships",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("relationship_kind", sa.String(length=80), nullable=False),
|
||||
sa.Column("subject_identity_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("target_group_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("related_identity_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("role", sa.String(length=120), nullable=True),
|
||||
sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("valid_until", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("status", sa.String(length=20), nullable=False),
|
||||
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("revoked_by", sa.String(length=36), nullable=True),
|
||||
sa.Column("revocation_reason", sa.Text(), nullable=True),
|
||||
sa.Column("expired_event_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("source_provider", sa.String(length=80), nullable=False),
|
||||
sa.Column("source_resource_type", sa.String(length=120), nullable=True),
|
||||
sa.Column("source_resource_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("source_revision", sa.String(length=255), nullable=True),
|
||||
sa.Column("properties", sa.JSON(), nullable=False),
|
||||
sa.Column("provenance", sa.JSON(), nullable=False),
|
||||
sa.Column("revision", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.CheckConstraint(
|
||||
"((target_group_id IS NOT NULL AND related_identity_id IS NULL) OR "
|
||||
"(target_group_id IS NULL AND related_identity_id IS NOT NULL))",
|
||||
name="ck_idm_relationship_exactly_one_target",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"valid_until IS NULL OR valid_from IS NULL OR valid_until > valid_from",
|
||||
name="ck_idm_relationship_valid_window",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["subject_identity_id"],
|
||||
["identity_identities.id"],
|
||||
name=op.f(
|
||||
"fk_idm_identity_relationships_subject_identity_id_identity_identities"
|
||||
),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["related_identity_id"],
|
||||
["identity_identities.id"],
|
||||
name=op.f(
|
||||
"fk_idm_identity_relationships_related_identity_id_identity_identities"
|
||||
),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["target_group_id"],
|
||||
["idm_typed_groups.id"],
|
||||
name=op.f(
|
||||
"fk_idm_identity_relationships_target_group_id_idm_typed_groups"
|
||||
),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint(
|
||||
"id", name=op.f("pk_idm_identity_relationships")
|
||||
),
|
||||
)
|
||||
for name, columns in (
|
||||
("ix_idm_identity_relationships_tenant_id", ["tenant_id"]),
|
||||
("ix_idm_identity_relationships_relationship_kind", ["relationship_kind"]),
|
||||
("ix_idm_identity_relationships_subject_identity_id", ["subject_identity_id"]),
|
||||
("ix_idm_identity_relationships_target_group_id", ["target_group_id"]),
|
||||
("ix_idm_identity_relationships_related_identity_id", ["related_identity_id"]),
|
||||
(
|
||||
"ix_idm_relationships_tenant_subject_effective",
|
||||
["tenant_id", "subject_identity_id", "status", "valid_from", "valid_until"],
|
||||
),
|
||||
(
|
||||
"ix_idm_relationships_tenant_group_effective",
|
||||
["tenant_id", "target_group_id", "status", "valid_from", "valid_until"],
|
||||
),
|
||||
(
|
||||
"ix_idm_relationships_expiry_due",
|
||||
["status", "expired_event_at", "valid_until"],
|
||||
),
|
||||
):
|
||||
op.create_index(
|
||||
name,
|
||||
"idm_identity_relationships",
|
||||
columns,
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("idm_identity_relationships")
|
||||
op.drop_table("idm_typed_groups")
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
"""Add durable function-assignment review escalation state.
|
||||
|
||||
Revision ID: c2d3e4f5a6b7
|
||||
Revises: b1c2d3e4f5a6
|
||||
Create Date: 2026-08-22 00:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "c2d3e4f5a6b7"
|
||||
down_revision = "b1c2d3e4f5a6"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
table = "idm_function_assignment_changes"
|
||||
op.add_column(
|
||||
table,
|
||||
sa.Column("review_deadline_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
table,
|
||||
sa.Column("escalated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
table,
|
||||
sa.Column("escalation_from_state", sa.String(length=40), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
table,
|
||||
sa.Column(
|
||||
"escalation_target_function_id",
|
||||
sa.String(length=36),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_idm_function_assignment_changes_review_deadline_at"),
|
||||
table,
|
||||
["review_deadline_at"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_idm_function_assignment_changes_escalation_target_function_id"),
|
||||
table,
|
||||
["escalation_target_function_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
table = "idm_function_assignment_changes"
|
||||
op.drop_index(
|
||||
op.f("ix_idm_function_assignment_changes_escalation_target_function_id"),
|
||||
table_name=table,
|
||||
)
|
||||
op.drop_index(
|
||||
op.f("ix_idm_function_assignment_changes_review_deadline_at"),
|
||||
table_name=table,
|
||||
)
|
||||
op.drop_column(table, "escalation_target_function_id")
|
||||
op.drop_column(table, "escalation_from_state")
|
||||
op.drop_column(table, "escalated_at")
|
||||
op.drop_column(table, "review_deadline_at")
|
||||
@@ -0,0 +1,386 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import or_
|
||||
|
||||
from govoplan_core.core.identity import IdentityDirectory
|
||||
from govoplan_core.core.idm import (
|
||||
IdentityRelationshipDecisionRef,
|
||||
IdentityRelationshipRef,
|
||||
IdmRelationshipDirectory,
|
||||
TypedGroupMembershipResolutionRef,
|
||||
TypedGroupRef,
|
||||
)
|
||||
from govoplan_core.db.session import get_database
|
||||
from govoplan_core.security.time import ensure_aware_utc, utc_now
|
||||
from govoplan_idm.backend.db.models import (
|
||||
IdmIdentityRelationship,
|
||||
IdmTypedGroup,
|
||||
)
|
||||
|
||||
|
||||
def typed_group_ref(item: IdmTypedGroup) -> TypedGroupRef:
|
||||
return TypedGroupRef(
|
||||
id=item.id,
|
||||
tenant_id=item.tenant_id,
|
||||
key=item.key,
|
||||
name=item.name,
|
||||
group_type=item.group_type,
|
||||
description=item.description,
|
||||
status=item.status, # type: ignore[arg-type]
|
||||
source_provider=item.source_provider,
|
||||
source_resource_type=item.source_resource_type,
|
||||
source_resource_id=item.source_resource_id,
|
||||
source_revision=item.source_revision,
|
||||
properties=dict(item.properties),
|
||||
provenance=dict(item.provenance),
|
||||
revision=item.revision,
|
||||
)
|
||||
|
||||
|
||||
def identity_relationship_ref(
|
||||
item: IdmIdentityRelationship,
|
||||
) -> IdentityRelationshipRef:
|
||||
return IdentityRelationshipRef(
|
||||
id=item.id,
|
||||
tenant_id=item.tenant_id,
|
||||
relationship_kind=item.relationship_kind,
|
||||
subject_identity_id=item.subject_identity_id,
|
||||
target_group_id=item.target_group_id,
|
||||
related_identity_id=item.related_identity_id,
|
||||
role=item.role,
|
||||
valid_from=item.valid_from,
|
||||
valid_until=item.valid_until,
|
||||
status=item.status, # type: ignore[arg-type]
|
||||
revoked_at=item.revoked_at,
|
||||
revoked_by=item.revoked_by,
|
||||
revocation_reason=item.revocation_reason,
|
||||
source_provider=item.source_provider,
|
||||
source_resource_type=item.source_resource_type,
|
||||
source_resource_id=item.source_resource_id,
|
||||
source_revision=item.source_revision,
|
||||
properties=dict(item.properties),
|
||||
provenance=dict(item.provenance),
|
||||
revision=item.revision,
|
||||
)
|
||||
|
||||
|
||||
class SqlIdmRelationshipDirectory(IdmRelationshipDirectory):
|
||||
def __init__(self, *, identities: IdentityDirectory) -> None:
|
||||
self._identities = identities
|
||||
|
||||
def get_typed_group(
|
||||
self,
|
||||
group_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
) -> TypedGroupRef | None:
|
||||
with get_database().session() as session:
|
||||
item = session.get(IdmTypedGroup, group_id)
|
||||
if item is None:
|
||||
return None
|
||||
if tenant_id is not None and item.tenant_id != tenant_id:
|
||||
raise ValueError("Typed group belongs to another tenant.")
|
||||
return typed_group_ref(item)
|
||||
|
||||
def list_typed_groups(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
query: str | None = None,
|
||||
group_types: Sequence[str] = (),
|
||||
include_inactive: bool = False,
|
||||
limit: int = 100,
|
||||
) -> tuple[TypedGroupRef, ...]:
|
||||
if limit < 1 or limit > 1000:
|
||||
raise ValueError("Typed-group limit must be between 1 and 1000.")
|
||||
with get_database().session() as session:
|
||||
statement = session.query(IdmTypedGroup).filter(
|
||||
IdmTypedGroup.tenant_id == tenant_id
|
||||
)
|
||||
if not include_inactive:
|
||||
statement = statement.filter(IdmTypedGroup.status == "active")
|
||||
if group_types:
|
||||
statement = statement.filter(
|
||||
IdmTypedGroup.group_type.in_(tuple(dict.fromkeys(group_types)))
|
||||
)
|
||||
if query and query.strip():
|
||||
pattern = f"%{query.strip()}%"
|
||||
statement = statement.filter(
|
||||
or_(
|
||||
IdmTypedGroup.name.ilike(pattern),
|
||||
IdmTypedGroup.key.ilike(pattern),
|
||||
IdmTypedGroup.description.ilike(pattern),
|
||||
)
|
||||
)
|
||||
items = (
|
||||
statement.order_by(
|
||||
IdmTypedGroup.name.asc(),
|
||||
IdmTypedGroup.id.asc(),
|
||||
)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return tuple(typed_group_ref(item) for item in items)
|
||||
|
||||
def identity_relationships_for_identity(
|
||||
self,
|
||||
identity_id: str,
|
||||
*,
|
||||
tenant_id: str,
|
||||
effective_at: datetime | None = None,
|
||||
relationship_kinds: Sequence[str] = (),
|
||||
) -> tuple[IdentityRelationshipRef, ...]:
|
||||
return tuple(
|
||||
self.identity_relationships_for_identities(
|
||||
(identity_id,),
|
||||
tenant_id=tenant_id,
|
||||
effective_at=effective_at,
|
||||
relationship_kinds=relationship_kinds,
|
||||
).get(identity_id, ())
|
||||
)
|
||||
|
||||
def identity_relationships_for_identities(
|
||||
self,
|
||||
identity_ids: Sequence[str],
|
||||
*,
|
||||
tenant_id: str,
|
||||
effective_at: datetime | None = None,
|
||||
relationship_kinds: Sequence[str] = (),
|
||||
) -> dict[str, tuple[IdentityRelationshipRef, ...]]:
|
||||
requested = tuple(dict.fromkeys(identity_ids))
|
||||
result: dict[str, list[IdentityRelationshipRef]] = {
|
||||
identity_id: [] for identity_id in requested
|
||||
}
|
||||
if not requested:
|
||||
return {}
|
||||
with get_database().session() as session:
|
||||
items = self._effective_relationships(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
effective_at=effective_at or utc_now(),
|
||||
identity_ids=requested,
|
||||
relationship_kinds=relationship_kinds,
|
||||
)
|
||||
for item in items:
|
||||
result[item.subject_identity_id].append(
|
||||
identity_relationship_ref(item)
|
||||
)
|
||||
return {key: tuple(value) for key, value in result.items()}
|
||||
|
||||
def identity_relationships_for_group(
|
||||
self,
|
||||
group_id: str,
|
||||
*,
|
||||
tenant_id: str,
|
||||
effective_at: datetime | None = None,
|
||||
relationship_kinds: Sequence[str] = (),
|
||||
) -> tuple[IdentityRelationshipRef, ...]:
|
||||
return tuple(
|
||||
self.identity_relationships_for_groups(
|
||||
(group_id,),
|
||||
tenant_id=tenant_id,
|
||||
effective_at=effective_at,
|
||||
relationship_kinds=relationship_kinds,
|
||||
).get(group_id, ())
|
||||
)
|
||||
|
||||
def identity_relationships_for_groups(
|
||||
self,
|
||||
group_ids: Sequence[str],
|
||||
*,
|
||||
tenant_id: str,
|
||||
effective_at: datetime | None = None,
|
||||
relationship_kinds: Sequence[str] = (),
|
||||
) -> dict[str, tuple[IdentityRelationshipRef, ...]]:
|
||||
requested = tuple(dict.fromkeys(group_ids))
|
||||
result: dict[str, list[IdentityRelationshipRef]] = {
|
||||
group_id: [] for group_id in requested
|
||||
}
|
||||
if not requested:
|
||||
return {}
|
||||
with get_database().session() as session:
|
||||
self._validate_group_tenants(session, requested, tenant_id)
|
||||
items = self._effective_relationships(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
effective_at=effective_at or utc_now(),
|
||||
group_ids=requested,
|
||||
relationship_kinds=relationship_kinds,
|
||||
)
|
||||
for item in items:
|
||||
if item.target_group_id is not None:
|
||||
result[item.target_group_id].append(
|
||||
identity_relationship_ref(item)
|
||||
)
|
||||
return {key: tuple(value) for key, value in result.items()}
|
||||
|
||||
def resolve_typed_group_memberships(
|
||||
self,
|
||||
group_ids: Sequence[str],
|
||||
*,
|
||||
tenant_id: str,
|
||||
effective_at: datetime | None = None,
|
||||
relationship_kinds: Sequence[str] = ("member",),
|
||||
) -> dict[str, TypedGroupMembershipResolutionRef]:
|
||||
requested = tuple(dict.fromkeys(group_ids))
|
||||
if not requested:
|
||||
return {}
|
||||
moment = ensure_aware_utc(effective_at) or utc_now()
|
||||
with get_database().session() as session:
|
||||
groups = self._validate_group_tenants(session, requested, tenant_id)
|
||||
items = (
|
||||
session.query(IdmIdentityRelationship)
|
||||
.filter(
|
||||
IdmIdentityRelationship.tenant_id == tenant_id,
|
||||
IdmIdentityRelationship.target_group_id.in_(requested),
|
||||
)
|
||||
.order_by(
|
||||
IdmIdentityRelationship.created_at.asc(),
|
||||
IdmIdentityRelationship.id.asc(),
|
||||
)
|
||||
)
|
||||
if relationship_kinds:
|
||||
items = items.filter(
|
||||
IdmIdentityRelationship.relationship_kind.in_(
|
||||
tuple(dict.fromkeys(relationship_kinds))
|
||||
)
|
||||
)
|
||||
rows = items.all()
|
||||
|
||||
identities = {
|
||||
identity_id: self._identities.get_identity(identity_id)
|
||||
for identity_id in dict.fromkeys(
|
||||
item.subject_identity_id for item in rows
|
||||
)
|
||||
}
|
||||
decisions: dict[str, list[IdentityRelationshipDecisionRef]] = {
|
||||
group_id: [] for group_id in requested
|
||||
}
|
||||
for item in rows:
|
||||
group = groups[item.target_group_id or ""]
|
||||
identity = identities[item.subject_identity_id]
|
||||
included, code, explanation = _membership_decision(
|
||||
item,
|
||||
group=group,
|
||||
identity_status=identity.status if identity is not None else None,
|
||||
effective_at=moment,
|
||||
)
|
||||
decisions[group.id].append(
|
||||
IdentityRelationshipDecisionRef(
|
||||
relationship=identity_relationship_ref(item),
|
||||
included=included,
|
||||
code=code,
|
||||
explanation=explanation,
|
||||
identity_status=(
|
||||
identity.status if identity is not None else None
|
||||
),
|
||||
)
|
||||
)
|
||||
return {
|
||||
group_id: TypedGroupMembershipResolutionRef(
|
||||
group=typed_group_ref(groups[group_id]),
|
||||
effective_at=moment,
|
||||
decisions=tuple(decisions[group_id]),
|
||||
)
|
||||
for group_id in requested
|
||||
if group_id in groups
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _validate_group_tenants(session, group_ids, tenant_id):
|
||||
groups: dict[str, IdmTypedGroup] = {}
|
||||
for group_id in group_ids:
|
||||
item = session.get(IdmTypedGroup, group_id)
|
||||
if item is None:
|
||||
continue
|
||||
if item.tenant_id != tenant_id:
|
||||
raise ValueError("Typed group belongs to another tenant.")
|
||||
groups[item.id] = item
|
||||
return groups
|
||||
|
||||
@staticmethod
|
||||
def _effective_relationships(
|
||||
session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
effective_at: datetime,
|
||||
identity_ids: Sequence[str] = (),
|
||||
group_ids: Sequence[str] = (),
|
||||
relationship_kinds: Sequence[str] = (),
|
||||
) -> tuple[IdmIdentityRelationship, ...]:
|
||||
query = session.query(IdmIdentityRelationship).filter(
|
||||
IdmIdentityRelationship.tenant_id == tenant_id,
|
||||
IdmIdentityRelationship.status == "active",
|
||||
or_(
|
||||
IdmIdentityRelationship.valid_from.is_(None),
|
||||
IdmIdentityRelationship.valid_from <= effective_at,
|
||||
),
|
||||
or_(
|
||||
IdmIdentityRelationship.valid_until.is_(None),
|
||||
IdmIdentityRelationship.valid_until > effective_at,
|
||||
),
|
||||
)
|
||||
if identity_ids:
|
||||
query = query.filter(
|
||||
IdmIdentityRelationship.subject_identity_id.in_(identity_ids)
|
||||
)
|
||||
if group_ids:
|
||||
query = query.filter(
|
||||
IdmIdentityRelationship.target_group_id.in_(group_ids)
|
||||
)
|
||||
if relationship_kinds:
|
||||
query = query.filter(
|
||||
IdmIdentityRelationship.relationship_kind.in_(
|
||||
tuple(dict.fromkeys(relationship_kinds))
|
||||
)
|
||||
)
|
||||
return tuple(
|
||||
query.order_by(
|
||||
IdmIdentityRelationship.created_at.asc(),
|
||||
IdmIdentityRelationship.id.asc(),
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def _membership_decision(
|
||||
item: IdmIdentityRelationship,
|
||||
*,
|
||||
group: IdmTypedGroup,
|
||||
identity_status: str | None,
|
||||
effective_at: datetime,
|
||||
) -> tuple[bool, str, str]:
|
||||
if group.status != "active":
|
||||
return False, "group.inactive", "The typed group is inactive."
|
||||
if item.status == "revoked":
|
||||
return False, "relationship.revoked", "The relationship was revoked."
|
||||
if item.status != "active":
|
||||
return False, "relationship.inactive", "The relationship is not active."
|
||||
valid_from = ensure_aware_utc(item.valid_from)
|
||||
if valid_from is not None and valid_from > effective_at:
|
||||
return (
|
||||
False,
|
||||
"relationship.not_yet_effective",
|
||||
"The relationship is not effective yet.",
|
||||
)
|
||||
valid_until = ensure_aware_utc(item.valid_until)
|
||||
if valid_until is not None and valid_until <= effective_at:
|
||||
return False, "relationship.expired", "The relationship has expired."
|
||||
if identity_status is None:
|
||||
return False, "identity.missing", "The related identity no longer exists."
|
||||
if identity_status != "active":
|
||||
return (
|
||||
False,
|
||||
"identity.not_active",
|
||||
f"The related identity lifecycle status is {identity_status}.",
|
||||
)
|
||||
return True, "relationship.effective", "The relationship is effective."
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SqlIdmRelationshipDirectory",
|
||||
"identity_relationship_ref",
|
||||
"typed_group_ref",
|
||||
]
|
||||
@@ -0,0 +1,553 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Literal, Protocol
|
||||
from urllib.parse import urlencode, urljoin, urlsplit
|
||||
|
||||
from govoplan_core.security.http_fetch import HttpFetchResponse, fetch_http, validate_http_url
|
||||
|
||||
|
||||
SCIM_LIST_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:ListResponse"
|
||||
SCIM_USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User"
|
||||
SCIM_GROUP_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Group"
|
||||
SCIM_EXTERNAL_PROVIDER_ID = "idm.scim2"
|
||||
MAX_SCIM_PAGE_SIZE = 500
|
||||
MAX_SCIM_RESULTS = 10_000
|
||||
MAX_SCIM_RESPONSE_BYTES = 8 * 1024 * 1024
|
||||
|
||||
ScimResourceType = Literal["User", "Group"]
|
||||
ScimPlanAction = Literal["create", "link", "update", "deactivate", "quarantine"]
|
||||
|
||||
|
||||
class ScimError(RuntimeError):
|
||||
"""Stable, sanitized SCIM discovery and planning error."""
|
||||
|
||||
|
||||
class ScimTransport(Protocol):
|
||||
def __call__(
|
||||
self,
|
||||
url: str,
|
||||
*,
|
||||
method: str,
|
||||
headers: Mapping[str, str],
|
||||
body: bytes | None,
|
||||
) -> HttpFetchResponse: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ScimProfile:
|
||||
"""Non-secret provider-neutral SCIM 2.0 reconciliation policy."""
|
||||
|
||||
provider_id: str
|
||||
base_url: str
|
||||
credential_ref: str
|
||||
immutable_match_attribute: str
|
||||
immutable_match_case_exact: bool = True
|
||||
absent_user_action: Literal["review", "deactivate"] = "review"
|
||||
group_projection_mode: Literal["business_membership_only"] = "business_membership_only"
|
||||
page_size: int = 200
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in ("provider_id", "credential_ref", "immutable_match_attribute"):
|
||||
value = str(getattr(self, name) or "").strip()
|
||||
if not value or len(value) > 255:
|
||||
raise ValueError(f"SCIM {name.replace('_', ' ')} is required and limited to 255 characters.")
|
||||
object.__setattr__(self, name, value)
|
||||
if self.immutable_match_attribute in {"id", "userName", "emails", "displayName"}:
|
||||
raise ValueError(
|
||||
"SCIM matching requires an explicitly governed immutable attribute, not a mutable login, email, or display field."
|
||||
)
|
||||
if not 1 <= self.page_size <= MAX_SCIM_PAGE_SIZE:
|
||||
raise ValueError(f"SCIM page_size must be between 1 and {MAX_SCIM_PAGE_SIZE}.")
|
||||
object.__setattr__(
|
||||
self,
|
||||
"base_url",
|
||||
validate_http_url(self.base_url, label="SCIM base URL").rstrip("/"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ScimResource:
|
||||
resource_type: ScimResourceType
|
||||
resource_id: str
|
||||
external_id: str | None
|
||||
version: str | None
|
||||
active: bool
|
||||
display_name: str
|
||||
attributes: Mapping[str, object]
|
||||
source_sha256: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ScimSnapshot:
|
||||
provider_id: str
|
||||
observed_at: datetime
|
||||
users: tuple[ScimResource, ...] = ()
|
||||
groups: tuple[ScimResource, ...] = ()
|
||||
complete: bool = False
|
||||
page_count: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ScimLocalProjection:
|
||||
local_id: str
|
||||
resource_type: ScimResourceType
|
||||
immutable_match_value: str
|
||||
revision: int
|
||||
active: bool = True
|
||||
provider_resource_id: str | None = None
|
||||
source_sha256: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.local_id.strip() or not self.immutable_match_value.strip():
|
||||
raise ValueError("SCIM local projections require local and immutable-match identity.")
|
||||
if self.revision < 1:
|
||||
raise ValueError("SCIM local projection revisions must be positive.")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ScimPlanOperation:
|
||||
action: ScimPlanAction
|
||||
resource_type: ScimResourceType
|
||||
provider_resource_id: str | None
|
||||
local_id: str | None
|
||||
immutable_match_value: str | None
|
||||
source_sha256: str | None
|
||||
expected_local_revision: int | None
|
||||
reason: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ScimProvisioningPlan:
|
||||
provider_id: str
|
||||
observed_at: datetime
|
||||
snapshot_complete: bool
|
||||
operations: tuple[ScimPlanOperation, ...]
|
||||
plan_sha256: str
|
||||
warnings: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ScimPage:
|
||||
resources: tuple[ScimResource, ...]
|
||||
total_results: int
|
||||
start_index: int
|
||||
items_per_page: int
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ScimClient:
|
||||
profile: ScimProfile
|
||||
bearer_token: str | None = field(default=None, repr=False)
|
||||
transport: ScimTransport | None = field(default=None, repr=False)
|
||||
timeout_seconds: int = 30
|
||||
|
||||
def fetch_snapshot(self) -> ScimSnapshot:
|
||||
observed_at = datetime.now(UTC)
|
||||
users, user_pages = self._fetch_collection("Users", "User")
|
||||
groups, group_pages = self._fetch_collection("Groups", "Group")
|
||||
return ScimSnapshot(
|
||||
provider_id=self.profile.provider_id,
|
||||
observed_at=observed_at,
|
||||
users=users,
|
||||
groups=groups,
|
||||
complete=True,
|
||||
page_count=user_pages + group_pages,
|
||||
)
|
||||
|
||||
def _fetch_collection(
|
||||
self,
|
||||
path: str,
|
||||
resource_type: ScimResourceType,
|
||||
) -> tuple[tuple[ScimResource, ...], int]:
|
||||
resources: list[ScimResource] = []
|
||||
start_index = 1
|
||||
page_count = 0
|
||||
expected_total: int | None = None
|
||||
while True:
|
||||
page = self._fetch_page(path, resource_type, start_index=start_index)
|
||||
page_count += 1
|
||||
if page.start_index != start_index:
|
||||
raise ScimError("SCIM provider returned a non-matching startIndex.")
|
||||
if expected_total is None:
|
||||
expected_total = page.total_results
|
||||
elif page.total_results != expected_total:
|
||||
raise ScimError("SCIM totalResults changed during the snapshot.")
|
||||
resources.extend(page.resources)
|
||||
if len(resources) > MAX_SCIM_RESULTS:
|
||||
raise ScimError(f"SCIM snapshot exceeds the governed limit of {MAX_SCIM_RESULTS} resources.")
|
||||
if len(resources) >= page.total_results:
|
||||
if len(resources) != page.total_results:
|
||||
raise ScimError("SCIM pagination returned more resources than totalResults.")
|
||||
return tuple(resources), page_count
|
||||
if page.items_per_page < 1 or not page.resources:
|
||||
raise ScimError("SCIM pagination did not make progress.")
|
||||
start_index += len(page.resources)
|
||||
|
||||
def _fetch_page(
|
||||
self,
|
||||
path: str,
|
||||
resource_type: ScimResourceType,
|
||||
*,
|
||||
start_index: int,
|
||||
) -> _ScimPage:
|
||||
query = urlencode({"startIndex": start_index, "count": self.profile.page_size})
|
||||
url = self._url(f"{path}?{query}")
|
||||
headers = {"Accept": "application/scim+json, application/json"}
|
||||
if self.bearer_token:
|
||||
headers["Authorization"] = f"Bearer {self.bearer_token}"
|
||||
if self.transport is not None:
|
||||
response = self.transport(url, method="GET", headers=headers, body=None)
|
||||
else:
|
||||
if not self.bearer_token:
|
||||
raise ScimError(
|
||||
"SCIM authentication is unavailable; resolve the configured credential envelope first."
|
||||
)
|
||||
response = fetch_http(
|
||||
url,
|
||||
method="GET",
|
||||
headers=headers,
|
||||
timeout=self.timeout_seconds,
|
||||
max_bytes=MAX_SCIM_RESPONSE_BYTES,
|
||||
label="SCIM 2.0 provider",
|
||||
redirect_sensitive_headers=("Authorization",),
|
||||
)
|
||||
if response.status != 200:
|
||||
raise ScimError(f"SCIM collection read returned HTTP {response.status}.")
|
||||
if len(response.body) > MAX_SCIM_RESPONSE_BYTES:
|
||||
raise ScimError("SCIM response exceeded the safety limit.")
|
||||
try:
|
||||
payload = json.loads(response.body)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise ScimError("SCIM provider returned malformed JSON.") from exc
|
||||
return parse_scim_list_response(payload, resource_type=resource_type)
|
||||
|
||||
def _url(self, relative_path: str) -> str:
|
||||
url = urljoin(f"{self.profile.base_url}/", relative_path)
|
||||
if _origin(urlsplit(url)) != _origin(urlsplit(self.profile.base_url)):
|
||||
raise ScimError("SCIM endpoint escaped the configured provider origin.")
|
||||
return url
|
||||
|
||||
|
||||
class ScimProvisioningPlanner:
|
||||
"""Build a deterministic dry-run; it never mutates Identity, IDM, or Access."""
|
||||
|
||||
def __init__(self, profile: ScimProfile) -> None:
|
||||
self.profile = profile
|
||||
|
||||
def plan(
|
||||
self,
|
||||
snapshot: ScimSnapshot,
|
||||
local_projections: Sequence[ScimLocalProjection],
|
||||
) -> ScimProvisioningPlan:
|
||||
if snapshot.provider_id != self.profile.provider_id:
|
||||
raise ScimError("SCIM snapshot belongs to another provider binding.")
|
||||
operations: list[ScimPlanOperation] = []
|
||||
warnings: list[str] = []
|
||||
locals_by_type = {
|
||||
resource_type: [item for item in local_projections if item.resource_type == resource_type]
|
||||
for resource_type in ("User", "Group")
|
||||
}
|
||||
for resource_type, resources in (("User", snapshot.users), ("Group", snapshot.groups)):
|
||||
operations.extend(
|
||||
self._plan_resource_type(
|
||||
resource_type,
|
||||
resources,
|
||||
locals_by_type[resource_type],
|
||||
snapshot_complete=snapshot.complete,
|
||||
)
|
||||
)
|
||||
if not snapshot.complete:
|
||||
warnings.append(
|
||||
"The snapshot is incomplete; absence-based deactivation is suppressed."
|
||||
)
|
||||
if self.profile.absent_user_action == "review":
|
||||
warnings.append(
|
||||
"Missing SCIM users are quarantined for review instead of being deactivated automatically."
|
||||
)
|
||||
payload = {
|
||||
"provider_id": snapshot.provider_id,
|
||||
"observed_at": snapshot.observed_at.isoformat(),
|
||||
"snapshot_complete": snapshot.complete,
|
||||
"operations": [_operation_dict(item) for item in operations],
|
||||
"warnings": warnings,
|
||||
}
|
||||
return ScimProvisioningPlan(
|
||||
provider_id=snapshot.provider_id,
|
||||
observed_at=snapshot.observed_at,
|
||||
snapshot_complete=snapshot.complete,
|
||||
operations=tuple(operations),
|
||||
plan_sha256=hashlib.sha256(_canonical_json(payload)).hexdigest(),
|
||||
warnings=tuple(warnings),
|
||||
)
|
||||
|
||||
def _plan_resource_type(
|
||||
self,
|
||||
resource_type: ScimResourceType,
|
||||
resources: Sequence[ScimResource],
|
||||
local: Sequence[ScimLocalProjection],
|
||||
*,
|
||||
snapshot_complete: bool,
|
||||
) -> list[ScimPlanOperation]:
|
||||
by_provider_id: dict[str, list[ScimLocalProjection]] = {}
|
||||
by_match: dict[str, list[ScimLocalProjection]] = {}
|
||||
for item in local:
|
||||
if item.provider_resource_id:
|
||||
by_provider_id.setdefault(item.provider_resource_id, []).append(item)
|
||||
by_match.setdefault(self._match_key(item.immutable_match_value), []).append(item)
|
||||
seen_remote_ids: set[str] = set()
|
||||
matched_local_ids: set[str] = set()
|
||||
operations: list[ScimPlanOperation] = []
|
||||
for resource in resources:
|
||||
if resource.resource_type != resource_type:
|
||||
raise ScimError("SCIM snapshot resource type is inconsistent.")
|
||||
if resource.resource_id in seen_remote_ids:
|
||||
raise ScimError("SCIM snapshot contains duplicate provider resource ids.")
|
||||
seen_remote_ids.add(resource.resource_id)
|
||||
match_value = _required_match_value(
|
||||
resource.attributes,
|
||||
self.profile.immutable_match_attribute,
|
||||
)
|
||||
bound = by_provider_id.get(resource.resource_id, [])
|
||||
if len(bound) > 1:
|
||||
operations.append(
|
||||
_quarantine(resource, match_value, "Multiple local objects are bound to the same SCIM resource id.")
|
||||
)
|
||||
continue
|
||||
if bound:
|
||||
item = bound[0]
|
||||
matched_local_ids.add(item.local_id)
|
||||
if self._match_key(item.immutable_match_value) != self._match_key(match_value):
|
||||
operations.append(
|
||||
_quarantine(resource, match_value, "The immutable match value changed for an existing binding.", item)
|
||||
)
|
||||
elif item.source_sha256 != resource.source_sha256 or item.active != resource.active:
|
||||
operations.append(
|
||||
_operation("update", resource, match_value, "The bound SCIM source revision changed.", item)
|
||||
)
|
||||
continue
|
||||
candidates = [
|
||||
item
|
||||
for item in by_match.get(self._match_key(match_value), [])
|
||||
if item.provider_resource_id is None
|
||||
]
|
||||
if len(candidates) > 1:
|
||||
operations.append(
|
||||
_quarantine(resource, match_value, "The immutable match value resolves to multiple local candidates.")
|
||||
)
|
||||
elif candidates:
|
||||
item = candidates[0]
|
||||
matched_local_ids.add(item.local_id)
|
||||
operations.append(
|
||||
_operation("link", resource, match_value, "One unbound local object matched the configured immutable attribute.", item)
|
||||
)
|
||||
else:
|
||||
operations.append(
|
||||
_operation("create", resource, match_value, "No local object matched the configured immutable attribute.")
|
||||
)
|
||||
if snapshot_complete:
|
||||
for item in local:
|
||||
if not item.provider_resource_id or item.local_id in matched_local_ids:
|
||||
continue
|
||||
if item.provider_resource_id in seen_remote_ids:
|
||||
continue
|
||||
action: ScimPlanAction = "quarantine"
|
||||
reason = "The bound SCIM object is absent from a complete snapshot and requires review."
|
||||
if resource_type == "User" and self.profile.absent_user_action == "deactivate":
|
||||
action = "deactivate"
|
||||
reason = "The bound SCIM user is absent from a complete snapshot under the reviewed deactivation policy."
|
||||
operations.append(
|
||||
ScimPlanOperation(
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
provider_resource_id=item.provider_resource_id,
|
||||
local_id=item.local_id,
|
||||
immutable_match_value=item.immutable_match_value,
|
||||
source_sha256=None,
|
||||
expected_local_revision=item.revision,
|
||||
reason=reason,
|
||||
)
|
||||
)
|
||||
return operations
|
||||
|
||||
def _match_key(self, value: str) -> str:
|
||||
normalized = value.strip()
|
||||
return normalized if self.profile.immutable_match_case_exact else normalized.casefold()
|
||||
|
||||
|
||||
def parse_scim_list_response(
|
||||
payload: object,
|
||||
*,
|
||||
resource_type: ScimResourceType,
|
||||
) -> _ScimPage:
|
||||
if not isinstance(payload, Mapping):
|
||||
raise ScimError("SCIM ListResponse must be a JSON object.")
|
||||
schemas = payload.get("schemas")
|
||||
if not isinstance(schemas, list) or SCIM_LIST_SCHEMA not in schemas:
|
||||
raise ScimError("SCIM response is missing the ListResponse schema.")
|
||||
total_results = _nonnegative_int(payload.get("totalResults"), "totalResults")
|
||||
start_index = _positive_int(payload.get("startIndex", 1), "startIndex")
|
||||
items = payload.get("Resources", [])
|
||||
if not isinstance(items, list):
|
||||
raise ScimError("SCIM Resources must be an array.")
|
||||
items_per_page = _nonnegative_int(payload.get("itemsPerPage", len(items)), "itemsPerPage")
|
||||
if items_per_page != len(items):
|
||||
raise ScimError("SCIM itemsPerPage does not match the returned resource count.")
|
||||
expected_schema = SCIM_USER_SCHEMA if resource_type == "User" else SCIM_GROUP_SCHEMA
|
||||
parsed: list[ScimResource] = []
|
||||
for item in items:
|
||||
if not isinstance(item, Mapping):
|
||||
raise ScimError("SCIM resources must be JSON objects.")
|
||||
resource_schemas = item.get("schemas")
|
||||
if not isinstance(resource_schemas, list) or expected_schema not in resource_schemas:
|
||||
raise ScimError(f"SCIM {resource_type} is missing its core schema.")
|
||||
resource_id = _required_text(item.get("id"), f"SCIM {resource_type} id")
|
||||
if resource_type == "User":
|
||||
display_name = _required_text(item.get("userName"), "SCIM User userName")
|
||||
active_value = item.get("active", True)
|
||||
if not isinstance(active_value, bool):
|
||||
raise ScimError("SCIM User active must be boolean.")
|
||||
active = active_value
|
||||
else:
|
||||
display_name = _required_text(item.get("displayName"), "SCIM Group displayName")
|
||||
active = True
|
||||
meta = item.get("meta")
|
||||
version = None
|
||||
if meta is not None:
|
||||
if not isinstance(meta, Mapping):
|
||||
raise ScimError("SCIM resource meta must be an object.")
|
||||
version = _optional_text(meta.get("version"))
|
||||
external_id = _optional_text(item.get("externalId"))
|
||||
normalized = dict(item)
|
||||
parsed.append(
|
||||
ScimResource(
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
external_id=external_id,
|
||||
version=version,
|
||||
active=active,
|
||||
display_name=display_name,
|
||||
attributes=normalized,
|
||||
source_sha256=hashlib.sha256(_canonical_json(normalized)).hexdigest(),
|
||||
)
|
||||
)
|
||||
return _ScimPage(
|
||||
resources=tuple(parsed),
|
||||
total_results=total_results,
|
||||
start_index=start_index,
|
||||
items_per_page=items_per_page,
|
||||
)
|
||||
|
||||
|
||||
def _required_match_value(attributes: Mapping[str, object], attribute: str) -> str:
|
||||
value: object = attributes.get(attribute)
|
||||
if value is None and "." in attribute:
|
||||
value = attributes
|
||||
for part in attribute.split("."):
|
||||
if not isinstance(value, Mapping):
|
||||
value = None
|
||||
break
|
||||
value = value.get(part)
|
||||
return _required_text(value, f"SCIM immutable attribute {attribute}")
|
||||
|
||||
|
||||
def _operation(
|
||||
action: ScimPlanAction,
|
||||
resource: ScimResource,
|
||||
match_value: str,
|
||||
reason: str,
|
||||
local: ScimLocalProjection | None = None,
|
||||
) -> ScimPlanOperation:
|
||||
return ScimPlanOperation(
|
||||
action=action,
|
||||
resource_type=resource.resource_type,
|
||||
provider_resource_id=resource.resource_id,
|
||||
local_id=local.local_id if local else None,
|
||||
immutable_match_value=match_value,
|
||||
source_sha256=resource.source_sha256,
|
||||
expected_local_revision=local.revision if local else None,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
|
||||
def _quarantine(
|
||||
resource: ScimResource,
|
||||
match_value: str,
|
||||
reason: str,
|
||||
local: ScimLocalProjection | None = None,
|
||||
) -> ScimPlanOperation:
|
||||
return _operation("quarantine", resource, match_value, reason, local)
|
||||
|
||||
|
||||
def _operation_dict(value: ScimPlanOperation) -> dict[str, object]:
|
||||
return {
|
||||
"action": value.action,
|
||||
"resource_type": value.resource_type,
|
||||
"provider_resource_id": value.provider_resource_id,
|
||||
"local_id": value.local_id,
|
||||
"immutable_match_value": value.immutable_match_value,
|
||||
"source_sha256": value.source_sha256,
|
||||
"expected_local_revision": value.expected_local_revision,
|
||||
"reason": value.reason,
|
||||
}
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
|
||||
|
||||
def _origin(parts) -> tuple[str, str, int | None]:
|
||||
return (
|
||||
parts.scheme.casefold(),
|
||||
(parts.hostname or "").casefold(),
|
||||
parts.port or (443 if parts.scheme.casefold() == "https" else 80),
|
||||
)
|
||||
|
||||
|
||||
def _required_text(value: object, label: str) -> str:
|
||||
if not isinstance(value, str) or not value.strip() or len(value.strip()) > 500:
|
||||
raise ScimError(f"{label} is required and limited to 500 characters.")
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _optional_text(value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, str) or not value.strip() or len(value.strip()) > 500:
|
||||
raise ScimError("SCIM optional text values must be non-empty strings limited to 500 characters.")
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _nonnegative_int(value: object, label: str) -> int:
|
||||
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
||||
raise ScimError(f"SCIM {label} must be a non-negative integer.")
|
||||
return value
|
||||
|
||||
|
||||
def _positive_int(value: object, label: str) -> int:
|
||||
parsed = _nonnegative_int(value, label)
|
||||
if parsed < 1:
|
||||
raise ScimError(f"SCIM {label} must be positive.")
|
||||
return parsed
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SCIM_EXTERNAL_PROVIDER_ID",
|
||||
"SCIM_GROUP_SCHEMA",
|
||||
"SCIM_LIST_SCHEMA",
|
||||
"SCIM_USER_SCHEMA",
|
||||
"ScimClient",
|
||||
"ScimError",
|
||||
"ScimLocalProjection",
|
||||
"ScimProfile",
|
||||
"ScimProvisioningPlan",
|
||||
"ScimProvisioningPlanner",
|
||||
"ScimResource",
|
||||
"ScimSnapshot",
|
||||
"parse_scim_list_response",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from urllib.parse import quote
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.events import PlatformEvent
|
||||
from govoplan_core.core.modules import ModuleContext
|
||||
from govoplan_core.core.search import (
|
||||
SearchAuthorizationRequest,
|
||||
SearchBackfillPage,
|
||||
SearchBackfillRequest,
|
||||
SearchDocument,
|
||||
SearchIndexChange,
|
||||
SearchResourceReference,
|
||||
SearchResourceType,
|
||||
)
|
||||
from govoplan_idm.backend.db.models import (
|
||||
IdmIdentityRelationship,
|
||||
IdmOrganizationFunctionAssignment,
|
||||
IdmTypedGroup,
|
||||
)
|
||||
|
||||
|
||||
PROVIDER_ID = "idm.directory"
|
||||
RESOURCE_MODELS = {
|
||||
"organization_function_assignment": IdmOrganizationFunctionAssignment,
|
||||
"typed_group": IdmTypedGroup,
|
||||
"identity_relationship": IdmIdentityRelationship,
|
||||
}
|
||||
RESOURCE_SCOPES = {
|
||||
"organization_function_assignment": (
|
||||
"idm:organization_assignment:read",
|
||||
"idm:organization_assignment:write",
|
||||
),
|
||||
"typed_group": ("idm:relationship:read", "idm:relationship:write"),
|
||||
"identity_relationship": (
|
||||
"idm:relationship:read",
|
||||
"idm:relationship:write",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class IdmSearchSource:
|
||||
def resource_types(self) -> Sequence[SearchResourceType]:
|
||||
return (
|
||||
SearchResourceType(
|
||||
provider_id=PROVIDER_ID,
|
||||
module_id="idm",
|
||||
resource_type="organization_function_assignment",
|
||||
label="Function assignments",
|
||||
requires_authorization_recheck=True,
|
||||
),
|
||||
SearchResourceType(
|
||||
provider_id=PROVIDER_ID,
|
||||
module_id="idm",
|
||||
resource_type="typed_group",
|
||||
label="Typed groups",
|
||||
requires_authorization_recheck=True,
|
||||
),
|
||||
SearchResourceType(
|
||||
provider_id=PROVIDER_ID,
|
||||
module_id="idm",
|
||||
resource_type="identity_relationship",
|
||||
label="Identity relationships",
|
||||
requires_authorization_recheck=True,
|
||||
),
|
||||
)
|
||||
|
||||
def backfill(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
request: SearchBackfillRequest,
|
||||
) -> SearchBackfillPage:
|
||||
model = _model(request.provider_id, request.resource_type)
|
||||
db = _session(session)
|
||||
statement = select(model).where(model.tenant_id == request.tenant_id)
|
||||
if request.cursor:
|
||||
statement = statement.where(model.id > request.cursor)
|
||||
rows = list(
|
||||
db.scalars(
|
||||
statement.order_by(model.id).limit(request.limit + 1)
|
||||
)
|
||||
)
|
||||
has_more = len(rows) > request.limit
|
||||
selected = rows[: request.limit]
|
||||
high_watermark = db.scalar(
|
||||
select(func.max(model.updated_at)).where(
|
||||
model.tenant_id == request.tenant_id
|
||||
)
|
||||
)
|
||||
return SearchBackfillPage(
|
||||
documents=tuple(
|
||||
_document(row, resource_type=request.resource_type)
|
||||
for row in selected
|
||||
),
|
||||
next_cursor=selected[-1].id if has_more and selected else None,
|
||||
complete=not has_more,
|
||||
high_watermark=(
|
||||
high_watermark.isoformat()
|
||||
if high_watermark is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
def authorize(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
requests: Sequence[SearchAuthorizationRequest],
|
||||
) -> Mapping[str, bool]:
|
||||
decisions = {item.reference.key: False for item in requests}
|
||||
if not isinstance(principal, ApiPrincipal):
|
||||
return decisions
|
||||
db = _session(session)
|
||||
by_type: dict[str, list[SearchAuthorizationRequest]] = {}
|
||||
for item in requests:
|
||||
reference = item.reference
|
||||
if (
|
||||
reference.tenant_id != principal.tenant_id
|
||||
or reference.module_id != "idm"
|
||||
or reference.resource_type not in RESOURCE_MODELS
|
||||
or not any(
|
||||
principal.has(scope)
|
||||
for scope in RESOURCE_SCOPES[reference.resource_type]
|
||||
)
|
||||
):
|
||||
continue
|
||||
by_type.setdefault(reference.resource_type, []).append(item)
|
||||
for resource_type, items in by_type.items():
|
||||
model = RESOURCE_MODELS[resource_type]
|
||||
ids = {item.reference.resource_id for item in items}
|
||||
existing = set(
|
||||
db.scalars(
|
||||
select(model.id).where(
|
||||
model.id.in_(ids),
|
||||
model.tenant_id == principal.tenant_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
for item in items:
|
||||
decisions[item.reference.key] = (
|
||||
item.reference.resource_id in existing
|
||||
)
|
||||
return decisions
|
||||
|
||||
def index_changes_for_event(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
event: PlatformEvent,
|
||||
delivery_key: str,
|
||||
) -> Sequence[SearchIndexChange]:
|
||||
if (
|
||||
event.module_id != "idm"
|
||||
or event.tenant is None
|
||||
or event.resource is None
|
||||
or event.resource.type not in RESOURCE_MODELS
|
||||
or event.resource.id is None
|
||||
):
|
||||
return ()
|
||||
db = _session(session)
|
||||
resource_type = event.resource.type
|
||||
model = RESOURCE_MODELS[resource_type]
|
||||
row = db.get(model, event.resource.id)
|
||||
deleted = row is None or row.tenant_id != event.tenant.id
|
||||
cursor = event.event_id
|
||||
document = (
|
||||
None
|
||||
if deleted
|
||||
else _document(
|
||||
row,
|
||||
resource_type=resource_type,
|
||||
change_cursor=cursor,
|
||||
)
|
||||
)
|
||||
reference = SearchResourceReference(
|
||||
tenant_id=event.tenant.id,
|
||||
module_id="idm",
|
||||
resource_type=resource_type,
|
||||
resource_id=event.resource.id,
|
||||
)
|
||||
return (
|
||||
SearchIndexChange(
|
||||
change_id=f"{delivery_key}:{PROVIDER_ID}:{resource_type}",
|
||||
provider_id=PROVIDER_ID,
|
||||
kind="delete" if deleted else "upsert",
|
||||
reference=reference,
|
||||
source_revision=(
|
||||
document.source_revision if document is not None else cursor
|
||||
),
|
||||
cursor=cursor,
|
||||
document=document,
|
||||
occurred_at=event.occurred_at,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def create_idm_search_source(_context: ModuleContext) -> IdmSearchSource:
|
||||
return IdmSearchSource()
|
||||
|
||||
|
||||
def _document(
|
||||
row: object,
|
||||
*,
|
||||
resource_type: str,
|
||||
change_cursor: str | None = None,
|
||||
) -> SearchDocument:
|
||||
scopes = RESOURCE_SCOPES[resource_type]
|
||||
updated_at = getattr(row, "updated_at") or getattr(row, "created_at")
|
||||
title, summary, body, keywords, metadata, url = _resource_content(
|
||||
row,
|
||||
resource_type=resource_type,
|
||||
)
|
||||
revision = getattr(row, "revision", None)
|
||||
return SearchDocument(
|
||||
tenant_id=str(getattr(row, "tenant_id")),
|
||||
module_id="idm",
|
||||
provider_id=PROVIDER_ID,
|
||||
resource_type=resource_type,
|
||||
resource_id=str(getattr(row, "id")),
|
||||
title=title[:500],
|
||||
url=url,
|
||||
summary=summary[:4000] if summary else None,
|
||||
body=body[:200_000] if body else None,
|
||||
keywords=tuple(item[:200] for item in keywords[:100]),
|
||||
visibility="restricted",
|
||||
acl_tokens=tuple(f"scope:{scope}" for scope in scopes),
|
||||
metadata=metadata,
|
||||
source_revision=f"{revision or 1}:{updated_at.isoformat()}",
|
||||
change_cursor=change_cursor,
|
||||
source_updated_at=updated_at,
|
||||
requires_authorization_recheck=True,
|
||||
)
|
||||
|
||||
|
||||
def _resource_content(
|
||||
row: object,
|
||||
*,
|
||||
resource_type: str,
|
||||
) -> tuple[str, str, str, tuple[str, ...], dict[str, object], str]:
|
||||
item_id = str(getattr(row, "id"))
|
||||
if resource_type == "typed_group":
|
||||
group = row
|
||||
return (
|
||||
str(getattr(group, "name")),
|
||||
str(getattr(group, "description") or ""),
|
||||
" ".join(
|
||||
str(value)
|
||||
for value in (
|
||||
getattr(group, "key"),
|
||||
getattr(group, "description"),
|
||||
getattr(group, "source_resource_id"),
|
||||
)
|
||||
if value
|
||||
),
|
||||
(
|
||||
str(getattr(group, "group_type")),
|
||||
str(getattr(group, "status")),
|
||||
str(getattr(group, "source_provider")),
|
||||
),
|
||||
{
|
||||
"key": getattr(group, "key"),
|
||||
"group_type": getattr(group, "group_type"),
|
||||
"status": getattr(group, "status"),
|
||||
"source_provider": getattr(group, "source_provider"),
|
||||
},
|
||||
f"/idm?groupId={quote(item_id, safe='')}",
|
||||
)
|
||||
if resource_type == "identity_relationship":
|
||||
relationship = row
|
||||
kind = str(getattr(relationship, "relationship_kind"))
|
||||
return (
|
||||
f"Relationship: {kind}",
|
||||
str(getattr(relationship, "role") or ""),
|
||||
" ".join(
|
||||
str(value)
|
||||
for value in (
|
||||
getattr(relationship, "subject_identity_id"),
|
||||
getattr(relationship, "target_group_id"),
|
||||
getattr(relationship, "related_identity_id"),
|
||||
getattr(relationship, "role"),
|
||||
)
|
||||
if value
|
||||
),
|
||||
(kind, str(getattr(relationship, "status"))),
|
||||
{
|
||||
"relationship_kind": kind,
|
||||
"status": getattr(relationship, "status"),
|
||||
"subject_identity_id": getattr(
|
||||
relationship, "subject_identity_id"
|
||||
),
|
||||
"target_group_id": getattr(relationship, "target_group_id"),
|
||||
"related_identity_id": getattr(
|
||||
relationship, "related_identity_id"
|
||||
),
|
||||
},
|
||||
f"/idm?relationshipId={quote(item_id, safe='')}",
|
||||
)
|
||||
assignment = row
|
||||
return (
|
||||
"Organization function assignment",
|
||||
" ".join(
|
||||
(
|
||||
str(getattr(assignment, "function_id")),
|
||||
str(getattr(assignment, "organization_unit_id")),
|
||||
)
|
||||
),
|
||||
" ".join(
|
||||
str(value)
|
||||
for value in (
|
||||
getattr(assignment, "identity_id"),
|
||||
getattr(assignment, "account_id"),
|
||||
getattr(assignment, "function_id"),
|
||||
getattr(assignment, "organization_unit_id"),
|
||||
)
|
||||
if value
|
||||
),
|
||||
(
|
||||
str(getattr(assignment, "source")),
|
||||
"active" if getattr(assignment, "is_active") else "inactive",
|
||||
),
|
||||
{
|
||||
"identity_id": getattr(assignment, "identity_id"),
|
||||
"function_id": getattr(assignment, "function_id"),
|
||||
"organization_unit_id": getattr(
|
||||
assignment, "organization_unit_id"
|
||||
),
|
||||
"is_active": getattr(assignment, "is_active"),
|
||||
"valid_from": (
|
||||
getattr(assignment, "valid_from").isoformat()
|
||||
if getattr(assignment, "valid_from")
|
||||
else None
|
||||
),
|
||||
"valid_until": (
|
||||
getattr(assignment, "valid_until").isoformat()
|
||||
if getattr(assignment, "valid_until")
|
||||
else None
|
||||
),
|
||||
},
|
||||
f"/idm?assignmentId={quote(item_id, safe='')}",
|
||||
)
|
||||
|
||||
|
||||
def _model(provider_id: str, resource_type: str):
|
||||
if provider_id != PROVIDER_ID or resource_type not in RESOURCE_MODELS:
|
||||
raise ValueError("Unsupported IDM search source.")
|
||||
return RESOURCE_MODELS[resource_type]
|
||||
|
||||
|
||||
def _session(value: object) -> Session:
|
||||
if not isinstance(value, Session):
|
||||
raise TypeError("IDM search requires a SQLAlchemy session.")
|
||||
return value
|
||||
|
||||
|
||||
__all__ = [
|
||||
"IdmSearchSource",
|
||||
"PROVIDER_ID",
|
||||
"RESOURCE_MODELS",
|
||||
"create_idm_search_source",
|
||||
]
|
||||
@@ -0,0 +1,193 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.workflows import WorkflowDefinitionContribution
|
||||
|
||||
|
||||
def function_assignment_workflow_definitions(
|
||||
*,
|
||||
module_version: str,
|
||||
) -> tuple[WorkflowDefinitionContribution, ...]:
|
||||
return (
|
||||
_contribution(
|
||||
module_version=module_version,
|
||||
definition_key="function-assignment-request",
|
||||
name="Request an organization function",
|
||||
description=(
|
||||
"Governed holder, authority, and optional recipient decisions "
|
||||
"for a self-requested organization function assignment."
|
||||
),
|
||||
kind="request",
|
||||
),
|
||||
_contribution(
|
||||
module_version=module_version,
|
||||
definition_key="function-assignment-grant",
|
||||
name="Bestow an organization function",
|
||||
description=(
|
||||
"Governed holder, authority, and recipient decisions for an "
|
||||
"organization function grant."
|
||||
),
|
||||
kind="grant",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _contribution(
|
||||
*,
|
||||
module_version: str,
|
||||
definition_key: str,
|
||||
name: str,
|
||||
description: str,
|
||||
kind: str,
|
||||
) -> WorkflowDefinitionContribution:
|
||||
return WorkflowDefinitionContribution(
|
||||
origin_module_id="idm",
|
||||
origin_module_version=module_version,
|
||||
definition_key=definition_key,
|
||||
name=name,
|
||||
description=description,
|
||||
graph=_graph(kind=kind),
|
||||
scope_type="system",
|
||||
inherit_to_lower_scopes=True,
|
||||
allow_start=True,
|
||||
allow_reuse=False,
|
||||
allow_automation=False,
|
||||
execution_mode="guided",
|
||||
activate_on_install=True,
|
||||
metadata={
|
||||
"domain": "idm.function_assignment_change",
|
||||
"change_kind": kind,
|
||||
"state_owner": "idm",
|
||||
},
|
||||
policy_metadata={
|
||||
"governance_capability": ("policy.functionAssignmentGovernance"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _graph(*, kind: str) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "start",
|
||||
"type": "workflow.start.manual",
|
||||
"label": "Submitted",
|
||||
"position": {"x": 20, "y": 120},
|
||||
"config": {
|
||||
"input_schema_ref": (f"govoplan/idm/function-assignment-{kind}.v1"),
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "holder_review",
|
||||
"type": "workflow.review",
|
||||
"label": "Holder review",
|
||||
"position": {"x": 230, "y": 120},
|
||||
"config": {
|
||||
"title": "Holder review",
|
||||
"reviewer": "effective-holder",
|
||||
"required_evidence": [],
|
||||
"view_surface_ids": [
|
||||
"idm.action.view-function-assignments",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "authority_review",
|
||||
"type": "workflow.review",
|
||||
"label": "Authority clearance",
|
||||
"position": {"x": 470, "y": 120},
|
||||
"config": {
|
||||
"title": "Authority clearance",
|
||||
"reviewer": "designated-authority",
|
||||
"required_evidence": [],
|
||||
"view_surface_ids": [
|
||||
"idm.action.view-function-assignments",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "recipient_review",
|
||||
"type": "workflow.review",
|
||||
"label": "Recipient acceptance",
|
||||
"position": {"x": 730, "y": 120},
|
||||
"config": {
|
||||
"title": "Recipient acceptance",
|
||||
"reviewer": "candidate",
|
||||
"required_evidence": [],
|
||||
"view_surface_ids": [
|
||||
"idm.action.view-function-assignments",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "completed",
|
||||
"type": "workflow.end.completed",
|
||||
"label": "Approved",
|
||||
"position": {"x": 990, "y": 70},
|
||||
"config": {"output_mapping": {}},
|
||||
},
|
||||
{
|
||||
"id": "rejected",
|
||||
"type": "workflow.end.cancelled",
|
||||
"label": "Rejected",
|
||||
"position": {"x": 990, "y": 230},
|
||||
"config": {"reason": "Function assignment change rejected"},
|
||||
},
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "start-holder",
|
||||
"source": "start",
|
||||
"target": "holder_review",
|
||||
},
|
||||
{
|
||||
"id": "holder-authority",
|
||||
"source": "holder_review",
|
||||
"source_port": "approved",
|
||||
"target": "authority_review",
|
||||
},
|
||||
{
|
||||
"id": "holder-rejected",
|
||||
"source": "holder_review",
|
||||
"source_port": "rejected",
|
||||
"target": "rejected",
|
||||
},
|
||||
{
|
||||
"id": "authority-recipient",
|
||||
"source": "authority_review",
|
||||
"source_port": "approved",
|
||||
"target": "recipient_review",
|
||||
},
|
||||
{
|
||||
"id": "authority-rejected",
|
||||
"source": "authority_review",
|
||||
"source_port": "rejected",
|
||||
"target": "rejected",
|
||||
},
|
||||
{
|
||||
"id": "recipient-completed",
|
||||
"source": "recipient_review",
|
||||
"source_port": "approved",
|
||||
"target": "completed",
|
||||
},
|
||||
{
|
||||
"id": "recipient-rejected",
|
||||
"source": "recipient_review",
|
||||
"source_port": "rejected",
|
||||
"target": "rejected",
|
||||
},
|
||||
],
|
||||
"metadata": {
|
||||
"notation": "govoplan.workflow.native",
|
||||
"domain": "idm.function_assignment_change",
|
||||
"change_kind": kind,
|
||||
"optional_steps": [
|
||||
"holder_review",
|
||||
"authority_review",
|
||||
"recipient_review",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["function_assignment_workflow_definitions"]
|
||||
@@ -0,0 +1,343 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
from govoplan_core.core.events import EventBus, PlatformEvent, event_bus_context
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.db.session import configure_database, reset_database
|
||||
from govoplan_identity.backend.db import models as identity_models # noqa: F401
|
||||
from govoplan_idm.backend.assignment_lifecycle import SqlIdmAssignmentLifecycle
|
||||
from govoplan_idm.backend.db.models import (
|
||||
IdmFunctionAssignmentChange,
|
||||
IdmFunctionAssignmentChangeEvent,
|
||||
IdmIdentityRelationship,
|
||||
IdmOrganizationFunctionAssignment,
|
||||
IdmTypedGroup,
|
||||
)
|
||||
from govoplan_organizations.backend.db import models as organization_models # noqa: F401
|
||||
|
||||
|
||||
class AssignmentExpiryTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.database = configure_database("sqlite:///:memory:")
|
||||
Base.metadata.create_all(
|
||||
self.database.engine,
|
||||
tables=[
|
||||
IdmOrganizationFunctionAssignment.__table__,
|
||||
IdmFunctionAssignmentChange.__table__,
|
||||
IdmFunctionAssignmentChangeEvent.__table__,
|
||||
IdmTypedGroup.__table__,
|
||||
IdmIdentityRelationship.__table__,
|
||||
ChangeSequenceEntry.__table__,
|
||||
],
|
||||
)
|
||||
self.lifecycle = SqlIdmAssignmentLifecycle()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
reset_database(dispose=True)
|
||||
|
||||
@staticmethod
|
||||
def _assignment(
|
||||
assignment_id: str,
|
||||
*,
|
||||
tenant_id: str = "tenant-1",
|
||||
valid_until: datetime,
|
||||
active: bool = True,
|
||||
expired_event_at: datetime | None = None,
|
||||
) -> IdmOrganizationFunctionAssignment:
|
||||
return IdmOrganizationFunctionAssignment(
|
||||
id=assignment_id,
|
||||
tenant_id=tenant_id,
|
||||
identity_id=f"identity-{assignment_id}",
|
||||
function_id="function-1",
|
||||
organization_unit_id="unit-1",
|
||||
source="direct",
|
||||
valid_until=valid_until,
|
||||
expired_event_at=expired_event_at,
|
||||
is_active=active,
|
||||
settings={},
|
||||
)
|
||||
|
||||
def test_sweep_claims_due_assignments_once_and_preserves_provenance(self) -> None:
|
||||
boundary = datetime(2026, 7, 31, 12, tzinfo=timezone.utc)
|
||||
with self.database.session() as session:
|
||||
session.add_all(
|
||||
(
|
||||
self._assignment(
|
||||
"due",
|
||||
valid_until=boundary - timedelta(seconds=1),
|
||||
),
|
||||
self._assignment(
|
||||
"future",
|
||||
valid_until=boundary + timedelta(seconds=1),
|
||||
),
|
||||
self._assignment(
|
||||
"revoked",
|
||||
valid_until=boundary - timedelta(seconds=1),
|
||||
active=False,
|
||||
),
|
||||
self._assignment(
|
||||
"already-emitted",
|
||||
valid_until=boundary - timedelta(seconds=1),
|
||||
expired_event_at=boundary - timedelta(minutes=1),
|
||||
),
|
||||
self._assignment(
|
||||
"other-tenant",
|
||||
tenant_id="tenant-2",
|
||||
valid_until=boundary - timedelta(seconds=1),
|
||||
),
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
events: list[PlatformEvent] = []
|
||||
bus = EventBus()
|
||||
bus.subscribe("idm.function_assignment.expired.v1", events.append)
|
||||
with self.database.SessionLocal() as session, event_bus_context(bus):
|
||||
result = self.lifecycle.process_expired(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
effective_at=boundary,
|
||||
)
|
||||
session.commit()
|
||||
|
||||
self.assertEqual(1, result["selected"])
|
||||
self.assertEqual(1, result["expired"])
|
||||
self.assertEqual(["due"], result["assignment_ids"])
|
||||
self.assertEqual(1, len(events))
|
||||
self.assertEqual("system", events[0].actor.type)
|
||||
self.assertEqual("tenant-1", events[0].tenant.id)
|
||||
self.assertEqual("identity-due", events[0].payload["identity_id"])
|
||||
self.assertEqual("function-1", events[0].payload["function_id"])
|
||||
|
||||
with self.database.session() as session:
|
||||
due = session.get(IdmOrganizationFunctionAssignment, "due")
|
||||
self.assertEqual(boundary, due.expired_event_at.replace(tzinfo=timezone.utc))
|
||||
repeated = self.lifecycle.process_expired(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
effective_at=boundary,
|
||||
)
|
||||
session.commit()
|
||||
self.assertEqual(0, repeated["expired"])
|
||||
self.assertEqual(1, len(events))
|
||||
|
||||
def test_sweep_rollback_releases_marker_and_event(self) -> None:
|
||||
boundary = datetime(2026, 7, 31, 12, tzinfo=timezone.utc)
|
||||
with self.database.session() as session:
|
||||
session.add(
|
||||
self._assignment(
|
||||
"rolled-back",
|
||||
valid_until=boundary - timedelta(seconds=1),
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
events: list[PlatformEvent] = []
|
||||
bus = EventBus()
|
||||
bus.subscribe("idm.function_assignment.expired.v1", events.append)
|
||||
with self.database.SessionLocal() as session, event_bus_context(bus):
|
||||
self.lifecycle.process_expired(session, effective_at=boundary)
|
||||
session.rollback()
|
||||
self.assertEqual([], events)
|
||||
|
||||
with self.database.session() as session:
|
||||
item = session.get(IdmOrganizationFunctionAssignment, "rolled-back")
|
||||
self.assertIsNone(item.expired_event_at)
|
||||
|
||||
def test_limit_validation_is_bounded(self) -> None:
|
||||
with self.database.session() as session:
|
||||
for value in (0, 1001):
|
||||
with self.subTest(limit=value), self.assertRaises(ValueError):
|
||||
self.lifecycle.process_expired(session, limit=value)
|
||||
|
||||
def test_sweep_expires_open_governed_changes_once(self) -> None:
|
||||
boundary = datetime(2026, 7, 31, 12, tzinfo=timezone.utc)
|
||||
with self.database.session() as session:
|
||||
session.add(
|
||||
IdmFunctionAssignmentChange(
|
||||
id="change-due",
|
||||
tenant_id="tenant-1",
|
||||
kind="request",
|
||||
state="awaiting_holder",
|
||||
profile="holder_grant",
|
||||
function_id="function-1",
|
||||
organization_unit_id="unit-1",
|
||||
candidate_identity_id="identity-1",
|
||||
initiator_account_id="account-1",
|
||||
justification="Need the function",
|
||||
evidence=[],
|
||||
assignment_source="governance",
|
||||
required_steps=["holder"],
|
||||
completed_steps=[],
|
||||
policy_decision={},
|
||||
idempotency_key="request-1",
|
||||
expires_at=boundary - timedelta(seconds=1),
|
||||
metadata_={},
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
events: list[PlatformEvent] = []
|
||||
bus = EventBus()
|
||||
bus.subscribe("idm.function_change.expired.v1", events.append)
|
||||
with self.database.SessionLocal() as session, event_bus_context(bus):
|
||||
result = self.lifecycle.process_expired(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
effective_at=boundary,
|
||||
)
|
||||
session.commit()
|
||||
repeated = self.lifecycle.process_expired(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
effective_at=boundary,
|
||||
)
|
||||
session.commit()
|
||||
|
||||
self.assertEqual(1, result["expired_changes"])
|
||||
self.assertEqual(["change-due"], result["change_ids"])
|
||||
self.assertEqual(0, repeated["expired_changes"])
|
||||
self.assertEqual(1, len(events))
|
||||
with self.database.session() as session:
|
||||
change = session.get(IdmFunctionAssignmentChange, "change-due")
|
||||
self.assertEqual("expired", change.state)
|
||||
self.assertEqual(2, change.resource_revision)
|
||||
history = session.query(IdmFunctionAssignmentChangeEvent).all()
|
||||
self.assertEqual(["expired"], [item.action for item in history])
|
||||
|
||||
def test_sweep_escalates_due_review_once_without_substituting_approval(self) -> None:
|
||||
boundary = datetime(2026, 8, 22, 12, tzinfo=timezone.utc)
|
||||
with self.database.session() as session:
|
||||
session.add(
|
||||
IdmFunctionAssignmentChange(
|
||||
id="change-escalate",
|
||||
tenant_id="tenant-1",
|
||||
kind="grant",
|
||||
state="awaiting_holder",
|
||||
profile="holder_grant",
|
||||
function_id="function-1",
|
||||
organization_unit_id="unit-1",
|
||||
candidate_identity_id="identity-1",
|
||||
initiator_account_id="account-1",
|
||||
justification="Timed governed review",
|
||||
evidence=[],
|
||||
assignment_source="governance",
|
||||
required_steps=["holder"],
|
||||
completed_steps=[],
|
||||
policy_decision={
|
||||
"escalation_rules": [
|
||||
{
|
||||
"step": "holder",
|
||||
"target_function_id": "function-escalation",
|
||||
"timeout_hours": 4,
|
||||
}
|
||||
]
|
||||
},
|
||||
idempotency_key="grant-escalate-1",
|
||||
expires_at=boundary + timedelta(days=2),
|
||||
review_deadline_at=boundary - timedelta(seconds=1),
|
||||
escalation_target_function_id="function-escalation",
|
||||
metadata_={"step_approvals": {}},
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
events: list[PlatformEvent] = []
|
||||
audit_events: list[PlatformEvent] = []
|
||||
bus = EventBus()
|
||||
bus.subscribe("idm.function_change.escalated.v1", events.append)
|
||||
bus.subscribe(
|
||||
"idm.function_assignment_change.escalated",
|
||||
audit_events.append,
|
||||
)
|
||||
with self.database.SessionLocal() as session, event_bus_context(bus):
|
||||
result = self.lifecycle.process_expired(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
effective_at=boundary,
|
||||
)
|
||||
session.commit()
|
||||
repeated = self.lifecycle.process_expired(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
effective_at=boundary,
|
||||
)
|
||||
session.commit()
|
||||
|
||||
self.assertEqual(["change-escalate"], result["escalated_change_ids"])
|
||||
self.assertEqual(0, repeated["escalated_changes"])
|
||||
self.assertEqual(1, len(events))
|
||||
self.assertEqual(1, len(audit_events))
|
||||
with self.database.session() as session:
|
||||
change = session.get(IdmFunctionAssignmentChange, "change-escalate")
|
||||
self.assertEqual("escalated", change.state)
|
||||
self.assertEqual("awaiting_holder", change.escalation_from_state)
|
||||
self.assertEqual("function-escalation", change.escalation_target_function_id)
|
||||
self.assertEqual([], change.completed_steps)
|
||||
self.assertIsNone(change.resulting_assignment_id)
|
||||
history = session.query(IdmFunctionAssignmentChangeEvent).all()
|
||||
self.assertEqual(["escalated"], [item.action for item in history])
|
||||
self.assertFalse(
|
||||
history[0].details["automatic_approver_substitution"]
|
||||
)
|
||||
|
||||
def test_sweep_emits_relationship_expiry_once(self) -> None:
|
||||
boundary = datetime(2026, 8, 2, 12, tzinfo=timezone.utc)
|
||||
with self.database.session() as session:
|
||||
session.add(
|
||||
IdmTypedGroup(
|
||||
id="group-1",
|
||||
tenant_id="tenant-1",
|
||||
key="eligible",
|
||||
name="Eligible",
|
||||
group_type="business_status",
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
IdmIdentityRelationship(
|
||||
id="relationship-due",
|
||||
tenant_id="tenant-1",
|
||||
relationship_kind="member",
|
||||
subject_identity_id="identity-1",
|
||||
target_group_id="group-1",
|
||||
valid_until=boundary - timedelta(seconds=1),
|
||||
status="active",
|
||||
properties={},
|
||||
provenance={},
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
events: list[PlatformEvent] = []
|
||||
bus = EventBus()
|
||||
bus.subscribe("idm.relationship.expired.v1", events.append)
|
||||
with self.database.SessionLocal() as session, event_bus_context(bus):
|
||||
result = self.lifecycle.process_expired(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
effective_at=boundary,
|
||||
)
|
||||
session.commit()
|
||||
repeated = self.lifecycle.process_expired(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
effective_at=boundary,
|
||||
)
|
||||
session.commit()
|
||||
|
||||
self.assertEqual(["relationship-due"], result["relationship_ids"])
|
||||
self.assertEqual(1, result["expired_relationships"])
|
||||
self.assertEqual(0, repeated["expired_relationships"])
|
||||
self.assertEqual(1, len(events))
|
||||
self.assertEqual("identity-1", events[0].subject.id)
|
||||
with self.database.session() as session:
|
||||
item = session.get(IdmIdentityRelationship, "relationship-due")
|
||||
self.assertEqual(2, item.revision)
|
||||
self.assertIsNotNone(item.expired_event_at)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,299 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
from govoplan_idm.backend.assignment_transitions import (
|
||||
AssignmentSnapshot,
|
||||
AssignmentTransitionError,
|
||||
lifecycle_event_types,
|
||||
plan_assignment_update,
|
||||
validate_assignment_shape,
|
||||
validate_assignment_source_rules,
|
||||
)
|
||||
|
||||
|
||||
def assignment(**overrides: object) -> SimpleNamespace:
|
||||
values = {
|
||||
"id": "assignment-1",
|
||||
"identity_id": "identity-1",
|
||||
"account_id": "account-1",
|
||||
"function_id": "function-1",
|
||||
"organization_unit_id": "unit-1",
|
||||
"applies_to_subunits": False,
|
||||
"source": "direct",
|
||||
"delegated_from_assignment_id": None,
|
||||
"acting_for_account_id": None,
|
||||
"valid_from": None,
|
||||
"valid_until": None,
|
||||
"is_active": True,
|
||||
"settings": {},
|
||||
}
|
||||
values.update(overrides)
|
||||
return SimpleNamespace(**values)
|
||||
|
||||
|
||||
def function(**overrides: object) -> SimpleNamespace:
|
||||
values = {"delegable": True, "act_in_place_allowed": True}
|
||||
values.update(overrides)
|
||||
return SimpleNamespace(**values)
|
||||
|
||||
|
||||
class AssignmentWorkflowTests(unittest.TestCase):
|
||||
def assert_invalid(self, expected_detail: str, callback: object) -> None:
|
||||
with self.assertRaises(AssignmentTransitionError) as captured:
|
||||
callback()
|
||||
self.assertEqual(str(captured.exception), expected_detail)
|
||||
|
||||
def test_direct_assignment_accepts_plain_shape(self) -> None:
|
||||
item = assignment()
|
||||
|
||||
validate_assignment_shape(item) # type: ignore[arg-type]
|
||||
validate_assignment_source_rules( # type: ignore[arg-type]
|
||||
item,
|
||||
function=function(),
|
||||
base=None,
|
||||
account_linked_to_identity=lambda _identity_id, _account_id: False,
|
||||
)
|
||||
|
||||
def test_shape_rejects_backwards_validity_window(self) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
item = assignment(valid_from=now, valid_until=now - timedelta(days=1))
|
||||
|
||||
self.assert_invalid("Valid until must be after valid from.", lambda: validate_assignment_shape(item)) # type: ignore[arg-type]
|
||||
|
||||
def test_shape_rejects_self_delegation(self) -> None:
|
||||
item = assignment(id="assignment-1", delegated_from_assignment_id="assignment-1")
|
||||
|
||||
self.assert_invalid("A function assignment cannot delegate from itself.", lambda: validate_assignment_shape(item)) # type: ignore[arg-type]
|
||||
|
||||
def test_delegated_assignment_accepts_different_target(self) -> None:
|
||||
base = assignment(id="source-1", identity_id="identity-1", account_id="account-1")
|
||||
item = assignment(
|
||||
id="assignment-2",
|
||||
identity_id="identity-2",
|
||||
account_id="account-2",
|
||||
source="delegated",
|
||||
delegated_from_assignment_id="source-1",
|
||||
)
|
||||
|
||||
validate_assignment_source_rules( # type: ignore[arg-type]
|
||||
item,
|
||||
function=function(delegable=True),
|
||||
base=base,
|
||||
account_linked_to_identity=lambda _identity_id, _account_id: False,
|
||||
)
|
||||
|
||||
def test_delegated_assignment_rejects_same_target(self) -> None:
|
||||
base = assignment(id="source-1", identity_id="identity-1", account_id="account-1")
|
||||
item = assignment(source="delegated", delegated_from_assignment_id="source-1")
|
||||
|
||||
self.assert_invalid(
|
||||
"A delegated assignment must target another identity or account.",
|
||||
lambda: validate_assignment_source_rules( # type: ignore[arg-type]
|
||||
item,
|
||||
function=function(delegable=True),
|
||||
base=base,
|
||||
account_linked_to_identity=lambda _identity_id, _account_id: False,
|
||||
),
|
||||
)
|
||||
|
||||
def test_delegated_assignment_rejects_function_that_forbids_delegation(self) -> None:
|
||||
base = assignment(id="source-1")
|
||||
item = assignment(
|
||||
id="assignment-2",
|
||||
identity_id="identity-2",
|
||||
account_id="account-2",
|
||||
source="delegated",
|
||||
delegated_from_assignment_id="source-1",
|
||||
)
|
||||
|
||||
self.assert_invalid(
|
||||
"This organization function does not allow delegation.",
|
||||
lambda: validate_assignment_source_rules( # type: ignore[arg-type]
|
||||
item,
|
||||
function=function(delegable=False),
|
||||
base=base,
|
||||
account_linked_to_identity=lambda _identity_id, _account_id: False,
|
||||
),
|
||||
)
|
||||
|
||||
def test_acting_for_assignment_accepts_identity_linked_account(self) -> None:
|
||||
base = assignment(id="source-1", identity_id="identity-1", account_id=None)
|
||||
item = assignment(
|
||||
id="assignment-2",
|
||||
source="acting_for",
|
||||
delegated_from_assignment_id="source-1",
|
||||
account_id="acting-account",
|
||||
acting_for_account_id="represented-account",
|
||||
)
|
||||
|
||||
validate_assignment_source_rules( # type: ignore[arg-type]
|
||||
item,
|
||||
function=function(act_in_place_allowed=True),
|
||||
base=base,
|
||||
account_linked_to_identity=lambda identity_id, account_id: identity_id == "identity-1" and account_id == "represented-account",
|
||||
)
|
||||
|
||||
def test_acting_for_assignment_rejects_unlinked_represented_account(self) -> None:
|
||||
base = assignment(id="source-1", identity_id="identity-1", account_id=None)
|
||||
item = assignment(
|
||||
id="assignment-2",
|
||||
source="acting_for",
|
||||
delegated_from_assignment_id="source-1",
|
||||
account_id="acting-account",
|
||||
acting_for_account_id="represented-account",
|
||||
)
|
||||
|
||||
self.assert_invalid(
|
||||
"Acting-for account must belong to the source assignment identity.",
|
||||
lambda: validate_assignment_source_rules( # type: ignore[arg-type]
|
||||
item,
|
||||
function=function(act_in_place_allowed=True),
|
||||
base=base,
|
||||
account_linked_to_identity=lambda _identity_id, _account_id: False,
|
||||
),
|
||||
)
|
||||
|
||||
def test_acting_for_assignment_rejects_function_that_forbids_representation(self) -> None:
|
||||
base = assignment(id="source-1")
|
||||
item = assignment(
|
||||
id="assignment-2",
|
||||
identity_id="identity-2",
|
||||
account_id="acting-account",
|
||||
source="acting_for",
|
||||
delegated_from_assignment_id="source-1",
|
||||
acting_for_account_id="account-1",
|
||||
)
|
||||
|
||||
self.assert_invalid(
|
||||
"This organization function does not allow acting in place.",
|
||||
lambda: validate_assignment_source_rules( # type: ignore[arg-type]
|
||||
item,
|
||||
function=function(act_in_place_allowed=False),
|
||||
base=base,
|
||||
account_linked_to_identity=lambda _identity_id, _account_id: False,
|
||||
),
|
||||
)
|
||||
|
||||
def test_update_plan_does_not_mutate_until_applied(self) -> None:
|
||||
item = assignment()
|
||||
|
||||
plan = plan_assignment_update(
|
||||
item, # type: ignore[arg-type]
|
||||
{
|
||||
"function_id": "function-2",
|
||||
"account_id": "account-2",
|
||||
"settings": {"source": "test"},
|
||||
},
|
||||
organization_unit_id="unit-2",
|
||||
)
|
||||
|
||||
self.assertEqual("function-1", item.function_id)
|
||||
self.assertEqual("function-2", plan.after.function_id)
|
||||
self.assertEqual("unit-2", plan.after.organization_unit_id)
|
||||
self.assertEqual(
|
||||
("account_id", "function_id", "settings", "organization_unit_id"),
|
||||
plan.changed_fields,
|
||||
)
|
||||
plan.apply(item) # type: ignore[arg-type]
|
||||
self.assertEqual("function-2", item.function_id)
|
||||
self.assertEqual("unit-2", item.organization_unit_id)
|
||||
|
||||
def test_update_decision_table_rejects_required_nulls(self) -> None:
|
||||
item = assignment()
|
||||
cases = {
|
||||
"identity_id": "Identity is required.",
|
||||
"function_id": "Function is required.",
|
||||
"applies_to_subunits": "Subunit applicability cannot be empty.",
|
||||
"source": "Assignment source is required.",
|
||||
"is_active": "Active state cannot be empty.",
|
||||
"settings": "Settings cannot be empty.",
|
||||
}
|
||||
for field, message in cases.items():
|
||||
with self.subTest(field=field):
|
||||
self.assert_invalid(
|
||||
message,
|
||||
lambda field=field: plan_assignment_update( # type: ignore[misc]
|
||||
item, # type: ignore[arg-type]
|
||||
{field: None},
|
||||
organization_unit_id=(
|
||||
"unit-2" if field == "function_id" else None
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
def test_lifecycle_event_decision_table(self) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
before = AssignmentSnapshot.from_assignment(assignment()) # type: ignore[arg-type]
|
||||
cases = (
|
||||
(
|
||||
"ordinary update",
|
||||
plan_assignment_update(
|
||||
assignment(), # type: ignore[arg-type]
|
||||
{"settings": {"changed": True}},
|
||||
).after,
|
||||
("idm.function_assignment.changed.v1",),
|
||||
),
|
||||
(
|
||||
"revocation",
|
||||
plan_assignment_update(
|
||||
assignment(), # type: ignore[arg-type]
|
||||
{"is_active": False},
|
||||
).after,
|
||||
(
|
||||
"idm.function_assignment.changed.v1",
|
||||
"idm.function_assignment.revoked.v1",
|
||||
),
|
||||
),
|
||||
(
|
||||
"new expiration",
|
||||
plan_assignment_update(
|
||||
assignment(), # type: ignore[arg-type]
|
||||
{"valid_until": now - timedelta(minutes=1)},
|
||||
).after,
|
||||
(
|
||||
"idm.function_assignment.changed.v1",
|
||||
"idm.function_assignment.expired.v1",
|
||||
),
|
||||
),
|
||||
(
|
||||
"future validity",
|
||||
plan_assignment_update(
|
||||
assignment(), # type: ignore[arg-type]
|
||||
{"valid_until": now + timedelta(minutes=1)},
|
||||
).after,
|
||||
("idm.function_assignment.changed.v1",),
|
||||
),
|
||||
)
|
||||
for label, after, expected in cases:
|
||||
with self.subTest(label=label):
|
||||
self.assertEqual(
|
||||
expected,
|
||||
lifecycle_event_types(before, after, now=now),
|
||||
)
|
||||
|
||||
def test_reactivation_of_elapsed_assignment_emits_expiry(self) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
item = assignment(
|
||||
is_active=False,
|
||||
valid_until=now - timedelta(minutes=1),
|
||||
)
|
||||
before = AssignmentSnapshot.from_assignment(item) # type: ignore[arg-type]
|
||||
after = plan_assignment_update( # type: ignore[arg-type]
|
||||
item,
|
||||
{"is_active": True},
|
||||
).after
|
||||
|
||||
self.assertEqual(
|
||||
(
|
||||
"idm.function_assignment.changed.v1",
|
||||
"idm.function_assignment.expired.v1",
|
||||
),
|
||||
lifecycle_event_types(before, after, now=now),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,183 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.policy import FunctionAssignmentGovernanceDecision
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.db.session import configure_database, reset_database
|
||||
from govoplan_identity.backend.db import models as identity_models # noqa: F401
|
||||
from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment
|
||||
from govoplan_idm.backend.delegation_routes import validate_delegation_chain
|
||||
from govoplan_organizations.backend.db import models as organization_models # noqa: F401
|
||||
|
||||
|
||||
class DelegationRouteTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.database = configure_database("sqlite:///:memory:")
|
||||
Base.metadata.create_all(
|
||||
self.database.engine,
|
||||
tables=[IdmOrganizationFunctionAssignment.__table__],
|
||||
)
|
||||
self.now = datetime(2026, 8, 22, 12, tzinfo=timezone.utc)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
reset_database(dispose=True)
|
||||
|
||||
def assignment(
|
||||
self,
|
||||
assignment_id: str,
|
||||
*,
|
||||
source: str = "direct",
|
||||
parent: str | None = None,
|
||||
active: bool = True,
|
||||
valid_from: datetime | None = None,
|
||||
valid_until: datetime | None = None,
|
||||
) -> IdmOrganizationFunctionAssignment:
|
||||
return IdmOrganizationFunctionAssignment(
|
||||
id=assignment_id,
|
||||
tenant_id="tenant-1",
|
||||
identity_id=f"identity-{assignment_id}",
|
||||
account_id=f"account-{assignment_id}",
|
||||
function_id="function-1",
|
||||
organization_unit_id="unit-1",
|
||||
source=source,
|
||||
delegated_from_assignment_id=parent,
|
||||
is_active=active,
|
||||
valid_from=valid_from,
|
||||
valid_until=valid_until,
|
||||
settings={},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def decision(
|
||||
*,
|
||||
allowed: bool = True,
|
||||
depth: int = 3,
|
||||
validity_days: int | None = None,
|
||||
) -> FunctionAssignmentGovernanceDecision:
|
||||
return FunctionAssignmentGovernanceDecision(
|
||||
allowed=True,
|
||||
delegation_allowed=allowed,
|
||||
maximum_delegation_depth=depth if allowed else 0,
|
||||
maximum_delegated_validity_days=validity_days,
|
||||
)
|
||||
|
||||
def test_complete_effective_chain_is_accepted(self) -> None:
|
||||
with self.database.session() as session:
|
||||
root = self.assignment(
|
||||
"root",
|
||||
valid_from=self.now - timedelta(days=30),
|
||||
valid_until=self.now + timedelta(days=30),
|
||||
)
|
||||
first = self.assignment(
|
||||
"first",
|
||||
source="delegated",
|
||||
parent="root",
|
||||
valid_from=self.now - timedelta(days=10),
|
||||
valid_until=self.now + timedelta(days=20),
|
||||
)
|
||||
second = self.assignment(
|
||||
"second",
|
||||
source="delegated",
|
||||
parent="first",
|
||||
valid_from=self.now - timedelta(days=1),
|
||||
valid_until=self.now + timedelta(days=5),
|
||||
)
|
||||
session.add_all((root, first, second))
|
||||
session.flush()
|
||||
|
||||
route = validate_delegation_chain(
|
||||
session,
|
||||
assignment=second,
|
||||
tenant_id="tenant-1",
|
||||
function_id="function-1",
|
||||
decision=self.decision(depth=2),
|
||||
effective_at=self.now,
|
||||
)
|
||||
|
||||
self.assertTrue(route.effective)
|
||||
self.assertEqual(("second", "first", "root"), route.chain_assignment_ids)
|
||||
self.assertEqual(2, route.delegation_depth)
|
||||
|
||||
def test_policy_tightening_and_over_depth_fail_closed(self) -> None:
|
||||
with self.database.session() as session:
|
||||
root = self.assignment("root")
|
||||
first = self.assignment("first", source="delegated", parent="root")
|
||||
second = self.assignment("second", source="delegated", parent="first")
|
||||
session.add_all((root, first, second))
|
||||
session.flush()
|
||||
|
||||
disabled = validate_delegation_chain(
|
||||
session,
|
||||
assignment=second,
|
||||
tenant_id="tenant-1",
|
||||
function_id="function-1",
|
||||
decision=self.decision(allowed=False),
|
||||
effective_at=self.now,
|
||||
)
|
||||
shallow = validate_delegation_chain(
|
||||
session,
|
||||
assignment=second,
|
||||
tenant_id="tenant-1",
|
||||
function_id="function-1",
|
||||
decision=self.decision(depth=1),
|
||||
effective_at=self.now,
|
||||
)
|
||||
|
||||
self.assertEqual("policy_tightened", disabled.code)
|
||||
self.assertEqual("over_depth", shallow.code)
|
||||
|
||||
def test_expired_cyclic_and_overlong_routes_are_explained(self) -> None:
|
||||
with self.database.session() as session:
|
||||
expired = self.assignment(
|
||||
"expired",
|
||||
valid_until=self.now - timedelta(seconds=1),
|
||||
)
|
||||
cycle_a = self.assignment("cycle-a", source="delegated", parent="cycle-b")
|
||||
cycle_b = self.assignment("cycle-b", source="delegated", parent="cycle-a")
|
||||
overlong = self.assignment(
|
||||
"overlong",
|
||||
source="delegated",
|
||||
parent="root",
|
||||
valid_from=self.now,
|
||||
valid_until=self.now + timedelta(days=31),
|
||||
)
|
||||
root = self.assignment("root")
|
||||
session.add_all(
|
||||
(expired, cycle_a, cycle_b, root, overlong)
|
||||
)
|
||||
session.flush()
|
||||
|
||||
expired_route = validate_delegation_chain(
|
||||
session,
|
||||
assignment=expired,
|
||||
tenant_id="tenant-1",
|
||||
function_id="function-1",
|
||||
decision=self.decision(),
|
||||
effective_at=self.now,
|
||||
)
|
||||
cyclic_route = validate_delegation_chain(
|
||||
session,
|
||||
assignment=cycle_a,
|
||||
tenant_id="tenant-1",
|
||||
function_id="function-1",
|
||||
decision=self.decision(),
|
||||
effective_at=self.now,
|
||||
)
|
||||
overlong_route = validate_delegation_chain(
|
||||
session,
|
||||
assignment=overlong,
|
||||
tenant_id="tenant-1",
|
||||
function_id="function-1",
|
||||
decision=self.decision(validity_days=30),
|
||||
effective_at=self.now,
|
||||
)
|
||||
|
||||
self.assertEqual("expired", expired_route.code)
|
||||
self.assertEqual("cyclic", cyclic_route.code)
|
||||
self.assertEqual("policy_tightened", overlong_route.code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,336 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from govoplan_core.core.identity import IdentityAccountLinkRef, IdentityRef
|
||||
from govoplan_core.core.organizations import OrganizationFunctionRef
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.db.session import configure_database, reset_database
|
||||
from govoplan_identity.backend.db import models as identity_models # noqa: F401 - resolve assignment foreign keys
|
||||
from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment
|
||||
from govoplan_idm.backend.directory import SqlIdmDirectory
|
||||
from govoplan_organizations.backend.db import models as organization_models # noqa: F401 - resolve assignment foreign keys
|
||||
|
||||
|
||||
class StubIdentityDirectory:
|
||||
def __init__(self, identities: tuple[IdentityRef, ...] = ()) -> None:
|
||||
self.identities = identities
|
||||
|
||||
def get_identity(self, identity_id: str) -> IdentityRef | None:
|
||||
return next(
|
||||
(item for item in self.identities if item.id == identity_id),
|
||||
None,
|
||||
)
|
||||
|
||||
def identity_for_account(self, account_id: str) -> IdentityRef | None:
|
||||
return next(
|
||||
(
|
||||
item
|
||||
for item in self.identities
|
||||
if account_id in item.account_ids
|
||||
or item.primary_account_id == account_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
def identities_for_accounts(self, account_ids: tuple[str, ...]) -> tuple[IdentityRef, ...]:
|
||||
requested = set(account_ids)
|
||||
return tuple(
|
||||
item
|
||||
for item in self.identities
|
||||
if requested.intersection(item.account_ids)
|
||||
or item.primary_account_id in requested
|
||||
)
|
||||
|
||||
def accounts_for_identity(self, identity_id: str) -> tuple[IdentityAccountLinkRef, ...]:
|
||||
identity = self.get_identity(identity_id)
|
||||
if identity is None:
|
||||
return ()
|
||||
return tuple(
|
||||
IdentityAccountLinkRef(
|
||||
id=f"{identity_id}:{account_id}",
|
||||
identity_id=identity_id,
|
||||
account_id=account_id,
|
||||
is_primary=account_id == identity.primary_account_id,
|
||||
)
|
||||
for account_id in identity.account_ids
|
||||
)
|
||||
|
||||
|
||||
class StubOrganizationDirectory:
|
||||
def get_function(self, function_id: str) -> OrganizationFunctionRef | None:
|
||||
return OrganizationFunctionRef(
|
||||
id=function_id,
|
||||
tenant_id="tenant-1",
|
||||
organization_unit_id="unit-1",
|
||||
slug=function_id,
|
||||
name=function_id,
|
||||
status="active",
|
||||
)
|
||||
|
||||
def get_organization_unit(self, organization_unit_id: str):
|
||||
return None
|
||||
|
||||
def organization_units_for_tenant(self, tenant_id: str):
|
||||
return ()
|
||||
|
||||
def functions_for_organization_unit(self, organization_unit_id: str, *, include_subunits: bool = False):
|
||||
return ()
|
||||
|
||||
|
||||
class IdmDirectoryDelegationTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.database = configure_database("sqlite:///:memory:")
|
||||
Base.metadata.create_all(
|
||||
self.database.engine,
|
||||
tables=[IdmOrganizationFunctionAssignment.__table__],
|
||||
)
|
||||
self.directory = SqlIdmDirectory(
|
||||
identities=StubIdentityDirectory(), # type: ignore[arg-type]
|
||||
organizations=StubOrganizationDirectory(), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
reset_database(dispose=True)
|
||||
|
||||
def _source_and_child(
|
||||
self,
|
||||
*,
|
||||
case: str,
|
||||
child_source: str = "delegated",
|
||||
source_tenant_id: str = "tenant-1",
|
||||
source_function_id: str = "function-1",
|
||||
source_is_active: bool = True,
|
||||
source_valid_from: datetime | None = None,
|
||||
source_valid_until: datetime | None = None,
|
||||
) -> tuple[IdmOrganizationFunctionAssignment, IdmOrganizationFunctionAssignment]:
|
||||
source = IdmOrganizationFunctionAssignment(
|
||||
id=f"source-{case}",
|
||||
tenant_id=source_tenant_id,
|
||||
identity_id=f"source-identity-{case}",
|
||||
function_id=source_function_id,
|
||||
organization_unit_id="unit-1",
|
||||
source="direct",
|
||||
is_active=source_is_active,
|
||||
valid_from=source_valid_from,
|
||||
valid_until=source_valid_until,
|
||||
settings={},
|
||||
)
|
||||
child = IdmOrganizationFunctionAssignment(
|
||||
id=f"child-{case}",
|
||||
tenant_id="tenant-1",
|
||||
identity_id=f"target-identity-{case}",
|
||||
function_id="function-1",
|
||||
organization_unit_id="unit-1",
|
||||
source=child_source,
|
||||
delegated_from_assignment_id=source.id,
|
||||
acting_for_account_id="represented-account" if child_source == "acting_for" else None,
|
||||
is_active=True,
|
||||
settings={},
|
||||
)
|
||||
return source, child
|
||||
|
||||
def test_effective_delegations_require_a_current_matching_source(self) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
cases = (
|
||||
self._source_and_child(case="valid"),
|
||||
self._source_and_child(case="acting", child_source="acting_for"),
|
||||
self._source_and_child(case="revoked", source_is_active=False),
|
||||
self._source_and_child(case="expired", source_valid_until=now - timedelta(days=1)),
|
||||
self._source_and_child(case="future", source_valid_from=now + timedelta(days=1)),
|
||||
self._source_and_child(case="wrong-tenant", source_tenant_id="tenant-2"),
|
||||
self._source_and_child(case="wrong-function", source_function_id="function-2"),
|
||||
)
|
||||
with self.database.session() as session:
|
||||
for source, child in cases:
|
||||
session.add_all((source, child))
|
||||
session.commit()
|
||||
|
||||
expected = {
|
||||
"valid": ("child-valid",),
|
||||
"acting": ("child-acting",),
|
||||
"revoked": (),
|
||||
"expired": (),
|
||||
"future": (),
|
||||
"wrong-tenant": (),
|
||||
"wrong-function": (),
|
||||
}
|
||||
for case, expected_ids in expected.items():
|
||||
with self.subTest(case=case):
|
||||
assignments = self.directory.organization_function_assignments_for_identity(
|
||||
f"target-identity-{case}",
|
||||
tenant_id="tenant-1",
|
||||
)
|
||||
self.assertEqual(expected_ids, tuple(item.id for item in assignments))
|
||||
|
||||
def test_reverse_lookup_returns_only_effective_function_assignments(self) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
active = IdmOrganizationFunctionAssignment(
|
||||
id="active-function-holder",
|
||||
tenant_id="tenant-1",
|
||||
identity_id="identity-active",
|
||||
account_id="account-active",
|
||||
function_id="function-1",
|
||||
organization_unit_id="unit-1",
|
||||
source="direct",
|
||||
is_active=True,
|
||||
settings={},
|
||||
)
|
||||
expired = IdmOrganizationFunctionAssignment(
|
||||
id="expired-function-holder",
|
||||
tenant_id="tenant-1",
|
||||
identity_id="identity-expired",
|
||||
function_id="function-1",
|
||||
organization_unit_id="unit-1",
|
||||
source="direct",
|
||||
is_active=True,
|
||||
valid_until=now - timedelta(minutes=1),
|
||||
settings={},
|
||||
)
|
||||
other_tenant = IdmOrganizationFunctionAssignment(
|
||||
id="other-tenant-holder",
|
||||
tenant_id="tenant-2",
|
||||
identity_id="identity-other",
|
||||
function_id="function-1",
|
||||
organization_unit_id="unit-1",
|
||||
source="direct",
|
||||
is_active=True,
|
||||
settings={},
|
||||
)
|
||||
with self.database.session() as session:
|
||||
session.add_all((active, expired, other_tenant))
|
||||
session.commit()
|
||||
|
||||
assignments = self.directory.organization_function_assignments_for_function(
|
||||
"function-1",
|
||||
tenant_id="tenant-1",
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
("active-function-holder",),
|
||||
tuple(item.id for item in assignments),
|
||||
)
|
||||
|
||||
def test_batch_incumbency_reports_vacancy_and_honors_effective_time(
|
||||
self,
|
||||
) -> None:
|
||||
boundary = datetime.now(timezone.utc)
|
||||
assignment = IdmOrganizationFunctionAssignment(
|
||||
id="bounded-holder",
|
||||
tenant_id="tenant-1",
|
||||
identity_id="identity-bounded",
|
||||
account_id="account-bounded",
|
||||
function_id="function-1",
|
||||
organization_unit_id="unit-1",
|
||||
source="direct",
|
||||
is_active=True,
|
||||
valid_from=boundary - timedelta(hours=1),
|
||||
valid_until=boundary + timedelta(hours=1),
|
||||
settings={},
|
||||
)
|
||||
with self.database.session() as session:
|
||||
session.add(assignment)
|
||||
session.commit()
|
||||
|
||||
current = self.directory.organization_function_incumbencies(
|
||||
("function-1", "function-2"),
|
||||
tenant_id="tenant-1",
|
||||
effective_at=boundary,
|
||||
)
|
||||
later = self.directory.organization_function_incumbencies(
|
||||
("function-1",),
|
||||
tenant_id="tenant-1",
|
||||
effective_at=boundary + timedelta(hours=2),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
("bounded-holder",),
|
||||
tuple(item.id for item in current["function-1"].assignments),
|
||||
)
|
||||
self.assertFalse(current["function-1"].vacant)
|
||||
self.assertTrue(current["function-2"].vacant)
|
||||
self.assertTrue(later["function-1"].vacant)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "another tenant"):
|
||||
self.directory.organization_function_incumbencies(
|
||||
("function-1",),
|
||||
tenant_id="tenant-2",
|
||||
)
|
||||
|
||||
def test_batch_accounts_preserve_account_and_subunit_provenance(self) -> None:
|
||||
self.directory = SqlIdmDirectory(
|
||||
identities=StubIdentityDirectory(
|
||||
(
|
||||
IdentityRef(
|
||||
id="identity-shared",
|
||||
primary_account_id="account-1",
|
||||
account_ids=("account-1", "account-2"),
|
||||
),
|
||||
)
|
||||
), # type: ignore[arg-type]
|
||||
organizations=StubOrganizationDirectory(), # type: ignore[arg-type]
|
||||
)
|
||||
broad = IdmOrganizationFunctionAssignment(
|
||||
id="broad",
|
||||
tenant_id="tenant-1",
|
||||
identity_id="identity-shared",
|
||||
account_id=None,
|
||||
function_id="function-1",
|
||||
organization_unit_id="unit-1",
|
||||
applies_to_subunits=True,
|
||||
source="direct",
|
||||
is_active=True,
|
||||
settings={},
|
||||
)
|
||||
account_specific = IdmOrganizationFunctionAssignment(
|
||||
id="account-specific",
|
||||
tenant_id="tenant-1",
|
||||
identity_id="identity-shared",
|
||||
account_id="account-2",
|
||||
function_id="function-2",
|
||||
organization_unit_id="unit-1",
|
||||
source="governance",
|
||||
is_active=True,
|
||||
settings={},
|
||||
)
|
||||
simultaneous = IdmOrganizationFunctionAssignment(
|
||||
id="second-incumbent",
|
||||
tenant_id="tenant-1",
|
||||
identity_id="identity-second",
|
||||
function_id="function-1",
|
||||
organization_unit_id="unit-1",
|
||||
source="direct",
|
||||
is_active=True,
|
||||
settings={},
|
||||
)
|
||||
with self.database.session() as session:
|
||||
session.add_all((broad, account_specific, simultaneous))
|
||||
session.commit()
|
||||
|
||||
resolved = self.directory.organization_function_assignments_for_accounts(
|
||||
("account-1", "account-2", "missing"),
|
||||
tenant_id="tenant-1",
|
||||
)
|
||||
self.assertEqual(("broad",), tuple(item.id for item in resolved["account-1"]))
|
||||
self.assertEqual(
|
||||
("broad", "account-specific"),
|
||||
tuple(item.id for item in resolved["account-2"]),
|
||||
)
|
||||
self.assertEqual((), resolved["missing"])
|
||||
self.assertTrue(resolved["account-1"][0].applies_to_subunits)
|
||||
self.assertEqual("governance", resolved["account-2"][1].source)
|
||||
|
||||
incumbency = self.directory.organization_function_incumbencies(
|
||||
("function-1",),
|
||||
tenant_id="tenant-1",
|
||||
)["function-1"]
|
||||
self.assertEqual(
|
||||
("broad", "second-incumbent"),
|
||||
tuple(item.id for item in incumbency.assignments),
|
||||
)
|
||||
self.assertFalse(incumbency.vacant)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,560 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from govoplan_core.core.dsar import (
|
||||
DsarErasureActionRef,
|
||||
DsarProvider,
|
||||
DsarSubjectRef,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.privacy.dsar_workflow import (
|
||||
create_data_subject_request,
|
||||
search_data_subject_request,
|
||||
)
|
||||
from govoplan_identity.backend.db.models import CanonicalIdentity
|
||||
from govoplan_idm.backend.db.models import (
|
||||
IdmFunctionAssignmentChange,
|
||||
IdmFunctionAssignmentChangeEvent,
|
||||
IdmIdentityRelationship,
|
||||
IdmOrganizationFunctionAssignment,
|
||||
IdmTypedGroup,
|
||||
)
|
||||
from govoplan_idm.backend.dsar_provider import IDM_DSAR_CAPABILITY, IdmDsarProvider
|
||||
from govoplan_idm.backend.manifest import manifest
|
||||
from govoplan_organizations.backend.db.models import (
|
||||
OrganizationFunction,
|
||||
OrganizationUnit,
|
||||
)
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, provider: IdmDsarProvider, *, idm_active: bool = True) -> None:
|
||||
self.provider = provider
|
||||
self.idm_active = idm_active
|
||||
|
||||
def capability_names(self):
|
||||
return (IDM_DSAR_CAPABILITY,)
|
||||
|
||||
def capability_owner(self, name):
|
||||
self._assert_capability(name)
|
||||
return "idm"
|
||||
|
||||
def tenant_entitlement_resolver(self):
|
||||
idm_active = self.idm_active
|
||||
|
||||
class _Resolver:
|
||||
@staticmethod
|
||||
def resolve(session, tenant_id):
|
||||
del session, tenant_id
|
||||
return type(
|
||||
"State",
|
||||
(),
|
||||
{"effective_modules": ("idm",) if idm_active else ()},
|
||||
)()
|
||||
|
||||
return _Resolver()
|
||||
|
||||
def require_tenant_capability(self, name, session, **kwargs):
|
||||
del session, kwargs
|
||||
self._assert_capability(name)
|
||||
return self.provider
|
||||
|
||||
def manifests(self):
|
||||
return (type("Manifest", (), {"id": "idm"})(),)
|
||||
|
||||
@staticmethod
|
||||
def _assert_capability(name: str) -> None:
|
||||
if name != IDM_DSAR_CAPABILITY:
|
||||
raise KeyError(name)
|
||||
|
||||
|
||||
class IdmDsarProviderTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:", future=True)
|
||||
Base.metadata.create_all(bind=self.engine)
|
||||
self.session = sessionmaker(bind=self.engine, future=True)()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
self.identity = CanonicalIdentity(
|
||||
id="identity-1",
|
||||
display_name="Subject",
|
||||
settings={"secret": "identity-settings-do-not-export"},
|
||||
)
|
||||
other_identity = CanonicalIdentity(
|
||||
id="identity-other",
|
||||
display_name="Unrelated Person",
|
||||
settings={"secret": "other-identity-settings-do-not-export"},
|
||||
)
|
||||
unit = OrganizationUnit(
|
||||
id="unit-1",
|
||||
tenant_id="tenant-1",
|
||||
slug="residents",
|
||||
name="Residents Office",
|
||||
)
|
||||
function = OrganizationFunction(
|
||||
id="function-1",
|
||||
tenant_id="tenant-1",
|
||||
organization_unit_id=unit.id,
|
||||
slug="case-worker",
|
||||
name="Case worker",
|
||||
)
|
||||
acting_function = OrganizationFunction(
|
||||
id="function-2",
|
||||
tenant_id="tenant-1",
|
||||
organization_unit_id=unit.id,
|
||||
slug="acting-case-worker",
|
||||
name="Acting case worker",
|
||||
)
|
||||
self.assignment = IdmOrganizationFunctionAssignment(
|
||||
id="assignment-1",
|
||||
tenant_id="tenant-1",
|
||||
identity_id=self.identity.id,
|
||||
account_id="account-1",
|
||||
function_id=function.id,
|
||||
organization_unit_id=unit.id,
|
||||
source="direct",
|
||||
valid_from=now,
|
||||
settings={"secret": "assignment-settings-do-not-export"},
|
||||
)
|
||||
acting_assignment = IdmOrganizationFunctionAssignment(
|
||||
id="assignment-acting",
|
||||
tenant_id="tenant-1",
|
||||
identity_id=other_identity.id,
|
||||
account_id="account-other",
|
||||
function_id=acting_function.id,
|
||||
organization_unit_id=unit.id,
|
||||
source="acting_for",
|
||||
delegated_from_assignment_id=self.assignment.id,
|
||||
acting_for_account_id="account-1",
|
||||
valid_from=now,
|
||||
settings={"secret": "acting-settings-do-not-export"},
|
||||
)
|
||||
self.unrelated_assignment = IdmOrganizationFunctionAssignment(
|
||||
id="assignment-unrelated",
|
||||
tenant_id="tenant-1",
|
||||
identity_id=other_identity.id,
|
||||
account_id="account-other",
|
||||
function_id=function.id,
|
||||
organization_unit_id=unit.id,
|
||||
source="direct",
|
||||
settings={"secret": "unrelated-assignment-do-not-export"},
|
||||
)
|
||||
tenant_two_assignment = IdmOrganizationFunctionAssignment(
|
||||
id="assignment-tenant-2",
|
||||
tenant_id="tenant-2",
|
||||
identity_id=self.identity.id,
|
||||
account_id="account-1",
|
||||
function_id=function.id,
|
||||
organization_unit_id=unit.id,
|
||||
source="directory",
|
||||
settings={"secret": "other-tenant-assignment-do-not-export"},
|
||||
)
|
||||
self.group = IdmTypedGroup(
|
||||
id="group-1",
|
||||
tenant_id="tenant-1",
|
||||
key="residents",
|
||||
name="Residents",
|
||||
group_type="business_group",
|
||||
source_provider="ldap",
|
||||
source_resource_id="private-group-ref-do-not-export",
|
||||
properties={"secret": "group-properties-do-not-export"},
|
||||
provenance={"secret": "group-provenance-do-not-export"},
|
||||
)
|
||||
self.relationship = IdmIdentityRelationship(
|
||||
id="relationship-1",
|
||||
tenant_id="tenant-1",
|
||||
relationship_kind="member_of",
|
||||
subject_identity_id=self.identity.id,
|
||||
target_group_id=self.group.id,
|
||||
role="member",
|
||||
valid_from=now,
|
||||
source_provider="ldap",
|
||||
source_resource_id="private-relationship-ref-do-not-export",
|
||||
source_revision="private-source-revision-do-not-export",
|
||||
properties={"secret": "relationship-properties-do-not-export"},
|
||||
provenance={"secret": "relationship-provenance-do-not-export"},
|
||||
)
|
||||
related_relationship = IdmIdentityRelationship(
|
||||
id="relationship-related",
|
||||
tenant_id="tenant-1",
|
||||
relationship_kind="representative_for",
|
||||
subject_identity_id=other_identity.id,
|
||||
related_identity_id=self.identity.id,
|
||||
role="representative",
|
||||
properties={"secret": "related-properties-do-not-export"},
|
||||
provenance={"secret": "related-provenance-do-not-export"},
|
||||
)
|
||||
unrelated_relationship = IdmIdentityRelationship(
|
||||
id="relationship-unrelated",
|
||||
tenant_id="tenant-1",
|
||||
relationship_kind="member_of",
|
||||
subject_identity_id=other_identity.id,
|
||||
target_group_id=self.group.id,
|
||||
role="member",
|
||||
)
|
||||
tenant_two_relationship = IdmIdentityRelationship(
|
||||
id="relationship-tenant-2",
|
||||
tenant_id="tenant-2",
|
||||
relationship_kind="member_of",
|
||||
subject_identity_id=self.identity.id,
|
||||
target_group_id=self.group.id,
|
||||
role="member",
|
||||
properties={"secret": "other-tenant-relationship-do-not-export"},
|
||||
)
|
||||
self.change = IdmFunctionAssignmentChange(
|
||||
id="change-1",
|
||||
tenant_id="tenant-1",
|
||||
kind="request",
|
||||
state="approved",
|
||||
profile="self_request",
|
||||
function_id=function.id,
|
||||
organization_unit_id=unit.id,
|
||||
candidate_identity_id=self.identity.id,
|
||||
candidate_account_id="account-1",
|
||||
initiator_account_id="account-other",
|
||||
initiator_identity_id=other_identity.id,
|
||||
justification="private-justification-do-not-export",
|
||||
evidence=["private-evidence-do-not-export"],
|
||||
requested_valid_from=now,
|
||||
required_steps=["approval-secret-do-not-export"],
|
||||
completed_steps=["approval-secret-do-not-export"],
|
||||
policy_decision={"secret": "policy-decision-do-not-export"},
|
||||
workflow_definition_id="workflow-secret-do-not-export",
|
||||
workflow_instance_id="workflow-instance-do-not-export",
|
||||
idempotency_key="idempotency-key-do-not-export",
|
||||
outcome_reason="outcome-reason-do-not-export",
|
||||
metadata_={"secret": "change-metadata-do-not-export"},
|
||||
)
|
||||
initiated_change = IdmFunctionAssignmentChange(
|
||||
id="change-initiated",
|
||||
tenant_id="tenant-1",
|
||||
kind="grant",
|
||||
state="pending",
|
||||
profile="authority_grant",
|
||||
function_id=acting_function.id,
|
||||
organization_unit_id=unit.id,
|
||||
candidate_identity_id=other_identity.id,
|
||||
candidate_account_id="account-other",
|
||||
initiator_account_id="account-1",
|
||||
initiator_identity_id=self.identity.id,
|
||||
justification="third-party-justification-do-not-export",
|
||||
evidence=["third-party-evidence-do-not-export"],
|
||||
idempotency_key="initiated-change-key-do-not-export",
|
||||
metadata_={"secret": "initiated-metadata-do-not-export"},
|
||||
)
|
||||
unrelated_change = IdmFunctionAssignmentChange(
|
||||
id="change-unrelated",
|
||||
tenant_id="tenant-1",
|
||||
kind="grant",
|
||||
state="pending",
|
||||
profile="authority_grant",
|
||||
function_id=function.id,
|
||||
organization_unit_id=unit.id,
|
||||
candidate_identity_id=other_identity.id,
|
||||
candidate_account_id="account-other",
|
||||
initiator_account_id="account-other",
|
||||
initiator_identity_id=other_identity.id,
|
||||
justification="unrelated-change-do-not-export",
|
||||
idempotency_key="unrelated-change-key",
|
||||
)
|
||||
tenant_two_change = IdmFunctionAssignmentChange(
|
||||
id="change-tenant-2",
|
||||
tenant_id="tenant-2",
|
||||
kind="request",
|
||||
state="pending",
|
||||
profile="self_request",
|
||||
function_id=function.id,
|
||||
organization_unit_id=unit.id,
|
||||
candidate_identity_id=self.identity.id,
|
||||
candidate_account_id="account-1",
|
||||
initiator_account_id="account-1",
|
||||
initiator_identity_id=self.identity.id,
|
||||
justification="other-tenant-change-do-not-export",
|
||||
idempotency_key="tenant-two-key",
|
||||
)
|
||||
self.event = IdmFunctionAssignmentChangeEvent(
|
||||
id="event-1",
|
||||
tenant_id="tenant-1",
|
||||
change_id=self.change.id,
|
||||
sequence=1,
|
||||
action="approved",
|
||||
from_state="pending",
|
||||
to_state="approved",
|
||||
actor_account_id="account-other",
|
||||
actor_identity_id=other_identity.id,
|
||||
actor_assignment_id=self.unrelated_assignment.id,
|
||||
comment="private-event-comment-do-not-export",
|
||||
evidence=["private-event-evidence-do-not-export"],
|
||||
policy_decision={"secret": "event-policy-do-not-export"},
|
||||
workflow_step_id="workflow-step-do-not-export",
|
||||
details={"secret": "event-details-do-not-export"},
|
||||
created_at=now,
|
||||
)
|
||||
actor_event = IdmFunctionAssignmentChangeEvent(
|
||||
id="event-actor",
|
||||
tenant_id="tenant-1",
|
||||
change_id=unrelated_change.id,
|
||||
sequence=1,
|
||||
action="reviewed",
|
||||
from_state="pending",
|
||||
to_state="pending",
|
||||
actor_account_id="account-1",
|
||||
actor_identity_id=self.identity.id,
|
||||
actor_assignment_id=self.assignment.id,
|
||||
comment="actor-comment-do-not-export",
|
||||
evidence=["actor-evidence-do-not-export"],
|
||||
policy_decision={"secret": "actor-policy-do-not-export"},
|
||||
workflow_step_id="actor-workflow-step-do-not-export",
|
||||
details={"secret": "actor-details-do-not-export"},
|
||||
created_at=now,
|
||||
)
|
||||
tenant_two_event = IdmFunctionAssignmentChangeEvent(
|
||||
id="event-tenant-2",
|
||||
tenant_id="tenant-2",
|
||||
change_id=tenant_two_change.id,
|
||||
sequence=1,
|
||||
action="requested",
|
||||
to_state="pending",
|
||||
actor_account_id="account-1",
|
||||
actor_identity_id=self.identity.id,
|
||||
details={"secret": "other-tenant-event-do-not-export"},
|
||||
created_at=now,
|
||||
)
|
||||
self.session.add_all(
|
||||
[
|
||||
self.identity,
|
||||
other_identity,
|
||||
unit,
|
||||
function,
|
||||
acting_function,
|
||||
self.assignment,
|
||||
acting_assignment,
|
||||
self.unrelated_assignment,
|
||||
tenant_two_assignment,
|
||||
self.group,
|
||||
self.relationship,
|
||||
related_relationship,
|
||||
unrelated_relationship,
|
||||
tenant_two_relationship,
|
||||
self.change,
|
||||
initiated_change,
|
||||
unrelated_change,
|
||||
tenant_two_change,
|
||||
self.event,
|
||||
actor_event,
|
||||
tenant_two_event,
|
||||
]
|
||||
)
|
||||
self.session.commit()
|
||||
self.provider = IdmDsarProvider()
|
||||
self.subject = DsarSubjectRef(
|
||||
account_id="account-1",
|
||||
identity_id=self.identity.id,
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def test_manifest_publishes_protocol_conforming_provider(self) -> None:
|
||||
self.assertIn(
|
||||
IDM_DSAR_CAPABILITY,
|
||||
{item.name for item in manifest.provides_interfaces},
|
||||
)
|
||||
provider = manifest.capability_factories[IDM_DSAR_CAPABILITY](None)
|
||||
self.assertIsInstance(provider, DsarProvider)
|
||||
|
||||
def test_search_is_tenant_scoped_third_party_safe_and_minimized(self) -> None:
|
||||
records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=self.subject,
|
||||
)
|
||||
|
||||
self.assertTrue(
|
||||
{
|
||||
"idm_function_assignment",
|
||||
"idm_identity_relationship",
|
||||
"idm_typed_group_context",
|
||||
"idm_function_assignment_change",
|
||||
"idm_function_assignment_change_event",
|
||||
}.issubset({record.resource_type for record in records})
|
||||
)
|
||||
serialized = repr([record.to_dict() for record in records])
|
||||
self.assertIn("assignment-acting", serialized)
|
||||
self.assertIn("relationship-related", serialized)
|
||||
self.assertIn("change-initiated", serialized)
|
||||
self.assertIn("event-actor", serialized)
|
||||
excluded = (
|
||||
"identity-other",
|
||||
"account-other",
|
||||
"assignment-settings-do-not-export",
|
||||
"private-group-ref-do-not-export",
|
||||
"group-properties-do-not-export",
|
||||
"group-provenance-do-not-export",
|
||||
"private-relationship-ref-do-not-export",
|
||||
"private-source-revision-do-not-export",
|
||||
"relationship-properties-do-not-export",
|
||||
"relationship-provenance-do-not-export",
|
||||
"private-justification-do-not-export",
|
||||
"private-evidence-do-not-export",
|
||||
"approval-secret-do-not-export",
|
||||
"policy-decision-do-not-export",
|
||||
"workflow-secret-do-not-export",
|
||||
"workflow-instance-do-not-export",
|
||||
"idempotency-key-do-not-export",
|
||||
"outcome-reason-do-not-export",
|
||||
"change-metadata-do-not-export",
|
||||
"private-event-comment-do-not-export",
|
||||
"private-event-evidence-do-not-export",
|
||||
"event-policy-do-not-export",
|
||||
"workflow-step-do-not-export",
|
||||
"event-details-do-not-export",
|
||||
"unrelated-change-do-not-export",
|
||||
"other-tenant-assignment-do-not-export",
|
||||
"other-tenant-relationship-do-not-export",
|
||||
"other-tenant-change-do-not-export",
|
||||
"other-tenant-event-do-not-export",
|
||||
)
|
||||
for value in excluded:
|
||||
self.assertNotIn(value, serialized)
|
||||
|
||||
def test_conflicting_and_uncorroborated_direct_selectors_fail_closed(self) -> None:
|
||||
conflict = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(
|
||||
identity_id=self.identity.id,
|
||||
external_references={"idm.identity": "identity-other"},
|
||||
),
|
||||
)
|
||||
direct_conflict = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(
|
||||
identity_id=self.identity.id,
|
||||
external_references={
|
||||
"idm.assignment": self.unrelated_assignment.id,
|
||||
},
|
||||
),
|
||||
)
|
||||
self.assertEqual((), conflict)
|
||||
self.assertEqual((), direct_conflict)
|
||||
|
||||
def test_plan_retains_evidence_and_routes_facts_to_manual_review(self) -> None:
|
||||
records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=self.subject,
|
||||
)
|
||||
actions = self.provider.plan_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=self.subject,
|
||||
records=records,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
{"manual_review", "retain"},
|
||||
{action.kind for action in actions},
|
||||
)
|
||||
self.assertFalse(any(action.executable for action in actions))
|
||||
results = self.provider.execute_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=self.subject,
|
||||
actions=actions,
|
||||
request_id="dsar-idm-1",
|
||||
)
|
||||
self.assertEqual({"blocked"}, {result.status for result in results})
|
||||
self.assertIsNotNone(
|
||||
self.session.get(IdmOrganizationFunctionAssignment, self.assignment.id)
|
||||
)
|
||||
|
||||
def test_execution_rejects_foreign_and_forged_executable_actions(self) -> None:
|
||||
actions = (
|
||||
DsarErasureActionRef(
|
||||
action_id="identity:delete:assignment:assignment-1",
|
||||
provider_id="identity",
|
||||
module_id="identity",
|
||||
kind="delete",
|
||||
resource_type="idm_function_assignment",
|
||||
resource_id=self.assignment.id,
|
||||
title="Foreign action",
|
||||
rationale="Must be rejected",
|
||||
executable=True,
|
||||
),
|
||||
DsarErasureActionRef(
|
||||
action_id="idm:delete:assignment:assignment-1",
|
||||
provider_id="idm",
|
||||
module_id="idm",
|
||||
kind="delete",
|
||||
resource_type="idm_function_assignment",
|
||||
resource_id=self.assignment.id,
|
||||
title="Forged action",
|
||||
rationale="Must be rejected",
|
||||
executable=True,
|
||||
),
|
||||
)
|
||||
for action in actions:
|
||||
with self.assertRaises(ValueError):
|
||||
self.provider.execute_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=self.subject,
|
||||
actions=(action,),
|
||||
request_id="dsar-idm-2",
|
||||
)
|
||||
|
||||
def test_core_workflow_discovers_active_and_inactive_provider(self) -> None:
|
||||
request = create_data_subject_request(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
reference="DSAR-IDM-1",
|
||||
request_kind="access",
|
||||
subject=self.subject,
|
||||
purpose="Respond to an authorized privacy request.",
|
||||
legal_basis="Article 15 GDPR",
|
||||
due_at=None,
|
||||
requested_by_account_id="privacy-officer",
|
||||
)
|
||||
self.session.commit()
|
||||
search_data_subject_request(
|
||||
self.session,
|
||||
registry=_Registry(self.provider),
|
||||
row=request,
|
||||
expected_revision=1,
|
||||
)
|
||||
self.assertEqual(["idm"], request.coverage["covered_modules"])
|
||||
|
||||
disabled = create_data_subject_request(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
reference="DSAR-IDM-DISABLED",
|
||||
request_kind="access",
|
||||
subject=self.subject,
|
||||
purpose="Verify disabled-module coverage.",
|
||||
legal_basis="Article 15 GDPR",
|
||||
due_at=None,
|
||||
requested_by_account_id="privacy-officer",
|
||||
)
|
||||
search_data_subject_request(
|
||||
self.session,
|
||||
registry=_Registry(self.provider, idm_active=False),
|
||||
row=disabled,
|
||||
expected_revision=1,
|
||||
)
|
||||
self.assertEqual(0, disabled.search_result["record_count"])
|
||||
self.assertEqual(
|
||||
[IDM_DSAR_CAPABILITY],
|
||||
disabled.coverage["inactive_provider_capabilities"],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,502 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from datetime import timedelta
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
from govoplan_core.core.concurrency import RevisionConflictError
|
||||
from govoplan_core.core.organizations import OrganizationFunctionRef
|
||||
from govoplan_core.core.policy import (
|
||||
FunctionAssignmentEscalationRule,
|
||||
FunctionAssignmentGovernanceDecision,
|
||||
)
|
||||
from govoplan_core.security.time import utc_now
|
||||
from govoplan_idm.backend.assignment_lifecycle import SqlIdmAssignmentLifecycle
|
||||
from govoplan_core.core.workflows import WorkflowInstanceRef
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.db.session import configure_database, reset_database
|
||||
from govoplan_identity.backend.db import models as identity_models # noqa: F401
|
||||
from govoplan_idm.backend.api.v1.schemas import FunctionAssignmentChangeCreateRequest
|
||||
from govoplan_idm.backend.api.v1.function_changes import _change_item
|
||||
from govoplan_idm.backend.db.models import (
|
||||
IdmFunctionAssignmentChange,
|
||||
IdmFunctionAssignmentChangeEvent,
|
||||
IdmIdentityRelationship,
|
||||
IdmOrganizationFunctionAssignment,
|
||||
IdmTenantSettings,
|
||||
IdmTypedGroup,
|
||||
)
|
||||
from govoplan_idm.backend.function_assignment_changes import (
|
||||
FunctionAssignmentChangeConflict,
|
||||
create_function_assignment_change,
|
||||
transition_function_assignment_change,
|
||||
)
|
||||
from govoplan_organizations.backend.db import models as organization_models # noqa: F401
|
||||
|
||||
|
||||
class _Policy:
|
||||
def resolve_function_assignment_action(self, session=None, *, request):
|
||||
del session
|
||||
steps = ("holder", "authority")
|
||||
context = request.context
|
||||
allowed = {
|
||||
"submit": bool(context.get("candidate_is_actor")),
|
||||
"approve_holder": bool(context.get("actor_is_holder")),
|
||||
"approve_authority": bool(context.get("actor_is_authority")),
|
||||
"approve_escalation": bool(
|
||||
context.get("actor_is_escalation_target")
|
||||
),
|
||||
"accept_recipient": bool(context.get("candidate_is_actor")),
|
||||
"request_changes": bool(
|
||||
context.get("actor_is_holder") or context.get("actor_is_authority")
|
||||
),
|
||||
"respond": bool(
|
||||
context.get("actor_is_initiator") or context.get("candidate_is_actor")
|
||||
),
|
||||
"reject": bool(
|
||||
context.get("actor_is_holder") or context.get("actor_is_authority")
|
||||
),
|
||||
"withdraw": bool(context.get("actor_is_initiator")),
|
||||
"recover": request.actor.account_id == "admin",
|
||||
"apply": bool(context.get("approvals_complete")),
|
||||
}.get(request.action, False)
|
||||
return FunctionAssignmentGovernanceDecision(
|
||||
allowed=allowed,
|
||||
reason=None if allowed else "Not eligible for this action.",
|
||||
profile="holder_with_authority_clearance",
|
||||
required_steps=steps,
|
||||
authority_function_id="authority-function",
|
||||
separation_of_duties=False,
|
||||
request_expiry_hours=24,
|
||||
escalation_rules=(
|
||||
FunctionAssignmentEscalationRule(
|
||||
step="holder",
|
||||
target_function_id="escalation-function",
|
||||
timeout_hours=1,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class _Workflow:
|
||||
nodes = ("holder_review", "authority_review", "recipient_review")
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.instances: dict[str, WorkflowInstanceRef] = {}
|
||||
|
||||
def start_standard(self, session, principal, *, request):
|
||||
del session, principal
|
||||
instance_id = f"workflow-{request.idempotency_key}"
|
||||
existing = self.instances.get(instance_id)
|
||||
if existing is not None:
|
||||
return replace(existing, replayed=True)
|
||||
reference = WorkflowInstanceRef(
|
||||
id=instance_id,
|
||||
tenant_id=request.tenant_id,
|
||||
definition_id="definition-1",
|
||||
definition_revision_id="revision-1",
|
||||
definition_revision=1,
|
||||
definition_hash="a" * 64,
|
||||
status="waiting",
|
||||
current_step_id="step-holder_review",
|
||||
current_node_id="holder_review",
|
||||
)
|
||||
self.instances[instance_id] = reference
|
||||
return reference
|
||||
|
||||
def resolve_current_step(
|
||||
self,
|
||||
session,
|
||||
principal,
|
||||
*,
|
||||
tenant_id,
|
||||
instance_id,
|
||||
resolution,
|
||||
):
|
||||
del session, principal, tenant_id
|
||||
current = self.instances[instance_id]
|
||||
if resolution.expected_step_id != current.current_step_id:
|
||||
raise ValueError("Workflow current step changed.")
|
||||
if resolution.action == "changes":
|
||||
return current
|
||||
if resolution.action in {"reject", "cancel"}:
|
||||
result = replace(
|
||||
current,
|
||||
status="completed",
|
||||
current_step_id=None,
|
||||
current_node_id=None,
|
||||
)
|
||||
else:
|
||||
index = self.nodes.index(current.current_node_id or "") + 1
|
||||
if index >= len(self.nodes):
|
||||
result = replace(
|
||||
current,
|
||||
status="completed",
|
||||
current_step_id=None,
|
||||
current_node_id=None,
|
||||
)
|
||||
else:
|
||||
node = self.nodes[index]
|
||||
result = replace(
|
||||
current,
|
||||
current_step_id=f"step-{node}",
|
||||
current_node_id=node,
|
||||
)
|
||||
self.instances[instance_id] = result
|
||||
return result
|
||||
|
||||
def get_instance(self, session, *, tenant_id, instance_id):
|
||||
del session, tenant_id
|
||||
return self.instances[instance_id]
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self) -> None:
|
||||
self.policy = _Policy()
|
||||
self.workflow = _Workflow()
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return name in {
|
||||
"policy.functionAssignmentGovernance",
|
||||
"workflow.orchestration",
|
||||
}
|
||||
|
||||
def capability(self, name: str):
|
||||
if name == "policy.functionAssignmentGovernance":
|
||||
return self.policy
|
||||
if name == "workflow.orchestration":
|
||||
return self.workflow
|
||||
return None
|
||||
|
||||
|
||||
def principal(account_id: str, identity_id: str) -> ApiPrincipal:
|
||||
return ApiPrincipal(
|
||||
principal=PrincipalRef(
|
||||
account_id=account_id,
|
||||
membership_id=f"membership-{account_id}",
|
||||
tenant_id="tenant-1",
|
||||
identity_id=identity_id,
|
||||
scopes=frozenset({"idm:function_change:decide"}),
|
||||
),
|
||||
account=object(),
|
||||
user=object(),
|
||||
)
|
||||
|
||||
|
||||
def function() -> OrganizationFunctionRef:
|
||||
return OrganizationFunctionRef(
|
||||
id="target-function",
|
||||
tenant_id="tenant-1",
|
||||
organization_unit_id="unit-1",
|
||||
slug="target",
|
||||
name="Target function",
|
||||
settings={
|
||||
"assignment_governance": {
|
||||
"request_profile": "holder_with_authority_clearance",
|
||||
"authority_function_id": "authority-function",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def payload() -> FunctionAssignmentChangeCreateRequest:
|
||||
return FunctionAssignmentChangeCreateRequest(
|
||||
kind="request",
|
||||
function_id="target-function",
|
||||
candidate_identity_id="candidate-identity",
|
||||
candidate_account_id="candidate",
|
||||
justification="The function is needed for the assigned work.",
|
||||
idempotency_key="request-1",
|
||||
)
|
||||
|
||||
|
||||
class FunctionAssignmentChangeTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.database = configure_database("sqlite:///:memory:")
|
||||
Base.metadata.create_all(
|
||||
self.database.engine,
|
||||
tables=[
|
||||
IdmOrganizationFunctionAssignment.__table__,
|
||||
IdmFunctionAssignmentChange.__table__,
|
||||
IdmFunctionAssignmentChangeEvent.__table__,
|
||||
IdmTenantSettings.__table__,
|
||||
IdmTypedGroup.__table__,
|
||||
IdmIdentityRelationship.__table__,
|
||||
ChangeSequenceEntry.__table__,
|
||||
],
|
||||
)
|
||||
self.registry = _Registry()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
reset_database(dispose=True)
|
||||
|
||||
def _add_reviewer_assignments(self, session) -> None:
|
||||
session.add_all(
|
||||
(
|
||||
IdmOrganizationFunctionAssignment(
|
||||
id="holder-assignment",
|
||||
tenant_id="tenant-1",
|
||||
identity_id="holder-identity",
|
||||
account_id="holder",
|
||||
function_id="target-function",
|
||||
organization_unit_id="unit-1",
|
||||
source="direct",
|
||||
is_active=True,
|
||||
settings={},
|
||||
),
|
||||
IdmOrganizationFunctionAssignment(
|
||||
id="authority-assignment",
|
||||
tenant_id="tenant-1",
|
||||
identity_id="authority-identity",
|
||||
account_id="authority",
|
||||
function_id="authority-function",
|
||||
organization_unit_id="unit-1",
|
||||
source="direct",
|
||||
is_active=True,
|
||||
settings={},
|
||||
),
|
||||
)
|
||||
)
|
||||
session.flush()
|
||||
|
||||
def test_request_is_idempotent_and_applies_once_after_required_steps(self) -> None:
|
||||
with self.database.session() as session:
|
||||
self._add_reviewer_assignments(session)
|
||||
change, replayed = create_function_assignment_change(
|
||||
session,
|
||||
principal=principal("candidate", "candidate-identity"),
|
||||
registry=self.registry,
|
||||
function=function(),
|
||||
payload=payload(),
|
||||
)
|
||||
session.flush()
|
||||
same, replay = create_function_assignment_change(
|
||||
session,
|
||||
principal=principal("candidate", "candidate-identity"),
|
||||
registry=self.registry,
|
||||
function=function(),
|
||||
payload=payload(),
|
||||
)
|
||||
self.assertFalse(replayed)
|
||||
self.assertTrue(replay)
|
||||
self.assertEqual(change.id, same.id)
|
||||
self.assertEqual("awaiting_holder", change.state)
|
||||
|
||||
transition_function_assignment_change(
|
||||
session,
|
||||
principal=principal("holder", "holder-identity"),
|
||||
registry=self.registry,
|
||||
change=change,
|
||||
function=function(),
|
||||
action="approve",
|
||||
base_revision=1,
|
||||
comment="Holder approval",
|
||||
evidence=(),
|
||||
)
|
||||
self.assertEqual("awaiting_authority", change.state)
|
||||
transition_function_assignment_change(
|
||||
session,
|
||||
principal=principal("authority", "authority-identity"),
|
||||
registry=self.registry,
|
||||
change=change,
|
||||
function=function(),
|
||||
action="approve",
|
||||
base_revision=2,
|
||||
comment="Authority approval",
|
||||
evidence=(),
|
||||
)
|
||||
session.commit()
|
||||
|
||||
self.assertEqual("applied", change.state)
|
||||
self.assertIsNotNone(change.resulting_assignment_id)
|
||||
resulting = session.get(
|
||||
IdmOrganizationFunctionAssignment,
|
||||
change.resulting_assignment_id,
|
||||
)
|
||||
self.assertEqual("candidate-identity", resulting.identity_id)
|
||||
self.assertEqual("governance", resulting.source)
|
||||
self.assertEqual(
|
||||
change.id,
|
||||
resulting.settings["governance"]["change_id"],
|
||||
)
|
||||
self.assertEqual(
|
||||
1,
|
||||
session.query(IdmOrganizationFunctionAssignment)
|
||||
.filter(
|
||||
IdmOrganizationFunctionAssignment.identity_id
|
||||
== "candidate-identity"
|
||||
)
|
||||
.count(),
|
||||
)
|
||||
|
||||
def test_vacant_function_blocks_and_revision_claim_rejects_stale_action(
|
||||
self,
|
||||
) -> None:
|
||||
with self.database.session() as session:
|
||||
change, _ = create_function_assignment_change(
|
||||
session,
|
||||
principal=principal("candidate", "candidate-identity"),
|
||||
registry=self.registry,
|
||||
function=function(),
|
||||
payload=payload(),
|
||||
)
|
||||
session.flush()
|
||||
self.assertEqual("blocked", change.state)
|
||||
self.assertIn("vacant", change.outcome_reason)
|
||||
|
||||
self._add_reviewer_assignments(session)
|
||||
change.state = "awaiting_holder"
|
||||
change.resource_revision = 2
|
||||
session.flush()
|
||||
with self.assertRaises(RevisionConflictError):
|
||||
transition_function_assignment_change(
|
||||
session,
|
||||
principal=principal("holder", "holder-identity"),
|
||||
registry=self.registry,
|
||||
change=change,
|
||||
function=function(),
|
||||
action="approve",
|
||||
base_revision=1,
|
||||
comment=None,
|
||||
evidence=(),
|
||||
)
|
||||
|
||||
def test_historical_change_remains_readable_after_function_removal(self) -> None:
|
||||
with self.database.session() as session:
|
||||
change, _ = create_function_assignment_change(
|
||||
session,
|
||||
principal=principal("candidate", "candidate-identity"),
|
||||
registry=self.registry,
|
||||
function=function(),
|
||||
payload=payload(),
|
||||
)
|
||||
session.flush()
|
||||
|
||||
with patch(
|
||||
"govoplan_idm.backend.api.v1.function_changes._historical_function",
|
||||
return_value=None,
|
||||
):
|
||||
item = _change_item(
|
||||
session,
|
||||
principal("candidate", "candidate-identity"),
|
||||
change,
|
||||
include_events=False,
|
||||
)
|
||||
|
||||
self.assertEqual(change.id, item.id)
|
||||
self.assertEqual([], item.available_actions)
|
||||
self.assertIn("no longer available", item.availability_reason)
|
||||
|
||||
def test_escalated_approval_rechecks_changed_routes_and_applies_exactly_once(
|
||||
self,
|
||||
) -> None:
|
||||
with self.database.session() as session:
|
||||
self._add_reviewer_assignments(session)
|
||||
escalation_assignment = IdmOrganizationFunctionAssignment(
|
||||
id="escalation-assignment",
|
||||
tenant_id="tenant-1",
|
||||
identity_id="escalation-identity",
|
||||
account_id="escalation",
|
||||
function_id="escalation-function",
|
||||
organization_unit_id="unit-1",
|
||||
source="direct",
|
||||
is_active=True,
|
||||
settings={},
|
||||
)
|
||||
session.add(escalation_assignment)
|
||||
change, _ = create_function_assignment_change(
|
||||
session,
|
||||
principal=principal("candidate", "candidate-identity"),
|
||||
registry=self.registry,
|
||||
function=function(),
|
||||
payload=payload(),
|
||||
)
|
||||
change.review_deadline_at = utc_now() - timedelta(seconds=1)
|
||||
session.commit()
|
||||
|
||||
lifecycle = SqlIdmAssignmentLifecycle()
|
||||
result = lifecycle.process_expired(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
effective_at=utc_now(),
|
||||
)
|
||||
session.flush()
|
||||
self.assertEqual([change.id], result["escalated_change_ids"])
|
||||
self.assertEqual("escalated", change.state)
|
||||
|
||||
transition_function_assignment_change(
|
||||
session,
|
||||
principal=principal("escalation", "escalation-identity"),
|
||||
registry=self.registry,
|
||||
change=change,
|
||||
function=function(),
|
||||
action="approve",
|
||||
base_revision=2,
|
||||
comment="Explicit escalated holder decision",
|
||||
evidence=(),
|
||||
)
|
||||
self.assertEqual("awaiting_authority", change.state)
|
||||
|
||||
escalation_assignment.is_active = False
|
||||
session.flush()
|
||||
transition_function_assignment_change(
|
||||
session,
|
||||
principal=principal("authority", "authority-identity"),
|
||||
registry=self.registry,
|
||||
change=change,
|
||||
function=function(),
|
||||
action="approve",
|
||||
base_revision=3,
|
||||
comment="Authority approval",
|
||||
evidence=(),
|
||||
)
|
||||
self.assertEqual("failed_manual_review", change.state)
|
||||
self.assertIn("no longer active", change.outcome_reason)
|
||||
self.assertIsNone(change.resulting_assignment_id)
|
||||
|
||||
escalation_assignment.is_active = True
|
||||
session.flush()
|
||||
transition_function_assignment_change(
|
||||
session,
|
||||
principal=principal("admin", "admin-identity"),
|
||||
registry=self.registry,
|
||||
change=change,
|
||||
function=function(),
|
||||
action="recover",
|
||||
base_revision=4,
|
||||
comment="Current routes rechecked",
|
||||
evidence=(),
|
||||
)
|
||||
session.commit()
|
||||
|
||||
self.assertEqual("applied", change.state)
|
||||
self.assertIsNotNone(change.resulting_assignment_id)
|
||||
self.assertEqual(
|
||||
1,
|
||||
session.query(IdmOrganizationFunctionAssignment)
|
||||
.filter(
|
||||
IdmOrganizationFunctionAssignment.identity_id
|
||||
== "candidate-identity"
|
||||
)
|
||||
.count(),
|
||||
)
|
||||
with self.assertRaises(FunctionAssignmentChangeConflict):
|
||||
transition_function_assignment_change(
|
||||
session,
|
||||
principal=principal("admin", "admin-identity"),
|
||||
registry=self.registry,
|
||||
change=change,
|
||||
function=function(),
|
||||
action="recover",
|
||||
base_revision=5,
|
||||
comment=None,
|
||||
evidence=(),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from govoplan_idm.backend.api.v1.routes import (
|
||||
_validate_function_governance_defaults,
|
||||
)
|
||||
|
||||
|
||||
class FunctionGovernanceSettingsTests(unittest.TestCase):
|
||||
def test_bounded_delegation_and_escalation_defaults_are_accepted(self) -> None:
|
||||
_validate_function_governance_defaults(
|
||||
{
|
||||
"function_assignment_governance_defaults": {
|
||||
"delegation_allowed": True,
|
||||
"maximum_delegation_depth": 2,
|
||||
"maximum_delegated_validity_days": 30,
|
||||
"escalation": {
|
||||
"holder": {
|
||||
"target_function_id": "function-escalation",
|
||||
"timeout_hours": 24,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
def test_incomplete_or_unbounded_rules_are_rejected(self) -> None:
|
||||
invalid = (
|
||||
{"maximum_delegation_depth": 0},
|
||||
{"maximum_delegated_validity_days": 3651},
|
||||
{"escalation": {"holder": {"timeout_hours": 24}}},
|
||||
{
|
||||
"escalation": {
|
||||
"authority": {
|
||||
"target_function_id": "function-escalation",
|
||||
"timeout_hours": 0,
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"escalation": {
|
||||
"unknown": {
|
||||
"target_function_id": "function-escalation",
|
||||
"timeout_hours": 24,
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
for defaults in invalid:
|
||||
with self.subTest(defaults=defaults), self.assertRaises(HTTPException):
|
||||
_validate_function_governance_defaults(
|
||||
{"function_assignment_governance_defaults": defaults}
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_idm.backend.manifest import manifest
|
||||
from govoplan_idm.backend.api.v1.routes import ORGANIZATION_IDENTITY_READ_SCOPES
|
||||
|
||||
|
||||
class IdmInterfaceDocumentationContractTests(unittest.TestCase):
|
||||
def test_all_static_topics_have_complete_german_content(self) -> None:
|
||||
for topic in manifest.documentation:
|
||||
german = (topic.translations or {}).get("de", {})
|
||||
self.assertEqual({"title", "summary", "body"}, set(german), topic.id)
|
||||
self.assertTrue(
|
||||
all(str(value).strip() for value in german.values()), topic.id
|
||||
)
|
||||
|
||||
def test_route_and_contributed_action_remain_declared(self) -> None:
|
||||
frontend = manifest.frontend
|
||||
self.assertIsNotNone(frontend)
|
||||
self.assertEqual(
|
||||
{"/idm"},
|
||||
{route.path for route in frontend.routes}, # type: ignore[union-attr]
|
||||
)
|
||||
self.assertEqual(
|
||||
{"idm.action.view-function-assignments"},
|
||||
{surface.id for surface in frontend.view_surfaces}, # type: ignore[union-attr]
|
||||
)
|
||||
self.assertTrue(all(item.icon == "users" for item in manifest.nav_items))
|
||||
|
||||
def test_topics_publish_workflow_blocker_and_consequence_metadata(self) -> None:
|
||||
topics = {topic.id: topic for topic in manifest.documentation}
|
||||
self.assertIn("idm.workflow.assign-function-to-identity", topics)
|
||||
self.assertIn("idm.reference.assignment-governance", topics)
|
||||
self.assertIn("idm.reference.fields-and-consequences", topics)
|
||||
self.assertIn("idm.reference.typed-relationships", topics)
|
||||
|
||||
workflow = topics["idm.workflow.assign-function-to-identity"]
|
||||
self.assertIn("idm.blocker.permission", workflow.metadata["help_contexts"])
|
||||
governance = topics["idm.reference.assignment-governance"]
|
||||
self.assertIn("function_decision", governance.metadata["consequence_classes"])
|
||||
reference = topics["idm.reference.fields-and-consequences"]
|
||||
self.assertIn("idm.field.acting-for", reference.metadata["help_contexts"])
|
||||
self.assertIn("idm.field.escalation", reference.metadata["help_contexts"])
|
||||
self.assertIn("deactivate_or_expire", reference.metadata["consequence_classes"])
|
||||
self.assertIn("escalation", reference.metadata["consequence_classes"])
|
||||
|
||||
relationships = topics["idm.reference.typed-relationships"]
|
||||
self.assertEqual(
|
||||
{
|
||||
"title",
|
||||
"summary",
|
||||
"body",
|
||||
},
|
||||
set(relationships.translations["de"]),
|
||||
)
|
||||
self.assertIn(
|
||||
"idm.relationships.field.revocation-reason",
|
||||
relationships.metadata["help_contexts"],
|
||||
)
|
||||
self.assertIn(
|
||||
"idm.typed-groups.action.resolve-memberships",
|
||||
relationships.metadata["help_contexts"],
|
||||
)
|
||||
self.assertIn(
|
||||
"Revocation immediately", relationships.metadata["consequences"][2]
|
||||
)
|
||||
self.assertIn(
|
||||
"Access permissions remain unchanged",
|
||||
relationships.metadata["verification"],
|
||||
)
|
||||
|
||||
def test_relationship_writers_may_use_identity_search_selectors(self) -> None:
|
||||
self.assertIn("idm:relationship:write", ORGANIZATION_IDENTITY_READ_SCOPES)
|
||||
self.assertNotIn("idm:relationship:read", ORGANIZATION_IDENTITY_READ_SCOPES)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from alembic.runtime.migration import MigrationContext
|
||||
from sqlalchemy import create_engine, inspect
|
||||
|
||||
from govoplan_core.db.migrations import migrate_database
|
||||
from govoplan_identity.backend.manifest import get_manifest as identity_manifest
|
||||
from govoplan_idm.backend.manifest import get_manifest as idm_manifest
|
||||
from govoplan_organizations.backend.manifest import (
|
||||
get_manifest as organizations_manifest,
|
||||
)
|
||||
|
||||
|
||||
class IdmMigrationTests(unittest.TestCase):
|
||||
def test_migrations_create_typed_relationship_tables_and_head(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-idm-migration-") as directory:
|
||||
url = f"sqlite:///{Path(directory) / 'idm.db'}"
|
||||
migrate_database(
|
||||
database_url=url,
|
||||
enabled_modules=("identity", "organizations", "idm"),
|
||||
manifest_factories=(
|
||||
identity_manifest,
|
||||
organizations_manifest,
|
||||
idm_manifest,
|
||||
),
|
||||
)
|
||||
engine = create_engine(url)
|
||||
try:
|
||||
with engine.connect() as connection:
|
||||
self.assertIn(
|
||||
"c2d3e4f5a6b7",
|
||||
set(MigrationContext.configure(connection).get_current_heads()),
|
||||
)
|
||||
self.assertEqual(
|
||||
{
|
||||
"idm_function_assignment_change_events",
|
||||
"idm_function_assignment_changes",
|
||||
"idm_identity_relationships",
|
||||
"idm_organization_function_assignments",
|
||||
"idm_tenant_settings",
|
||||
"idm_typed_groups",
|
||||
},
|
||||
{
|
||||
name
|
||||
for name in inspect(connection).get_table_names()
|
||||
if name.startswith("idm_")
|
||||
},
|
||||
)
|
||||
change_columns = {
|
||||
item["name"]
|
||||
for item in inspect(connection).get_columns(
|
||||
"idm_function_assignment_changes"
|
||||
)
|
||||
}
|
||||
self.assertTrue(
|
||||
{
|
||||
"review_deadline_at",
|
||||
"escalated_at",
|
||||
"escalation_from_state",
|
||||
"escalation_target_function_id",
|
||||
}.issubset(change_columns)
|
||||
)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,220 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from govoplan_core.core.identity import IdentityAccountLinkRef, IdentityRef
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.db.session import configure_database, reset_database
|
||||
from govoplan_identity.backend.db.models import CanonicalIdentity
|
||||
from govoplan_idm.backend.db.models import (
|
||||
IdmIdentityRelationship,
|
||||
IdmTypedGroup,
|
||||
)
|
||||
from govoplan_idm.backend.relationships import SqlIdmRelationshipDirectory
|
||||
|
||||
|
||||
class StubIdentityDirectory:
|
||||
def __init__(self, identities: tuple[IdentityRef, ...]) -> None:
|
||||
self._identities = {item.id: item for item in identities}
|
||||
|
||||
def get_identity(self, identity_id: str) -> IdentityRef | None:
|
||||
return self._identities.get(identity_id)
|
||||
|
||||
def identity_for_account(self, account_id: str) -> IdentityRef | None:
|
||||
return None
|
||||
|
||||
def identities_for_accounts(self, account_ids):
|
||||
return ()
|
||||
|
||||
def accounts_for_identity(self, identity_id: str) -> tuple[IdentityAccountLinkRef, ...]:
|
||||
return ()
|
||||
|
||||
|
||||
class IdmRelationshipDirectoryTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.database = configure_database("sqlite:///:memory:")
|
||||
Base.metadata.create_all(
|
||||
self.database.engine,
|
||||
tables=[
|
||||
CanonicalIdentity.__table__,
|
||||
IdmTypedGroup.__table__,
|
||||
IdmIdentityRelationship.__table__,
|
||||
],
|
||||
)
|
||||
identities = (
|
||||
IdentityRef(id="identity-active", display_name="Active", status="active"),
|
||||
IdentityRef(id="identity-future", display_name="Future", status="active"),
|
||||
IdentityRef(id="identity-expired", display_name="Expired", status="active"),
|
||||
IdentityRef(id="identity-revoked", display_name="Revoked", status="active"),
|
||||
IdentityRef(id="identity-suspended", display_name="Suspended", status="suspended"),
|
||||
)
|
||||
self.directory = SqlIdmRelationshipDirectory(
|
||||
identities=StubIdentityDirectory(identities) # type: ignore[arg-type]
|
||||
)
|
||||
with self.database.session() as session:
|
||||
session.add_all(
|
||||
CanonicalIdentity(
|
||||
id=item.id,
|
||||
display_name=item.display_name,
|
||||
source="local",
|
||||
is_active=item.status == "active",
|
||||
settings={},
|
||||
)
|
||||
for item in identities
|
||||
)
|
||||
session.add_all(
|
||||
(
|
||||
IdmTypedGroup(
|
||||
id="group-1",
|
||||
tenant_id="tenant-1",
|
||||
key="permit-holder",
|
||||
name="Permit holders",
|
||||
group_type="business_status",
|
||||
source_provider="ldap",
|
||||
source_resource_type="group",
|
||||
source_resource_id="cn=permit-holders,dc=example",
|
||||
source_revision="directory-42",
|
||||
properties={"classification": "resident"},
|
||||
provenance={"connector_id": "ldap-1"},
|
||||
),
|
||||
IdmTypedGroup(
|
||||
id="group-other",
|
||||
tenant_id="tenant-2",
|
||||
key="other",
|
||||
name="Other tenant",
|
||||
group_type="business_status",
|
||||
),
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
reset_database(dispose=True)
|
||||
|
||||
@staticmethod
|
||||
def _relationship(
|
||||
relationship_id: str,
|
||||
identity_id: str,
|
||||
*,
|
||||
boundary: datetime,
|
||||
valid_from: datetime | None = None,
|
||||
valid_until: datetime | None = None,
|
||||
status: str = "active",
|
||||
) -> IdmIdentityRelationship:
|
||||
return IdmIdentityRelationship(
|
||||
id=relationship_id,
|
||||
tenant_id="tenant-1",
|
||||
relationship_kind="member",
|
||||
subject_identity_id=identity_id,
|
||||
target_group_id="group-1",
|
||||
valid_from=valid_from,
|
||||
valid_until=valid_until,
|
||||
status=status,
|
||||
revoked_at=boundary if status == "revoked" else None,
|
||||
revoked_by="account-1" if status == "revoked" else None,
|
||||
revocation_reason="No longer eligible" if status == "revoked" else None,
|
||||
source_provider="ldap",
|
||||
source_resource_type="membership",
|
||||
source_resource_id=f"member:{identity_id}",
|
||||
source_revision="directory-42",
|
||||
properties={"rank": 1},
|
||||
provenance={"sync_run_id": "sync-1"},
|
||||
revision=1,
|
||||
)
|
||||
|
||||
def test_resolution_explains_current_future_expired_revoked_and_lifecycle(self) -> None:
|
||||
boundary = datetime(2026, 8, 2, 12, tzinfo=timezone.utc)
|
||||
with self.database.session() as session:
|
||||
session.add_all(
|
||||
(
|
||||
self._relationship("active", "identity-active", boundary=boundary),
|
||||
self._relationship(
|
||||
"future",
|
||||
"identity-future",
|
||||
boundary=boundary,
|
||||
valid_from=boundary + timedelta(days=1),
|
||||
),
|
||||
self._relationship(
|
||||
"expired",
|
||||
"identity-expired",
|
||||
boundary=boundary,
|
||||
valid_until=boundary,
|
||||
),
|
||||
self._relationship(
|
||||
"revoked",
|
||||
"identity-revoked",
|
||||
boundary=boundary,
|
||||
status="revoked",
|
||||
),
|
||||
self._relationship(
|
||||
"suspended",
|
||||
"identity-suspended",
|
||||
boundary=boundary,
|
||||
),
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
resolved = self.directory.resolve_typed_group_memberships(
|
||||
("group-1",),
|
||||
tenant_id="tenant-1",
|
||||
effective_at=boundary,
|
||||
)["group-1"]
|
||||
|
||||
self.assertEqual(("identity-active",), resolved.identity_ids)
|
||||
self.assertEqual(
|
||||
{
|
||||
"active": "relationship.effective",
|
||||
"future": "relationship.not_yet_effective",
|
||||
"expired": "relationship.expired",
|
||||
"revoked": "relationship.revoked",
|
||||
"suspended": "identity.not_active",
|
||||
},
|
||||
{item.relationship.id: item.code for item in resolved.decisions},
|
||||
)
|
||||
self.assertEqual("directory-42", resolved.group.source_revision)
|
||||
self.assertEqual("ldap", resolved.decisions[0].relationship.source_provider)
|
||||
|
||||
def test_forward_reverse_batch_queries_return_only_effective_relationships(self) -> None:
|
||||
boundary = datetime(2026, 8, 2, 12, tzinfo=timezone.utc)
|
||||
with self.database.session() as session:
|
||||
session.add_all(
|
||||
(
|
||||
self._relationship("active", "identity-active", boundary=boundary),
|
||||
self._relationship(
|
||||
"future",
|
||||
"identity-future",
|
||||
boundary=boundary,
|
||||
valid_from=boundary + timedelta(days=1),
|
||||
),
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
forward = self.directory.identity_relationships_for_identities(
|
||||
("identity-active", "identity-future"),
|
||||
tenant_id="tenant-1",
|
||||
effective_at=boundary,
|
||||
)
|
||||
reverse = self.directory.identity_relationships_for_groups(
|
||||
("group-1",),
|
||||
tenant_id="tenant-1",
|
||||
effective_at=boundary,
|
||||
)
|
||||
|
||||
self.assertEqual(("active",), tuple(item.id for item in forward["identity-active"]))
|
||||
self.assertEqual((), forward["identity-future"])
|
||||
self.assertEqual(("active",), tuple(item.id for item in reverse["group-1"]))
|
||||
|
||||
def test_cross_tenant_group_references_are_rejected(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "another tenant"):
|
||||
self.directory.get_typed_group("group-other", tenant_id="tenant-1")
|
||||
with self.assertRaisesRegex(ValueError, "another tenant"):
|
||||
self.directory.identity_relationships_for_group(
|
||||
"group-other", tenant_id="tenant-1"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,191 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from govoplan_core.security.http_fetch import HttpFetchResponse
|
||||
from govoplan_idm.backend.scim import (
|
||||
SCIM_GROUP_SCHEMA,
|
||||
SCIM_LIST_SCHEMA,
|
||||
SCIM_USER_SCHEMA,
|
||||
ScimClient,
|
||||
ScimError,
|
||||
ScimLocalProjection,
|
||||
ScimProfile,
|
||||
ScimProvisioningPlanner,
|
||||
ScimSnapshot,
|
||||
parse_scim_list_response,
|
||||
)
|
||||
|
||||
|
||||
def _profile(*, absent_user_action="review") -> ScimProfile:
|
||||
return ScimProfile(
|
||||
provider_id="institutional-idp",
|
||||
base_url="https://idp.example.test/scim/v2",
|
||||
credential_ref="core-credential:scim",
|
||||
immutable_match_attribute="urn:example:params:scim:schemas:extension:staff:2.0:User:personnelNumber",
|
||||
absent_user_action=absent_user_action,
|
||||
page_size=2,
|
||||
)
|
||||
|
||||
|
||||
def _user(resource_id: str, number: str, *, active: bool = True) -> dict[str, object]:
|
||||
return {
|
||||
"schemas": [
|
||||
SCIM_USER_SCHEMA,
|
||||
"urn:example:params:scim:schemas:extension:staff:2.0:User",
|
||||
],
|
||||
"id": resource_id,
|
||||
"userName": f"user-{number}",
|
||||
"active": active,
|
||||
"urn:example:params:scim:schemas:extension:staff:2.0:User:personnelNumber": number,
|
||||
"meta": {"version": f'W/"{resource_id}"'},
|
||||
}
|
||||
|
||||
|
||||
def _list(resources: list[dict[str, object]], *, total: int, start: int) -> bytes:
|
||||
return json.dumps(
|
||||
{
|
||||
"schemas": [SCIM_LIST_SCHEMA],
|
||||
"totalResults": total,
|
||||
"startIndex": start,
|
||||
"itemsPerPage": len(resources),
|
||||
"Resources": resources,
|
||||
}
|
||||
).encode()
|
||||
|
||||
|
||||
def test_scim_client_reads_complete_one_based_paginated_snapshot() -> None:
|
||||
calls: list[str] = []
|
||||
|
||||
def transport(url, *, method, headers, body):
|
||||
calls.append(url)
|
||||
assert method == "GET" and body is None
|
||||
assert "Authorization" not in headers
|
||||
if "/Users?" in url and "startIndex=1" in url:
|
||||
payload = _list([_user("u-1", "100"), _user("u-2", "200")], total=3, start=1)
|
||||
elif "/Users?" in url:
|
||||
payload = _list([_user("u-3", "300")], total=3, start=3)
|
||||
else:
|
||||
payload = _list([], total=0, start=1)
|
||||
return HttpFetchResponse(200, {"Content-Type": "application/scim+json"}, payload)
|
||||
|
||||
snapshot = ScimClient(_profile(), transport=transport).fetch_snapshot()
|
||||
|
||||
assert snapshot.complete is True
|
||||
assert [item.resource_id for item in snapshot.users] == ["u-1", "u-2", "u-3"]
|
||||
assert snapshot.groups == ()
|
||||
assert snapshot.page_count == 3
|
||||
assert len(calls) == 3
|
||||
|
||||
|
||||
def test_planner_links_by_explicit_immutable_attribute_and_quarantines_collision() -> None:
|
||||
page = parse_scim_list_response(
|
||||
json.loads(_list([_user("u-1", "100"), _user("u-2", "200")], total=2, start=1)),
|
||||
resource_type="User",
|
||||
)
|
||||
snapshot = ScimSnapshot(
|
||||
provider_id="institutional-idp",
|
||||
observed_at=datetime(2026, 8, 23, tzinfo=UTC),
|
||||
users=page.resources,
|
||||
complete=True,
|
||||
page_count=1,
|
||||
)
|
||||
local = (
|
||||
ScimLocalProjection("local-100", "User", "100", revision=2),
|
||||
ScimLocalProjection("local-200-a", "User", "200", revision=1),
|
||||
ScimLocalProjection("local-200-b", "User", "200", revision=1),
|
||||
)
|
||||
|
||||
plan = ScimProvisioningPlanner(_profile()).plan(snapshot, local)
|
||||
|
||||
assert [(item.action, item.local_id) for item in plan.operations] == [
|
||||
("link", "local-100"),
|
||||
("quarantine", None),
|
||||
]
|
||||
assert len(plan.plan_sha256) == 64
|
||||
|
||||
|
||||
def test_absence_never_deactivates_from_incomplete_snapshot() -> None:
|
||||
local = (
|
||||
ScimLocalProjection(
|
||||
"local-100",
|
||||
"User",
|
||||
"100",
|
||||
revision=3,
|
||||
provider_resource_id="u-1",
|
||||
),
|
||||
)
|
||||
incomplete = ScimSnapshot(
|
||||
provider_id="institutional-idp",
|
||||
observed_at=datetime(2026, 8, 23, tzinfo=UTC),
|
||||
complete=False,
|
||||
)
|
||||
complete = ScimSnapshot(
|
||||
provider_id="institutional-idp",
|
||||
observed_at=datetime(2026, 8, 23, tzinfo=UTC),
|
||||
complete=True,
|
||||
)
|
||||
|
||||
first = ScimProvisioningPlanner(_profile(absent_user_action="deactivate")).plan(incomplete, local)
|
||||
second = ScimProvisioningPlanner(_profile(absent_user_action="deactivate")).plan(complete, local)
|
||||
|
||||
assert first.operations == ()
|
||||
assert first.warnings
|
||||
assert second.operations[0].action == "deactivate"
|
||||
assert second.operations[0].expected_local_revision == 3
|
||||
|
||||
|
||||
def test_groups_are_business_projections_and_not_access_grants() -> None:
|
||||
group = {
|
||||
"schemas": [SCIM_GROUP_SCHEMA],
|
||||
"id": "g-1",
|
||||
"displayName": "Payroll reviewers",
|
||||
"externalId": "group-100",
|
||||
"members": [{"value": "u-1"}],
|
||||
}
|
||||
profile = ScimProfile(
|
||||
provider_id="institutional-idp",
|
||||
base_url="https://idp.example.test/scim/v2",
|
||||
credential_ref="core-credential:scim",
|
||||
immutable_match_attribute="externalIdImmutable",
|
||||
)
|
||||
group["externalIdImmutable"] = "group-stable-100"
|
||||
page = parse_scim_list_response(
|
||||
{
|
||||
"schemas": [SCIM_LIST_SCHEMA],
|
||||
"totalResults": 1,
|
||||
"startIndex": 1,
|
||||
"itemsPerPage": 1,
|
||||
"Resources": [group],
|
||||
},
|
||||
resource_type="Group",
|
||||
)
|
||||
plan = ScimProvisioningPlanner(profile).plan(
|
||||
ScimSnapshot(
|
||||
provider_id="institutional-idp",
|
||||
observed_at=datetime(2026, 8, 23, tzinfo=UTC),
|
||||
groups=page.resources,
|
||||
complete=True,
|
||||
),
|
||||
(),
|
||||
)
|
||||
|
||||
assert plan.operations[0].resource_type == "Group"
|
||||
assert plan.operations[0].action == "create"
|
||||
assert profile.group_projection_mode == "business_membership_only"
|
||||
|
||||
|
||||
def test_parser_and_profile_reject_unsafe_identity_assumptions() -> None:
|
||||
with pytest.raises(ValueError, match="immutable"):
|
||||
ScimProfile(
|
||||
provider_id="idp",
|
||||
base_url="https://idp.example.test/scim/v2",
|
||||
credential_ref="credential",
|
||||
immutable_match_attribute="userName",
|
||||
)
|
||||
with pytest.raises(ScimError, match="ListResponse"):
|
||||
parse_scim_list_response({"Resources": []}, resource_type="User")
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.events import EventObjectRef, EventTenantRef, PlatformEvent
|
||||
from govoplan_core.core.search import (
|
||||
SearchAuthorizationRequest,
|
||||
SearchBackfillRequest,
|
||||
SearchResourceReference,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_idm.backend.db.models import IdmTypedGroup
|
||||
from govoplan_idm.backend.search_source import IdmSearchSource, PROVIDER_ID
|
||||
|
||||
|
||||
class IdmSearchSourceTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(
|
||||
self.engine,
|
||||
tables=(IdmTypedGroup.__table__,),
|
||||
)
|
||||
self.session = Session(self.engine)
|
||||
self.session.add_all(
|
||||
(
|
||||
IdmTypedGroup(
|
||||
id="group-1",
|
||||
tenant_id="tenant-1",
|
||||
key="permit-holder",
|
||||
name="Permit holders",
|
||||
group_type="business_status",
|
||||
),
|
||||
IdmTypedGroup(
|
||||
id="group-other",
|
||||
tenant_id="tenant-2",
|
||||
key="other",
|
||||
name="Other tenant",
|
||||
group_type="business_status",
|
||||
),
|
||||
)
|
||||
)
|
||||
self.session.commit()
|
||||
self.source = IdmSearchSource()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def test_typed_group_search_is_tenant_and_scope_bounded(self) -> None:
|
||||
page = self.source.backfill(
|
||||
self.session,
|
||||
request=SearchBackfillRequest(
|
||||
tenant_id="tenant-1",
|
||||
provider_id=PROVIDER_ID,
|
||||
resource_type="typed_group",
|
||||
rebuild_id="rebuild-1",
|
||||
),
|
||||
)
|
||||
self.assertEqual(("group-1",), tuple(doc.resource_id for doc in page.documents))
|
||||
reference = SearchResourceReference(
|
||||
tenant_id="tenant-1",
|
||||
module_id="idm",
|
||||
resource_type="typed_group",
|
||||
resource_id="group-1",
|
||||
)
|
||||
request = SearchAuthorizationRequest(reference=reference, source_revision="1")
|
||||
self.assertTrue(
|
||||
self.source.authorize(
|
||||
self.session,
|
||||
_principal({"idm:relationship:read"}),
|
||||
requests=(request,),
|
||||
)[reference.key]
|
||||
)
|
||||
self.assertFalse(
|
||||
self.source.authorize(
|
||||
self.session,
|
||||
_principal(set()),
|
||||
requests=(request,),
|
||||
)[reference.key]
|
||||
)
|
||||
|
||||
def test_typed_group_event_is_translated_to_an_upsert(self) -> None:
|
||||
event = PlatformEvent(
|
||||
type="idm.typed_group.updated",
|
||||
module_id="idm",
|
||||
tenant=EventTenantRef(id="tenant-1"),
|
||||
resource=EventObjectRef(type="typed_group", id="group-1"),
|
||||
)
|
||||
change = self.source.index_changes_for_event(
|
||||
self.session,
|
||||
event=event,
|
||||
delivery_key="delivery-1",
|
||||
)[0]
|
||||
self.assertEqual("upsert", change.kind)
|
||||
self.assertEqual("group-1", change.reference.resource_id)
|
||||
|
||||
|
||||
def _principal(scopes: set[str]) -> ApiPrincipal:
|
||||
return ApiPrincipal(
|
||||
principal=PrincipalRef(
|
||||
account_id="account-1",
|
||||
membership_id="user-1",
|
||||
tenant_id="tenant-1",
|
||||
scopes=frozenset(scopes),
|
||||
),
|
||||
account=SimpleNamespace(id="account-1"),
|
||||
user=SimpleNamespace(id="user-1"),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+10
-7
@@ -1,11 +1,14 @@
|
||||
{
|
||||
"name": "@govoplan/idm-webui",
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.22",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"module": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"test:interface-patterns": "node scripts/test-interface-pattern-language.mjs"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
@@ -14,14 +17,14 @@
|
||||
"./styles/idm.css": "./src/styles/idm.css"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.7",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"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",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.6"
|
||||
"vite": "^7.3.6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
function source(path) {
|
||||
return readFileSync(new URL(path, import.meta.url), "utf8");
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
const page = source("../src/features/IdmPage.tsx");
|
||||
const changes = source("../src/features/FunctionAssignmentChangesPanel.tsx");
|
||||
const relationships = source("../src/features/TypedRelationshipsPanel.tsx");
|
||||
const api = source("../src/api/idm.ts");
|
||||
const patterns = source("../src/features/interfacePatterns.ts");
|
||||
const moduleSource = source("../src/module.ts");
|
||||
const translations = source("../src/i18n/generatedTranslations.ts");
|
||||
const styles = source("../src/styles/idm.css");
|
||||
|
||||
assert(page.includes("ActionBlockerHint") && page.includes("DocumentationHelpLink"), "IDM permissions and prerequisites expose actionable help");
|
||||
assert(page.includes("assignmentBaseline") && page.includes("draftKey(assignmentDraft) !== draftKey(assignmentBaseline)"), "Existing assignments compare against their loaded draft baseline");
|
||||
assert(page.includes("requestDiscard(() => void loadData())") && page.includes("closeAssignmentEditor"), "Reload and dialog close preserve actual dirty drafts");
|
||||
assert(page.includes("disabledReason") && changes.includes("disabledReason"), "Unavailable assignment and decision actions explain their state");
|
||||
assert(changes.includes("ConfirmDialog") && changes.includes("confirm_function_action"), "Governed function decisions require explicit shared confirmation");
|
||||
assert(changes.includes("useUnsavedDraftGuard") && changes.includes("usePlatformLanguage"), "Governed editors protect drafts and format dates with the platform locale");
|
||||
assert(patterns.includes('topicId: "idm.reference.fields-and-consequences"'), "IDM fields use manifest-backed consequence help");
|
||||
assert(moduleSource.includes('version: "0.1.8"') && moduleSource.includes('label: "i18n:govoplan-idm.view_assignments.2d40d6a5"'), "WebUI metadata matches the module release and localizes its action surface");
|
||||
assert(translations.includes('"i18n:govoplan-idm.state_awaiting_authority"'), "Governed states and decisions are in the translation catalogue");
|
||||
assert(!page.includes("window.confirm") && !changes.includes("window.confirm"), "IDM does not use browser-native consequential confirmation");
|
||||
assert(styles.includes(".idm-page") && !/\.idm-page\s*\{[^}]*max-width/s.test(styles), "The IDM workspace uses the full shared application width");
|
||||
assert(page.includes("TypedRelationshipsPanel") && page.includes("<TypedRelationshipsPanel settings={settings} auth={auth} />"), "The IDM workspace exposes typed-group and relationship administration");
|
||||
assert(relationships.includes("SearchableSelect") && relationships.includes('aria-label="Subject identity"') && relationships.includes('aria-label="Target typed group"') && relationships.includes('aria-label="Related identity"'), "Identity and group references use searchable selectors");
|
||||
assert(relationships.includes('"future" | "active" | "expired" | "revoked"') && relationships.includes("relationshipState(row)"), "Relationship lifecycle states remain visually distinct");
|
||||
assert(relationships.includes("revocationReason.trim()") && relationships.includes('helpContextId="idm.relationships.confirm-revoke"'), "Relationship revocation requires a reason and explicit governed confirmation");
|
||||
assert(relationships.includes("resolveTypedGroupMemberships") && relationships.includes('id="idm-typed-group-membership-resolution"'), "Effective group membership is inspectable through the shared resolver");
|
||||
assert(relationships.includes("sourceResourceId") && relationships.includes("sourceRevision") && relationships.includes("provenance"), "Relationship administration retains source and provenance evidence");
|
||||
assert(api.includes("/api/v1/idm/typed-groups") && api.includes("/api/v1/idm/relationships") && api.includes("/memberships?"), "The WebUI API uses the implemented relationship lifecycle endpoints");
|
||||
assert(moduleSource.includes('"idm:relationship:read"') && moduleSource.includes('"idm:relationship:write"'), "Relationship-only administrators can enter the IDM product surface");
|
||||
assert(translations.includes('"Typed groups and identity relationships": "Typisierte Gruppen und Identitätsbeziehungen"') && translations.includes('"Revoked": "Widerrufen"'), "The relationship administration vocabulary has German reference translations");
|
||||
assert(!relationships.includes("window.confirm"), "Relationship administration does not use browser-native consequential confirmation");
|
||||
|
||||
console.log("IDM surfaces satisfy the recorded interface pattern-language contract.");
|
||||
+342
-14
@@ -1,4 +1,4 @@
|
||||
import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
|
||||
import { apiFetch, apiPatchJson, apiPostJson, type ApiSettings } from "@govoplan/core-webui";
|
||||
|
||||
export type OrganizationUnitItem = {
|
||||
id: string;
|
||||
@@ -49,6 +49,117 @@ export type IdentityListResponse = {
|
||||
identities: IdentityOption[];
|
||||
};
|
||||
|
||||
export type TypedGroupItem = {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
group_type: string;
|
||||
description?: string | null;
|
||||
status: "active" | "inactive";
|
||||
source_provider: string;
|
||||
source_resource_type?: string | null;
|
||||
source_resource_id?: string | null;
|
||||
source_revision?: string | null;
|
||||
properties: Record<string, unknown>;
|
||||
provenance: Record<string, unknown>;
|
||||
revision: number;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
export type TypedGroupList = {
|
||||
groups: TypedGroupItem[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type TypedGroupPayload = {
|
||||
key: string;
|
||||
name: string;
|
||||
group_type: string;
|
||||
description?: string | null;
|
||||
source_provider?: string;
|
||||
source_resource_type?: string | null;
|
||||
source_resource_id?: string | null;
|
||||
source_revision?: string | null;
|
||||
properties?: Record<string, unknown>;
|
||||
provenance?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type TypedGroupUpdatePayload = Partial<TypedGroupPayload> & {
|
||||
base_revision: number;
|
||||
status?: "active" | "inactive";
|
||||
};
|
||||
|
||||
export type IdentityRelationshipItem = {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
relationship_kind: string;
|
||||
subject_identity_id: string;
|
||||
target_group_id?: string | null;
|
||||
related_identity_id?: string | null;
|
||||
role?: string | null;
|
||||
valid_from?: string | null;
|
||||
valid_until?: string | null;
|
||||
status: "active" | "revoked";
|
||||
revoked_at?: string | null;
|
||||
revoked_by?: string | null;
|
||||
revocation_reason?: string | null;
|
||||
expired_event_at?: string | null;
|
||||
source_provider: string;
|
||||
source_resource_type?: string | null;
|
||||
source_resource_id?: string | null;
|
||||
source_revision?: string | null;
|
||||
properties: Record<string, unknown>;
|
||||
provenance: Record<string, unknown>;
|
||||
revision: number;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
export type IdentityRelationshipList = {
|
||||
relationships: IdentityRelationshipItem[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type IdentityRelationshipPayload = {
|
||||
relationship_kind: string;
|
||||
subject_identity_id: string;
|
||||
target_group_id?: string | null;
|
||||
related_identity_id?: string | null;
|
||||
role?: string | null;
|
||||
valid_from?: string | null;
|
||||
valid_until?: string | null;
|
||||
source_provider?: string;
|
||||
source_resource_type?: string | null;
|
||||
source_resource_id?: string | null;
|
||||
source_revision?: string | null;
|
||||
properties?: Record<string, unknown>;
|
||||
provenance?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type IdentityRelationshipUpdatePayload = Omit<
|
||||
Partial<IdentityRelationshipPayload>,
|
||||
"subject_identity_id"
|
||||
> & {
|
||||
base_revision: number;
|
||||
};
|
||||
|
||||
export type IdentityRelationshipDecisionItem = {
|
||||
relationship: IdentityRelationshipItem;
|
||||
included: boolean;
|
||||
code: string;
|
||||
explanation: string;
|
||||
identity_status?: string | null;
|
||||
};
|
||||
|
||||
export type TypedGroupMembershipResolution = {
|
||||
group: TypedGroupItem;
|
||||
effective_at: string;
|
||||
decisions: IdentityRelationshipDecisionItem[];
|
||||
identity_ids: string[];
|
||||
};
|
||||
|
||||
export type OrganizationFunctionAssignmentItem = {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
@@ -70,6 +181,10 @@ export type OrganizationFunctionAssignmentItem = {
|
||||
|
||||
export type OrganizationFunctionAssignmentList = {
|
||||
assignments: OrganizationFunctionAssignmentItem[];
|
||||
total?: number;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
pages?: number;
|
||||
};
|
||||
|
||||
export type IdmSettings = {
|
||||
@@ -95,24 +210,119 @@ export type OrganizationFunctionAssignmentPayload = {
|
||||
is_active?: boolean;
|
||||
settings?: Record<string, unknown>;
|
||||
change_request_id?: string | null;
|
||||
governance_override_reason?: string | null;
|
||||
governance_override_evidence?: string[];
|
||||
};
|
||||
|
||||
export type IdmSettingsPayload = Partial<Pick<IdmSettings, "require_assignment_change_requests" | "audit_detail_level" | "change_retention_days" | "settings">>;
|
||||
|
||||
function post<T, P extends Record<string, unknown>>(settings: ApiSettings, path: string, payload: P): Promise<T> {
|
||||
return apiFetch<T>(settings, path, { method: "POST", body: JSON.stringify(payload) });
|
||||
}
|
||||
export type FunctionAssignmentChangeKind = "request" | "grant";
|
||||
export type FunctionAssignmentChangeAction = "approve" | "reject" | "accept" | "request_changes" | "respond" | "withdraw" | "recover";
|
||||
|
||||
function patch<T, P extends Record<string, unknown>>(settings: ApiSettings, path: string, payload: P): Promise<T> {
|
||||
return apiFetch<T>(settings, path, { method: "PATCH", body: JSON.stringify(payload) });
|
||||
}
|
||||
export type FunctionAssignmentChangeEvent = {
|
||||
id: string;
|
||||
sequence: number;
|
||||
action: string;
|
||||
from_state?: string | null;
|
||||
to_state: string;
|
||||
actor_account_id?: string | null;
|
||||
actor_identity_id?: string | null;
|
||||
comment?: string | null;
|
||||
evidence: string[];
|
||||
policy_decision: Record<string, unknown>;
|
||||
workflow_step_id?: string | null;
|
||||
details: Record<string, unknown>;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type FunctionAssignmentChange = {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
kind: FunctionAssignmentChangeKind;
|
||||
state: string;
|
||||
profile: string;
|
||||
function_id: string;
|
||||
organization_unit_id: string;
|
||||
candidate_identity_id: string;
|
||||
candidate_account_id?: string | null;
|
||||
initiator_account_id: string;
|
||||
initiator_identity_id?: string | null;
|
||||
justification: string;
|
||||
evidence: string[];
|
||||
requested_valid_from?: string | null;
|
||||
requested_valid_until?: string | null;
|
||||
required_steps: string[];
|
||||
completed_steps: string[];
|
||||
policy_decision: Record<string, unknown>;
|
||||
workflow_definition_revision?: number | null;
|
||||
workflow_definition_hash?: string | null;
|
||||
workflow_instance_id?: string | null;
|
||||
resulting_assignment_id?: string | null;
|
||||
expires_at?: string | null;
|
||||
review_deadline_at?: string | null;
|
||||
escalated_at?: string | null;
|
||||
escalation_from_state?: string | null;
|
||||
escalation_target_function_id?: string | null;
|
||||
outcome_reason?: string | null;
|
||||
resource_revision: number;
|
||||
etag: string;
|
||||
metadata: Record<string, unknown>;
|
||||
events: FunctionAssignmentChangeEvent[];
|
||||
available_actions: FunctionAssignmentChangeAction[];
|
||||
availability_reason?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type FunctionAssignmentChangeList = {
|
||||
changes: FunctionAssignmentChange[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
pages: number;
|
||||
};
|
||||
|
||||
export type FunctionAssignmentChangePayload = {
|
||||
kind: FunctionAssignmentChangeKind;
|
||||
function_id: string;
|
||||
candidate_identity_id: string;
|
||||
candidate_account_id?: string | null;
|
||||
justification: string;
|
||||
evidence?: string[];
|
||||
requested_valid_from?: string | null;
|
||||
requested_valid_until?: string | null;
|
||||
applies_to_subunits?: boolean;
|
||||
assignment_source?: "governance" | "delegated";
|
||||
represented_assignment_id?: string | null;
|
||||
idempotency_key: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export function getOrganizationModel(settings: ApiSettings): Promise<OrganizationModel> {
|
||||
return apiFetch<OrganizationModel>(settings, "/api/v1/organizations/model");
|
||||
}
|
||||
|
||||
export function getOrganizationFunctionAssignments(settings: ApiSettings): Promise<OrganizationFunctionAssignmentList> {
|
||||
return apiFetch<OrganizationFunctionAssignmentList>(settings, "/api/v1/idm/organization-function-assignments");
|
||||
export async function getOrganizationFunctionAssignments(settings: ApiSettings): Promise<OrganizationFunctionAssignmentList> {
|
||||
const pageSize = 500;
|
||||
const assignments: OrganizationFunctionAssignmentItem[] = [];
|
||||
let total = 0;
|
||||
for (let page = 1; ; page += 1) {
|
||||
const response = await apiFetch<OrganizationFunctionAssignmentList>(
|
||||
settings,
|
||||
`/api/v1/idm/organization-function-assignments?page=${page}&page_size=${pageSize}`
|
||||
);
|
||||
assignments.push(...response.assignments);
|
||||
total = response.total ?? assignments.length;
|
||||
if (page >= (response.pages ?? 1)) {
|
||||
return {
|
||||
assignments,
|
||||
total,
|
||||
page: 1,
|
||||
page_size: assignments.length,
|
||||
pages: 1
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getIdmSettings(settings: ApiSettings): Promise<IdmSettings> {
|
||||
@@ -120,22 +330,103 @@ export function getIdmSettings(settings: ApiSettings): Promise<IdmSettings> {
|
||||
}
|
||||
|
||||
export function patchIdmSettings(settings: ApiSettings, payload: IdmSettingsPayload): Promise<IdmSettings> {
|
||||
return patch(settings, "/api/v1/idm/settings", payload);
|
||||
return apiPatchJson(settings, "/api/v1/idm/settings", payload);
|
||||
}
|
||||
|
||||
export function searchOrganizationIdentityOptions(settings: ApiSettings, query = "", limit = 50): Promise<IdentityListResponse> {
|
||||
export function searchOrganizationIdentityOptions(
|
||||
settings: ApiSettings,
|
||||
query = "",
|
||||
limit = 50,
|
||||
signal?: AbortSignal
|
||||
): Promise<IdentityListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
const trimmed = query.trim();
|
||||
if (trimmed) params.set("query", trimmed);
|
||||
params.set("limit", String(limit));
|
||||
return apiFetch<IdentityListResponse>(settings, `/api/v1/idm/organization-identities?${params.toString()}`);
|
||||
return apiFetch<IdentityListResponse>(settings, `/api/v1/idm/organization-identities?${params.toString()}`, { signal });
|
||||
}
|
||||
|
||||
export function getTypedGroups(
|
||||
settings: ApiSettings,
|
||||
options: { query?: string; includeInactive?: boolean; limit?: number; signal?: AbortSignal } = {}
|
||||
): Promise<TypedGroupList> {
|
||||
const params = new URLSearchParams();
|
||||
if (options.query?.trim()) params.set("query", options.query.trim());
|
||||
if (options.includeInactive) params.set("include_inactive", "true");
|
||||
params.set("limit", String(options.limit ?? 1000));
|
||||
return apiFetch<TypedGroupList>(settings, `/api/v1/idm/typed-groups?${params.toString()}`, { signal: options.signal });
|
||||
}
|
||||
|
||||
export function createTypedGroup(settings: ApiSettings, payload: TypedGroupPayload): Promise<TypedGroupItem> {
|
||||
return apiPostJson(settings, "/api/v1/idm/typed-groups", payload);
|
||||
}
|
||||
|
||||
export function patchTypedGroup(
|
||||
settings: ApiSettings,
|
||||
groupId: string,
|
||||
payload: TypedGroupUpdatePayload
|
||||
): Promise<TypedGroupItem> {
|
||||
return apiPatchJson(settings, `/api/v1/idm/typed-groups/${encodeURIComponent(groupId)}`, payload);
|
||||
}
|
||||
|
||||
export function getIdentityRelationships(
|
||||
settings: ApiSettings,
|
||||
options: { includeRevoked?: boolean; identityId?: string; groupId?: string; relationshipKind?: string; limit?: number } = {}
|
||||
): Promise<IdentityRelationshipList> {
|
||||
const params = new URLSearchParams();
|
||||
if (options.includeRevoked) params.set("include_revoked", "true");
|
||||
if (options.identityId) params.set("identity_id", options.identityId);
|
||||
if (options.groupId) params.set("group_id", options.groupId);
|
||||
if (options.relationshipKind) params.set("relationship_kind", options.relationshipKind);
|
||||
params.set("limit", String(options.limit ?? 1000));
|
||||
return apiFetch<IdentityRelationshipList>(settings, `/api/v1/idm/relationships?${params.toString()}`);
|
||||
}
|
||||
|
||||
export function createIdentityRelationship(
|
||||
settings: ApiSettings,
|
||||
payload: IdentityRelationshipPayload
|
||||
): Promise<IdentityRelationshipItem> {
|
||||
return apiPostJson(settings, "/api/v1/idm/relationships", payload);
|
||||
}
|
||||
|
||||
export function patchIdentityRelationship(
|
||||
settings: ApiSettings,
|
||||
relationshipId: string,
|
||||
payload: IdentityRelationshipUpdatePayload
|
||||
): Promise<IdentityRelationshipItem> {
|
||||
return apiPatchJson(settings, `/api/v1/idm/relationships/${encodeURIComponent(relationshipId)}`, payload);
|
||||
}
|
||||
|
||||
export function revokeIdentityRelationship(
|
||||
settings: ApiSettings,
|
||||
relationship: Pick<IdentityRelationshipItem, "id" | "revision">,
|
||||
reason: string
|
||||
): Promise<IdentityRelationshipItem> {
|
||||
return apiPostJson(settings, `/api/v1/idm/relationships/${encodeURIComponent(relationship.id)}/revoke`, {
|
||||
base_revision: relationship.revision,
|
||||
reason
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveTypedGroupMemberships(
|
||||
settings: ApiSettings,
|
||||
groupId: string,
|
||||
options: { effectiveAt?: string; relationshipKinds?: string[] } = {}
|
||||
): Promise<TypedGroupMembershipResolution> {
|
||||
const params = new URLSearchParams();
|
||||
if (options.effectiveAt) params.set("effective_at", options.effectiveAt);
|
||||
for (const kind of options.relationshipKinds ?? ["member"]) params.append("relationship_kind", kind);
|
||||
return apiFetch<TypedGroupMembershipResolution>(
|
||||
settings,
|
||||
`/api/v1/idm/typed-groups/${encodeURIComponent(groupId)}/memberships?${params.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
export function createOrganizationFunctionAssignment(
|
||||
settings: ApiSettings,
|
||||
payload: OrganizationFunctionAssignmentPayload
|
||||
): Promise<OrganizationFunctionAssignmentItem> {
|
||||
return post(settings, "/api/v1/idm/organization-function-assignments", payload);
|
||||
return apiPostJson(settings, "/api/v1/idm/organization-function-assignments", payload);
|
||||
}
|
||||
|
||||
export function patchOrganizationFunctionAssignment(
|
||||
@@ -143,5 +434,42 @@ export function patchOrganizationFunctionAssignment(
|
||||
id: string,
|
||||
payload: Partial<OrganizationFunctionAssignmentPayload>
|
||||
): Promise<OrganizationFunctionAssignmentItem> {
|
||||
return patch(settings, `/api/v1/idm/organization-function-assignments/${encodeURIComponent(id)}`, payload);
|
||||
return apiPatchJson(settings, `/api/v1/idm/organization-function-assignments/${encodeURIComponent(id)}`, payload);
|
||||
}
|
||||
|
||||
export function getFunctionAssignmentChanges(settings: ApiSettings): Promise<FunctionAssignmentChangeList> {
|
||||
return apiFetch<FunctionAssignmentChangeList>(settings, "/api/v1/idm/function-assignment-changes?page_size=200");
|
||||
}
|
||||
|
||||
export function getFunctionAssignmentChange(settings: ApiSettings, id: string): Promise<FunctionAssignmentChange> {
|
||||
return apiFetch<FunctionAssignmentChange>(settings, `/api/v1/idm/function-assignment-changes/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
export function createFunctionAssignmentChange(
|
||||
settings: ApiSettings,
|
||||
payload: FunctionAssignmentChangePayload
|
||||
): Promise<FunctionAssignmentChange> {
|
||||
return apiPostJson(settings, "/api/v1/idm/function-assignment-changes", payload);
|
||||
}
|
||||
|
||||
export function actOnFunctionAssignmentChange(
|
||||
settings: ApiSettings,
|
||||
change: FunctionAssignmentChange,
|
||||
action: FunctionAssignmentChangeAction,
|
||||
comment?: string
|
||||
): Promise<FunctionAssignmentChange> {
|
||||
return apiFetch<FunctionAssignmentChange>(
|
||||
settings,
|
||||
`/api/v1/idm/function-assignment-changes/${encodeURIComponent(change.id)}/actions`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "If-Match": change.etag },
|
||||
body: JSON.stringify({
|
||||
action,
|
||||
base_revision: change.resource_revision,
|
||||
comment: comment?.trim() || null,
|
||||
evidence: []
|
||||
})
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
||||
import { Check, Eye, Plus, RotateCcw, Undo2, X } from "lucide-react";
|
||||
import { FormLayout,
|
||||
AdminIconButton,
|
||||
ApiError,
|
||||
Button,
|
||||
Card,
|
||||
ConfirmDialog,
|
||||
DataGrid,
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
DocumentationHelpLink,
|
||||
FormField,
|
||||
LoadingFrame,
|
||||
SegmentedControl,
|
||||
StatusBadge,
|
||||
TableActionGroup,
|
||||
hasScope,
|
||||
i18nMessage,
|
||||
usePlatformLanguage,
|
||||
useUnsavedChanges,
|
||||
useUnsavedDraftGuard,
|
||||
type ApiSettings,
|
||||
type AuthInfo,
|
||||
type DataGridColumn
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
actOnFunctionAssignmentChange,
|
||||
createFunctionAssignmentChange,
|
||||
getFunctionAssignmentChange,
|
||||
getFunctionAssignmentChanges,
|
||||
type FunctionAssignmentChange,
|
||||
type FunctionAssignmentChangeAction,
|
||||
type FunctionAssignmentChangeKind,
|
||||
type IdentityOption,
|
||||
type OrganizationModel
|
||||
} from "../api/idm";
|
||||
import {
|
||||
IDM_FIELD_DOCUMENTATION,
|
||||
IDM_GOVERNANCE_DOCUMENTATION,
|
||||
IDM_INTERFACE_I18N,
|
||||
idmDisabledReason
|
||||
} from "./interfacePatterns";
|
||||
|
||||
type Props = {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
model: OrganizationModel;
|
||||
identities: IdentityOption[];
|
||||
};
|
||||
|
||||
type Draft = {
|
||||
kind: FunctionAssignmentChangeKind;
|
||||
functionId: string;
|
||||
identityId: string;
|
||||
accountId: string;
|
||||
justification: string;
|
||||
evidence: string;
|
||||
validFrom: string;
|
||||
validUntil: string;
|
||||
};
|
||||
|
||||
function emptyDraft(auth: AuthInfo, kind: FunctionAssignmentChangeKind): Draft {
|
||||
return {
|
||||
kind,
|
||||
functionId: "",
|
||||
identityId: kind === "request" ? auth.principal?.identity_id ?? "" : "",
|
||||
accountId: kind === "request" ? auth.principal?.account_id ?? "" : "",
|
||||
justification: "",
|
||||
evidence: "",
|
||||
validFrom: "",
|
||||
validUntil: ""
|
||||
};
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof ApiError) {
|
||||
try {
|
||||
const body = JSON.parse(error.body) as { detail?: string | { message?: string } };
|
||||
if (typeof body.detail === "string") return body.detail;
|
||||
if (body.detail?.message) return body.detail.message;
|
||||
} catch {
|
||||
// Use the transport message.
|
||||
}
|
||||
return error.message;
|
||||
}
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function dateTimeValue(value: string): string | null {
|
||||
return value ? new Date(value).toISOString() : null;
|
||||
}
|
||||
|
||||
function statusTone(state: string): string {
|
||||
if (state === "applied") return "success";
|
||||
if (["rejected", "expired", "withdrawn", "cancelled"].includes(state)) return "inactive";
|
||||
if (["blocked", "failed_manual_review", "escalated"].includes(state)) return "danger";
|
||||
return "warning";
|
||||
}
|
||||
|
||||
const DOMAIN_LABELS: Record<string, string> = {
|
||||
request: "i18n:govoplan-idm.kind_request",
|
||||
grant: "i18n:govoplan-idm.kind_grant",
|
||||
submitted: "i18n:govoplan-idm.state_submitted",
|
||||
awaiting_holder: "i18n:govoplan-idm.state_awaiting_holder",
|
||||
awaiting_authority: "i18n:govoplan-idm.state_awaiting_authority",
|
||||
awaiting_recipient: "i18n:govoplan-idm.state_awaiting_recipient",
|
||||
changes_requested: "i18n:govoplan-idm.state_changes_requested",
|
||||
applied: "i18n:govoplan-idm.state_applied",
|
||||
rejected: "i18n:govoplan-idm.state_rejected",
|
||||
expired: "i18n:govoplan-idm.state_expired",
|
||||
withdrawn: "i18n:govoplan-idm.state_withdrawn",
|
||||
cancelled: "i18n:govoplan-idm.state_cancelled",
|
||||
blocked: "i18n:govoplan-idm.state_blocked",
|
||||
failed_manual_review: "i18n:govoplan-idm.state_failed_manual_review",
|
||||
escalated: "Escalated",
|
||||
escalated_review: "Escalated review",
|
||||
approve_holder: "i18n:govoplan-idm.step_approve_holder",
|
||||
approve_authority: "i18n:govoplan-idm.step_approve_authority",
|
||||
accept_recipient: "i18n:govoplan-idm.step_accept_recipient",
|
||||
holder_review: "i18n:govoplan-idm.profile_holder_review",
|
||||
authority_review: "i18n:govoplan-idm.profile_authority_review",
|
||||
recipient_review: "i18n:govoplan-idm.profile_recipient_review",
|
||||
approve: "i18n:govoplan-idm.action_approve",
|
||||
reject: "i18n:govoplan-idm.action_reject",
|
||||
accept: "i18n:govoplan-idm.action_accept",
|
||||
request_changes: "i18n:govoplan-idm.action_request_changes",
|
||||
respond: "i18n:govoplan-idm.action_respond",
|
||||
withdraw: "i18n:govoplan-idm.action_withdraw",
|
||||
recover: "i18n:govoplan-idm.action_recheck"
|
||||
};
|
||||
|
||||
function domainLabel(value: string): string {
|
||||
return DOMAIN_LABELS[value] ?? value.replaceAll("_", " ");
|
||||
}
|
||||
|
||||
function actionLabel(action: FunctionAssignmentChangeAction): string {
|
||||
return {
|
||||
approve: "i18n:govoplan-idm.action_approve",
|
||||
reject: "i18n:govoplan-idm.action_reject",
|
||||
accept: "i18n:govoplan-idm.action_accept",
|
||||
request_changes: "i18n:govoplan-idm.action_request_changes",
|
||||
respond: "i18n:govoplan-idm.action_respond",
|
||||
withdraw: "i18n:govoplan-idm.action_withdraw",
|
||||
recover: "i18n:govoplan-idm.action_recheck"
|
||||
}[action];
|
||||
}
|
||||
|
||||
function actionIcon(action: FunctionAssignmentChangeAction): JSX.Element {
|
||||
const props = { size: 16, "aria-hidden": true as const };
|
||||
if (action === "approve" || action === "accept") return <Check {...props} />;
|
||||
if (action === "reject") return <X {...props} />;
|
||||
if (action === "request_changes") return <Undo2 {...props} />;
|
||||
if (action === "recover") return <RotateCcw {...props} />;
|
||||
return <Undo2 {...props} />;
|
||||
}
|
||||
|
||||
export default function FunctionAssignmentChangesPanel({ settings, auth, model, identities }: Props) {
|
||||
const canRequest = hasScope(auth, "idm:function_request:create");
|
||||
const canGrant = hasScope(auth, "idm:function_grant:create") || hasScope(auth, "idm:organization_assignment:write");
|
||||
const visible = canRequest || canGrant || hasScope(auth, "idm:function_change:read") || hasScope(auth, "idm:function_change:decide") || hasScope(auth, "idm:function_change:admin");
|
||||
const initialKind: FunctionAssignmentChangeKind = canRequest ? "request" : "grant";
|
||||
const [changes, setChanges] = useState<FunctionAssignmentChange[]>([]);
|
||||
const [draft, setDraft] = useState<Draft>(() => emptyDraft(auth, initialKind));
|
||||
const [draftBaseline, setDraftBaseline] = useState<Draft>(() => emptyDraft(auth, initialKind));
|
||||
const [selected, setSelected] = useState<FunctionAssignmentChange | null>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [comment, setComment] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [pendingAction, setPendingAction] = useState<FunctionAssignmentChangeAction | null>(null);
|
||||
const { language } = usePlatformLanguage();
|
||||
const { requestDiscard } = useUnsavedChanges();
|
||||
const initialChangeId = useMemo(() => {
|
||||
if (typeof window === "undefined") return "";
|
||||
return new URLSearchParams(window.location.search).get("change") ?? "";
|
||||
}, []);
|
||||
const initialChangeOpened = useRef(false);
|
||||
const functionById = useMemo(() => new Map(model.functions.map((item) => [item.id, item])), [model.functions]);
|
||||
const identityById = useMemo(() => new Map(identities.map((item) => [item.id, item])), [identities]);
|
||||
const selectedIdentity = identityById.get(draft.identityId);
|
||||
const draftDirty = createOpen && draftKey(draft) !== draftKey(draftBaseline);
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty: draftDirty,
|
||||
title: "Unsaved function change",
|
||||
message: "Save or discard the function request or grant before leaving this surface.",
|
||||
onSave: submit,
|
||||
onDiscard: discardCreateDraft
|
||||
});
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!visible) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await getFunctionAssignmentChanges(settings);
|
||||
setChanges(response.changes);
|
||||
if (initialChangeId && !initialChangeOpened.current) {
|
||||
initialChangeOpened.current = true;
|
||||
const detail = await getFunctionAssignmentChange(settings, initialChangeId);
|
||||
setSelected(detail);
|
||||
setDetailOpen(true);
|
||||
}
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [initialChangeId, settings, visible]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
function setKind(kind: FunctionAssignmentChangeKind) {
|
||||
const next = emptyDraft(auth, kind);
|
||||
setDraft(next);
|
||||
setDraftBaseline(next);
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
const next = emptyDraft(auth, initialKind);
|
||||
setDraft(next);
|
||||
setDraftBaseline(next);
|
||||
setCreateOpen(true);
|
||||
}
|
||||
|
||||
function discardCreateDraft() {
|
||||
const next = emptyDraft(auth, initialKind);
|
||||
setDraft(next);
|
||||
setDraftBaseline(next);
|
||||
setCreateOpen(false);
|
||||
}
|
||||
|
||||
function closeCreate() {
|
||||
if (busy) return;
|
||||
if (draftDirty) requestDiscard(discardCreateDraft);
|
||||
else discardCreateDraft();
|
||||
}
|
||||
|
||||
function setIdentity(identityId: string) {
|
||||
const identity = identityById.get(identityId);
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
identityId,
|
||||
accountId: identity?.primary_account_id ?? identity?.account_ids[0] ?? ""
|
||||
}));
|
||||
}
|
||||
|
||||
async function openDetail(item: FunctionAssignmentChange) {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const detail = await getFunctionAssignmentChange(settings, item.id);
|
||||
setSelected(detail);
|
||||
setComment("");
|
||||
setDetailOpen(true);
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(event?: FormEvent): Promise<boolean> {
|
||||
event?.preventDefault();
|
||||
if (!draft.functionId || !draft.identityId || !draft.justification.trim()) return false;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const created = await createFunctionAssignmentChange(settings, {
|
||||
kind: draft.kind,
|
||||
function_id: draft.functionId,
|
||||
candidate_identity_id: draft.identityId,
|
||||
candidate_account_id: draft.accountId || null,
|
||||
justification: draft.justification.trim(),
|
||||
evidence: draft.evidence.split(/\r?\n/).map((item) => item.trim()).filter(Boolean),
|
||||
requested_valid_from: dateTimeValue(draft.validFrom),
|
||||
requested_valid_until: dateTimeValue(draft.validUntil),
|
||||
idempotency_key: crypto.randomUUID(),
|
||||
metadata: {}
|
||||
});
|
||||
setCreateOpen(false);
|
||||
const next = emptyDraft(auth, initialKind);
|
||||
setDraft(next);
|
||||
setDraftBaseline(next);
|
||||
setSelected(created);
|
||||
setDetailOpen(true);
|
||||
await load();
|
||||
return true;
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function performAction(action: FunctionAssignmentChangeAction) {
|
||||
if (!selected) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const updated = await actOnFunctionAssignmentChange(settings, selected, action, comment);
|
||||
setSelected(updated);
|
||||
setComment("");
|
||||
await load();
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: DataGridColumn<FunctionAssignmentChange>[] = [
|
||||
{ id: "kind", header: "Kind", width: 110, sortable: true, filterable: true, value: (row) => row.kind, render: (row) => domainLabel(row.kind) },
|
||||
{
|
||||
id: "function",
|
||||
header: "Function",
|
||||
minWidth: 220,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
value: (row) => functionById.get(row.function_id)?.name ?? row.function_id,
|
||||
render: (row) => functionById.get(row.function_id)?.name ?? row.function_id
|
||||
},
|
||||
{
|
||||
id: "candidate",
|
||||
header: "Candidate",
|
||||
minWidth: 220,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
value: (row) => identityById.get(row.candidate_identity_id)?.display_name ?? row.candidate_identity_id,
|
||||
render: (row) => identityById.get(row.candidate_identity_id)?.display_name ?? row.candidate_identity_id
|
||||
},
|
||||
{ id: "state", header: "State", width: 170, sortable: true, filterable: true, value: (row) => row.state, render: (row) => <StatusBadge status={statusTone(row.state)} label={domainLabel(row.state)} /> },
|
||||
{ id: "progress", header: "Decisions", width: 140, value: (row) => `${row.completed_steps.length}/${row.required_steps.length}`, render: (row) => `${row.completed_steps.length} / ${row.required_steps.length}` },
|
||||
{ id: "updated", header: "Updated", width: 170, sortable: true, value: (row) => row.updated_at, render: (row) => new Date(row.updated_at).toLocaleString(language) },
|
||||
{ id: "actions", header: "Actions", width: 72, sticky: "end", render: (row) => <TableActionGroup actions={[{ id: "view", label: "Open change", icon: <Eye size={16} aria-hidden="true" />, onClick: () => void openDetail(row) }]} /> }
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
<Card
|
||||
title="Function requests and grants"
|
||||
collapsible
|
||||
collapseKey="idm.function-assignment-changes"
|
||||
actions={(
|
||||
<div className="button-row compact-actions">
|
||||
<DocumentationHelpLink reference={IDM_GOVERNANCE_DOCUMENTATION} />
|
||||
{(canRequest || canGrant) ? <AdminIconButton label="Start governed change" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={busy} disabledReason={idmDisabledReason(false, busy)} onClick={openCreate} /> : null}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<LoadingFrame loading={loading} label="Loading governed function changes">
|
||||
<DataGrid id="idm-function-assignment-changes" rows={changes} columns={columns} getRowKey={(row) => row.id} emptyText="No governed function changes" initialFit="container" />
|
||||
</LoadingFrame>
|
||||
</Card>
|
||||
|
||||
<Dialog variant="administration" size="wide"
|
||||
open={createOpen}
|
||||
title="Start governed function change"
|
||||
className="idm-change-dialog"
|
||||
onClose={closeCreate}
|
||||
closeDisabled={busy}
|
||||
footer={<><Button type="button" onClick={closeCreate} disabled={busy} disabledReason={idmDisabledReason(false, busy)}>Cancel</Button><Button type="submit" form="idm-change-create" variant="primary" disabled={busy || !draft.functionId || !draft.identityId || !draft.justification.trim()} disabledReason={idmDisabledReason(false, busy) ?? ((!draft.functionId || !draft.identityId || !draft.justification.trim()) ? IDM_INTERFACE_I18N.incomplete : undefined)}>Submit</Button></>}
|
||||
>
|
||||
<FormLayout columns={2} gap="small" collapseAt="workspace" id="idm-change-create" className="" onSubmit={(event) => void submit(event)}>
|
||||
<div className="wide">
|
||||
<SegmentedControl
|
||||
ariaLabel="Function change kind"
|
||||
value={draft.kind}
|
||||
onChange={setKind}
|
||||
options={[
|
||||
{ id: "request", label: "Request function", disabled: !canRequest },
|
||||
{ id: "grant", label: "Grant function", disabled: !canGrant }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<FormField label="Function" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<select value={draft.functionId} onChange={(event) => setDraft((current) => ({ ...current, functionId: event.target.value }))} disabled={busy}>
|
||||
<option value="">Select function</option>
|
||||
{model.functions.filter((item) => item.is_active).map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Candidate identity" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<select value={draft.identityId} onChange={(event) => setIdentity(event.target.value)} disabled={busy || draft.kind === "request"}>
|
||||
<option value="">Select identity</option>
|
||||
{identities.map((item) => <option key={item.id} value={item.id}>{item.display_name || item.external_subject || item.id}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Candidate account" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<select value={draft.accountId} onChange={(event) => setDraft((current) => ({ ...current, accountId: event.target.value }))} disabled={busy}>
|
||||
<option value="">No linked account</option>
|
||||
{(selectedIdentity?.account_ids ?? []).map((accountId) => <option key={accountId} value={accountId}>{accountId}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Valid from" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<input type="datetime-local" value={draft.validFrom} onChange={(event) => setDraft((current) => ({ ...current, validFrom: event.target.value }))} disabled={busy} />
|
||||
</FormField>
|
||||
<FormField label="Valid until" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<input type="datetime-local" value={draft.validUntil} onChange={(event) => setDraft((current) => ({ ...current, validUntil: event.target.value }))} disabled={busy} />
|
||||
</FormField>
|
||||
<div className="wide">
|
||||
<FormField label="Justification" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<textarea rows={4} value={draft.justification} onChange={(event) => setDraft((current) => ({ ...current, justification: event.target.value }))} disabled={busy} />
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="wide">
|
||||
<FormField label="Evidence references (one per line)" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<textarea rows={3} value={draft.evidence} onChange={(event) => setDraft((current) => ({ ...current, evidence: event.target.value }))} disabled={busy} />
|
||||
</FormField>
|
||||
</div>
|
||||
</FormLayout>
|
||||
</Dialog>
|
||||
|
||||
<Dialog variant="administration" size="wide"
|
||||
open={detailOpen && selected !== null}
|
||||
title={selected
|
||||
? i18nMessage(
|
||||
selected.kind === "request"
|
||||
? "i18n:govoplan-idm.function_request_title"
|
||||
: "i18n:govoplan-idm.function_grant_title",
|
||||
{ function: functionById.get(selected.function_id)?.name ?? selected.function_id }
|
||||
)
|
||||
: "Function change"}
|
||||
className="idm-change-dialog"
|
||||
onClose={() => !busy && setDetailOpen(false)}
|
||||
closeDisabled={busy}
|
||||
footer={<Button type="button" onClick={() => setDetailOpen(false)} disabled={busy} disabledReason={idmDisabledReason(false, busy)}>Close</Button>}
|
||||
>
|
||||
{selected && (
|
||||
<div className="idm-change-detail">
|
||||
<dl className="idm-change-summary">
|
||||
<div><dt>State</dt><dd><StatusBadge status={statusTone(selected.state)} label={domainLabel(selected.state)} /></dd></div>
|
||||
<div><dt>Candidate</dt><dd>{identityById.get(selected.candidate_identity_id)?.display_name ?? selected.candidate_identity_id}</dd></div>
|
||||
<div><dt>Profile</dt><dd>{domainLabel(selected.profile)}</dd></div>
|
||||
<div><dt>Workflow revision</dt><dd>{selected.workflow_definition_revision ?? "-"}</dd></div>
|
||||
<div><dt>Required decisions</dt><dd>{selected.required_steps.map(domainLabel).join(", ") || "None"}</dd></div>
|
||||
<div><dt>Completed decisions</dt><dd>{selected.completed_steps.map(domainLabel).join(", ") || "None"}</dd></div>
|
||||
{selected.review_deadline_at && <div><dt>Review deadline</dt><dd>{new Date(selected.review_deadline_at).toLocaleString(language)}</dd></div>}
|
||||
{selected.escalated_at && <div><dt>Escalated</dt><dd>{new Date(selected.escalated_at).toLocaleString(language)}</dd></div>}
|
||||
{selected.escalation_from_state && <div><dt>Escalated from</dt><dd>{domainLabel(selected.escalation_from_state)}</dd></div>}
|
||||
{selected.escalation_target_function_id && <div><dt>Escalation target</dt><dd>{functionById.get(selected.escalation_target_function_id)?.name ?? selected.escalation_target_function_id}</dd></div>}
|
||||
<div className="wide"><dt>Justification</dt><dd>{selected.justification}</dd></div>
|
||||
{selected.outcome_reason && <div className="wide"><dt>Explanation</dt><dd>{selected.outcome_reason}</dd></div>}
|
||||
</dl>
|
||||
{selected.available_actions.length > 0 && (
|
||||
<div className="idm-change-actions">
|
||||
<FormField label="Decision comment" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<textarea rows={2} value={comment} onChange={(event) => setComment(event.target.value)} disabled={busy} />
|
||||
</FormField>
|
||||
<div className="button-row compact-actions">
|
||||
{selected.available_actions.map((action) => (
|
||||
<Button key={action} type="button" variant={action === "reject" ? "danger" : action === "approve" || action === "accept" ? "primary" : "secondary"} disabled={busy} disabledReason={idmDisabledReason(false, busy)} onClick={() => setPendingAction(action)}>
|
||||
{actionIcon(action)} {actionLabel(action)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selected.availability_reason && selected.available_actions.length === 0 && <p className="idm-muted">{selected.availability_reason}</p>}
|
||||
<div>
|
||||
<h3>History</h3>
|
||||
<ol className="idm-change-history">
|
||||
{selected.events.map((event) => <li key={event.id}><strong>{domainLabel(event.action)}</strong><span>{new Date(event.created_at).toLocaleString(language)}</span><span>{event.from_state ? `${domainLabel(event.from_state)} -> ` : ""}{domainLabel(event.to_state)}</span>{event.comment && <p>{event.comment}</p>}</li>)}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
<ConfirmDialog
|
||||
open={Boolean(pendingAction && selected)}
|
||||
title="Confirm function decision"
|
||||
message={pendingAction && selected
|
||||
? i18nMessage("i18n:govoplan-idm.confirm_function_action", {
|
||||
action: actionLabel(pendingAction),
|
||||
function: functionById.get(selected.function_id)?.name ?? selected.function_id
|
||||
})
|
||||
: ""}
|
||||
confirmLabel={pendingAction ? actionLabel(pendingAction) : "Confirm"}
|
||||
tone={pendingAction === "reject" || pendingAction === "withdraw" ? "danger" : "default"}
|
||||
busy={busy}
|
||||
onConfirm={() => {
|
||||
if (!pendingAction) return;
|
||||
const action = pendingAction;
|
||||
setPendingAction(null);
|
||||
void performAction(action);
|
||||
}}
|
||||
onCancel={() => setPendingAction(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function draftKey(value: unknown): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
+331
-83
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
||||
import { Edit3, Plus, RefreshCw } from "lucide-react";
|
||||
import {
|
||||
import { FormLayout, ActionToolbar,
|
||||
ActionBlockerHint,
|
||||
AdminIconButton,
|
||||
ApiError,
|
||||
Button,
|
||||
@@ -8,11 +9,16 @@ import {
|
||||
DataGrid,
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
DocumentationHelpLink,
|
||||
FormField,
|
||||
LoadingFrame,
|
||||
PageScrollViewport,
|
||||
PageTitle,
|
||||
StatusBadge,
|
||||
TableActionGroup,
|
||||
ToggleSwitch,
|
||||
hasScope,
|
||||
useUnsavedChanges,
|
||||
useUnsavedDraftGuard,
|
||||
type ApiSettings,
|
||||
type AuthInfo,
|
||||
@@ -34,6 +40,15 @@ import {
|
||||
type OrganizationModel,
|
||||
type OrganizationUnitItem
|
||||
} from "../api/idm";
|
||||
import FunctionAssignmentChangesPanel from "./FunctionAssignmentChangesPanel";
|
||||
import TypedRelationshipsPanel from "./TypedRelationshipsPanel";
|
||||
import {
|
||||
IDM_DOCUMENTATION,
|
||||
IDM_FIELD_DOCUMENTATION,
|
||||
IDM_GOVERNANCE_DOCUMENTATION,
|
||||
IDM_INTERFACE_I18N,
|
||||
idmDisabledReason
|
||||
} from "./interfacePatterns";
|
||||
|
||||
type IdmPageProps = {
|
||||
settings: ApiSettings;
|
||||
@@ -49,12 +64,23 @@ type AssignmentDraft = {
|
||||
delegated_from_assignment_id: string;
|
||||
acting_for_account_id: string;
|
||||
is_active: boolean;
|
||||
governance_override_reason: string;
|
||||
governance_override_evidence: string;
|
||||
};
|
||||
|
||||
type SettingsDraft = {
|
||||
require_assignment_change_requests: boolean;
|
||||
audit_detail_level: "summary" | "standard" | "full";
|
||||
change_retention_days: string;
|
||||
delegation_allowed: boolean;
|
||||
maximum_delegation_depth: string;
|
||||
maximum_delegated_validity_days: string;
|
||||
holder_escalation_target: string;
|
||||
holder_escalation_hours: string;
|
||||
authority_escalation_target: string;
|
||||
authority_escalation_hours: string;
|
||||
recipient_escalation_target: string;
|
||||
recipient_escalation_hours: string;
|
||||
};
|
||||
|
||||
const EMPTY_MODEL: OrganizationModel = {
|
||||
@@ -88,7 +114,9 @@ function emptyAssignmentDraft(): AssignmentDraft {
|
||||
source: "direct",
|
||||
delegated_from_assignment_id: "",
|
||||
acting_for_account_id: "",
|
||||
is_active: true
|
||||
is_active: true,
|
||||
governance_override_reason: "",
|
||||
governance_override_evidence: ""
|
||||
};
|
||||
}
|
||||
|
||||
@@ -107,7 +135,12 @@ function assignmentPayload(draft: AssignmentDraft): OrganizationFunctionAssignme
|
||||
delegated_from_assignment_id: textOrNull(draft.delegated_from_assignment_id),
|
||||
acting_for_account_id: textOrNull(draft.acting_for_account_id),
|
||||
is_active: draft.is_active,
|
||||
settings: {}
|
||||
settings: {},
|
||||
governance_override_reason: textOrNull(draft.governance_override_reason),
|
||||
governance_override_evidence: draft.governance_override_evidence
|
||||
.split(/\r?\n/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -120,39 +153,74 @@ function assignmentDraftFrom(item: OrganizationFunctionAssignmentItem): Assignme
|
||||
source: item.source || "direct",
|
||||
delegated_from_assignment_id: item.delegated_from_assignment_id ?? "",
|
||||
acting_for_account_id: item.acting_for_account_id ?? "",
|
||||
is_active: item.is_active
|
||||
is_active: item.is_active,
|
||||
governance_override_reason: "",
|
||||
governance_override_evidence: ""
|
||||
};
|
||||
}
|
||||
|
||||
function isAssignmentDirty(draft: AssignmentDraft): boolean {
|
||||
return Boolean(
|
||||
draft.identity_id ||
|
||||
draft.account_id ||
|
||||
draft.function_id ||
|
||||
draft.applies_to_subunits ||
|
||||
draft.source !== "direct" ||
|
||||
draft.delegated_from_assignment_id ||
|
||||
draft.acting_for_account_id ||
|
||||
!draft.is_active
|
||||
);
|
||||
}
|
||||
|
||||
function settingsDraftFrom(item: IdmSettings | null): SettingsDraft {
|
||||
const source = item ?? DEFAULT_IDM_SETTINGS;
|
||||
const defaults = source.settings.function_assignment_governance_defaults;
|
||||
const governance = defaults && typeof defaults === "object" && !Array.isArray(defaults)
|
||||
? defaults as Record<string, unknown>
|
||||
: {};
|
||||
const escalationValue = governance.escalation;
|
||||
const escalation = escalationValue && typeof escalationValue === "object" && !Array.isArray(escalationValue)
|
||||
? escalationValue as Record<string, unknown>
|
||||
: {};
|
||||
const escalationRule = (step: string): Record<string, unknown> => {
|
||||
const value = escalation[step];
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
};
|
||||
const holder = escalationRule("holder");
|
||||
const authority = escalationRule("authority");
|
||||
const recipient = escalationRule("recipient");
|
||||
return {
|
||||
require_assignment_change_requests: source.require_assignment_change_requests,
|
||||
audit_detail_level: source.audit_detail_level,
|
||||
change_retention_days: source.change_retention_days == null ? "" : String(source.change_retention_days)
|
||||
change_retention_days: source.change_retention_days == null ? "" : String(source.change_retention_days),
|
||||
delegation_allowed: governance.delegation_allowed === true,
|
||||
maximum_delegation_depth: governance.maximum_delegation_depth == null ? "1" : String(governance.maximum_delegation_depth),
|
||||
maximum_delegated_validity_days: governance.maximum_delegated_validity_days == null ? "" : String(governance.maximum_delegated_validity_days),
|
||||
holder_escalation_target: String(holder.target_function_id ?? ""),
|
||||
holder_escalation_hours: holder.timeout_hours == null ? "" : String(holder.timeout_hours),
|
||||
authority_escalation_target: String(authority.target_function_id ?? ""),
|
||||
authority_escalation_hours: authority.timeout_hours == null ? "" : String(authority.timeout_hours),
|
||||
recipient_escalation_target: String(recipient.target_function_id ?? ""),
|
||||
recipient_escalation_hours: recipient.timeout_hours == null ? "" : String(recipient.timeout_hours)
|
||||
};
|
||||
}
|
||||
|
||||
function settingsPayload(draft: SettingsDraft, item: IdmSettings | null): Pick<IdmSettings, "require_assignment_change_requests" | "audit_detail_level" | "change_retention_days" | "settings"> {
|
||||
const trimmedDays = draft.change_retention_days.trim();
|
||||
const sourceSettings = item?.settings ?? {};
|
||||
const sourceDefaults = sourceSettings.function_assignment_governance_defaults;
|
||||
const existingDefaults = sourceDefaults && typeof sourceDefaults === "object" && !Array.isArray(sourceDefaults)
|
||||
? sourceDefaults as Record<string, unknown>
|
||||
: {};
|
||||
const escalation: Record<string, { target_function_id: string; timeout_hours: number }> = {};
|
||||
for (const [step, target, hours] of [
|
||||
["holder", draft.holder_escalation_target, draft.holder_escalation_hours],
|
||||
["authority", draft.authority_escalation_target, draft.authority_escalation_hours],
|
||||
["recipient", draft.recipient_escalation_target, draft.recipient_escalation_hours]
|
||||
] as const) {
|
||||
if (target && hours) escalation[step] = { target_function_id: target, timeout_hours: Number(hours) };
|
||||
}
|
||||
return {
|
||||
require_assignment_change_requests: draft.require_assignment_change_requests,
|
||||
audit_detail_level: draft.audit_detail_level,
|
||||
change_retention_days: trimmedDays ? Number(trimmedDays) : null,
|
||||
settings: item?.settings ?? {}
|
||||
settings: {
|
||||
...sourceSettings,
|
||||
function_assignment_governance_defaults: {
|
||||
...existingDefaults,
|
||||
delegation_allowed: draft.delegation_allowed,
|
||||
maximum_delegation_depth: Number(draft.maximum_delegation_depth || "1"),
|
||||
maximum_delegated_validity_days: draft.maximum_delegated_validity_days ? Number(draft.maximum_delegated_validity_days) : null,
|
||||
escalation
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -161,10 +229,26 @@ function isSettingsDirty(draft: SettingsDraft, item: IdmSettings | null): boolea
|
||||
return (
|
||||
draft.require_assignment_change_requests !== baseline.require_assignment_change_requests ||
|
||||
draft.audit_detail_level !== baseline.audit_detail_level ||
|
||||
draft.change_retention_days.trim() !== baseline.change_retention_days.trim()
|
||||
draft.change_retention_days.trim() !== baseline.change_retention_days.trim() ||
|
||||
draft.delegation_allowed !== baseline.delegation_allowed ||
|
||||
draft.maximum_delegation_depth !== baseline.maximum_delegation_depth ||
|
||||
draft.maximum_delegated_validity_days !== baseline.maximum_delegated_validity_days ||
|
||||
draft.holder_escalation_target !== baseline.holder_escalation_target ||
|
||||
draft.holder_escalation_hours !== baseline.holder_escalation_hours ||
|
||||
draft.authority_escalation_target !== baseline.authority_escalation_target ||
|
||||
draft.authority_escalation_hours !== baseline.authority_escalation_hours ||
|
||||
draft.recipient_escalation_target !== baseline.recipient_escalation_target ||
|
||||
draft.recipient_escalation_hours !== baseline.recipient_escalation_hours
|
||||
);
|
||||
}
|
||||
|
||||
function settingsDraftInvalid(draft: SettingsDraft): boolean {
|
||||
const incompleteRule = (target: string, hours: string) => Boolean(target) !== Boolean(hours);
|
||||
return incompleteRule(draft.holder_escalation_target, draft.holder_escalation_hours)
|
||||
|| incompleteRule(draft.authority_escalation_target, draft.authority_escalation_hours)
|
||||
|| incompleteRule(draft.recipient_escalation_target, draft.recipient_escalation_hours);
|
||||
}
|
||||
|
||||
function mapById<T extends { id: string }>(items: T[]): Map<string, T> {
|
||||
return new Map(items.map((item) => [item.id, item]));
|
||||
}
|
||||
@@ -221,6 +305,16 @@ function sourceLabel(value: string): string {
|
||||
return SOURCE_OPTIONS.find((item) => item.value === value)?.label ?? value;
|
||||
}
|
||||
|
||||
function isGovernedFunction(item: OrganizationFunctionItem | undefined): boolean {
|
||||
const governance = item?.settings.assignment_governance;
|
||||
if (!governance || typeof governance !== "object" || Array.isArray(governance)) return false;
|
||||
const values = governance as Record<string, unknown>;
|
||||
return ["request_profile", "grant_profile"].some((key) => {
|
||||
const value = String(values[key] ?? "unavailable").trim().toLowerCase();
|
||||
return Boolean(value && value !== "unavailable");
|
||||
});
|
||||
}
|
||||
|
||||
function idmInitialQuery(): { assignmentId: string; functionId: string } {
|
||||
if (typeof window === "undefined") return { assignmentId: "", functionId: "" };
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
@@ -242,6 +336,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
const [actingForOptions, setActingForOptions] = useState<IdentityOption[]>([]);
|
||||
const [actingForLoading, setActingForLoading] = useState(false);
|
||||
const [assignmentDraft, setAssignmentDraft] = useState<AssignmentDraft>(() => emptyAssignmentDraft());
|
||||
const [assignmentBaseline, setAssignmentBaseline] = useState<AssignmentDraft>(() => emptyAssignmentDraft());
|
||||
const [settingsDraft, setSettingsDraft] = useState<SettingsDraft>(() => settingsDraftFrom(null));
|
||||
const [editingAssignmentId, setEditingAssignmentId] = useState<string | null>(null);
|
||||
const [assignmentEditorOpen, setAssignmentEditorOpen] = useState(false);
|
||||
@@ -252,8 +347,12 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
const [success, setSuccess] = useState("");
|
||||
const initialQuery = useMemo(() => idmInitialQuery(), []);
|
||||
const appliedInitialQueryRef = useRef(false);
|
||||
const { requestDiscard } = useUnsavedChanges();
|
||||
|
||||
const canReadAssignments = hasScope(auth, "idm:organization_assignment:read") || hasScope(auth, "idm:organization_assignment:write") || hasScope(auth, "organizations:function:assign");
|
||||
const canManage = hasScope(auth, "idm:organization_assignment:write") || hasScope(auth, "organizations:function:assign");
|
||||
const canUseFunctionChanges = hasScope(auth, "idm:function_change:read") || hasScope(auth, "idm:function_request:create") || hasScope(auth, "idm:function_grant:create") || hasScope(auth, "idm:function_change:decide") || hasScope(auth, "idm:function_change:admin");
|
||||
const canUseAssignmentWorkspace = canReadAssignments || canUseFunctionChanges;
|
||||
const canSearchIdentities = canManage || hasScope(auth, "idm:organization_identity:read") || hasScope(auth, "admin:users:read");
|
||||
const canReadSettings = hasScope(auth, "idm:settings:read") || hasScope(auth, "idm:settings:write") || hasScope(auth, "idm:organization_assignment:read") || hasScope(auth, "idm:organization_assignment:write");
|
||||
const canManageSettings = hasScope(auth, "idm:settings:write");
|
||||
@@ -262,6 +361,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
const identityOptionById = useMemo(() => mapById(identityOptions), [identityOptions]);
|
||||
const actingForOptionById = useMemo(() => mapById(actingForOptions), [actingForOptions]);
|
||||
const selectedIdentity = identityOptionById.get(assignmentDraft.identity_id);
|
||||
const selectedFunctionIsGoverned = isGovernedFunction(functionById.get(assignmentDraft.function_id));
|
||||
|
||||
const identitySelectOptions = useMemo(() => {
|
||||
if (!assignmentDraft.identity_id || identityOptionById.has(assignmentDraft.identity_id)) return identityOptions;
|
||||
@@ -302,7 +402,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
return Array.from(values).sort((left, right) => left.localeCompare(right));
|
||||
}, [actingForOptionById, actingForOptions, assignmentDraft.acting_for_account_id, identityOptionById, sourceAssignment]);
|
||||
const initialFunctionFilter = initialQuery.functionId;
|
||||
const hasDirtyAssignmentDraft = assignmentEditorOpen && isAssignmentDirty(assignmentDraft);
|
||||
const hasDirtyAssignmentDraft = assignmentEditorOpen && draftKey(assignmentDraft) !== draftKey(assignmentBaseline);
|
||||
const hasDirtySettingsDraft = canReadSettings && isSettingsDirty(settingsDraft, idmSettings);
|
||||
const hasDirtyDraft = hasDirtyAssignmentDraft || hasDirtySettingsDraft;
|
||||
|
||||
@@ -311,8 +411,8 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
setError("");
|
||||
try {
|
||||
const [nextModel, nextAssignments, nextSettings] = await Promise.all([
|
||||
getOrganizationModel(settings),
|
||||
getOrganizationFunctionAssignments(settings),
|
||||
canUseAssignmentWorkspace ? getOrganizationModel(settings) : Promise.resolve(EMPTY_MODEL),
|
||||
canReadAssignments ? getOrganizationFunctionAssignments(settings) : Promise.resolve({ assignments: [], total: 0, page: 1, page_size: 0, pages: 1 }),
|
||||
canReadSettings ? getIdmSettings(settings).catch(() => null) : Promise.resolve(null)
|
||||
]);
|
||||
setModel(nextModel);
|
||||
@@ -324,7 +424,9 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
if (initialAssignment) {
|
||||
appliedInitialQueryRef.current = true;
|
||||
setEditingAssignmentId(initialAssignment.id);
|
||||
setAssignmentDraft(assignmentDraftFrom(initialAssignment));
|
||||
const nextDraft = assignmentDraftFrom(initialAssignment);
|
||||
setAssignmentDraft(nextDraft);
|
||||
setAssignmentBaseline(nextDraft);
|
||||
setAssignmentEditorOpen(true);
|
||||
}
|
||||
}
|
||||
@@ -344,7 +446,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [canReadSettings, canSearchIdentities, initialFunctionFilter, initialQuery.assignmentId, settings]);
|
||||
}, [canReadAssignments, canReadSettings, canSearchIdentities, canUseAssignmentWorkspace, initialQuery.assignmentId, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
@@ -409,7 +511,9 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
}, [loadData]);
|
||||
|
||||
const discardAssignmentDraft = useCallback(() => {
|
||||
setAssignmentDraft(emptyAssignmentDraft());
|
||||
const nextDraft = emptyAssignmentDraft();
|
||||
setAssignmentDraft(nextDraft);
|
||||
setAssignmentBaseline(nextDraft);
|
||||
setEditingAssignmentId(null);
|
||||
setAssignmentEditorOpen(false);
|
||||
setAssignmentChangeRequestId("");
|
||||
@@ -447,6 +551,10 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
setError("i18n:govoplan-idm.function_is_required.5cce5b41");
|
||||
return false;
|
||||
}
|
||||
if (selectedFunctionIsGoverned && !assignmentDraft.governance_override_reason.trim()) {
|
||||
setError("Direct changes to this governed function require an emergency override reason.");
|
||||
return false;
|
||||
}
|
||||
const requestId = textOrNull(assignmentChangeRequestId);
|
||||
const payload = requestId ? { ...assignmentPayload(assignmentDraft), change_request_id: requestId } : assignmentPayload(assignmentDraft);
|
||||
const ok = await runAction(
|
||||
@@ -455,7 +563,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
);
|
||||
if (ok) discardAssignmentDraft();
|
||||
return ok;
|
||||
}, [assignmentChangeRequestId, assignmentDraft, canManage, discardAssignmentDraft, editingAssignmentId, runAction, settings]);
|
||||
}, [assignmentChangeRequestId, assignmentDraft, canManage, discardAssignmentDraft, editingAssignmentId, runAction, selectedFunctionIsGoverned, settings]);
|
||||
|
||||
const saveDrafts = useCallback(async (): Promise<boolean> => {
|
||||
if (hasDirtyAssignmentDraft && !(await submitAssignment())) return false;
|
||||
@@ -473,18 +581,28 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
|
||||
const openCreateAssignment = useCallback(() => {
|
||||
setEditingAssignmentId(null);
|
||||
setAssignmentDraft({ ...emptyAssignmentDraft(), function_id: initialFunctionFilter && functionById.has(initialFunctionFilter) ? initialFunctionFilter : "" });
|
||||
const nextDraft = { ...emptyAssignmentDraft(), function_id: initialFunctionFilter && functionById.has(initialFunctionFilter) ? initialFunctionFilter : "" };
|
||||
setAssignmentDraft(nextDraft);
|
||||
setAssignmentBaseline(nextDraft);
|
||||
setAssignmentChangeRequestId("");
|
||||
setAssignmentEditorOpen(true);
|
||||
}, [functionById, initialFunctionFilter]);
|
||||
|
||||
const editAssignment = useCallback((item: OrganizationFunctionAssignmentItem) => {
|
||||
const nextDraft = assignmentDraftFrom(item);
|
||||
setEditingAssignmentId(item.id);
|
||||
setAssignmentDraft(assignmentDraftFrom(item));
|
||||
setAssignmentDraft(nextDraft);
|
||||
setAssignmentBaseline(nextDraft);
|
||||
setAssignmentChangeRequestId("");
|
||||
setAssignmentEditorOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeAssignmentEditor = useCallback(() => {
|
||||
if (busy) return;
|
||||
if (hasDirtyAssignmentDraft) requestDiscard(discardAssignmentDraft);
|
||||
else discardAssignmentDraft();
|
||||
}, [busy, discardAssignmentDraft, hasDirtyAssignmentDraft, requestDiscard]);
|
||||
|
||||
function onIdentityChange(identityId: string) {
|
||||
const identity = identityOptionById.get(identityId);
|
||||
setAssignmentDraft({
|
||||
@@ -559,52 +677,91 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
width: 88,
|
||||
header: "Actions",
|
||||
width: 72,
|
||||
sticky: "end",
|
||||
render: (row) => (
|
||||
<div className="idm-row-actions">
|
||||
<AdminIconButton label="i18n:govoplan-idm.edit.a5a0f3cc" icon={<Edit3 size={16} aria-hidden="true" />} disabled={!canManage || busy} onClick={() => editAssignment(row)} />
|
||||
</div>
|
||||
)
|
||||
render: (row) => <TableActionGroup actions={[{
|
||||
id: "edit",
|
||||
label: "i18n:govoplan-idm.edit.a5a0f3cc",
|
||||
icon: <Edit3 size={16} aria-hidden="true" />,
|
||||
disabled: !canManage || busy,
|
||||
disabledReason: idmDisabledReason(false, busy, canManage),
|
||||
onClick: () => editAssignment(row)
|
||||
}]} />
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<PageScrollViewport>
|
||||
<div className="content-pad idm-page">
|
||||
<div className="page-heading split idm-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>i18n:govoplan-idm.idm.61f4a7a2</PageTitle>
|
||||
<p>i18n:govoplan-idm.identity_links_intro.45fed9dd</p>
|
||||
</div>
|
||||
<div className="idm-toolbar">
|
||||
<Button type="button" onClick={() => void loadData()} disabled={loading || busy} title="i18n:govoplan-idm.reload.870ca3ec">
|
||||
<ActionToolbar justify="end" className="idm-toolbar">
|
||||
<DocumentationHelpLink reference={IDM_DOCUMENTATION} />
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => requestDiscard(() => void loadData())}
|
||||
disabled={loading || busy}
|
||||
disabledReason={idmDisabledReason(loading, busy)}
|
||||
title="i18n:govoplan-idm.reload.870ca3ec"
|
||||
>
|
||||
<RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-idm.reload.870ca3ec
|
||||
</Button>
|
||||
</div>
|
||||
</ActionToolbar>
|
||||
</div>
|
||||
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
{success && !error && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
|
||||
{!canManage && <DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-idm.write_permission_required.c7dde7c6</DismissibleAlert>}
|
||||
{canReadAssignments && !canManage && (
|
||||
<ActionBlockerHint
|
||||
reason={{
|
||||
summary: IDM_INTERFACE_I18N.writeReason,
|
||||
requiredAction: IDM_INTERFACE_I18N.permissionAction,
|
||||
actor: IDM_INTERFACE_I18N.permissionActor,
|
||||
target: IDM_INTERFACE_I18N.permissionDestination
|
||||
}}
|
||||
labels={{
|
||||
requiredAction: IDM_INTERFACE_I18N.requiredAction,
|
||||
actor: IDM_INTERFACE_I18N.actor,
|
||||
target: IDM_INTERFACE_I18N.destination
|
||||
}}
|
||||
documentation={IDM_DOCUMENTATION}
|
||||
/>
|
||||
)}
|
||||
{canUseAssignmentWorkspace && !model.functions.length && !loading && (
|
||||
<ActionBlockerHint
|
||||
reason={{
|
||||
summary: IDM_INTERFACE_I18N.noFunctions,
|
||||
requiredAction: IDM_INTERFACE_I18N.functionAction,
|
||||
actor: IDM_INTERFACE_I18N.functionActor,
|
||||
target: IDM_INTERFACE_I18N.functionDestination
|
||||
}}
|
||||
labels={{
|
||||
requiredAction: IDM_INTERFACE_I18N.requiredAction,
|
||||
actor: IDM_INTERFACE_I18N.actor,
|
||||
target: IDM_INTERFACE_I18N.destination
|
||||
}}
|
||||
documentation={IDM_DOCUMENTATION}
|
||||
/>
|
||||
)}
|
||||
|
||||
<LoadingFrame loading={loading || busy} label="i18n:govoplan-idm.loading_idm_assignments.0b1501bd">
|
||||
<div className="idm-table-stack">
|
||||
{canReadSettings && (
|
||||
<Card title="i18n:govoplan-idm.idm_governance.6e4f3251" collapsible collapseKey="idm.governance">
|
||||
<form className="idm-form-grid" onSubmit={(event) => { event.preventDefault(); void submitSettings(); }}>
|
||||
<Card
|
||||
title="i18n:govoplan-idm.idm_governance.6e4f3251"
|
||||
collapsible
|
||||
collapseKey="idm.governance"
|
||||
actions={<DocumentationHelpLink reference={IDM_GOVERNANCE_DOCUMENTATION} />}
|
||||
>
|
||||
<FormLayout columns={2} gap="small" collapseAt="workspace" className="" onSubmit={(event) => { event.preventDefault(); void submitSettings(); }}>
|
||||
<div className="idm-check-list wide">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settingsDraft.require_assignment_change_requests}
|
||||
disabled={!canManageSettings || busy}
|
||||
onChange={(event) => setSettingsDraft({ ...settingsDraft, require_assignment_change_requests: event.target.checked })}
|
||||
/>
|
||||
<span>i18n:govoplan-idm.require_assignment_change_requests.697718a1</span>
|
||||
</label>
|
||||
<ToggleSwitch label="i18n:govoplan-idm.require_assignment_change_requests.697718a1" checked={settingsDraft.require_assignment_change_requests} disabled={!canManageSettings || busy} help={idmDisabledReason(false, busy, canManageSettings)} onChange={(require_assignment_change_requests) => setSettingsDraft({ ...settingsDraft, require_assignment_change_requests })} />
|
||||
</div>
|
||||
<FormField label="i18n:govoplan-idm.audit_detail_level.eb2e6fd2">
|
||||
<FormField label="i18n:govoplan-idm.audit_detail_level.eb2e6fd2" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<select
|
||||
value={settingsDraft.audit_detail_level}
|
||||
disabled={!canManageSettings || busy}
|
||||
@@ -615,7 +772,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
<option value="full">i18n:govoplan-idm.full.7f021a14</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="i18n:govoplan-idm.change_retention_days.4a91f7d3">
|
||||
<FormField label="i18n:govoplan-idm.change_retention_days.4a91f7d3" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
@@ -624,16 +781,61 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
onChange={(event) => setSettingsDraft({ ...settingsDraft, change_retention_days: event.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="idm-form-actions wide">
|
||||
<Button type="submit" variant="primary" disabled={!canManageSettings || busy || !hasDirtySettingsDraft}>
|
||||
<div className="wide"><h3>Delegation and timed escalation defaults</h3><p>Function-specific policy may tighten these tenant defaults. A timeout changes the review to a visible escalated state; it never approves automatically.</p></div>
|
||||
<div className="idm-check-list wide">
|
||||
<ToggleSwitch label="Allow governed delegation" checked={settingsDraft.delegation_allowed} disabled={!canManageSettings || busy} help={idmDisabledReason(false, busy, canManageSettings)} onChange={(delegation_allowed) => setSettingsDraft({ ...settingsDraft, delegation_allowed })} />
|
||||
</div>
|
||||
<FormField label="Maximum delegation-chain depth" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<input type="number" min="1" max="20" value={settingsDraft.maximum_delegation_depth} disabled={!canManageSettings || busy || !settingsDraft.delegation_allowed} onChange={(event) => setSettingsDraft({ ...settingsDraft, maximum_delegation_depth: event.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="Maximum delegated validity (days)" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<input type="number" min="1" max="3650" value={settingsDraft.maximum_delegated_validity_days} placeholder="No additional ceiling" disabled={!canManageSettings || busy || !settingsDraft.delegation_allowed} onChange={(event) => setSettingsDraft({ ...settingsDraft, maximum_delegated_validity_days: event.target.value })} />
|
||||
</FormField>
|
||||
{([
|
||||
["Holder review", "holder_escalation_target", "holder_escalation_hours"],
|
||||
["Authority review", "authority_escalation_target", "authority_escalation_hours"],
|
||||
["Recipient review", "recipient_escalation_target", "recipient_escalation_hours"]
|
||||
] as const).map(([label, targetKey, hoursKey]) => (
|
||||
<div className="wide" key={targetKey}>
|
||||
<FormLayout columns={2} gap="small" collapseAt="workspace" className="">
|
||||
<FormField label={`${label} escalation target`} documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<select value={settingsDraft[targetKey]} disabled={!canManageSettings || busy} onChange={(event) => setSettingsDraft({ ...settingsDraft, [targetKey]: event.target.value })}>
|
||||
<option value="">No timed escalation</option>
|
||||
{model.functions.filter((item) => item.is_active).map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label={`${label} timeout (hours)`} documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<input type="number" min="1" max="8760" value={settingsDraft[hoursKey]} disabled={!canManageSettings || busy || !settingsDraft[targetKey]} onChange={(event) => setSettingsDraft({ ...settingsDraft, [hoursKey]: event.target.value })} />
|
||||
</FormField>
|
||||
</FormLayout>
|
||||
</div>
|
||||
))}
|
||||
<div className="button-row compact-actions wide">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={!canManageSettings || busy || !hasDirtySettingsDraft || settingsDraftInvalid(settingsDraft)}
|
||||
disabledReason={idmDisabledReason(false, busy, canManageSettings) ?? (settingsDraftInvalid(settingsDraft) ? "Each escalation rule needs both a target and timeout." : !hasDirtySettingsDraft ? IDM_INTERFACE_I18N.noChanges : undefined)}
|
||||
>
|
||||
i18n:govoplan-idm.save_settings.4602c430
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</FormLayout>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card title="i18n:govoplan-idm.assignments.a0d19ec5" collapsible collapseKey="idm.assignments" actions={<AdminIconButton label="i18n:govoplan-idm.add_assignment.08f2a0d5" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canManage || busy} onClick={openCreateAssignment} />}>
|
||||
{canUseFunctionChanges && (
|
||||
<FunctionAssignmentChangesPanel
|
||||
settings={settings}
|
||||
auth={auth}
|
||||
model={model}
|
||||
identities={identityOptions}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TypedRelationshipsPanel settings={settings} auth={auth} />
|
||||
|
||||
{canReadAssignments && <Card title="i18n:govoplan-idm.assignments.a0d19ec5" collapsible collapseKey="idm.assignments" actions={<AdminIconButton label="i18n:govoplan-idm.add_assignment.08f2a0d5" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canManage || busy || !model.functions.length} disabledReason={idmDisabledReason(false, busy, canManage) ?? (!model.functions.length ? IDM_INTERFACE_I18N.noFunctions : undefined)} onClick={openCreateAssignment} />}>
|
||||
<DataGrid
|
||||
id="idm-organization-function-assignments"
|
||||
rows={assignments}
|
||||
@@ -643,33 +845,40 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
initialFilters={initialFunctionFilter ? { function: initialFunctionFilter } : undefined}
|
||||
initialFit="container"
|
||||
/>
|
||||
</Card>
|
||||
</Card>}
|
||||
</div>
|
||||
</LoadingFrame>
|
||||
{renderAssignmentDialog()}
|
||||
</div>
|
||||
</PageScrollViewport>
|
||||
);
|
||||
|
||||
function renderAssignmentDialog() {
|
||||
const formId = "idm-assignment-editor";
|
||||
return (
|
||||
<Dialog
|
||||
<Dialog variant="administration" size="wide"
|
||||
open={assignmentEditorOpen}
|
||||
title={editingAssignmentId ? "i18n:govoplan-idm.update_assignment.e20f52aa" : "i18n:govoplan-idm.add_assignment.08f2a0d5"}
|
||||
onClose={() => !busy && discardAssignmentDraft()}
|
||||
onClose={closeAssignmentEditor}
|
||||
closeDisabled={busy}
|
||||
className="admin-dialog admin-dialog-wide idm-editor-dialog"
|
||||
className="idm-editor-dialog"
|
||||
footer={(
|
||||
<>
|
||||
<Button type="button" onClick={discardAssignmentDraft} disabled={busy}>i18n:govoplan-idm.cancel_edit.ea4781e0</Button>
|
||||
<Button type="submit" form={formId} variant="primary" disabled={!canManage || busy || !assignmentDraft.identity_id || !assignmentDraft.function_id}>
|
||||
<Button type="button" onClick={closeAssignmentEditor} disabled={busy} disabledReason={idmDisabledReason(false, busy)}>i18n:govoplan-idm.cancel_edit.ea4781e0</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form={formId}
|
||||
variant="primary"
|
||||
disabled={!canManage || busy || !assignmentDraft.identity_id || !assignmentDraft.function_id || (selectedFunctionIsGoverned && !assignmentDraft.governance_override_reason.trim())}
|
||||
disabledReason={idmDisabledReason(false, busy, canManage) ?? ((!assignmentDraft.identity_id || !assignmentDraft.function_id || (selectedFunctionIsGoverned && !assignmentDraft.governance_override_reason.trim())) ? IDM_INTERFACE_I18N.incomplete : undefined)}
|
||||
>
|
||||
{editingAssignmentId ? "i18n:govoplan-idm.update_assignment.e20f52aa" : "i18n:govoplan-idm.add_assignment.08f2a0d5"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<form id={formId} className="idm-form-grid" onSubmit={(event) => void submitAssignment(event)}>
|
||||
<FormField label="i18n:govoplan-idm.identity_search.d3460fcf">
|
||||
<FormLayout columns={2} gap="small" collapseAt="workspace" id={formId} className="" onSubmit={(event) => void submitAssignment(event)}>
|
||||
<FormField label="i18n:govoplan-idm.identity_search.d3460fcf" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<input
|
||||
value={identitySearch}
|
||||
placeholder="i18n:govoplan-idm.search_identities.88a9ef15"
|
||||
@@ -677,7 +886,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
onChange={(event) => setIdentitySearch(event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="i18n:govoplan-idm.select_identity.91d31615">
|
||||
<FormField label="i18n:govoplan-idm.select_identity.91d31615" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<select value={assignmentDraft.identity_id} disabled={!canManage || busy} onChange={(event) => onIdentityChange(event.target.value)}>
|
||||
<option value="">i18n:govoplan-idm.select_identity.91d31615</option>
|
||||
{identitySelectOptions.map((item) => (
|
||||
@@ -685,25 +894,25 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="i18n:govoplan-idm.account.2b2936f8">
|
||||
<FormField label="i18n:govoplan-idm.account.2b2936f8" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<select value={assignmentDraft.account_id} disabled={!canManage || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, account_id: event.target.value })}>
|
||||
<option value="">i18n:govoplan-idm.none.2baf5c66</option>
|
||||
{accountIds.map((accountId) => <option key={accountId} value={accountId}>{accountId}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="i18n:govoplan-idm.select_function.2bec86e0">
|
||||
<FormField label="i18n:govoplan-idm.select_function.2bec86e0" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<select value={assignmentDraft.function_id} disabled={!canManage || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, function_id: event.target.value })}>
|
||||
<option value="">i18n:govoplan-idm.select_function.2bec86e0</option>
|
||||
{model.functions.map((item) => <option key={item.id} value={item.id}>{functionLabel(item, unitById)}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="i18n:govoplan-idm.source.d15b50c9">
|
||||
<FormField label="i18n:govoplan-idm.source.d15b50c9" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<select value={assignmentDraft.source} disabled={!canManage || busy} onChange={(event) => onSourceChange(event.target.value)}>
|
||||
{SOURCE_OPTIONS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
{(assignmentDraft.source === "delegated" || assignmentDraft.source === "acting_for") && (
|
||||
<FormField label="i18n:govoplan-idm.delegated_from_assignment_id.20d4a548">
|
||||
<FormField label="i18n:govoplan-idm.delegated_from_assignment_id.20d4a548" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<select value={assignmentDraft.delegated_from_assignment_id} disabled={!canManage || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, delegated_from_assignment_id: event.target.value, acting_for_account_id: "" })}>
|
||||
<option value="">i18n:govoplan-idm.none.2baf5c66</option>
|
||||
{assignmentOptions.map((item) => (
|
||||
@@ -714,7 +923,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
)}
|
||||
{assignmentDraft.source === "acting_for" && (
|
||||
<>
|
||||
<FormField label="i18n:govoplan-idm.acting_for_search.b7c526c7">
|
||||
<FormField label="i18n:govoplan-idm.acting_for_search.b7c526c7" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<input
|
||||
value={actingForSearch}
|
||||
placeholder="i18n:govoplan-idm.search_identities.88a9ef15"
|
||||
@@ -722,7 +931,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
onChange={(event) => setActingForSearch(event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="i18n:govoplan-idm.acting_for_account_id.5d7ade5b">
|
||||
<FormField label="i18n:govoplan-idm.acting_for_account_id.5d7ade5b" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<select value={assignmentDraft.acting_for_account_id} disabled={!canManage || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, acting_for_account_id: event.target.value })}>
|
||||
<option value="">i18n:govoplan-idm.select_account.982ee1ad</option>
|
||||
{actingForAccountIds.map((accountId) => <option key={accountId} value={accountId}>{accountId}</option>)}
|
||||
@@ -731,27 +940,66 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
</>
|
||||
)}
|
||||
<div className="idm-check-list">
|
||||
<label>
|
||||
<input type="checkbox" checked={assignmentDraft.applies_to_subunits} disabled={!canManage || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, applies_to_subunits: event.target.checked })} />
|
||||
<span>i18n:govoplan-idm.applies_to_subunits.2e31b50b</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" checked={assignmentDraft.is_active} disabled={!canManage || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, is_active: event.target.checked })} />
|
||||
<span>i18n:govoplan-idm.active.7bd0e9f8</span>
|
||||
</label>
|
||||
<ToggleSwitch label="i18n:govoplan-idm.applies_to_subunits.2e31b50b" checked={assignmentDraft.applies_to_subunits} disabled={!canManage || busy} help={idmDisabledReason(false, busy, canManage)} onChange={(applies_to_subunits) => setAssignmentDraft({ ...assignmentDraft, applies_to_subunits })} />
|
||||
<ToggleSwitch label="i18n:govoplan-idm.active.7bd0e9f8" checked={assignmentDraft.is_active} disabled={!canManage || busy} help={idmDisabledReason(false, busy, canManage)} onChange={(is_active) => setAssignmentDraft({ ...assignmentDraft, is_active })} />
|
||||
</div>
|
||||
{selectedFunctionIsGoverned && (
|
||||
<div className="wide idm-governance-override">
|
||||
<DismissibleAlert tone="warning" dismissible={false}>
|
||||
Direct changes to this governed function are emergency overrides. Use a request or grant above for the normal process.
|
||||
<DocumentationHelpLink reference={IDM_GOVERNANCE_DOCUMENTATION} />
|
||||
</DismissibleAlert>
|
||||
<FormField label="Emergency override reason" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={assignmentDraft.governance_override_reason}
|
||||
onChange={(event) => setAssignmentDraft({ ...assignmentDraft, governance_override_reason: event.target.value })}
|
||||
disabled={!canManage || busy}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Override evidence references (one per line)" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={assignmentDraft.governance_override_evidence}
|
||||
onChange={(event) => setAssignmentDraft({ ...assignmentDraft, governance_override_evidence: event.target.value })}
|
||||
disabled={!canManage || busy}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
)}
|
||||
<div className="wide idm-dialog-change-request">
|
||||
<FormField label="i18n:govoplan-idm.change_request_id.b7d816db">
|
||||
<FormField label="i18n:govoplan-idm.change_request_id.b7d816db" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<input value={assignmentChangeRequestId} onChange={(event) => setAssignmentChangeRequestId(event.target.value)} placeholder="cfgreq-..." disabled={busy} />
|
||||
</FormField>
|
||||
<p className="idm-muted">i18n:govoplan-idm.change_request_id_help.cc7de508</p>
|
||||
</div>
|
||||
{!identityLookupAvailable && <p className="idm-muted wide">i18n:govoplan-idm.identity_lookup_unavailable.b76f7714</p>}
|
||||
{!identityLookupAvailable && (
|
||||
<div className="wide">
|
||||
<ActionBlockerHint
|
||||
reason={{
|
||||
summary: "i18n:govoplan-idm.identity_lookup_unavailable.b76f7714",
|
||||
requiredAction: IDM_INTERFACE_I18N.permissionAction,
|
||||
actor: IDM_INTERFACE_I18N.permissionActor,
|
||||
target: IDM_INTERFACE_I18N.permissionDestination
|
||||
}}
|
||||
labels={{
|
||||
requiredAction: IDM_INTERFACE_I18N.requiredAction,
|
||||
actor: IDM_INTERFACE_I18N.actor,
|
||||
target: IDM_INTERFACE_I18N.destination
|
||||
}}
|
||||
documentation={IDM_DOCUMENTATION}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{identityLoading && <p className="idm-muted wide">i18n:govoplan-idm.loading_identities.f3b84693</p>}
|
||||
{actingForLoading && <p className="idm-muted wide">i18n:govoplan-idm.loading_acting_for_accounts.c9894b1e</p>}
|
||||
{!model.functions.length && <p className="idm-muted wide">i18n:govoplan-idm.no_functions_available.51ba08eb</p>}
|
||||
</form>
|
||||
</FormLayout>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function draftKey(value: unknown): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,972 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Eye, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import {
|
||||
ActionBlockerHint,
|
||||
ActionToolbar,
|
||||
AdminIconButton,
|
||||
ApiError,
|
||||
Button,
|
||||
Card,
|
||||
DataGrid,
|
||||
DateTimeField,
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
DocumentationHelpLink,
|
||||
FormField,
|
||||
FormLayout,
|
||||
LoadingFrame,
|
||||
SearchableSelect,
|
||||
StatusBadge,
|
||||
TableActionGroup,
|
||||
ToggleSwitch,
|
||||
hasScope,
|
||||
i18nMessage,
|
||||
usePlatformLanguage,
|
||||
useUnsavedChanges,
|
||||
useUnsavedDraftGuard,
|
||||
type ApiSettings,
|
||||
type AuthInfo,
|
||||
type DataGridColumn,
|
||||
type SearchableSelectOption
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
createIdentityRelationship,
|
||||
createTypedGroup,
|
||||
getIdentityRelationships,
|
||||
getTypedGroups,
|
||||
patchIdentityRelationship,
|
||||
patchTypedGroup,
|
||||
resolveTypedGroupMemberships,
|
||||
revokeIdentityRelationship,
|
||||
searchOrganizationIdentityOptions,
|
||||
type IdentityOption,
|
||||
type IdentityRelationshipDecisionItem,
|
||||
type IdentityRelationshipItem,
|
||||
type IdentityRelationshipPayload,
|
||||
type TypedGroupItem,
|
||||
type TypedGroupMembershipResolution,
|
||||
type TypedGroupPayload
|
||||
} from "../api/idm";
|
||||
import { IDM_RELATIONSHIP_DOCUMENTATION } from "./interfacePatterns";
|
||||
|
||||
type Props = {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
};
|
||||
|
||||
type GroupDraft = {
|
||||
key: string;
|
||||
name: string;
|
||||
groupType: string;
|
||||
description: string;
|
||||
status: "active" | "inactive";
|
||||
sourceProvider: string;
|
||||
sourceResourceType: string;
|
||||
sourceResourceId: string;
|
||||
sourceRevision: string;
|
||||
properties: string;
|
||||
provenance: string;
|
||||
};
|
||||
|
||||
type RelationshipDraft = {
|
||||
relationshipKind: string;
|
||||
subjectIdentityId: string;
|
||||
targetType: "group" | "identity";
|
||||
targetGroupId: string;
|
||||
relatedIdentityId: string;
|
||||
role: string;
|
||||
validFrom: string;
|
||||
validUntil: string;
|
||||
sourceProvider: string;
|
||||
sourceResourceType: string;
|
||||
sourceResourceId: string;
|
||||
sourceRevision: string;
|
||||
properties: string;
|
||||
provenance: string;
|
||||
};
|
||||
|
||||
const EMPTY_GROUP_DRAFT: GroupDraft = {
|
||||
key: "",
|
||||
name: "",
|
||||
groupType: "business_group",
|
||||
description: "",
|
||||
status: "active",
|
||||
sourceProvider: "local",
|
||||
sourceResourceType: "",
|
||||
sourceResourceId: "",
|
||||
sourceRevision: "",
|
||||
properties: "{}",
|
||||
provenance: "{}"
|
||||
};
|
||||
|
||||
const EMPTY_RELATIONSHIP_DRAFT: RelationshipDraft = {
|
||||
relationshipKind: "member",
|
||||
subjectIdentityId: "",
|
||||
targetType: "group",
|
||||
targetGroupId: "",
|
||||
relatedIdentityId: "",
|
||||
role: "",
|
||||
validFrom: "",
|
||||
validUntil: "",
|
||||
sourceProvider: "local",
|
||||
sourceResourceType: "",
|
||||
sourceResourceId: "",
|
||||
sourceRevision: "",
|
||||
properties: "{}",
|
||||
provenance: "{}"
|
||||
};
|
||||
|
||||
export default function TypedRelationshipsPanel({ settings, auth }: Props) {
|
||||
const [groups, setGroups] = useState<TypedGroupItem[]>([]);
|
||||
const [relationships, setRelationships] = useState<IdentityRelationshipItem[]>([]);
|
||||
const [identities, setIdentities] = useState<IdentityOption[]>([]);
|
||||
const [showInactiveGroups, setShowInactiveGroups] = useState(false);
|
||||
const [showRevokedRelationships, setShowRevokedRelationships] = useState(false);
|
||||
const [groupEditor, setGroupEditor] = useState<TypedGroupItem | "create" | null>(null);
|
||||
const [groupDraft, setGroupDraft] = useState<GroupDraft>({ ...EMPTY_GROUP_DRAFT });
|
||||
const [groupBaseline, setGroupBaseline] = useState<GroupDraft>({ ...EMPTY_GROUP_DRAFT });
|
||||
const [relationshipEditor, setRelationshipEditor] = useState<IdentityRelationshipItem | "create" | null>(null);
|
||||
const [relationshipDraft, setRelationshipDraft] = useState<RelationshipDraft>({ ...EMPTY_RELATIONSHIP_DRAFT });
|
||||
const [relationshipBaseline, setRelationshipBaseline] = useState<RelationshipDraft>({ ...EMPTY_RELATIONSHIP_DRAFT });
|
||||
const [revokeTarget, setRevokeTarget] = useState<IdentityRelationshipItem | null>(null);
|
||||
const [revocationReason, setRevocationReason] = useState("");
|
||||
const [membershipGroup, setMembershipGroup] = useState<TypedGroupItem | null>(null);
|
||||
const [membershipEffectiveAt, setMembershipEffectiveAt] = useState("");
|
||||
const [membershipKinds, setMembershipKinds] = useState("member");
|
||||
const [membershipResolution, setMembershipResolution] = useState<TypedGroupMembershipResolution | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [membershipLoading, setMembershipLoading] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const appliedDeepLink = useRef(false);
|
||||
const { language } = usePlatformLanguage();
|
||||
const { requestDiscard } = useUnsavedChanges();
|
||||
|
||||
const canRead = hasScope(auth, "idm:relationship:read") || hasScope(auth, "idm:relationship:write");
|
||||
const canWrite = hasScope(auth, "idm:relationship:write");
|
||||
const canSearchIdentities = canWrite
|
||||
|| hasScope(auth, "idm:organization_identity:read")
|
||||
|| hasScope(auth, "idm:organization_assignment:write")
|
||||
|| hasScope(auth, "admin:users:read");
|
||||
const groupById = useMemo(() => new Map(groups.map((item) => [item.id, item])), [groups]);
|
||||
const visibleGroups = useMemo(
|
||||
() => showInactiveGroups ? groups : groups.filter((item) => item.status === "active"),
|
||||
[groups, showInactiveGroups]
|
||||
);
|
||||
const identityById = useMemo(() => new Map(identities.map((item) => [item.id, item])), [identities]);
|
||||
const groupOptions = useMemo<SearchableSelectOption[]>(
|
||||
() => groups.filter((item) => item.status === "active").map(groupOption),
|
||||
[groups]
|
||||
);
|
||||
const dirty = groupEditor
|
||||
? draftKey(groupDraft) !== draftKey(groupBaseline)
|
||||
: relationshipEditor
|
||||
? draftKey(relationshipDraft) !== draftKey(relationshipBaseline)
|
||||
: false;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!canRead) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [groupResponse, relationshipResponse, identityResponse] = await Promise.all([
|
||||
getTypedGroups(settings, { includeInactive: true }),
|
||||
getIdentityRelationships(settings, { includeRevoked: showRevokedRelationships }),
|
||||
canSearchIdentities
|
||||
? searchOrganizationIdentityOptions(settings, "", 100).catch(() => ({ identities: [] }))
|
||||
: Promise.resolve({ identities: [] })
|
||||
]);
|
||||
setGroups(groupResponse.groups);
|
||||
setRelationships(relationshipResponse.relationships);
|
||||
setIdentities(identityResponse.identities);
|
||||
if (!appliedDeepLink.current && typeof window !== "undefined") {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const groupId = params.get("groupId");
|
||||
const relationshipId = params.get("relationshipId");
|
||||
const linkedGroup = groupResponse.groups.find((item) => item.id === groupId);
|
||||
const linkedRelationship = relationshipResponse.relationships.find((item) => item.id === relationshipId);
|
||||
if (linkedGroup) {
|
||||
appliedDeepLink.current = true;
|
||||
openMembership(linkedGroup);
|
||||
} else if (linkedRelationship) {
|
||||
appliedDeepLink.current = true;
|
||||
openRelationshipEditor(linkedRelationship);
|
||||
}
|
||||
}
|
||||
} catch (caught) {
|
||||
setError(apiErrorMessage(caught));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [canRead, canSearchIdentities, settings, showRevokedRelationships]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const loadIdentityOptions = useCallback(async (
|
||||
query: string,
|
||||
options: { limit: number; signal: AbortSignal }
|
||||
): Promise<SearchableSelectOption[]> => {
|
||||
const response = await searchOrganizationIdentityOptions(settings, query, options.limit, options.signal);
|
||||
if (!options.signal.aborted) {
|
||||
setIdentities((current) => mergeIdentities(current, response.identities));
|
||||
}
|
||||
return response.identities.map(identityOption);
|
||||
}, [settings]);
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty,
|
||||
onSave: async () => groupEditor ? saveGroup() : saveRelationship(),
|
||||
onDiscard: closeEditors,
|
||||
title: "Unsaved relationship administration",
|
||||
message: "Save or discard the typed-group or relationship draft before leaving this surface."
|
||||
});
|
||||
|
||||
const groupColumns = useMemo<DataGridColumn<TypedGroupItem>[]>(() => [
|
||||
{
|
||||
id: "name",
|
||||
header: "Name",
|
||||
minWidth: 210,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
value: (row) => row.name,
|
||||
render: (row) => <div><strong>{row.name}</strong><div className="idm-id">{row.key}</div></div>
|
||||
},
|
||||
{ id: "type", header: "Group type", minWidth: 170, sortable: true, filterable: true, value: (row) => row.group_type },
|
||||
{ id: "source", header: "Source", minWidth: 160, sortable: true, value: (row) => row.source_provider, render: (row) => sourceSummary(row) },
|
||||
{ id: "status", header: "Status", width: 120, sortable: true, value: (row) => row.status, render: (row) => <StatusBadge status={row.status} label={groupStatusLabel(row.status)} /> },
|
||||
{ id: "revision", header: "Revision", width: 100, sortable: true, value: (row) => row.revision },
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
width: 108,
|
||||
sticky: "end",
|
||||
render: (row) => <TableActionGroup actions={[
|
||||
{
|
||||
id: "memberships",
|
||||
label: i18nMessage("i18n:govoplan-idm.inspect_memberships_value", { value0: row.name }),
|
||||
icon: <Eye size={16} aria-hidden="true" />,
|
||||
helpContextId: "idm.typed-groups.action.inspect-memberships",
|
||||
helpModuleId: "idm",
|
||||
onClick: () => openMembership(row)
|
||||
},
|
||||
{
|
||||
id: "edit",
|
||||
label: i18nMessage("i18n:govoplan-idm.edit_group_value", { value0: row.name }),
|
||||
icon: <Pencil size={16} aria-hidden="true" />,
|
||||
helpContextId: "idm.typed-groups.action.edit",
|
||||
helpModuleId: "idm",
|
||||
disabled: !canWrite || busy,
|
||||
disabledReason: !canWrite ? "Typed-group write permission is required." : undefined,
|
||||
onClick: () => openGroupEditor(row)
|
||||
}
|
||||
]} />
|
||||
}
|
||||
], [busy, canWrite]);
|
||||
|
||||
const relationshipColumns = useMemo<DataGridColumn<IdentityRelationshipItem>[]>(() => [
|
||||
{
|
||||
id: "subject",
|
||||
header: "Subject identity",
|
||||
minWidth: 220,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
filterValue: (row) => identityLabel(identityById.get(row.subject_identity_id), row.subject_identity_id),
|
||||
render: (row) => identityDisplay(identityById.get(row.subject_identity_id), row.subject_identity_id)
|
||||
},
|
||||
{ id: "kind", header: "Relationship", minWidth: 170, sortable: true, filterable: true, value: (row) => row.relationship_kind, render: (row) => row.role ? `${row.relationship_kind} · ${row.role}` : row.relationship_kind },
|
||||
{
|
||||
id: "target",
|
||||
header: "Target",
|
||||
minWidth: 220,
|
||||
filterable: true,
|
||||
filterValue: (row) => relationshipTargetLabel(row, groupById, identityById),
|
||||
render: (row) => relationshipTarget(row, groupById, identityById)
|
||||
},
|
||||
{
|
||||
id: "effective_status",
|
||||
header: "Effective state",
|
||||
width: 145,
|
||||
sortable: true,
|
||||
value: (row) => relationshipState(row),
|
||||
render: (row) => {
|
||||
const state = relationshipState(row);
|
||||
return <StatusBadge status={stateStatus(state)} label={relationshipStateLabel(state)} />;
|
||||
}
|
||||
},
|
||||
{ id: "window", header: "Effective window", minWidth: 220, value: (row) => `${row.valid_from ?? ""} ${row.valid_until ?? ""}`, render: (row) => effectiveWindow(row, language) },
|
||||
{ id: "source", header: "Source", minWidth: 150, value: (row) => row.source_provider, render: (row) => sourceSummary(row) },
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
width: 108,
|
||||
sticky: "end",
|
||||
render: (row) => <TableActionGroup actions={[
|
||||
{
|
||||
id: "edit",
|
||||
label: "Edit relationship",
|
||||
icon: <Pencil size={16} aria-hidden="true" />,
|
||||
helpContextId: "idm.relationships.action.edit",
|
||||
helpModuleId: "idm",
|
||||
disabled: !canWrite || busy || row.status === "revoked",
|
||||
disabledReason: !canWrite ? "Relationship write permission is required." : row.status === "revoked" ? "Revoked relationships are retained as immutable evidence." : undefined,
|
||||
onClick: () => openRelationshipEditor(row)
|
||||
},
|
||||
{
|
||||
id: "revoke",
|
||||
label: "Revoke relationship",
|
||||
icon: <Trash2 size={16} aria-hidden="true" />,
|
||||
variant: "danger",
|
||||
helpContextId: "idm.relationships.action.revoke",
|
||||
helpModuleId: "idm",
|
||||
applicable: row.status !== "revoked",
|
||||
disabled: !canWrite || busy,
|
||||
disabledReason: !canWrite ? "Relationship write permission is required." : undefined,
|
||||
onClick: () => {
|
||||
setRevokeTarget(row);
|
||||
setRevocationReason("");
|
||||
}
|
||||
}
|
||||
]} />
|
||||
}
|
||||
], [busy, canWrite, groupById, identityById, language]);
|
||||
|
||||
const membershipColumns = useMemo<DataGridColumn<IdentityRelationshipDecisionItem>[]>(() => [
|
||||
{
|
||||
id: "identity",
|
||||
header: "Identity",
|
||||
minWidth: 220,
|
||||
value: (row) => row.relationship.subject_identity_id,
|
||||
render: (row) => identityDisplay(identityById.get(row.relationship.subject_identity_id), row.relationship.subject_identity_id)
|
||||
},
|
||||
{ id: "kind", header: "Relationship", minWidth: 150, value: (row) => row.relationship.relationship_kind },
|
||||
{ id: "decision", header: "Resolution", minWidth: 150, value: (row) => row.code, render: (row) => <StatusBadge status={row.included ? "success" : "inactive"} label={row.code} /> },
|
||||
{ id: "identity_status", header: "Identity state", minWidth: 130, value: (row) => row.identity_status ?? "", render: (row) => row.identity_status ?? "Not available" },
|
||||
{ id: "explanation", header: "Explanation", minWidth: 280, fill: true, value: (row) => row.explanation }
|
||||
], [identityById]);
|
||||
|
||||
if (!canRead) {
|
||||
return (
|
||||
<Card title="Typed groups and identity relationships" collapsible collapseKey="idm.typed-relationships">
|
||||
<ActionBlockerHint
|
||||
reason={{
|
||||
summary: "You do not have permission to view typed identity relationships.",
|
||||
requiredAction: "Ask for typed-relationship read permission.",
|
||||
actor: "An Access or tenant administrator",
|
||||
target: "Access role assignments"
|
||||
}}
|
||||
labels={{ requiredAction: "Required action", actor: "Responsible actor", target: "Destination" }}
|
||||
documentation={IDM_RELATIONSHIP_DOCUMENTATION}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="idm-relationship-stack" data-help-context-id="idm.relationships.page" data-help-module-id="idm">
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
{success && !error && <DismissibleAlert tone="success" resetKey={success}>{success}</DismissibleAlert>}
|
||||
{!canWrite && (
|
||||
<ActionBlockerHint
|
||||
reason={{
|
||||
summary: "You may inspect relationship evidence but not change it.",
|
||||
requiredAction: "Ask for typed-relationship write permission before creating, editing, or revoking records.",
|
||||
actor: "An Access or tenant administrator",
|
||||
target: "Access role assignments"
|
||||
}}
|
||||
labels={{ requiredAction: "Required action", actor: "Responsible actor", target: "Destination" }}
|
||||
documentation={IDM_RELATIONSHIP_DOCUMENTATION}
|
||||
/>
|
||||
)}
|
||||
|
||||
<LoadingFrame loading={loading} label="Loading typed groups and relationships">
|
||||
<Card
|
||||
title="Typed groups"
|
||||
collapsible
|
||||
collapseKey="idm.typed-groups"
|
||||
actions={(
|
||||
<ActionToolbar justify="end">
|
||||
<DocumentationHelpLink reference={IDM_RELATIONSHIP_DOCUMENTATION} />
|
||||
<ToggleSwitch
|
||||
label="Show inactive groups"
|
||||
checked={showInactiveGroups}
|
||||
helpContextId="idm.typed-groups.field.show-inactive"
|
||||
helpModuleId="idm"
|
||||
onChange={setShowInactiveGroups}
|
||||
/>
|
||||
<Button helpContextId="idm.typed-groups.action.reload" helpModuleId="idm" onClick={() => void load()} disabled={loading || busy}>
|
||||
<RefreshCw size={16} aria-hidden="true" /> Reload
|
||||
</Button>
|
||||
<AdminIconButton
|
||||
label="Create typed group"
|
||||
icon={<Plus size={16} aria-hidden="true" />}
|
||||
variant="primary"
|
||||
helpContextId="idm.typed-groups.action.create"
|
||||
helpModuleId="idm"
|
||||
disabled={!canWrite || busy}
|
||||
disabledReason={!canWrite ? "Typed-group write permission is required." : undefined}
|
||||
onClick={() => openGroupEditor("create")}
|
||||
/>
|
||||
</ActionToolbar>
|
||||
)}
|
||||
>
|
||||
<DataGrid id="idm-typed-groups" rows={visibleGroups} columns={groupColumns} getRowKey={(row) => row.id} emptyText="No typed groups found." initialFit="container" />
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Effective identity relationships"
|
||||
collapsible
|
||||
collapseKey="idm.identity-relationships"
|
||||
actions={(
|
||||
<ActionToolbar justify="end">
|
||||
<DocumentationHelpLink reference={IDM_RELATIONSHIP_DOCUMENTATION} />
|
||||
<ToggleSwitch
|
||||
label="Show revoked relationships"
|
||||
checked={showRevokedRelationships}
|
||||
helpContextId="idm.relationships.field.show-revoked"
|
||||
helpModuleId="idm"
|
||||
onChange={setShowRevokedRelationships}
|
||||
/>
|
||||
<Button helpContextId="idm.relationships.action.reload" helpModuleId="idm" onClick={() => void load()} disabled={loading || busy}>
|
||||
<RefreshCw size={16} aria-hidden="true" /> Reload
|
||||
</Button>
|
||||
<AdminIconButton
|
||||
label="Create relationship"
|
||||
icon={<Plus size={16} aria-hidden="true" />}
|
||||
variant="primary"
|
||||
helpContextId="idm.relationships.action.create"
|
||||
helpModuleId="idm"
|
||||
disabled={!canWrite || busy || groups.every((item) => item.status !== "active")}
|
||||
disabledReason={!canWrite ? "Relationship write permission is required." : groups.every((item) => item.status !== "active") ? "Create an active typed group first." : undefined}
|
||||
onClick={() => openRelationshipEditor("create")}
|
||||
/>
|
||||
</ActionToolbar>
|
||||
)}
|
||||
>
|
||||
<DataGrid id="idm-identity-relationships" rows={relationships} columns={relationshipColumns} getRowKey={(row) => row.id} emptyText="No identity relationships found." initialFit="container" />
|
||||
<p className="idm-muted idm-card-note">Business membership is an institutional fact. It does not grant application permissions; Access evaluates authority separately.</p>
|
||||
</Card>
|
||||
</LoadingFrame>
|
||||
|
||||
{renderGroupEditor()}
|
||||
{renderRelationshipEditor()}
|
||||
{renderRevokeDialog()}
|
||||
{renderMembershipDialog()}
|
||||
</div>
|
||||
);
|
||||
|
||||
function openGroupEditor(item: TypedGroupItem | "create") {
|
||||
const next = item === "create" ? { ...EMPTY_GROUP_DRAFT } : groupDraftFrom(item);
|
||||
setGroupEditor(item);
|
||||
setGroupDraft(next);
|
||||
setGroupBaseline(next);
|
||||
setRelationshipEditor(null);
|
||||
setError("");
|
||||
}
|
||||
|
||||
function openRelationshipEditor(item: IdentityRelationshipItem | "create") {
|
||||
const next = item === "create" ? { ...EMPTY_RELATIONSHIP_DRAFT } : relationshipDraftFrom(item);
|
||||
setRelationshipEditor(item);
|
||||
setRelationshipDraft(next);
|
||||
setRelationshipBaseline(next);
|
||||
setGroupEditor(null);
|
||||
setError("");
|
||||
}
|
||||
|
||||
function closeEditors() {
|
||||
setGroupEditor(null);
|
||||
setRelationshipEditor(null);
|
||||
setGroupDraft({ ...EMPTY_GROUP_DRAFT });
|
||||
setGroupBaseline({ ...EMPTY_GROUP_DRAFT });
|
||||
setRelationshipDraft({ ...EMPTY_RELATIONSHIP_DRAFT });
|
||||
setRelationshipBaseline({ ...EMPTY_RELATIONSHIP_DRAFT });
|
||||
}
|
||||
|
||||
function requestCloseEditors() {
|
||||
if (busy) return;
|
||||
if (dirty) requestDiscard(closeEditors);
|
||||
else closeEditors();
|
||||
}
|
||||
|
||||
async function saveGroup(): Promise<boolean> {
|
||||
if (!groupEditor || !canWrite) return false;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const payload = groupPayload(groupDraft);
|
||||
if (groupEditor === "create") {
|
||||
await createTypedGroup(settings, payload);
|
||||
setSuccess("Typed group created.");
|
||||
} else {
|
||||
await patchTypedGroup(settings, groupEditor.id, {
|
||||
...payload,
|
||||
base_revision: groupEditor.revision,
|
||||
status: groupDraft.status
|
||||
});
|
||||
setSuccess("Typed group updated.");
|
||||
}
|
||||
closeEditors();
|
||||
await load();
|
||||
return true;
|
||||
} catch (caught) {
|
||||
setError(apiErrorMessage(caught));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRelationship(): Promise<boolean> {
|
||||
if (!relationshipEditor || !canWrite) return false;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const payload = relationshipPayload(relationshipDraft);
|
||||
if (relationshipEditor === "create") {
|
||||
await createIdentityRelationship(settings, payload);
|
||||
setSuccess("Identity relationship created.");
|
||||
} else {
|
||||
const { subject_identity_id: _subject, ...update } = payload;
|
||||
await patchIdentityRelationship(settings, relationshipEditor.id, {
|
||||
...update,
|
||||
base_revision: relationshipEditor.revision
|
||||
});
|
||||
setSuccess("Identity relationship updated.");
|
||||
}
|
||||
closeEditors();
|
||||
await load();
|
||||
return true;
|
||||
} catch (caught) {
|
||||
setError(apiErrorMessage(caught));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeRelationship(): Promise<void> {
|
||||
if (!revokeTarget || !revocationReason.trim() || !canWrite) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await revokeIdentityRelationship(settings, revokeTarget, revocationReason.trim());
|
||||
setSuccess("Identity relationship revoked. Effective membership and downstream business resolution stop immediately.");
|
||||
setRevokeTarget(null);
|
||||
setRevocationReason("");
|
||||
await load();
|
||||
} catch (caught) {
|
||||
setError(apiErrorMessage(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openMembership(group: TypedGroupItem) {
|
||||
setMembershipGroup(group);
|
||||
setMembershipEffectiveAt("");
|
||||
setMembershipKinds("member");
|
||||
setMembershipResolution(null);
|
||||
void loadMembership(group, "", "member");
|
||||
}
|
||||
|
||||
async function loadMembership(group = membershipGroup, effectiveAt = membershipEffectiveAt, kinds = membershipKinds): Promise<void> {
|
||||
if (!group) return;
|
||||
setMembershipLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const resolution = await resolveTypedGroupMemberships(settings, group.id, {
|
||||
effectiveAt: optionalDateTime(effectiveAt) ?? undefined,
|
||||
relationshipKinds: kinds.split(",").map((item) => item.trim()).filter(Boolean)
|
||||
});
|
||||
setMembershipResolution(resolution);
|
||||
} catch (caught) {
|
||||
setError(apiErrorMessage(caught));
|
||||
} finally {
|
||||
setMembershipLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function renderGroupEditor() {
|
||||
const formId = "idm-typed-group-editor";
|
||||
const editing = groupEditor && groupEditor !== "create" ? groupEditor : null;
|
||||
return (
|
||||
<Dialog
|
||||
variant="administration"
|
||||
size="wide"
|
||||
open={Boolean(groupEditor)}
|
||||
title={editing ? "Edit typed group" : "Create typed group"}
|
||||
helpContextId="idm.typed-groups.editor"
|
||||
helpModuleId="idm"
|
||||
onClose={requestCloseEditors}
|
||||
closeDisabled={busy}
|
||||
className=""
|
||||
footer={<><Button onClick={requestCloseEditors} disabled={busy}>Cancel</Button><Button type="submit" form={formId} variant="primary" helpContextId="idm.typed-groups.action.save" helpModuleId="idm" disabled={!canWrite || busy || !groupDraft.key.trim() || !groupDraft.name.trim() || !groupDraft.groupType.trim()}>{busy ? "Saving..." : "Save group"}</Button></>}
|
||||
>
|
||||
<FormLayout id={formId} columns={2} gap="small" collapseAt="workspace" className="" onSubmit={(event) => { event.preventDefault(); void saveGroup(); }}>
|
||||
<FormField label="Key" helpContextId="idm.typed-groups.field.key" helpModuleId="idm"><input required value={groupDraft.key} disabled={busy} onChange={(event) => setGroupDraft({ ...groupDraft, key: event.target.value })} /></FormField>
|
||||
<FormField label="Name" helpContextId="idm.typed-groups.field.name" helpModuleId="idm"><input required value={groupDraft.name} disabled={busy} onChange={(event) => setGroupDraft({ ...groupDraft, name: event.target.value })} /></FormField>
|
||||
<FormField label="Group type" helpContextId="idm.typed-groups.field.type" helpModuleId="idm"><input required value={groupDraft.groupType} disabled={busy} onChange={(event) => setGroupDraft({ ...groupDraft, groupType: event.target.value })} /></FormField>
|
||||
{editing && <FormField label="Status" helpContextId="idm.typed-groups.field.status" helpModuleId="idm"><select value={groupDraft.status} disabled={busy} onChange={(event) => setGroupDraft({ ...groupDraft, status: event.target.value as GroupDraft["status"] })}><option value="active">Active</option><option value="inactive">Inactive</option></select></FormField>}
|
||||
<FormField label="Description" helpContextId="idm.typed-groups.field.description" helpModuleId="idm" className="wide"><textarea rows={3} value={groupDraft.description} disabled={busy} onChange={(event) => setGroupDraft({ ...groupDraft, description: event.target.value })} /></FormField>
|
||||
<FormField label="Source provider" helpContextId="idm.typed-groups.field.source-provider" helpModuleId="idm"><input required value={groupDraft.sourceProvider} disabled={busy} onChange={(event) => setGroupDraft({ ...groupDraft, sourceProvider: event.target.value })} /></FormField>
|
||||
<FormField label="Source resource type" helpContextId="idm.typed-groups.field.source-resource-type" helpModuleId="idm"><input value={groupDraft.sourceResourceType} disabled={busy} onChange={(event) => setGroupDraft({ ...groupDraft, sourceResourceType: event.target.value })} /></FormField>
|
||||
<FormField label="Source resource ID" helpContextId="idm.typed-groups.field.source-resource-id" helpModuleId="idm"><input value={groupDraft.sourceResourceId} disabled={busy} onChange={(event) => setGroupDraft({ ...groupDraft, sourceResourceId: event.target.value })} /></FormField>
|
||||
<FormField label="Source revision" helpContextId="idm.typed-groups.field.source-revision" helpModuleId="idm"><input value={groupDraft.sourceRevision} disabled={busy} onChange={(event) => setGroupDraft({ ...groupDraft, sourceRevision: event.target.value })} /></FormField>
|
||||
<FormField label="Properties (JSON object)" helpContextId="idm.typed-groups.field.properties" helpModuleId="idm" className="wide"><textarea rows={4} value={groupDraft.properties} disabled={busy} onChange={(event) => setGroupDraft({ ...groupDraft, properties: event.target.value })} /></FormField>
|
||||
<FormField label="Provenance (JSON object)" helpContextId="idm.typed-groups.field.provenance" helpModuleId="idm" className="wide"><textarea rows={4} value={groupDraft.provenance} disabled={busy} onChange={(event) => setGroupDraft({ ...groupDraft, provenance: event.target.value })} /></FormField>
|
||||
{editing && <p className="idm-muted wide">{i18nMessage("i18n:govoplan-idm.group_revision_help", { value0: editing.revision })}</p>}
|
||||
</FormLayout>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function renderRelationshipEditor() {
|
||||
const formId = "idm-identity-relationship-editor";
|
||||
const editing = relationshipEditor && relationshipEditor !== "create" ? relationshipEditor : null;
|
||||
const subjectOption = selectedIdentityOption(relationshipDraft.subjectIdentityId, identityById);
|
||||
const relatedOption = selectedIdentityOption(relationshipDraft.relatedIdentityId, identityById);
|
||||
const targetGroupOption = relationshipDraft.targetGroupId ? groupOption(groupById.get(relationshipDraft.targetGroupId) ?? fallbackGroup(relationshipDraft.targetGroupId)) : null;
|
||||
const complete = Boolean(
|
||||
relationshipDraft.relationshipKind.trim()
|
||||
&& relationshipDraft.subjectIdentityId
|
||||
&& (relationshipDraft.targetType === "group" ? relationshipDraft.targetGroupId : relationshipDraft.relatedIdentityId)
|
||||
);
|
||||
return (
|
||||
<Dialog
|
||||
variant="administration"
|
||||
size="wide"
|
||||
open={Boolean(relationshipEditor)}
|
||||
title={editing ? "Edit identity relationship" : "Create identity relationship"}
|
||||
helpContextId="idm.relationships.editor"
|
||||
helpModuleId="idm"
|
||||
onClose={requestCloseEditors}
|
||||
closeDisabled={busy}
|
||||
className=""
|
||||
footer={<><Button onClick={requestCloseEditors} disabled={busy}>Cancel</Button><Button type="submit" form={formId} variant="primary" helpContextId="idm.relationships.action.save" helpModuleId="idm" disabled={!canWrite || busy || !complete}>{busy ? "Saving..." : "Save relationship"}</Button></>}
|
||||
>
|
||||
<FormLayout id={formId} columns={2} gap="small" collapseAt="workspace" className="" onSubmit={(event) => { event.preventDefault(); void saveRelationship(); }}>
|
||||
<FormField label="Relationship kind" helpContextId="idm.relationships.field.kind" helpModuleId="idm"><input required value={relationshipDraft.relationshipKind} disabled={busy} onChange={(event) => setRelationshipDraft({ ...relationshipDraft, relationshipKind: event.target.value })} /></FormField>
|
||||
<FormField label="Role" helpContextId="idm.relationships.field.role" helpModuleId="idm"><input value={relationshipDraft.role} disabled={busy} onChange={(event) => setRelationshipDraft({ ...relationshipDraft, role: event.target.value })} /></FormField>
|
||||
<FormField label="Subject identity" helpContextId="idm.relationships.field.subject-identity" helpModuleId="idm">
|
||||
<SearchableSelect
|
||||
value={relationshipDraft.subjectIdentityId}
|
||||
selectedOption={subjectOption}
|
||||
loadOptions={loadIdentityOptions}
|
||||
aria-label="Subject identity"
|
||||
placeholder="Search identities"
|
||||
minQueryLength={0}
|
||||
required
|
||||
disabled={busy || Boolean(editing)}
|
||||
helpContextId="idm.relationships.field.subject-identity"
|
||||
helpModuleId="idm"
|
||||
onChange={(subjectIdentityId) => setRelationshipDraft({ ...relationshipDraft, subjectIdentityId })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Target type" helpContextId="idm.relationships.field.target-type" helpModuleId="idm"><select value={relationshipDraft.targetType} disabled={busy} onChange={(event) => setRelationshipDraft({ ...relationshipDraft, targetType: event.target.value as RelationshipDraft["targetType"], targetGroupId: "", relatedIdentityId: "" })}><option value="group">Typed group</option><option value="identity">Related identity</option></select></FormField>
|
||||
{relationshipDraft.targetType === "group" ? (
|
||||
<FormField label="Target group" helpContextId="idm.relationships.field.target-group" helpModuleId="idm">
|
||||
<SearchableSelect
|
||||
value={relationshipDraft.targetGroupId}
|
||||
selectedOption={targetGroupOption}
|
||||
options={groupOptions}
|
||||
aria-label="Target typed group"
|
||||
placeholder="Search typed groups"
|
||||
required
|
||||
disabled={busy}
|
||||
helpContextId="idm.relationships.field.target-group"
|
||||
helpModuleId="idm"
|
||||
onChange={(targetGroupId) => setRelationshipDraft({ ...relationshipDraft, targetGroupId })}
|
||||
/>
|
||||
</FormField>
|
||||
) : (
|
||||
<FormField label="Related identity" helpContextId="idm.relationships.field.related-identity" helpModuleId="idm">
|
||||
<SearchableSelect
|
||||
value={relationshipDraft.relatedIdentityId}
|
||||
selectedOption={relatedOption}
|
||||
loadOptions={loadIdentityOptions}
|
||||
aria-label="Related identity"
|
||||
placeholder="Search identities"
|
||||
minQueryLength={0}
|
||||
required
|
||||
disabled={busy}
|
||||
helpContextId="idm.relationships.field.related-identity"
|
||||
helpModuleId="idm"
|
||||
onChange={(relatedIdentityId) => setRelationshipDraft({ ...relationshipDraft, relatedIdentityId })}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
<FormField label="Valid from" helpContextId="idm.relationships.field.valid-from" helpModuleId="idm"><DateTimeField value={relationshipDraft.validFrom} disabled={busy} helpContextId="idm.relationships.field.valid-from" helpModuleId="idm" onChange={(validFrom) => setRelationshipDraft({ ...relationshipDraft, validFrom })} /></FormField>
|
||||
<FormField label="Valid until" helpContextId="idm.relationships.field.valid-until" helpModuleId="idm"><DateTimeField value={relationshipDraft.validUntil} disabled={busy} helpContextId="idm.relationships.field.valid-until" helpModuleId="idm" onChange={(validUntil) => setRelationshipDraft({ ...relationshipDraft, validUntil })} /></FormField>
|
||||
<FormField label="Source provider" helpContextId="idm.relationships.field.source-provider" helpModuleId="idm"><input required value={relationshipDraft.sourceProvider} disabled={busy} onChange={(event) => setRelationshipDraft({ ...relationshipDraft, sourceProvider: event.target.value })} /></FormField>
|
||||
<FormField label="Source resource type" helpContextId="idm.relationships.field.source-resource-type" helpModuleId="idm"><input value={relationshipDraft.sourceResourceType} disabled={busy} onChange={(event) => setRelationshipDraft({ ...relationshipDraft, sourceResourceType: event.target.value })} /></FormField>
|
||||
<FormField label="Source resource ID" helpContextId="idm.relationships.field.source-resource-id" helpModuleId="idm"><input value={relationshipDraft.sourceResourceId} disabled={busy} onChange={(event) => setRelationshipDraft({ ...relationshipDraft, sourceResourceId: event.target.value })} /></FormField>
|
||||
<FormField label="Source revision" helpContextId="idm.relationships.field.source-revision" helpModuleId="idm"><input value={relationshipDraft.sourceRevision} disabled={busy} onChange={(event) => setRelationshipDraft({ ...relationshipDraft, sourceRevision: event.target.value })} /></FormField>
|
||||
<FormField label="Properties (JSON object)" helpContextId="idm.relationships.field.properties" helpModuleId="idm" className="wide"><textarea rows={4} value={relationshipDraft.properties} disabled={busy} onChange={(event) => setRelationshipDraft({ ...relationshipDraft, properties: event.target.value })} /></FormField>
|
||||
<FormField label="Provenance (JSON object)" helpContextId="idm.relationships.field.provenance" helpModuleId="idm" className="wide"><textarea rows={4} value={relationshipDraft.provenance} disabled={busy} onChange={(event) => setRelationshipDraft({ ...relationshipDraft, provenance: event.target.value })} /></FormField>
|
||||
<p className="idm-muted wide">Future dates schedule the fact without granting current membership. Expiry and revocation remove it from effective resolution while retaining source and decision evidence. Membership never grants Access permissions by itself.</p>
|
||||
{editing && <p className="idm-muted wide">{i18nMessage("i18n:govoplan-idm.relationship_revision_help", { value0: editing.revision })}</p>}
|
||||
</FormLayout>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function renderRevokeDialog() {
|
||||
return (
|
||||
<Dialog
|
||||
variant="administration"
|
||||
size="large"
|
||||
open={Boolean(revokeTarget)}
|
||||
title="Revoke identity relationship"
|
||||
helpContextId="idm.relationships.confirm-revoke"
|
||||
helpModuleId="idm"
|
||||
onClose={() => {
|
||||
if (busy) return;
|
||||
const close = () => { setRevokeTarget(null); setRevocationReason(""); };
|
||||
if (revocationReason.trim()) requestDiscard(close);
|
||||
else close();
|
||||
}}
|
||||
closeDisabled={busy}
|
||||
className=""
|
||||
footer={<><Button onClick={() => { setRevokeTarget(null); setRevocationReason(""); }} disabled={busy}>Cancel</Button><Button variant="danger" helpContextId="idm.relationships.action.confirm-revoke" helpModuleId="idm" disabled={!canWrite || busy || !revocationReason.trim()} onClick={() => void revokeRelationship()}>{busy ? "Revoking..." : "Revoke relationship"}</Button></>}
|
||||
>
|
||||
<p>Revocation takes effect immediately for membership resolution and downstream business consumers. The record, actor, time, source, and reason remain as evidence; a revoked relationship cannot be edited or reactivated.</p>
|
||||
<FormField label="Revocation reason" helpContextId="idm.relationships.field.revocation-reason" helpModuleId="idm"><textarea rows={4} required value={revocationReason} disabled={busy} onChange={(event) => setRevocationReason(event.target.value)} /></FormField>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function renderMembershipDialog() {
|
||||
return (
|
||||
<Dialog
|
||||
variant="administration"
|
||||
size="wide"
|
||||
open={Boolean(membershipGroup)}
|
||||
title={membershipGroup ? i18nMessage("i18n:govoplan-idm.effective_memberships_value", { value0: membershipGroup.name }) : "Effective memberships"}
|
||||
helpContextId="idm.typed-groups.membership-resolution"
|
||||
helpModuleId="idm"
|
||||
onClose={() => !membershipLoading && setMembershipGroup(null)}
|
||||
closeDisabled={membershipLoading}
|
||||
className=""
|
||||
footer={<Button variant="primary" onClick={() => setMembershipGroup(null)} disabled={membershipLoading}>Close</Button>}
|
||||
>
|
||||
<FormLayout columns={2} gap="small" collapseAt="workspace" className="" onSubmit={(event) => { event.preventDefault(); void loadMembership(); }}>
|
||||
<FormField label="Effective at" helpContextId="idm.typed-groups.field.membership-effective-at" helpModuleId="idm"><DateTimeField value={membershipEffectiveAt} disabled={membershipLoading} helpContextId="idm.typed-groups.field.membership-effective-at" helpModuleId="idm" onChange={setMembershipEffectiveAt} /></FormField>
|
||||
<FormField label="Relationship kinds" helpContextId="idm.typed-groups.field.membership-kinds" helpModuleId="idm"><input value={membershipKinds} disabled={membershipLoading} placeholder="member" onChange={(event) => setMembershipKinds(event.target.value)} /></FormField>
|
||||
<div className="wide button-row compact-actions"><Button type="submit" helpContextId="idm.typed-groups.action.resolve-memberships" helpModuleId="idm" disabled={membershipLoading || !membershipKinds.trim()}><RefreshCw size={16} aria-hidden="true" /> Resolve memberships</Button></div>
|
||||
</FormLayout>
|
||||
<LoadingFrame loading={membershipLoading} label="Resolving effective memberships">
|
||||
{membershipResolution && (
|
||||
<>
|
||||
<p className="idm-muted">{i18nMessage("i18n:govoplan-idm.memberships_resolved_summary", { value0: new Date(membershipResolution.effective_at).toLocaleString(language), value1: membershipResolution.identity_ids.length })}</p>
|
||||
<DataGrid id="idm-typed-group-membership-resolution" rows={membershipResolution.decisions} columns={membershipColumns} getRowKey={(row) => row.relationship.id} emptyText="No membership relationships were evaluated." initialFit="container" />
|
||||
</>
|
||||
)}
|
||||
</LoadingFrame>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function groupDraftFrom(item: TypedGroupItem): GroupDraft {
|
||||
return {
|
||||
key: item.key,
|
||||
name: item.name,
|
||||
groupType: item.group_type,
|
||||
description: item.description ?? "",
|
||||
status: item.status,
|
||||
sourceProvider: item.source_provider,
|
||||
sourceResourceType: item.source_resource_type ?? "",
|
||||
sourceResourceId: item.source_resource_id ?? "",
|
||||
sourceRevision: item.source_revision ?? "",
|
||||
properties: prettyJson(item.properties),
|
||||
provenance: prettyJson(item.provenance)
|
||||
};
|
||||
}
|
||||
|
||||
function groupPayload(draft: GroupDraft): TypedGroupPayload {
|
||||
return {
|
||||
key: draft.key.trim(),
|
||||
name: draft.name.trim(),
|
||||
group_type: draft.groupType.trim(),
|
||||
description: optionalText(draft.description),
|
||||
source_provider: draft.sourceProvider.trim(),
|
||||
source_resource_type: optionalText(draft.sourceResourceType),
|
||||
source_resource_id: optionalText(draft.sourceResourceId),
|
||||
source_revision: optionalText(draft.sourceRevision),
|
||||
properties: parseJsonObject(draft.properties, "Properties"),
|
||||
provenance: parseJsonObject(draft.provenance, "Provenance")
|
||||
};
|
||||
}
|
||||
|
||||
function relationshipDraftFrom(item: IdentityRelationshipItem): RelationshipDraft {
|
||||
return {
|
||||
relationshipKind: item.relationship_kind,
|
||||
subjectIdentityId: item.subject_identity_id,
|
||||
targetType: item.target_group_id ? "group" : "identity",
|
||||
targetGroupId: item.target_group_id ?? "",
|
||||
relatedIdentityId: item.related_identity_id ?? "",
|
||||
role: item.role ?? "",
|
||||
validFrom: item.valid_from ?? "",
|
||||
validUntil: item.valid_until ?? "",
|
||||
sourceProvider: item.source_provider,
|
||||
sourceResourceType: item.source_resource_type ?? "",
|
||||
sourceResourceId: item.source_resource_id ?? "",
|
||||
sourceRevision: item.source_revision ?? "",
|
||||
properties: prettyJson(item.properties),
|
||||
provenance: prettyJson(item.provenance)
|
||||
};
|
||||
}
|
||||
|
||||
function relationshipPayload(draft: RelationshipDraft): IdentityRelationshipPayload {
|
||||
return {
|
||||
relationship_kind: draft.relationshipKind.trim(),
|
||||
subject_identity_id: draft.subjectIdentityId,
|
||||
target_group_id: draft.targetType === "group" ? draft.targetGroupId || null : null,
|
||||
related_identity_id: draft.targetType === "identity" ? draft.relatedIdentityId || null : null,
|
||||
role: optionalText(draft.role),
|
||||
valid_from: optionalDateTime(draft.validFrom),
|
||||
valid_until: optionalDateTime(draft.validUntil),
|
||||
source_provider: draft.sourceProvider.trim(),
|
||||
source_resource_type: optionalText(draft.sourceResourceType),
|
||||
source_resource_id: optionalText(draft.sourceResourceId),
|
||||
source_revision: optionalText(draft.sourceRevision),
|
||||
properties: parseJsonObject(draft.properties, "Properties"),
|
||||
provenance: parseJsonObject(draft.provenance, "Provenance")
|
||||
};
|
||||
}
|
||||
|
||||
function relationshipState(item: IdentityRelationshipItem): "future" | "active" | "expired" | "revoked" {
|
||||
if (item.status === "revoked") return "revoked";
|
||||
const now = Date.now();
|
||||
if (item.valid_from && new Date(item.valid_from).getTime() > now) return "future";
|
||||
if (item.valid_until && new Date(item.valid_until).getTime() <= now) return "expired";
|
||||
return "active";
|
||||
}
|
||||
|
||||
function stateStatus(state: ReturnType<typeof relationshipState>): string {
|
||||
if (state === "future") return "pending";
|
||||
return state;
|
||||
}
|
||||
|
||||
function relationshipStateLabel(state: ReturnType<typeof relationshipState>): string {
|
||||
return state === "future" ? "Future" : state === "active" ? "Active" : state === "expired" ? "Expired" : "Revoked";
|
||||
}
|
||||
|
||||
function groupStatusLabel(status: TypedGroupItem["status"]): string {
|
||||
return status === "active" ? "Active" : "Inactive";
|
||||
}
|
||||
|
||||
function identityOption(item: IdentityOption): SearchableSelectOption {
|
||||
const label = identityLabel(item, item.id);
|
||||
return { value: item.id, label, description: item.external_subject ?? item.id, searchText: `${item.id} ${item.account_ids.join(" ")}` };
|
||||
}
|
||||
|
||||
function selectedIdentityOption(id: string, identityById: ReadonlyMap<string, IdentityOption>): SearchableSelectOption | null {
|
||||
if (!id) return null;
|
||||
const item = identityById.get(id);
|
||||
return item ? identityOption(item) : { value: id, label: id, description: "Identity reference" };
|
||||
}
|
||||
|
||||
function identityLabel(item: IdentityOption | undefined, fallback: string): string {
|
||||
return item?.display_name || item?.external_subject || fallback;
|
||||
}
|
||||
|
||||
function identityDisplay(item: IdentityOption | undefined, fallback: string): JSX.Element {
|
||||
return <div><strong>{identityLabel(item, fallback)}</strong><div className="idm-id">{fallback}</div></div>;
|
||||
}
|
||||
|
||||
function groupOption(item: TypedGroupItem): SearchableSelectOption {
|
||||
return { value: item.id, label: item.name, description: `${item.group_type} · ${item.key}`, searchText: `${item.id} ${item.key}` };
|
||||
}
|
||||
|
||||
function fallbackGroup(id: string): TypedGroupItem {
|
||||
return { id, tenant_id: "", key: id, name: id, group_type: "unknown", status: "inactive", source_provider: "unknown", properties: {}, provenance: {}, revision: 1 };
|
||||
}
|
||||
|
||||
function sourceSummary(item: Pick<TypedGroupItem, "source_provider" | "source_revision">): JSX.Element {
|
||||
return <div><span>{item.source_provider}</span>{item.source_revision && <div className="idm-id">{item.source_revision}</div>}</div>;
|
||||
}
|
||||
|
||||
function relationshipTargetLabel(
|
||||
item: IdentityRelationshipItem,
|
||||
groupById: ReadonlyMap<string, TypedGroupItem>,
|
||||
identityById: ReadonlyMap<string, IdentityOption>
|
||||
): string {
|
||||
if (item.target_group_id) return groupById.get(item.target_group_id)?.name ?? item.target_group_id;
|
||||
return identityLabel(identityById.get(item.related_identity_id ?? ""), item.related_identity_id ?? "");
|
||||
}
|
||||
|
||||
function relationshipTarget(
|
||||
item: IdentityRelationshipItem,
|
||||
groupById: ReadonlyMap<string, TypedGroupItem>,
|
||||
identityById: ReadonlyMap<string, IdentityOption>
|
||||
): JSX.Element {
|
||||
if (item.target_group_id) {
|
||||
const group = groupById.get(item.target_group_id);
|
||||
return <div><strong>{group?.name ?? item.target_group_id}</strong><div className="idm-id">{group ? `${group.group_type} · ${group.key}` : item.target_group_id}</div></div>;
|
||||
}
|
||||
return identityDisplay(identityById.get(item.related_identity_id ?? ""), item.related_identity_id ?? "");
|
||||
}
|
||||
|
||||
function effectiveWindow(item: IdentityRelationshipItem, language: string): JSX.Element {
|
||||
return <div><div>{item.valid_from ? new Date(item.valid_from).toLocaleString(language) : "No start limit"}</div><div className="idm-muted">{item.valid_until ? new Date(item.valid_until).toLocaleString(language) : "No end limit"}</div></div>;
|
||||
}
|
||||
|
||||
function mergeIdentities(current: IdentityOption[], incoming: IdentityOption[]): IdentityOption[] {
|
||||
const merged = new Map(current.map((item) => [item.id, item]));
|
||||
for (const item of incoming) merged.set(item.id, item);
|
||||
return Array.from(merged.values());
|
||||
}
|
||||
|
||||
function optionalText(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
function optionalDateTime(value: string): string | null {
|
||||
if (!value.trim()) return null;
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) throw new Error("Enter a valid date and time.");
|
||||
return parsed.toISOString();
|
||||
}
|
||||
|
||||
function parseJsonObject(value: string, label: string): Record<string, unknown> {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value || "{}");
|
||||
} catch {
|
||||
throw new Error(`${label} must contain valid JSON.`);
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error(`${label} must be a JSON object.`);
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function prettyJson(value: Record<string, unknown>): string {
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function draftKey(value: unknown): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function apiErrorMessage(error: unknown): string {
|
||||
if (error instanceof ApiError) {
|
||||
try {
|
||||
const parsed = JSON.parse(error.body) as { detail?: string | { message?: string } };
|
||||
if (typeof parsed.detail === "string") return parsed.detail;
|
||||
if (parsed.detail && typeof parsed.detail.message === "string") return parsed.detail.message;
|
||||
} catch {
|
||||
// Fall back to the transport message.
|
||||
}
|
||||
return error.message;
|
||||
}
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { DocumentationHelpReference } from "@govoplan/core-webui";
|
||||
|
||||
export const IDM_DOCUMENTATION = {
|
||||
topicId: "idm.workflow.assign-function-to-identity",
|
||||
documentationType: "user"
|
||||
} satisfies DocumentationHelpReference;
|
||||
|
||||
export const IDM_GOVERNANCE_DOCUMENTATION = {
|
||||
topicId: "idm.reference.assignment-governance",
|
||||
documentationType: "admin"
|
||||
} satisfies DocumentationHelpReference;
|
||||
|
||||
export const IDM_FIELD_DOCUMENTATION = {
|
||||
topicId: "idm.reference.fields-and-consequences",
|
||||
documentationType: "admin"
|
||||
} satisfies DocumentationHelpReference;
|
||||
|
||||
export const IDM_RELATIONSHIP_DOCUMENTATION = {
|
||||
topicId: "idm.reference.typed-relationships",
|
||||
documentationType: "admin"
|
||||
} satisfies DocumentationHelpReference;
|
||||
|
||||
export const IDM_INTERFACE_I18N = {
|
||||
loading: "i18n:govoplan-idm.loading_reason",
|
||||
busy: "i18n:govoplan-idm.busy_reason",
|
||||
writeReason: "i18n:govoplan-idm.write_permission_required.c7dde7c6",
|
||||
settingsReason: "i18n:govoplan-idm.settings_write_permission_required.37f37efa",
|
||||
searchReason: "i18n:govoplan-idm.identity_search_permission_reason",
|
||||
incomplete: "i18n:govoplan-idm.incomplete_draft_reason",
|
||||
noChanges: "i18n:govoplan-idm.no_changes_reason",
|
||||
noFunctions: "i18n:govoplan-idm.no_functions_available.51ba08eb",
|
||||
requiredAction: "i18n:govoplan-idm.required_action",
|
||||
actor: "i18n:govoplan-idm.responsible_actor",
|
||||
destination: "i18n:govoplan-idm.destination",
|
||||
permissionAction: "i18n:govoplan-idm.permission_required_action",
|
||||
permissionActor: "i18n:govoplan-idm.permission_responsible_actor",
|
||||
permissionDestination: "i18n:govoplan-idm.permission_destination",
|
||||
functionAction: "i18n:govoplan-idm.function_required_action",
|
||||
functionActor: "i18n:govoplan-idm.function_responsible_actor",
|
||||
functionDestination: "i18n:govoplan-idm.function_destination"
|
||||
} as const;
|
||||
|
||||
export function idmDisabledReason(
|
||||
loading: boolean,
|
||||
busy: boolean,
|
||||
permitted = true
|
||||
): string | undefined {
|
||||
if (loading) return IDM_INTERFACE_I18N.loading;
|
||||
if (busy) return IDM_INTERFACE_I18N.busy;
|
||||
if (!permitted) return IDM_INTERFACE_I18N.writeReason;
|
||||
return undefined;
|
||||
}
|
||||
@@ -31,7 +31,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-idm.governance.b989a277": "governance",
|
||||
"i18n:govoplan-idm.identity.544a8347": "Identity",
|
||||
"i18n:govoplan-idm.identity_is_required.6ad4ee23": "Identity is required.",
|
||||
"i18n:govoplan-idm.identity_links_intro.45fed9dd": "Link identities to organization functions. Organizations defines the functions; IDM owns who holds them.",
|
||||
"i18n:govoplan-idm.identity_links_intro.45fed9dd": "Manage organization-function assignments, typed business groups, and effective identity relationships. IDM records institutional facts; Access evaluates application authority separately.",
|
||||
"i18n:govoplan-idm.identity_lookup_unavailable.b76f7714": "Identity lookup is unavailable.",
|
||||
"i18n:govoplan-idm.identity_search.d3460fcf": "Identity search",
|
||||
"i18n:govoplan-idm.idm_governance.6e4f3251": "IDM governance",
|
||||
@@ -62,7 +62,197 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-idm.unit.a94c2fbd": "Unit",
|
||||
"i18n:govoplan-idm.update_assignment.e20f52aa": "Update assignment",
|
||||
"i18n:govoplan-idm.view_assignments.2d40d6a5": "View assignments",
|
||||
"i18n:govoplan-idm.write_permission_required.c7dde7c6": "You do not have permission to manage IDM organization assignments."
|
||||
"i18n:govoplan-idm.write_permission_required.c7dde7c6": "You do not have permission to manage IDM organization assignments.",
|
||||
"i18n:govoplan-idm.loading_reason": "IDM data is still loading.",
|
||||
"i18n:govoplan-idm.busy_reason": "Another IDM action is still running.",
|
||||
"i18n:govoplan-idm.identity_search_permission_reason": "Your account may not search identity candidates.",
|
||||
"i18n:govoplan-idm.incomplete_draft_reason": "Complete all required fields before submitting.",
|
||||
"i18n:govoplan-idm.no_changes_reason": "There are no settings changes to save.",
|
||||
"i18n:govoplan-idm.required_action": "Required action",
|
||||
"i18n:govoplan-idm.responsible_actor": "Responsible actor",
|
||||
"i18n:govoplan-idm.destination": "Destination",
|
||||
"i18n:govoplan-idm.permission_required_action": "Ask for the corresponding IDM management permission.",
|
||||
"i18n:govoplan-idm.permission_responsible_actor": "An Access or tenant administrator",
|
||||
"i18n:govoplan-idm.permission_destination": "Access role assignments",
|
||||
"i18n:govoplan-idm.function_required_action": "Create or activate an organization function first.",
|
||||
"i18n:govoplan-idm.function_responsible_actor": "An organization administrator",
|
||||
"i18n:govoplan-idm.function_destination": "Organizations",
|
||||
"i18n:govoplan-idm.confirm_function_action": "{action} the governed change for {function}? The decision and actor are retained as evidence.",
|
||||
"i18n:govoplan-idm.function_request_title": "Function request: {function}",
|
||||
"i18n:govoplan-idm.function_grant_title": "Function grant: {function}",
|
||||
"i18n:govoplan-idm.action_approve": "Approve",
|
||||
"i18n:govoplan-idm.action_reject": "Reject",
|
||||
"i18n:govoplan-idm.action_accept": "Accept",
|
||||
"i18n:govoplan-idm.action_request_changes": "Request changes",
|
||||
"i18n:govoplan-idm.action_respond": "Respond",
|
||||
"i18n:govoplan-idm.action_withdraw": "Withdraw",
|
||||
"i18n:govoplan-idm.action_recheck": "Recheck",
|
||||
"i18n:govoplan-idm.kind_request": "Request",
|
||||
"i18n:govoplan-idm.kind_grant": "Grant",
|
||||
"i18n:govoplan-idm.state_submitted": "Submitted",
|
||||
"i18n:govoplan-idm.state_awaiting_holder": "Awaiting holder",
|
||||
"i18n:govoplan-idm.state_awaiting_authority": "Awaiting authority",
|
||||
"i18n:govoplan-idm.state_awaiting_recipient": "Awaiting recipient",
|
||||
"i18n:govoplan-idm.state_changes_requested": "Changes requested",
|
||||
"i18n:govoplan-idm.state_applied": "Applied",
|
||||
"i18n:govoplan-idm.state_rejected": "Rejected",
|
||||
"i18n:govoplan-idm.state_expired": "Expired",
|
||||
"i18n:govoplan-idm.state_withdrawn": "Withdrawn",
|
||||
"i18n:govoplan-idm.state_cancelled": "Cancelled",
|
||||
"i18n:govoplan-idm.state_blocked": "Blocked",
|
||||
"i18n:govoplan-idm.state_failed_manual_review": "Manual review failed",
|
||||
"i18n:govoplan-idm.step_approve_holder": "Holder approval",
|
||||
"i18n:govoplan-idm.step_approve_authority": "Authority approval",
|
||||
"i18n:govoplan-idm.step_accept_recipient": "Recipient acceptance",
|
||||
"i18n:govoplan-idm.profile_holder_review": "Holder review",
|
||||
"i18n:govoplan-idm.profile_authority_review": "Authority review",
|
||||
"i18n:govoplan-idm.profile_recipient_review": "Recipient review",
|
||||
"Actions": "Actions",
|
||||
"Direct changes to this governed function require an emergency override reason.": "Direct changes to this governed function require an emergency override reason.",
|
||||
"Direct changes to this governed function are emergency overrides. Use a request or grant above for the normal process.": "Direct changes to this governed function are emergency overrides. Use a request or grant above for the normal process.",
|
||||
"Emergency override reason": "Emergency override reason",
|
||||
"Override evidence references (one per line)": "Override evidence references (one per line)",
|
||||
"Unsaved function change": "Unsaved function change",
|
||||
"Save or discard the function request or grant before leaving this surface.": "Save or discard the function request or grant before leaving this surface.",
|
||||
"Kind": "Kind",
|
||||
"Function": "Function",
|
||||
"Candidate": "Candidate",
|
||||
"State": "State",
|
||||
"Decisions": "Decisions",
|
||||
"Updated": "Updated",
|
||||
"Open change": "Open change",
|
||||
"Function requests and grants": "Function requests and grants",
|
||||
"Start governed change": "Start governed change",
|
||||
"Loading governed function changes": "Loading governed function changes",
|
||||
"No governed function changes": "No governed function changes",
|
||||
"Start governed function change": "Start governed function change",
|
||||
"Cancel": "Cancel",
|
||||
"Submit": "Submit",
|
||||
"Function change kind": "Function change kind",
|
||||
"Request function": "Request function",
|
||||
"Grant function": "Grant function",
|
||||
"Select function": "Select function",
|
||||
"Candidate identity": "Candidate identity",
|
||||
"Select identity": "Select identity",
|
||||
"Candidate account": "Candidate account",
|
||||
"No linked account": "No linked account",
|
||||
"Valid from": "Valid from",
|
||||
"Valid until": "Valid until",
|
||||
"Justification": "Justification",
|
||||
"Evidence references (one per line)": "Evidence references (one per line)",
|
||||
"Function change": "Function change",
|
||||
"Close": "Close",
|
||||
"Profile": "Profile",
|
||||
"Workflow revision": "Workflow revision",
|
||||
"Required decisions": "Required decisions",
|
||||
"Completed decisions": "Completed decisions",
|
||||
"Explanation": "Explanation",
|
||||
"Decision comment": "Decision comment",
|
||||
"History": "History",
|
||||
"Confirm function decision": "Confirm function decision",
|
||||
"Confirm": "Confirm",
|
||||
"None": "None",
|
||||
"i18n:govoplan-idm.edit_group_value": "Edit {value0}",
|
||||
"i18n:govoplan-idm.inspect_memberships_value": "Inspect memberships for {value0}",
|
||||
"i18n:govoplan-idm.effective_memberships_value": "Effective memberships: {value0}",
|
||||
"i18n:govoplan-idm.memberships_resolved_summary": "Resolved at {value0}. {value1} identities are effective; excluded decisions remain visible for explanation.",
|
||||
"i18n:govoplan-idm.group_revision_help": "Revision {value0} is used for optimistic concurrency. If another administrator saves first, reload before applying your change.",
|
||||
"i18n:govoplan-idm.relationship_revision_help": "Revision {value0} is used for optimistic concurrency. The subject identity is immutable; replace the relationship if the subject is wrong.",
|
||||
"Access role assignments": "Access role assignments",
|
||||
"Active": "Active",
|
||||
"An Access or tenant administrator": "An Access or tenant administrator",
|
||||
"Ask for typed-relationship read permission.": "Ask for typed-relationship read permission.",
|
||||
"Ask for typed-relationship write permission before creating, editing, or revoking records.": "Ask for typed-relationship write permission before creating, editing, or revoking records.",
|
||||
"Business membership is an institutional fact. It does not grant application permissions; Access evaluates authority separately.": "Business membership is an institutional fact. It does not grant application permissions; Access evaluates authority separately.",
|
||||
"Create an active typed group first.": "Create an active typed group first.",
|
||||
"Create identity relationship": "Create identity relationship",
|
||||
"Create relationship": "Create relationship",
|
||||
"Create typed group": "Create typed group",
|
||||
"Description": "Description",
|
||||
"Destination": "Destination",
|
||||
"Edit identity relationship": "Edit identity relationship",
|
||||
"Edit relationship": "Edit relationship",
|
||||
"Edit typed group": "Edit typed group",
|
||||
"Effective at": "Effective at",
|
||||
"Effective identity relationships": "Effective identity relationships",
|
||||
"Effective memberships": "Effective memberships",
|
||||
"Effective state": "Effective state",
|
||||
"Effective window": "Effective window",
|
||||
"Enter a valid date and time.": "Enter a valid date and time.",
|
||||
"Expired": "Expired",
|
||||
"Future": "Future",
|
||||
"Future dates schedule the fact without granting current membership. Expiry and revocation remove it from effective resolution while retaining source and decision evidence. Membership never grants Access permissions by itself.": "Future dates schedule the fact without granting current membership. Expiry and revocation remove it from effective resolution while retaining source and decision evidence. Membership never grants Access permissions by itself.",
|
||||
"Group type": "Group type",
|
||||
"Identity": "Identity",
|
||||
"Identity reference": "Identity reference",
|
||||
"Identity relationship created.": "Identity relationship created.",
|
||||
"Identity relationship revoked. Effective membership and downstream business resolution stop immediately.": "Identity relationship revoked. Effective membership and downstream business resolution stop immediately.",
|
||||
"Identity relationship updated.": "Identity relationship updated.",
|
||||
"Identity state": "Identity state",
|
||||
"Inactive": "Inactive",
|
||||
"Key": "Key",
|
||||
"Loading typed groups and relationships": "Loading typed groups and relationships",
|
||||
"Name": "Name",
|
||||
"No end limit": "No end limit",
|
||||
"No identity relationships found.": "No identity relationships found.",
|
||||
"No membership relationships were evaluated.": "No membership relationships were evaluated.",
|
||||
"No start limit": "No start limit",
|
||||
"No typed groups found.": "No typed groups found.",
|
||||
"Not available": "Not available",
|
||||
"Properties (JSON object)": "Properties (JSON object)",
|
||||
"Properties must be a JSON object.": "Properties must be a JSON object.",
|
||||
"Properties must contain valid JSON.": "Properties must contain valid JSON.",
|
||||
"Provenance (JSON object)": "Provenance (JSON object)",
|
||||
"Provenance must be a JSON object.": "Provenance must be a JSON object.",
|
||||
"Provenance must contain valid JSON.": "Provenance must contain valid JSON.",
|
||||
"Related identity": "Related identity",
|
||||
"Reload": "Reload",
|
||||
"Relationship": "Relationship",
|
||||
"Relationship kind": "Relationship kind",
|
||||
"Relationship kinds": "Relationship kinds",
|
||||
"Relationship write permission is required.": "Relationship write permission is required.",
|
||||
"Required action": "Required action",
|
||||
"Resolution": "Resolution",
|
||||
"Resolve memberships": "Resolve memberships",
|
||||
"Resolving effective memberships": "Resolving effective memberships",
|
||||
"Responsible actor": "Responsible actor",
|
||||
"Revision": "Revision",
|
||||
"Revocation reason": "Revocation reason",
|
||||
"Revocation takes effect immediately for membership resolution and downstream business consumers. The record, actor, time, source, and reason remain as evidence; a revoked relationship cannot be edited or reactivated.": "Revocation takes effect immediately for membership resolution and downstream business consumers. The record, actor, time, source, and reason remain as evidence; a revoked relationship cannot be edited or reactivated.",
|
||||
"Revoke identity relationship": "Revoke identity relationship",
|
||||
"Revoke relationship": "Revoke relationship",
|
||||
"Revoked": "Revoked",
|
||||
"Revoked relationships are retained as immutable evidence.": "Revoked relationships are retained as immutable evidence.",
|
||||
"Revoking...": "Revoking...",
|
||||
"Role": "Role",
|
||||
"Save group": "Save group",
|
||||
"Save or discard the typed-group or relationship draft before leaving this surface.": "Save or discard the typed-group or relationship draft before leaving this surface.",
|
||||
"Save relationship": "Save relationship",
|
||||
"Saving...": "Saving...",
|
||||
"Search identities": "Search identities",
|
||||
"Search typed groups": "Search typed groups",
|
||||
"Show inactive groups": "Show inactive groups",
|
||||
"Show revoked relationships": "Show revoked relationships",
|
||||
"Source": "Source",
|
||||
"Source provider": "Source provider",
|
||||
"Source resource ID": "Source resource ID",
|
||||
"Source resource type": "Source resource type",
|
||||
"Source revision": "Source revision",
|
||||
"Status": "Status",
|
||||
"Subject identity": "Subject identity",
|
||||
"Target": "Target",
|
||||
"Target group": "Target group",
|
||||
"Target type": "Target type",
|
||||
"Target typed group": "Target typed group",
|
||||
"Typed group": "Typed group",
|
||||
"Typed group created.": "Typed group created.",
|
||||
"Typed group updated.": "Typed group updated.",
|
||||
"Typed groups": "Typed groups",
|
||||
"Typed groups and identity relationships": "Typed groups and identity relationships",
|
||||
"Typed-group write permission is required.": "Typed-group write permission is required.",
|
||||
"Unsaved relationship administration": "Unsaved relationship administration",
|
||||
"You do not have permission to view typed identity relationships.": "You do not have permission to view typed identity relationships.",
|
||||
"You may inspect relationship evidence but not change it.": "You may inspect relationship evidence but not change it."
|
||||
},
|
||||
de: {
|
||||
"i18n:govoplan-idm.account.2b2936f8": "Konto",
|
||||
@@ -94,7 +284,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-idm.governance.b989a277": "Governance",
|
||||
"i18n:govoplan-idm.identity.544a8347": "Identität",
|
||||
"i18n:govoplan-idm.identity_is_required.6ad4ee23": "Identität ist erforderlich.",
|
||||
"i18n:govoplan-idm.identity_links_intro.45fed9dd": "Verknüpfe Identitäten mit Organisationsfunktionen. Organisationen definiert die Funktionen; IDM verwaltet, wer sie innehat.",
|
||||
"i18n:govoplan-idm.identity_links_intro.45fed9dd": "Verwalten Sie Organisationsfunktionszuordnungen, typisierte Fachgruppen und zeitlich wirksame Identitätsbeziehungen. IDM erfasst institutionelle Tatsachen; Access bewertet Anwendungsbefugnisse getrennt.",
|
||||
"i18n:govoplan-idm.identity_lookup_unavailable.b76f7714": "Identitätssuche ist nicht verfügbar.",
|
||||
"i18n:govoplan-idm.identity_search.d3460fcf": "Identitätssuche",
|
||||
"i18n:govoplan-idm.idm_governance.6e4f3251": "IDM-Governance",
|
||||
@@ -125,6 +315,196 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-idm.unit.a94c2fbd": "Einheit",
|
||||
"i18n:govoplan-idm.update_assignment.e20f52aa": "Zuordnung aktualisieren",
|
||||
"i18n:govoplan-idm.view_assignments.2d40d6a5": "Zuordnungen anzeigen",
|
||||
"i18n:govoplan-idm.write_permission_required.c7dde7c6": "Du hast keine Berechtigung, IDM-Organisationszuordnungen zu verwalten."
|
||||
"i18n:govoplan-idm.write_permission_required.c7dde7c6": "Du hast keine Berechtigung, IDM-Organisationszuordnungen zu verwalten.",
|
||||
"i18n:govoplan-idm.loading_reason": "IDM-Daten werden noch geladen.",
|
||||
"i18n:govoplan-idm.busy_reason": "Eine andere IDM-Aktion läuft noch.",
|
||||
"i18n:govoplan-idm.identity_search_permission_reason": "Ihr Konto darf keine Identitätskandidaten suchen.",
|
||||
"i18n:govoplan-idm.incomplete_draft_reason": "Füllen Sie vor dem Absenden alle Pflichtfelder aus.",
|
||||
"i18n:govoplan-idm.no_changes_reason": "Es gibt keine Einstellungsänderungen zu speichern.",
|
||||
"i18n:govoplan-idm.required_action": "Erforderliche Aktion",
|
||||
"i18n:govoplan-idm.responsible_actor": "Verantwortliche Stelle",
|
||||
"i18n:govoplan-idm.destination": "Ziel",
|
||||
"i18n:govoplan-idm.permission_required_action": "Fordern Sie die entsprechende IDM-Verwaltungsberechtigung an.",
|
||||
"i18n:govoplan-idm.permission_responsible_actor": "Access- oder Mandantenadministration",
|
||||
"i18n:govoplan-idm.permission_destination": "Access-Rollenzuordnungen",
|
||||
"i18n:govoplan-idm.function_required_action": "Erstellen oder aktivieren Sie zuerst eine Organisationsfunktion.",
|
||||
"i18n:govoplan-idm.function_responsible_actor": "Organisationsadministration",
|
||||
"i18n:govoplan-idm.function_destination": "Organisationen",
|
||||
"i18n:govoplan-idm.confirm_function_action": "{action} der gesteuerten Änderung für {function}? Entscheidung und handelnde Person werden als Nachweis aufbewahrt.",
|
||||
"i18n:govoplan-idm.function_request_title": "Funktionsantrag: {function}",
|
||||
"i18n:govoplan-idm.function_grant_title": "Funktionsvergabe: {function}",
|
||||
"i18n:govoplan-idm.action_approve": "Genehmigen",
|
||||
"i18n:govoplan-idm.action_reject": "Ablehnen",
|
||||
"i18n:govoplan-idm.action_accept": "Annehmen",
|
||||
"i18n:govoplan-idm.action_request_changes": "Änderungen anfordern",
|
||||
"i18n:govoplan-idm.action_respond": "Antworten",
|
||||
"i18n:govoplan-idm.action_withdraw": "Zurückziehen",
|
||||
"i18n:govoplan-idm.action_recheck": "Erneut prüfen",
|
||||
"i18n:govoplan-idm.kind_request": "Antrag",
|
||||
"i18n:govoplan-idm.kind_grant": "Vergabe",
|
||||
"i18n:govoplan-idm.state_submitted": "Eingereicht",
|
||||
"i18n:govoplan-idm.state_awaiting_holder": "Wartet auf Funktionsinhaber",
|
||||
"i18n:govoplan-idm.state_awaiting_authority": "Wartet auf zuständige Stelle",
|
||||
"i18n:govoplan-idm.state_awaiting_recipient": "Wartet auf Empfänger",
|
||||
"i18n:govoplan-idm.state_changes_requested": "Änderungen angefordert",
|
||||
"i18n:govoplan-idm.state_applied": "Angewendet",
|
||||
"i18n:govoplan-idm.state_rejected": "Abgelehnt",
|
||||
"i18n:govoplan-idm.state_expired": "Abgelaufen",
|
||||
"i18n:govoplan-idm.state_withdrawn": "Zurückgezogen",
|
||||
"i18n:govoplan-idm.state_cancelled": "Abgebrochen",
|
||||
"i18n:govoplan-idm.state_blocked": "Blockiert",
|
||||
"i18n:govoplan-idm.state_failed_manual_review": "Manuelle Prüfung fehlgeschlagen",
|
||||
"i18n:govoplan-idm.step_approve_holder": "Genehmigung durch Funktionsinhaber",
|
||||
"i18n:govoplan-idm.step_approve_authority": "Genehmigung durch zuständige Stelle",
|
||||
"i18n:govoplan-idm.step_accept_recipient": "Annahme durch Empfänger",
|
||||
"i18n:govoplan-idm.profile_holder_review": "Prüfung durch Funktionsinhaber",
|
||||
"i18n:govoplan-idm.profile_authority_review": "Prüfung durch zuständige Stelle",
|
||||
"i18n:govoplan-idm.profile_recipient_review": "Prüfung durch Empfänger",
|
||||
"Actions": "Aktionen",
|
||||
"Direct changes to this governed function require an emergency override reason.": "Direkte Änderungen an dieser gesteuerten Funktion erfordern eine Begründung für die Notfallübersteuerung.",
|
||||
"Direct changes to this governed function are emergency overrides. Use a request or grant above for the normal process.": "Direkte Änderungen an dieser gesteuerten Funktion sind Notfallübersteuerungen. Verwenden Sie für den regulären Prozess einen Antrag oder eine Vergabe.",
|
||||
"Emergency override reason": "Begründung der Notfallübersteuerung",
|
||||
"Override evidence references (one per line)": "Nachweisreferenzen der Übersteuerung (eine pro Zeile)",
|
||||
"Unsaved function change": "Ungespeicherte Funktionsänderung",
|
||||
"Save or discard the function request or grant before leaving this surface.": "Speichern oder verwerfen Sie den Funktionsantrag oder die Vergabe, bevor Sie diesen Bereich verlassen.",
|
||||
"Kind": "Art",
|
||||
"Function": "Funktion",
|
||||
"Candidate": "Kandidat",
|
||||
"State": "Status",
|
||||
"Decisions": "Entscheidungen",
|
||||
"Updated": "Aktualisiert",
|
||||
"Open change": "Änderung öffnen",
|
||||
"Function requests and grants": "Funktionsanträge und -vergaben",
|
||||
"Start governed change": "Gesteuerte Änderung starten",
|
||||
"Loading governed function changes": "Gesteuerte Funktionsänderungen werden geladen",
|
||||
"No governed function changes": "Keine gesteuerten Funktionsänderungen",
|
||||
"Start governed function change": "Gesteuerte Funktionsänderung starten",
|
||||
"Cancel": "Abbrechen",
|
||||
"Submit": "Absenden",
|
||||
"Function change kind": "Art der Funktionsänderung",
|
||||
"Request function": "Funktion beantragen",
|
||||
"Grant function": "Funktion vergeben",
|
||||
"Select function": "Funktion auswählen",
|
||||
"Candidate identity": "Kandidatenidentität",
|
||||
"Select identity": "Identität auswählen",
|
||||
"Candidate account": "Kandidatenkonto",
|
||||
"No linked account": "Kein verknüpftes Konto",
|
||||
"Valid from": "Gültig ab",
|
||||
"Valid until": "Gültig bis",
|
||||
"Justification": "Begründung",
|
||||
"Evidence references (one per line)": "Nachweisreferenzen (eine pro Zeile)",
|
||||
"Function change": "Funktionsänderung",
|
||||
"Close": "Schließen",
|
||||
"Profile": "Profil",
|
||||
"Workflow revision": "Workflow-Revision",
|
||||
"Required decisions": "Erforderliche Entscheidungen",
|
||||
"Completed decisions": "Abgeschlossene Entscheidungen",
|
||||
"Explanation": "Erläuterung",
|
||||
"Decision comment": "Entscheidungskommentar",
|
||||
"History": "Verlauf",
|
||||
"Confirm function decision": "Funktionsentscheidung bestätigen",
|
||||
"Confirm": "Bestätigen",
|
||||
"None": "Keine",
|
||||
"i18n:govoplan-idm.edit_group_value": "{value0} bearbeiten",
|
||||
"i18n:govoplan-idm.inspect_memberships_value": "Mitgliedschaften von {value0} prüfen",
|
||||
"i18n:govoplan-idm.effective_memberships_value": "Wirksame Mitgliedschaften: {value0}",
|
||||
"i18n:govoplan-idm.memberships_resolved_summary": "Aufgelöst zum Zeitpunkt {value0}. {value1} Identitäten sind wirksam; ausgeschlossene Entscheidungen bleiben zur Erläuterung sichtbar.",
|
||||
"i18n:govoplan-idm.group_revision_help": "Revision {value0} schützt vor dem Überschreiben paralleler Änderungen. Falls eine andere Administration zuerst speichert, laden Sie vor Ihrer Änderung neu.",
|
||||
"i18n:govoplan-idm.relationship_revision_help": "Revision {value0} schützt vor dem Überschreiben paralleler Änderungen. Die Ausgangsidentität ist unveränderbar; ersetzen Sie die Beziehung, wenn sie falsch ist.",
|
||||
"Access role assignments": "Zuweisungen von Zugriffsrollen",
|
||||
"Active": "Aktiv",
|
||||
"An Access or tenant administrator": "Access- oder Mandantenadministration",
|
||||
"Ask for typed-relationship read permission.": "Fordern Sie die Leseberechtigung für typisierte Beziehungen an.",
|
||||
"Ask for typed-relationship write permission before creating, editing, or revoking records.": "Fordern Sie vor dem Erstellen, Bearbeiten oder Widerrufen die Schreibberechtigung für typisierte Beziehungen an.",
|
||||
"Business membership is an institutional fact. It does not grant application permissions; Access evaluates authority separately.": "Eine fachliche Mitgliedschaft ist eine institutionelle Tatsache. Sie erteilt keine Anwendungsberechtigungen; Access bewertet Befugnisse getrennt.",
|
||||
"Create an active typed group first.": "Erstellen Sie zuerst eine aktive typisierte Gruppe.",
|
||||
"Create identity relationship": "Identitätsbeziehung erstellen",
|
||||
"Create relationship": "Beziehung erstellen",
|
||||
"Create typed group": "Typisierte Gruppe erstellen",
|
||||
"Description": "Beschreibung",
|
||||
"Destination": "Ziel",
|
||||
"Edit identity relationship": "Identitätsbeziehung bearbeiten",
|
||||
"Edit relationship": "Beziehung bearbeiten",
|
||||
"Edit typed group": "Typisierte Gruppe bearbeiten",
|
||||
"Effective at": "Wirksam zum Zeitpunkt",
|
||||
"Effective identity relationships": "Wirksame Identitätsbeziehungen",
|
||||
"Effective memberships": "Wirksame Mitgliedschaften",
|
||||
"Effective state": "Wirksamkeitsstatus",
|
||||
"Effective window": "Wirksamkeitszeitraum",
|
||||
"Enter a valid date and time.": "Geben Sie ein gültiges Datum mit Uhrzeit ein.",
|
||||
"Expired": "Abgelaufen",
|
||||
"Future": "Zukünftig",
|
||||
"Future dates schedule the fact without granting current membership. Expiry and revocation remove it from effective resolution while retaining source and decision evidence. Membership never grants Access permissions by itself.": "Ein zukünftiger Beginn plant die Tatsache, ohne eine aktuelle Mitgliedschaft zu erzeugen. Ablauf und Widerruf entfernen sie aus der wirksamen Auflösung, während Quellen- und Entscheidungsnachweise erhalten bleiben. Eine Mitgliedschaft erteilt niemals selbstständig Access-Berechtigungen.",
|
||||
"Group type": "Gruppentyp",
|
||||
"Identity": "Identität",
|
||||
"Identity reference": "Identitätsreferenz",
|
||||
"Identity relationship created.": "Identitätsbeziehung erstellt.",
|
||||
"Identity relationship revoked. Effective membership and downstream business resolution stop immediately.": "Identitätsbeziehung widerrufen. Wirksame Mitgliedschaft und nachgelagerte fachliche Auflösungen enden sofort.",
|
||||
"Identity relationship updated.": "Identitätsbeziehung aktualisiert.",
|
||||
"Identity state": "Identitätsstatus",
|
||||
"Inactive": "Inaktiv",
|
||||
"Key": "Schlüssel",
|
||||
"Loading typed groups and relationships": "Typisierte Gruppen und Beziehungen werden geladen",
|
||||
"Name": "Name",
|
||||
"No end limit": "Kein Endzeitpunkt",
|
||||
"No identity relationships found.": "Keine Identitätsbeziehungen gefunden.",
|
||||
"No membership relationships were evaluated.": "Es wurden keine Mitgliedschaftsbeziehungen ausgewertet.",
|
||||
"No start limit": "Kein Startzeitpunkt",
|
||||
"No typed groups found.": "Keine typisierten Gruppen gefunden.",
|
||||
"Not available": "Nicht verfügbar",
|
||||
"Properties (JSON object)": "Eigenschaften (JSON-Objekt)",
|
||||
"Properties must be a JSON object.": "Eigenschaften müssen ein JSON-Objekt sein.",
|
||||
"Properties must contain valid JSON.": "Eigenschaften müssen gültiges JSON enthalten.",
|
||||
"Provenance (JSON object)": "Herkunftsnachweis (JSON-Objekt)",
|
||||
"Provenance must be a JSON object.": "Der Herkunftsnachweis muss ein JSON-Objekt sein.",
|
||||
"Provenance must contain valid JSON.": "Der Herkunftsnachweis muss gültiges JSON enthalten.",
|
||||
"Related identity": "Verknüpfte Identität",
|
||||
"Reload": "Neu laden",
|
||||
"Relationship": "Beziehung",
|
||||
"Relationship kind": "Beziehungsart",
|
||||
"Relationship kinds": "Beziehungsarten",
|
||||
"Relationship write permission is required.": "Die Schreibberechtigung für Beziehungen ist erforderlich.",
|
||||
"Required action": "Erforderliche Aktion",
|
||||
"Resolution": "Auflösung",
|
||||
"Resolve memberships": "Mitgliedschaften auflösen",
|
||||
"Resolving effective memberships": "Wirksame Mitgliedschaften werden aufgelöst",
|
||||
"Responsible actor": "Verantwortliche Stelle",
|
||||
"Revision": "Revision",
|
||||
"Revocation reason": "Widerrufsgrund",
|
||||
"Revocation takes effect immediately for membership resolution and downstream business consumers. The record, actor, time, source, and reason remain as evidence; a revoked relationship cannot be edited or reactivated.": "Der Widerruf wirkt sofort auf die Mitgliedschaftsauflösung und nachgelagerte fachliche Verbraucher. Datensatz, handelnde Person, Zeitpunkt, Quelle und Grund bleiben als Nachweis erhalten; eine widerrufene Beziehung kann weder bearbeitet noch reaktiviert werden.",
|
||||
"Revoke identity relationship": "Identitätsbeziehung widerrufen",
|
||||
"Revoke relationship": "Beziehung widerrufen",
|
||||
"Revoked": "Widerrufen",
|
||||
"Revoked relationships are retained as immutable evidence.": "Widerrufene Beziehungen bleiben als unveränderbarer Nachweis erhalten.",
|
||||
"Revoking...": "Wird widerrufen …",
|
||||
"Role": "Rolle",
|
||||
"Save group": "Gruppe speichern",
|
||||
"Save or discard the typed-group or relationship draft before leaving this surface.": "Speichern oder verwerfen Sie den Entwurf der typisierten Gruppe oder Beziehung, bevor Sie diesen Bereich verlassen.",
|
||||
"Save relationship": "Beziehung speichern",
|
||||
"Saving...": "Wird gespeichert …",
|
||||
"Search identities": "Identitäten durchsuchen",
|
||||
"Search typed groups": "Typisierte Gruppen durchsuchen",
|
||||
"Show inactive groups": "Inaktive Gruppen anzeigen",
|
||||
"Show revoked relationships": "Widerrufene Beziehungen anzeigen",
|
||||
"Source": "Quelle",
|
||||
"Source provider": "Quellanbieter",
|
||||
"Source resource ID": "Quellressourcen-ID",
|
||||
"Source resource type": "Quellressourcentyp",
|
||||
"Source revision": "Quellrevision",
|
||||
"Status": "Status",
|
||||
"Subject identity": "Ausgangsidentität",
|
||||
"Target": "Ziel",
|
||||
"Target group": "Zielgruppe",
|
||||
"Target type": "Zielart",
|
||||
"Target typed group": "Typisierte Zielgruppe",
|
||||
"Typed group": "Typisierte Gruppe",
|
||||
"Typed group created.": "Typisierte Gruppe erstellt.",
|
||||
"Typed group updated.": "Typisierte Gruppe aktualisiert.",
|
||||
"Typed groups": "Typisierte Gruppen",
|
||||
"Typed groups and identity relationships": "Typisierte Gruppen und Identitätsbeziehungen",
|
||||
"Typed-group write permission is required.": "Die Schreibberechtigung für typisierte Gruppen ist erforderlich.",
|
||||
"Unsaved relationship administration": "Ungespeicherte Beziehungsverwaltung",
|
||||
"You do not have permission to view typed identity relationships.": "Sie haben keine Berechtigung, typisierte Identitätsbeziehungen anzuzeigen.",
|
||||
"You may inspect relationship evidence but not change it.": "Sie können Beziehungsnachweise prüfen, aber nicht ändern."
|
||||
}
|
||||
};
|
||||
|
||||
+18
-13
@@ -1,5 +1,6 @@
|
||||
import { createElement, lazy } from "react";
|
||||
import { Button, type OrganizationFunctionActionsUiCapability, type PlatformWebModule } from "@govoplan/core-webui";
|
||||
import { Users } from "lucide-react";
|
||||
import type { OrganizationFunctionActionsUiCapability, PlatformWebModule } from "@govoplan/core-webui";
|
||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||
import "./styles/idm.css";
|
||||
|
||||
@@ -8,6 +9,8 @@ const IdmPage = lazy(() => import("./features/IdmPage"));
|
||||
const idmReadScopes = [
|
||||
"idm:organization_assignment:read",
|
||||
"idm:organization_assignment:write",
|
||||
"idm:relationship:read",
|
||||
"idm:relationship:write",
|
||||
"organizations:function:assign"
|
||||
];
|
||||
|
||||
@@ -20,23 +23,16 @@ const organizationFunctionActions: OrganizationFunctionActionsUiCapability = {
|
||||
actions: [
|
||||
{
|
||||
id: "idm.view-function-assignments",
|
||||
label: "i18n:govoplan-idm.assignments.a0d19ec5",
|
||||
surfaceId: "idm.action.view-function-assignments",
|
||||
label: "i18n:govoplan-idm.view_assignments.2d40d6a5",
|
||||
icon: createElement(Users, { size: 16 }),
|
||||
anyOf: idmReadScopes,
|
||||
order: 40,
|
||||
render: ({ function: item }) => createElement(
|
||||
Button,
|
||||
{
|
||||
type: "button",
|
||||
variant: "ghost",
|
||||
title: "i18n:govoplan-idm.view_assignments.2d40d6a5",
|
||||
onClick: () => {
|
||||
onClick: ({ function: item }) => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.href = `/idm?function_id=${encodeURIComponent(item.id)}`;
|
||||
}
|
||||
}
|
||||
},
|
||||
"i18n:govoplan-idm.assignments.a0d19ec5"
|
||||
)
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -44,9 +40,18 @@ const organizationFunctionActions: OrganizationFunctionActionsUiCapability = {
|
||||
export const idmModule: PlatformWebModule = {
|
||||
id: "idm",
|
||||
label: "i18n:govoplan-idm.idm.61f4a7a2",
|
||||
version: "0.1.6",
|
||||
version: "0.1.8",
|
||||
dependencies: ["identity", "organizations"],
|
||||
translations,
|
||||
viewSurfaces: [
|
||||
{
|
||||
id: "idm.action.view-function-assignments",
|
||||
moduleId: "idm",
|
||||
kind: "action",
|
||||
label: "i18n:govoplan-idm.view_assignments.2d40d6a5",
|
||||
order: 40
|
||||
}
|
||||
],
|
||||
navItems: [
|
||||
{
|
||||
to: "/idm",
|
||||
|
||||
+85
-28
@@ -2,21 +2,12 @@
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
width: 100%;
|
||||
max-width: 1480px;
|
||||
}
|
||||
|
||||
.idm-heading {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.idm-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.idm-table-stack {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
@@ -27,14 +18,14 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.idm-form-grid {
|
||||
.idm-relationship-stack {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
gap: 18px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.idm-form-grid .wide {
|
||||
grid-column: 1 / -1;
|
||||
.idm-card-note {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.idm-check-list {
|
||||
@@ -51,18 +42,6 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.idm-form-actions,
|
||||
.idm-row-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.idm-row-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.idm-identity {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
@@ -89,8 +68,86 @@
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.idm-form-grid {
|
||||
.idm-governance-override {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.idm-change-detail {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.idm-change-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px 18px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.idm-change-summary > div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.idm-change-summary > .wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.idm-change-summary dt {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.idm-change-summary dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.idm-change-actions {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding-block: 14px;
|
||||
border-block: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.idm-change-history {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.idm-change-history li {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 0.6fr) minmax(150px, 0.7fr) minmax(180px, 1fr);
|
||||
gap: 10px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.idm-change-history p {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.idm-change-summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.idm-change-summary > .wide {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.idm-change-history li {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user