27 Commits
Author SHA1 Message Date
zemion 9bcd2de587 Release v0.1.18
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 21:07:45 +02:00
zemion 85b5f80c59 Release v0.1.17
Module Package Release / publish-packages (push) Successful in 13s
2026-08-05 20:34:04 +02:00
zemion 6e9393c9c7 Release v0.1.16
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 19:52:11 +02:00
zemion bd72a3f277 Release v0.1.15
Module Package Release / publish-packages (push) Successful in 12s
2026-08-04 15:18:12 +02:00
zemion 09a4b9ce60 Make package publication retries hash-safe 2026-08-04 14:32:19 +02:00
zemion 2c83db3b49 Harden module package publication 2026-08-04 14:02:40 +02:00
zemion bbf9c824f1 Add protected package release workflow 2026-08-04 04:14:05 +02:00
zemion ad37b030e2 Add native permission-aware IDM search source 2026-08-04 03:03:27 +02:00
zemion d8643174d7 Migrate IDM interface patterns 2026-08-03 12:36:49 +02:00
zemion 6f6c45f6e2 Implement typed effective identity relationships 2026-08-02 14:44:42 +02:00
zemion 820ea5eeab refactor: use shared JSON mutation clients 2026-08-02 05:29:50 +02:00
zemion 43feca0244 docs: declare institutional architecture boundary 2026-08-01 17:48:36 +02:00
zemion c14719d55a Implement governed function assignment workflows 2026-07-31 19:40:27 +02:00
zemion f025b0c25b Complete effective assignment expiry lifecycle 2026-07-31 18:07:36 +02:00
zemion d1c5738ca8 docs: define governed function assignment workflows 2026-07-31 15:07:53 +02:00
zemion ebb3b82cf8 Align IDM WebUI runtime dependencies 2026-07-31 02:48:56 +02:00
zemion 94c1d08519 fix: invalidate auth context after IDM changes 2026-07-29 19:24:07 +02:00
zemion 15559a8fdf refactor: isolate IDM assignment transitions 2026-07-29 18:57:28 +02:00
zemion 01c1f7e13a feat: expose effective function incumbency contracts 2026-07-29 14:16:29 +02:00
zemion 5a8138ea03 fix: make IDM page scrollable 2026-07-28 22:50:11 +02:00
zemion 906879caf1 Declare organization action View surface 2026-07-28 21:04:54 +02:00
zemion dd1937a3af refactor(webui): describe function assignment action 2026-07-21 13:51:52 +02:00
zemion a0c9e59c34 fix(webui): require Core 0.1.9 for table actions 2026-07-21 13:46:20 +02:00
zemion 853125c80c refactor(webui): use central form breakpoint 2026-07-21 13:32:10 +02:00
zemion d098e3e9dd refactor(webui): use central form layout 2026-07-21 12:01:24 +02:00
zemion d2e34b323a refactor(idm): consume identity and organization contracts 2026-07-20 20:03:11 +02:00
zemion 74652686ca intermittent commit 2026-07-14 13:22:11 +02:00
43 changed files with 9642 additions and 352 deletions
+270
View File
@@ -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
+16
View File
@@ -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.
+26
View File
@@ -20,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,
@@ -73,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
@@ -84,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
@@ -128,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:
+135
View File
@@ -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.
+51
View File
@@ -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.
+51
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/idm-webui",
"version": "0.1.8",
"version": "0.1.18",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
@@ -19,14 +19,14 @@
"LICENSE"
],
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"@vitejs/plugin-react": "^4.3.4",
"@govoplan/core-webui": "^0.1.18",
"@vitejs/plugin-react": "^5.2.0",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9",
"typescript": "^5.7.2",
"vite": "^6.0.6"
"vite": "^7.3.6"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
+4 -4
View File
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-idm"
version = "0.1.8"
version = "0.1.18"
description = "GovOPlaN identity management bridge module."
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.8",
"govoplan-identity>=0.1.8",
"govoplan-organizations>=0.1.8",
"govoplan-core>=0.1.18",
"govoplan-identity>=0.1.18",
"govoplan-organizations>=0.1.18",
]
[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"]
+384 -162
View File
@@ -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)
+265
View File
@@ -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",
]
+311 -2
View File
@@ -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",
]
+260 -37
View File
@@ -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
+350 -8
View File
@@ -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.18"
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.8",
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",
@@ -170,14 +417,109 @@ manifest = ModuleManifest(
"Create the unit and function in Organizations first. Make sure the person and account exist in Identity. "
"Then create the assignment in IDM. Direct assignments state who holds the function. Delegated assignments require a source assignment and a delegable function. "
"Acting-for assignments require a source assignment, an acting account, and a function that allows acting in place. "
"Access maps accepted function facts to roles and rights; without such a mapping, the assignment is recorded but does not grant application permissions."
"Access maps accepted function facts to roles and rights; without such a mapping, the assignment is recorded but does not grant application permissions. "
"The assignment workspace uses the available application width so governance controls and assignment data remain visible together."
),
layer="configured",
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",),
),
)
@@ -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,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,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")
@@ -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")
+386
View File
@@ -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",
]
+367
View File
@@ -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"]
+267
View File
@@ -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()
+258
View File
@@ -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()
+336
View File
@@ -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()
+375
View File
@@ -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()
+58
View File
@@ -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()
+220
View File
@@ -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()
+118
View File
@@ -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
View File
@@ -1,11 +1,14 @@
{
"name": "@govoplan/idm-webui",
"version": "0.1.8",
"version": "0.1.18",
"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.8",
"@vitejs/plugin-react": "^4.3.4",
"@govoplan/core-webui": "^0.1.18",
"@vitejs/plugin-react": "^5.2.0",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9",
"typescript": "^5.7.2",
"vite": "^6.0.6"
"vite": "^7.3.6"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
@@ -0,0 +1,30 @@
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");
const styles = source("../src/styles/idm.css");
assert(page.includes("ActionBlockerHint") && page.includes("DocumentationHelpLink"), "IDM permissions and prerequisites expose actionable help");
assert(page.includes("assignmentBaseline") && page.includes("draftKey(assignmentDraft) !== draftKey(assignmentBaseline)"), "Existing assignments compare against their loaded draft baseline");
assert(page.includes("requestDiscard(() => void loadData())") && page.includes("closeAssignmentEditor"), "Reload and dialog close preserve actual dirty drafts");
assert(page.includes("disabledReason") && changes.includes("disabledReason"), "Unavailable assignment and decision actions explain their state");
assert(changes.includes("ConfirmDialog") && changes.includes("confirm_function_action"), "Governed function decisions require explicit shared confirmation");
assert(changes.includes("useUnsavedDraftGuard") && changes.includes("usePlatformLanguage"), "Governed editors protect drafts and format dates with the platform locale");
assert(patterns.includes('topicId: "idm.reference.fields-and-consequences"'), "IDM fields use manifest-backed consequence help");
assert(moduleSource.includes('version: "0.1.8"') && moduleSource.includes('label: "i18n:govoplan-idm.view_assignments.2d40d6a5"'), "WebUI metadata matches the module release and localizes its action surface");
assert(translations.includes('"i18n:govoplan-idm.state_awaiting_authority"'), "Governed states and decisions are in the translation catalogue");
assert(!page.includes("window.confirm") && !changes.includes("window.confirm"), "IDM does not use browser-native consequential confirmation");
assert(styles.includes(".idm-page") && !/\.idm-page\s*\{[^}]*max-width/s.test(styles), "The IDM workspace uses the full shared application width");
console.log("IDM surfaces satisfy the recorded interface pattern-language contract.");
+144 -12
View File
@@ -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
View File
@@ -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);
}
+48
View File
@@ -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;
}
+180 -2
View File
@@ -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
View File
@@ -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 -25
View File
@@ -2,7 +2,6 @@
display: grid;
gap: 18px;
width: 100%;
max-width: 1480px;
}
.idm-heading {
@@ -27,16 +26,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 +40,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 +66,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;
}
}