Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,10 @@ 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.
|
||||
|
||||
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 +139,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,135 @@
|
||||
# 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.
|
||||
|
||||
## 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`,
|
||||
`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`. Missing or malformed profiles fail closed.
|
||||
|
||||
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.
|
||||
|
||||
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,51 @@
|
||||
# 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 |
|
||||
| `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.
|
||||
- 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 fields, governed
|
||||
states, workflow steps, decisions, confirmations, and accessible labels. Dates
|
||||
follow the selected platform locale. Manifest topics provide stable route,
|
||||
field, blocker, workflow, and consequence references without importing optional
|
||||
Policy, Audit, Notifications, Access, or Workflow Engine implementations.
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# 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.
|
||||
|
||||
## 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.15",
|
||||
"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.15",
|
||||
"@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.15"
|
||||
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.15",
|
||||
"govoplan-identity>=0.1.15",
|
||||
"govoplan-organizations>=0.1.15",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
@@ -0,0 +1,551 @@
|
||||
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,
|
||||
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,
|
||||
"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),
|
||||
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,
|
||||
@@ -54,8 +75,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 +106,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 +122,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 +185,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 +263,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:
|
||||
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.")
|
||||
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.")
|
||||
return base
|
||||
|
||||
|
||||
def _requires_assignment_change_request(session: Session, tenant_id: str) -> bool:
|
||||
@@ -213,16 +337,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 +417,6 @@ def _record_assignment_change_applied(
|
||||
target=target,
|
||||
audit_event=IDM_ASSIGNMENT_AUDIT_EVENT,
|
||||
)
|
||||
session.commit()
|
||||
|
||||
|
||||
def _audit_capability_available() -> bool:
|
||||
@@ -304,10 +443,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),
|
||||
@@ -347,7 +544,8 @@ def update_idm_settings(
|
||||
if payload.settings is None:
|
||||
raise _invalid("Settings cannot be empty.")
|
||||
item.settings = payload.settings
|
||||
result = _settings_item(_commit(session, item))
|
||||
session.flush()
|
||||
result = _settings_item(item)
|
||||
_record_assignment_audit(
|
||||
session,
|
||||
principal,
|
||||
@@ -357,22 +555,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 +607,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 +629,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 +678,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 +711,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 +734,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)
|
||||
)
|
||||
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)
|
||||
del session, principal
|
||||
identities = _identity_search().search_identities(
|
||||
query,
|
||||
include_inactive=include_inactive,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
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,255 @@ 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
|
||||
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
|
||||
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,348 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.events import (
|
||||
EventActorRef,
|
||||
EventObjectRef,
|
||||
EventTenantRef,
|
||||
PlatformEvent,
|
||||
emit_platform_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,
|
||||
)
|
||||
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,
|
||||
"expired_relationships": len(expired_relationship_ids),
|
||||
"relationship_ids": expired_relationship_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"},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
__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,182 @@ 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
|
||||
)
|
||||
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",
|
||||
]
|
||||
|
||||
@@ -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:
|
||||
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),
|
||||
)
|
||||
.order_by(IdmOrganizationFunctionAssignment.created_at.asc())
|
||||
items = self._effective_assignment_items(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
effective_at=effective_at or utc_now(),
|
||||
identity_ids=requested,
|
||||
)
|
||||
if account_id is not None:
|
||||
query = query.filter(or_(IdmOrganizationFunctionAssignment.account_id.is_(None), IdmOrganizationFunctionAssignment.account_id == account_id))
|
||||
if tenant_id is not None:
|
||||
query = query.filter(IdmOrganizationFunctionAssignment.tenant_id == tenant_id)
|
||||
return tuple(_assignment_ref(item) for item in query.all())
|
||||
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)
|
||||
.filter(
|
||||
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,
|
||||
),
|
||||
)
|
||||
.order_by(IdmOrganizationFunctionAssignment.created_at.asc())
|
||||
)
|
||||
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
|
||||
)
|
||||
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)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,27 +2,70 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
||||
from govoplan_core.core.idm import CAPABILITY_IDM_DIRECTORY
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
)
|
||||
from govoplan_core.core.identity import (
|
||||
CAPABILITY_IDENTITY_DIRECTORY,
|
||||
CAPABILITY_IDENTITY_SEARCH,
|
||||
IdentityDirectory,
|
||||
)
|
||||
from govoplan_core.core.idm import (
|
||||
CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE,
|
||||
CAPABILITY_IDM_DIRECTORY,
|
||||
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
|
||||
CAPABILITY_IDM_RELATIONSHIPS,
|
||||
)
|
||||
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH
|
||||
from govoplan_core.core.policy import (
|
||||
CAPABILITY_POLICY_FUNCTION_ASSIGNMENT_GOVERNANCE,
|
||||
)
|
||||
from govoplan_core.core.workflows import CAPABILITY_WORKFLOW_ORCHESTRATION
|
||||
from govoplan_core.core.organizations import (
|
||||
CAPABILITY_ORGANIZATION_DIRECTORY,
|
||||
OrganizationDirectory,
|
||||
)
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
from govoplan_core.core.module_guards import persistent_table_uninstall_guard
|
||||
from govoplan_core.core.modules import (
|
||||
DocumentationCondition,
|
||||
DocumentationLink,
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
FrontendRoute,
|
||||
MigrationSpec,
|
||||
ModuleContext,
|
||||
ModuleInterfaceProvider,
|
||||
ModuleInterfaceRequirement,
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.core.search import SearchSourceProviderRegistration
|
||||
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_idm.backend.db import models as idm_models # noqa: F401 - populate metadata
|
||||
from govoplan_idm.backend.workflow_definitions import (
|
||||
function_assignment_workflow_definitions,
|
||||
)
|
||||
from govoplan_idm.backend.search_source import create_idm_search_source
|
||||
|
||||
|
||||
MODULE_VERSION = "0.1.15"
|
||||
|
||||
IDM_READ_SCOPES = (
|
||||
"idm:organization_assignment:read",
|
||||
"idm:organization_assignment:write",
|
||||
"idm:settings:read",
|
||||
"idm:function_change:read",
|
||||
"idm:function_request:create",
|
||||
"idm:function_grant:create",
|
||||
"idm:function_change:decide",
|
||||
"idm:function_change:admin",
|
||||
"idm:relationship:read",
|
||||
"idm:relationship:write",
|
||||
"organizations:function:assign",
|
||||
)
|
||||
|
||||
@@ -67,6 +110,41 @@ PERMISSIONS = (
|
||||
"Manage IDM settings",
|
||||
"Update IDM governance and assignment-change policy settings.",
|
||||
),
|
||||
_permission(
|
||||
"idm:function_change:read",
|
||||
"View function assignment changes",
|
||||
"View governed function requests, grants, decisions, and outcomes.",
|
||||
),
|
||||
_permission(
|
||||
"idm:function_request:create",
|
||||
"Request organization functions",
|
||||
"Request assignment to an eligible organization function.",
|
||||
),
|
||||
_permission(
|
||||
"idm:function_grant:create",
|
||||
"Propose organization function grants",
|
||||
"Bestow an organization function through its governed grant profile.",
|
||||
),
|
||||
_permission(
|
||||
"idm:function_change:decide",
|
||||
"Decide function assignment changes",
|
||||
"Approve, reject, or accept governed function assignment changes when eligible.",
|
||||
),
|
||||
_permission(
|
||||
"idm:function_change:admin",
|
||||
"Recover function assignment changes",
|
||||
"Inspect and recover blocked or failed function assignment workflows.",
|
||||
),
|
||||
_permission(
|
||||
"idm:relationship:read",
|
||||
"View typed identity relationships",
|
||||
"View typed groups and effective-dated identity relationships.",
|
||||
),
|
||||
_permission(
|
||||
"idm:relationship:write",
|
||||
"Manage typed identity relationships",
|
||||
"Create, change, revoke, and synchronize typed groups and identity relationships.",
|
||||
),
|
||||
)
|
||||
|
||||
ROLE_TEMPLATES = (
|
||||
@@ -80,6 +158,23 @@ ROLE_TEMPLATES = (
|
||||
"idm:organization_assignment:write",
|
||||
"idm:settings:read",
|
||||
"idm:settings:write",
|
||||
"idm:function_change:read",
|
||||
"idm:function_request:create",
|
||||
"idm:function_grant:create",
|
||||
"idm:function_change:decide",
|
||||
"idm:function_change:admin",
|
||||
"idm:relationship:read",
|
||||
"idm:relationship:write",
|
||||
),
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="idm_function_participant",
|
||||
name="IDM function participant",
|
||||
description="Request functions and participate in governed assignment decisions.",
|
||||
permissions=(
|
||||
"idm:function_change:read",
|
||||
"idm:function_request:create",
|
||||
"idm:function_change:decide",
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -93,28 +188,109 @@ def _route_factory(context: ModuleContext):
|
||||
|
||||
|
||||
def _idm_directory(context: ModuleContext) -> object:
|
||||
del context
|
||||
from govoplan_idm.backend.directory import SqlIdmDirectory
|
||||
|
||||
return SqlIdmDirectory()
|
||||
identities = context.registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY)
|
||||
organizations = context.registry.require_capability(CAPABILITY_ORGANIZATION_DIRECTORY)
|
||||
if not isinstance(identities, IdentityDirectory):
|
||||
raise RuntimeError(f"Invalid capability: {CAPABILITY_IDENTITY_DIRECTORY}")
|
||||
if not isinstance(organizations, OrganizationDirectory):
|
||||
raise RuntimeError(f"Invalid capability: {CAPABILITY_ORGANIZATION_DIRECTORY}")
|
||||
return SqlIdmDirectory(identities=identities, organizations=organizations)
|
||||
|
||||
|
||||
def _assignment_lifecycle(context: ModuleContext) -> object:
|
||||
from govoplan_idm.backend.assignment_lifecycle import (
|
||||
SqlIdmAssignmentLifecycle,
|
||||
)
|
||||
|
||||
return SqlIdmAssignmentLifecycle(registry=context.registry)
|
||||
|
||||
|
||||
def _relationship_directory(context: ModuleContext) -> object:
|
||||
from govoplan_idm.backend.relationships import SqlIdmRelationshipDirectory
|
||||
|
||||
identities = context.registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY)
|
||||
if not isinstance(identities, IdentityDirectory):
|
||||
raise RuntimeError(f"Invalid capability: {CAPABILITY_IDENTITY_DIRECTORY}")
|
||||
return SqlIdmRelationshipDirectory(identities=identities)
|
||||
|
||||
|
||||
manifest = ModuleManifest(
|
||||
id="idm",
|
||||
name="IDM",
|
||||
version="0.1.7",
|
||||
version=MODULE_VERSION,
|
||||
dependencies=("identity", "organizations"),
|
||||
optional_dependencies=("access", "audit"),
|
||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||
optional_dependencies=(
|
||||
"access",
|
||||
"audit",
|
||||
"notifications",
|
||||
"policy",
|
||||
"workflow_engine",
|
||||
"search",
|
||||
),
|
||||
optional_capabilities=(
|
||||
CAPABILITY_NOTIFICATIONS_DISPATCH,
|
||||
CAPABILITY_POLICY_FUNCTION_ASSIGNMENT_GOVERNANCE,
|
||||
CAPABILITY_WORKFLOW_ORCHESTRATION,
|
||||
),
|
||||
required_capabilities=(
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_IDENTITY_DIRECTORY,
|
||||
CAPABILITY_IDENTITY_SEARCH,
|
||||
CAPABILITY_ORGANIZATION_DIRECTORY,
|
||||
),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(
|
||||
name=CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
|
||||
version="0.1.8",
|
||||
),
|
||||
ModuleInterfaceProvider(
|
||||
name=CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE,
|
||||
version=MODULE_VERSION,
|
||||
),
|
||||
ModuleInterfaceProvider(
|
||||
name=CAPABILITY_IDM_RELATIONSHIPS,
|
||||
version="1.0.0",
|
||||
),
|
||||
ModuleInterfaceProvider(
|
||||
name="idm.function_assignment_changes",
|
||||
version="1.0.0",
|
||||
),
|
||||
),
|
||||
requires_interfaces=(
|
||||
ModuleInterfaceRequirement(
|
||||
name="search.source",
|
||||
version_min="1.0.0",
|
||||
version_max_exclusive="2.0.0",
|
||||
optional=True,
|
||||
),
|
||||
),
|
||||
permissions=PERMISSIONS,
|
||||
role_templates=ROLE_TEMPLATES,
|
||||
route_factory=_route_factory,
|
||||
search_sources=(
|
||||
SearchSourceProviderRegistration(
|
||||
id="idm.directory",
|
||||
factory=create_idm_search_source,
|
||||
),
|
||||
),
|
||||
nav_items=(NavItem(path="/idm", label="IDM", icon="users", required_any=IDM_READ_SCOPES, order=72),),
|
||||
frontend=FrontendModule(
|
||||
module_id="idm",
|
||||
package_name="@govoplan/idm-webui",
|
||||
routes=(FrontendRoute(path="/idm", component="IdmPage", required_any=IDM_READ_SCOPES, order=72),),
|
||||
nav_items=(NavItem(path="/idm", label="IDM", icon="users", required_any=IDM_READ_SCOPES, order=72),),
|
||||
view_surfaces=(
|
||||
ViewSurface(
|
||||
id="idm.action.view-function-assignments",
|
||||
module_id="idm",
|
||||
kind="action",
|
||||
label="View function assignments",
|
||||
order=40,
|
||||
),
|
||||
),
|
||||
),
|
||||
migration_spec=MigrationSpec(
|
||||
module_id="idm",
|
||||
@@ -125,13 +301,39 @@ manifest = ModuleManifest(
|
||||
persistent_table_uninstall_guard(
|
||||
idm_models.IdmOrganizationFunctionAssignment,
|
||||
idm_models.IdmTenantSettings,
|
||||
idm_models.IdmFunctionAssignmentChange,
|
||||
idm_models.IdmFunctionAssignmentChangeEvent,
|
||||
idm_models.IdmTypedGroup,
|
||||
idm_models.IdmIdentityRelationship,
|
||||
label="IDM",
|
||||
),
|
||||
),
|
||||
capability_factories={
|
||||
CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE: _assignment_lifecycle,
|
||||
CAPABILITY_IDM_DIRECTORY: _idm_directory,
|
||||
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS: _idm_directory,
|
||||
CAPABILITY_IDM_RELATIONSHIPS: _relationship_directory,
|
||||
},
|
||||
workflow_definitions=function_assignment_workflow_definitions(
|
||||
module_version=MODULE_VERSION,
|
||||
),
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="idm.search.directory",
|
||||
title="Search authorized IDM records",
|
||||
summary="Expose typed groups, effective-dated relationships, and organization-function assignments to permission-aware platform Search.",
|
||||
body=(
|
||||
"When Search is installed, IDM contributes bounded directory and assignment metadata without "
|
||||
"copying unrestricted provenance payloads. Every result is tenant-bounded and rechecks the current "
|
||||
"assignment or relationship read authority. Committed IDM lifecycle events update the derived "
|
||||
"index, while an operator rebuild reconciles records created before Search was enabled."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("tenant_admin", "access_admin", "operator"),
|
||||
related_modules=("search", "identity", "organizations"),
|
||||
order=25,
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="idm.organization_identity_bridge",
|
||||
title="Identity to organization bridge",
|
||||
@@ -154,14 +356,59 @@ manifest = ModuleManifest(
|
||||
body=(
|
||||
"Assignment links are high-impact because they can later feed access decisions. "
|
||||
"Tenants can enable recorded change requests for assignment create and update operations. "
|
||||
"A periodic worker emits one expiry event when a future-dated assignment elapses; the marker and event are committed together so retries remain idempotent. "
|
||||
"The legacy organizations:function:assign scope remains accepted for transition, while new role templates should grant idm:organization_assignment:write."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin",),
|
||||
audience=("tenant_admin", "access_admin", "operator"),
|
||||
related_modules=("identity", "organizations", "access", "audit", "policy"),
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="IDM workspace",
|
||||
href="/idm",
|
||||
kind="runtime",
|
||||
),
|
||||
DocumentationLink(
|
||||
label="IDM settings API",
|
||||
href="/api/v1/idm/settings",
|
||||
kind="api",
|
||||
),
|
||||
),
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"help_contexts": [
|
||||
"idm.governance.settings",
|
||||
"idm.assignment.change-request",
|
||||
"idm.assignment.emergency-override",
|
||||
"idm.function-change.request",
|
||||
"idm.function-change.grant",
|
||||
"idm.function-change.decision",
|
||||
],
|
||||
"consequence_classes": {
|
||||
"governance_settings": "Changes whether direct assignment mutations require approved change evidence.",
|
||||
"emergency_override": "Bypasses the normal governed request or grant path and requires retained reason and evidence.",
|
||||
"function_decision": "Advances or terminates a governed change and retains actor, comment, policy, and workflow evidence.",
|
||||
},
|
||||
},
|
||||
order=27,
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="idm.reference.typed-relationships",
|
||||
title="Typed groups and effective relationships",
|
||||
summary="IDM keeps business group membership separate from identity lifecycle status.",
|
||||
body=(
|
||||
"Typed groups and identity relationships are tenant-scoped, effective-dated facts. "
|
||||
"Current, future, expired, and revoked links remain explainable, including external "
|
||||
"directory source revisions and provenance. Consumers such as Distribution Lists use "
|
||||
"the IDM relationship capability and never infer application permissions from membership."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("tenant_admin", "operator", "module_admin"),
|
||||
related_modules=("identity", "organizations", "dist_lists"),
|
||||
order=28,
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="idm.workflow.assign-function-to-identity",
|
||||
title="Assign an organization function to an identity",
|
||||
@@ -176,8 +423,102 @@ manifest = ModuleManifest(
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("tenant_admin", "access_admin", "operator", "user"),
|
||||
related_modules=("identity", "organizations", "access"),
|
||||
conditions=(
|
||||
DocumentationCondition(any_scopes=IDM_READ_SCOPES),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="IDM assignments",
|
||||
href="/idm",
|
||||
kind="runtime",
|
||||
),
|
||||
DocumentationLink(
|
||||
label="Organization function assignments API",
|
||||
href="/api/v1/idm/organization-function-assignments",
|
||||
kind="api",
|
||||
),
|
||||
),
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"help_contexts": [
|
||||
"idm.route.assignments",
|
||||
"idm.action.view-function-assignments",
|
||||
"idm.blocker.permission",
|
||||
"idm.blocker.no-functions",
|
||||
"idm.blocker.identity-search",
|
||||
],
|
||||
},
|
||||
order=28,
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="idm.reference.fields-and-consequences",
|
||||
title="IDM assignment fields and consequences",
|
||||
summary=(
|
||||
"Reference for direct assignments, delegation, acting-for, "
|
||||
"effective dates, governed changes, evidence, and retention."
|
||||
),
|
||||
body=(
|
||||
"Identity and account select who receives the institutional fact; "
|
||||
"function and unit are owned by Organizations. Source distinguishes "
|
||||
"direct, delegated, acting-for, directory, governance, and system facts. "
|
||||
"Delegation and acting-for require a valid source assignment and the "
|
||||
"corresponding function permission. Subunit scope broadens the fact's "
|
||||
"organizational reach. Deactivation and expiry preserve provenance while "
|
||||
"removing the assignment from effective resolution. Governed request and "
|
||||
"grant decisions retain actor, policy, workflow revision, comments, and "
|
||||
"evidence. An emergency override is not the normal process and must carry "
|
||||
"an explicit reason. An IDM assignment alone never grants application "
|
||||
"permissions; Access requires an explicit mapping."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("tenant_admin", "access_admin", "operator", "user"),
|
||||
related_modules=("identity", "organizations", "access", "policy", "audit", "workflow_engine"),
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="Function assignment workflows",
|
||||
href="docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"help_contexts": [
|
||||
"idm.field.identity",
|
||||
"idm.field.account",
|
||||
"idm.field.function",
|
||||
"idm.field.source",
|
||||
"idm.field.delegation",
|
||||
"idm.field.acting-for",
|
||||
"idm.field.subunits",
|
||||
"idm.field.effective-dates",
|
||||
"idm.field.justification",
|
||||
"idm.field.evidence",
|
||||
"idm.field.retention",
|
||||
],
|
||||
"consequence_classes": {
|
||||
"assignment": "Changes the effective institutional function fact consumed by optional downstream capabilities.",
|
||||
"deactivate_or_expire": "Removes the fact from effective resolution while retaining provenance and lifecycle evidence.",
|
||||
"delegation": "Creates a bounded derived assignment that remains tied to its source assignment.",
|
||||
"acting_for": "Allows a bounded account to act in place of a source assignment when Organizations permits it.",
|
||||
"retention": "Changes how long detailed assignment-change evidence remains available.",
|
||||
},
|
||||
},
|
||||
order=29,
|
||||
),
|
||||
),
|
||||
architecture=declared_module_architecture(
|
||||
layer="institutional_foundation",
|
||||
kind="foundation",
|
||||
maturity="vertical_slice",
|
||||
documentation_ref="docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md",
|
||||
test_ref="tests/test_assignment_workflow.py",
|
||||
known_limits=("External directory provisioning and all authority-specific grant workflows are not reference-ready.",),
|
||||
owned_concepts=("function assignment", "assignment delegation", "acting-for assignment", "assignment request", "typed group", "identity relationship"),
|
||||
non_owned_concepts=("identity", "organization function", "application role", "workflow runtime"),
|
||||
recovery_docs=("docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md", "docs/TYPED_RELATIONSHIPS.md"),
|
||||
security_docs=("docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md", "docs/TYPED_RELATIONSHIPS.md"),
|
||||
operations_docs=("README.md",),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
+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")
|
||||
@@ -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,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,267 @@
|
||||
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_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,258 @@
|
||||
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_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_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,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,375 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
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 FunctionAssignmentGovernanceDecision
|
||||
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,
|
||||
IdmOrganizationFunctionAssignment,
|
||||
IdmTenantSettings,
|
||||
)
|
||||
from govoplan_idm.backend.function_assignment_changes import (
|
||||
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")),
|
||||
"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,
|
||||
)
|
||||
|
||||
|
||||
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__,
|
||||
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)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_idm.backend.manifest import manifest
|
||||
|
||||
|
||||
class IdmInterfaceDocumentationContractTests(unittest.TestCase):
|
||||
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)
|
||||
|
||||
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("deactivate_or_expire", reference.metadata["consequence_classes"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
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(
|
||||
"b1c2d3e4f5a6",
|
||||
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_")
|
||||
},
|
||||
)
|
||||
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,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.15",
|
||||
"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.15",
|
||||
"@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,29 @@
|
||||
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 patterns = source("../src/features/interfacePatterns.ts");
|
||||
const moduleSource = source("../src/module.ts");
|
||||
const translations = source("../src/i18n/generatedTranslations.ts");
|
||||
|
||||
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");
|
||||
|
||||
console.log("IDM surfaces satisfy the recorded interface pattern-language contract.");
|
||||
|
||||
+144
-12
@@ -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;
|
||||
@@ -70,6 +70,10 @@ export type OrganizationFunctionAssignmentItem = {
|
||||
|
||||
export type OrganizationFunctionAssignmentList = {
|
||||
assignments: OrganizationFunctionAssignmentItem[];
|
||||
total?: number;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
pages?: number;
|
||||
};
|
||||
|
||||
export type IdmSettings = {
|
||||
@@ -95,24 +99,115 @@ 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;
|
||||
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,7 +215,7 @@ 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> {
|
||||
@@ -135,7 +230,7 @@ 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 +238,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,497 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
||||
import { Check, Eye, Plus, RotateCcw, Undo2, X } from "lucide-react";
|
||||
import {
|
||||
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"].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",
|
||||
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
|
||||
open={createOpen}
|
||||
title="Start governed function change"
|
||||
className="admin-dialog admin-dialog-wide 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></>}
|
||||
>
|
||||
<form id="idm-change-create" className="admin-form-grid two-columns" 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>
|
||||
</form>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
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="admin-dialog admin-dialog-wide 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>
|
||||
<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);
|
||||
}
|
||||
+209
-69
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
||||
import { Edit3, Plus, RefreshCw } from "lucide-react";
|
||||
import {
|
||||
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,14 @@ import {
|
||||
type OrganizationModel,
|
||||
type OrganizationUnitItem
|
||||
} from "../api/idm";
|
||||
import FunctionAssignmentChangesPanel from "./FunctionAssignmentChangesPanel";
|
||||
import {
|
||||
IDM_DOCUMENTATION,
|
||||
IDM_FIELD_DOCUMENTATION,
|
||||
IDM_GOVERNANCE_DOCUMENTATION,
|
||||
IDM_INTERFACE_I18N,
|
||||
idmDisabledReason
|
||||
} from "./interfacePatterns";
|
||||
|
||||
type IdmPageProps = {
|
||||
settings: ApiSettings;
|
||||
@@ -49,6 +63,8 @@ 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 = {
|
||||
@@ -88,7 +104,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 +125,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,23 +143,12 @@ 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;
|
||||
return {
|
||||
@@ -221,6 +233,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 +264,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,6 +275,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
const [success, setSuccess] = useState("");
|
||||
const initialQuery = useMemo(() => idmInitialQuery(), []);
|
||||
const appliedInitialQueryRef = useRef(false);
|
||||
const { requestDiscard } = useUnsavedChanges();
|
||||
|
||||
const canManage = hasScope(auth, "idm:organization_assignment:write") || hasScope(auth, "organizations:function:assign");
|
||||
const canSearchIdentities = canManage || hasScope(auth, "idm:organization_identity:read") || hasScope(auth, "admin:users:read");
|
||||
@@ -262,6 +286,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 +327,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;
|
||||
|
||||
@@ -324,7 +349,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);
|
||||
}
|
||||
}
|
||||
@@ -409,7 +436,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 +476,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 +488,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 +506,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,18 +602,22 @@ 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>
|
||||
@@ -578,7 +625,14 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
<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">
|
||||
<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>
|
||||
@@ -586,25 +640,53 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
|
||||
{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>}
|
||||
{!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}
|
||||
/>
|
||||
)}
|
||||
{!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} />}
|
||||
>
|
||||
<form className="admin-form-grid two-columns" 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 +697,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,8 +706,13 @@ 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="button-row compact-actions wide">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={!canManageSettings || busy || !hasDirtySettingsDraft}
|
||||
disabledReason={idmDisabledReason(false, busy, canManageSettings) ?? (!hasDirtySettingsDraft ? IDM_INTERFACE_I18N.noChanges : undefined)}
|
||||
>
|
||||
i18n:govoplan-idm.save_settings.4602c430
|
||||
</Button>
|
||||
</div>
|
||||
@@ -633,7 +720,14 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
</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} />}>
|
||||
<FunctionAssignmentChangesPanel
|
||||
settings={settings}
|
||||
auth={auth}
|
||||
model={model}
|
||||
identities={identityOptions}
|
||||
/>
|
||||
|
||||
<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}
|
||||
@@ -648,6 +742,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
</LoadingFrame>
|
||||
{renderAssignmentDialog()}
|
||||
</div>
|
||||
</PageScrollViewport>
|
||||
);
|
||||
|
||||
function renderAssignmentDialog() {
|
||||
@@ -656,20 +751,26 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
<Dialog
|
||||
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"
|
||||
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">
|
||||
<form id={formId} className="admin-form-grid two-columns" 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 +778,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 +786,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 +815,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 +823,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,22 +832,57 @@ 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>}
|
||||
@@ -755,3 +891,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function draftKey(value: unknown): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
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_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;
|
||||
}
|
||||
|
||||
@@ -62,7 +62,96 @@ 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"
|
||||
},
|
||||
de: {
|
||||
"i18n:govoplan-idm.account.2b2936f8": "Konto",
|
||||
@@ -125,6 +214,95 @@ 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"
|
||||
}
|
||||
};
|
||||
|
||||
+20
-17
@@ -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";
|
||||
|
||||
@@ -20,23 +21,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: () => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.href = `/idm?function_id=${encodeURIComponent(item.id)}`;
|
||||
}
|
||||
}
|
||||
},
|
||||
"i18n:govoplan-idm.assignments.a0d19ec5"
|
||||
)
|
||||
onClick: ({ function: item }) => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.href = `/idm?function_id=${encodeURIComponent(item.id)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -44,9 +38,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",
|
||||
|
||||
+80
-24
@@ -27,16 +27,6 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.idm-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.idm-form-grid .wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.idm-check-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
@@ -51,18 +41,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 +67,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