Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0eda35195 | ||
|
|
65ff14a613 | ||
|
|
dc99a40384 | ||
|
|
2c6ee041b9 | ||
|
|
091ad975aa | ||
|
|
e9e8783cbd | ||
|
|
9bcd2de587 | ||
|
|
85b5f80c59 | ||
|
|
6e9393c9c7 |
@@ -91,6 +91,15 @@ planning.
|
|||||||
The WebUI exposed by this repository is a normal module UI at `/idm`. It is the
|
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.
|
editing surface for identity-to-organization-function assignment links.
|
||||||
|
|
||||||
|
The module also publishes `privacy.dsar.idm`. The provider finds tenant-scoped
|
||||||
|
function assignments, typed relationships, governed assignment changes, and
|
||||||
|
lifecycle events using corroborated identity/account selectors. Automated
|
||||||
|
exports minimize other candidates and actors and exclude opaque settings,
|
||||||
|
properties, provenance, external source references, policy/workflow internals,
|
||||||
|
idempotency material, evidence payloads, comments, and event details. Assignment
|
||||||
|
or relationship changes require the normal governed IDM lifecycle; immutable
|
||||||
|
change and event evidence is retained with an explicit reason.
|
||||||
|
|
||||||
Its interface archetypes, consequence classes, contextual-help contract, and
|
Its interface archetypes, consequence classes, contextual-help contract, and
|
||||||
accessibility evidence are recorded in
|
accessibility evidence are recorded in
|
||||||
[`docs/INTERFACE_PATTERN_MIGRATION.md`](docs/INTERFACE_PATTERN_MIGRATION.md).
|
[`docs/INTERFACE_PATTERN_MIGRATION.md`](docs/INTERFACE_PATTERN_MIGRATION.md).
|
||||||
|
|||||||
@@ -35,6 +35,34 @@ 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
|
it. Recipient acceptance can be required or waived only by an explicit policy
|
||||||
with recorded provenance.
|
with recorded provenance.
|
||||||
|
|
||||||
|
## Delegation, Substitution, And Acting In Place
|
||||||
|
|
||||||
|
These modes are explicit assignment sources; ordinary group membership never
|
||||||
|
creates them:
|
||||||
|
|
||||||
|
- A **delegated assignment** is a bounded substitution. For example, a registry
|
||||||
|
lead may delegate the same delegable function to a deputy until Friday. The
|
||||||
|
deputy acts as themself, and Access receives both the derived assignment ID
|
||||||
|
and its source assignment ID.
|
||||||
|
- An **acting-for assignment** is a bounded representation context. For example,
|
||||||
|
an assistant may select an active acting context for the represented function
|
||||||
|
holder. Access records the real account, the selected assignment, and the
|
||||||
|
represented account on the session and in audit evidence. No acting-for
|
||||||
|
authority is effective until that exact context is selected.
|
||||||
|
- A **direct assignment** is the holder's own function fact and has no source
|
||||||
|
assignment or represented account.
|
||||||
|
|
||||||
|
The Organizations function must permit the requested mode. The source and
|
||||||
|
derived assignments must belong to the same tenant and function, and the
|
||||||
|
source must be current and active. An actor also needs the assignment-write
|
||||||
|
scope; where a governance profile is enabled, Policy must authorize the
|
||||||
|
request/grant or an administrator must use the recorded emergency-override
|
||||||
|
path. Validity windows make substitutions expire automatically. Deactivation
|
||||||
|
revokes an assignment without deleting its provenance. IDM emits changed,
|
||||||
|
revoked, and expired lifecycle events and writes the normal assignment audit
|
||||||
|
record; effective-directory reads immediately exclude inactive, future,
|
||||||
|
expired, or source-invalid derived assignments.
|
||||||
|
|
||||||
## Grant Profiles
|
## Grant Profiles
|
||||||
|
|
||||||
The first policy profiles are:
|
The first policy profiles are:
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ IDM owns effective identity-to-function facts and their governed lifecycle.
|
|||||||
| Function request/grant list | Governed work queue | Start or inspect a function change | Shared grid/loading/status/action slot, localized state and workflow vocabulary |
|
| 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 |
|
| 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 |
|
| Function-change detail | Decision and provenance record | Approve, reject, accept, request changes, withdraw, respond, or recover | Shared confirmation, available-action contract, retained actor/policy/workflow/history evidence |
|
||||||
|
| Typed-group directory | Repeated administration | Create, edit, activate, or deactivate a tenant business group | Shared grid/card/dialog/action bar, optimistic revision, source and provenance fields, exact contextual help |
|
||||||
|
| Effective relationship directory | Effective-dated administration | Create, change, expire, or irreversibly revoke a business relationship | Searchable identity/group selectors, four distinct lifecycle states, dirty guard, reasoned destructive confirmation |
|
||||||
|
| Membership inspector | Point-in-time evidence reader | Resolve included and excluded identities for a group, time, and relationship kind | Shared resolver endpoint, localized time, decision codes, identity lifecycle explanation |
|
||||||
| `idm.action.view-function-assignments` | Contextual cross-module action | Navigate with function context | Declared capability surface, permission guard, no Organizations-private import |
|
| `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
|
## Consequence And Availability Rules
|
||||||
@@ -32,6 +35,14 @@ IDM owns effective identity-to-function facts and their governed lifecycle.
|
|||||||
the governed record.
|
the governed record.
|
||||||
- Deactivation and expiry remove a fact from effective resolution while
|
- Deactivation and expiry remove a fact from effective resolution while
|
||||||
retaining provenance and lifecycle evidence.
|
retaining provenance and lifecycle evidence.
|
||||||
|
- Future, active, expired, and revoked relationships remain visually distinct.
|
||||||
|
Revocation requires a reason, acts immediately, and leaves the record
|
||||||
|
immutable; later reuse requires a new relationship.
|
||||||
|
- Typed relationship managers use searchable Identity and group references.
|
||||||
|
External source, revision, properties, and provenance remain inspectable and
|
||||||
|
editable under optimistic concurrency.
|
||||||
|
- Membership inspection uses the production resolution capability and shows
|
||||||
|
excluded decisions instead of presenting only a flattened member list.
|
||||||
- Missing permission, identity search, and organization functions identify the
|
- Missing permission, identity search, and organization functions identify the
|
||||||
required action, responsible administrator, and destination.
|
required action, responsible administrator, and destination.
|
||||||
|
|
||||||
@@ -43,9 +54,9 @@ dialogs retain focus containment and return behavior; stable grid actions remain
|
|||||||
keyboard reachable. Existing responsive CSS collapses summaries and histories
|
keyboard reachable. Existing responsive CSS collapses summaries and histories
|
||||||
to one column at narrow widths.
|
to one column at narrow widths.
|
||||||
|
|
||||||
English and German catalogues cover route metadata, assignment fields, governed
|
English and German catalogues cover route metadata, assignment and relationship
|
||||||
states, workflow steps, decisions, confirmations, and accessible labels. Dates
|
fields, governed states, workflow steps, membership decisions, confirmations,
|
||||||
follow the selected platform locale. Manifest topics provide stable route,
|
and accessible labels. Dates follow the selected platform locale. Manifest
|
||||||
field, blocker, workflow, and consequence references without importing optional
|
topics provide stable route, field, blocker, workflow, lifecycle, provenance,
|
||||||
Policy, Audit, Notifications, Access, or Workflow Engine implementations.
|
and consequence references without importing optional Policy, Audit,
|
||||||
|
Notifications, Access, or Workflow Engine implementations.
|
||||||
|
|||||||
@@ -41,6 +41,26 @@ The existing IDM lifecycle worker claims an elapsed relationship and records its
|
|||||||
event marker in the same transaction. Repeated or concurrent sweeps therefore do
|
event marker in the same transaction. Repeated or concurrent sweeps therefore do
|
||||||
not publish duplicate expiry events.
|
not publish duplicate expiry events.
|
||||||
|
|
||||||
|
## Administration workspace
|
||||||
|
|
||||||
|
The `/idm` workspace exposes typed groups and effective relationships to users
|
||||||
|
with `idm:relationship:read`. Mutations require `idm:relationship:write`; the
|
||||||
|
write permission also permits the identity search used by the subject and
|
||||||
|
related-identity selectors without broadening read-only relationship access.
|
||||||
|
|
||||||
|
Group and relationship editors retain external provider, resource, revision,
|
||||||
|
property, and provenance values. Updates carry the loaded optimistic revision,
|
||||||
|
so a stale editor receives a conflict instead of overwriting another
|
||||||
|
administrator's change. The relationship directory distinguishes future,
|
||||||
|
active, expired, and revoked states from the validity window and lifecycle
|
||||||
|
record. Revocation requires a reason, takes effect immediately, and leaves the
|
||||||
|
record immutable as evidence.
|
||||||
|
|
||||||
|
The membership inspector accepts an effective time and one or more relationship
|
||||||
|
kinds. It shows both included and excluded decisions with stable reason codes
|
||||||
|
and identity lifecycle state. This is the same resolution contract used by
|
||||||
|
downstream consumers; it is not a preview with different semantics.
|
||||||
|
|
||||||
## Distribution Lists
|
## Distribution Lists
|
||||||
|
|
||||||
When Distribution Lists is enabled, an `idm_group` entry resolves through this
|
When Distribution Lists is enabled, an `idm_group` entry resolves through this
|
||||||
@@ -48,4 +68,3 @@ capability. Every effective identity becomes an internal-mail candidate when an
|
|||||||
active linked account exists. Every rejected relationship remains visible in the
|
active linked account exists. Every rejected relationship remains visible in the
|
||||||
expansion evidence with a stable reason code. Distribution Lists stores only the
|
expansion evidence with a stable reason code. Distribution Lists stores only the
|
||||||
provider reference and frozen expansion evidence, not IDM records.
|
provider reference and frozen expansion evidence, not IDM records.
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/idm-webui",
|
"name": "@govoplan/idm-webui",
|
||||||
"version": "0.1.15",
|
"version": "0.1.18",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "webui/src/index.ts",
|
"main": "webui/src/index.ts",
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
"LICENSE"
|
"LICENSE"
|
||||||
],
|
],
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.15",
|
"@govoplan/core-webui": "^0.1.18",
|
||||||
"@vitejs/plugin-react": "^5.2.0",
|
"@vitejs/plugin-react": "^5.2.0",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": ">=19.2.7 <20",
|
"react": ">=19.2.7 <20",
|
||||||
|
|||||||
+4
-4
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-idm"
|
name = "govoplan-idm"
|
||||||
version = "0.1.15"
|
version = "0.1.19"
|
||||||
description = "GovOPlaN identity management bridge module."
|
description = "GovOPlaN identity management bridge module."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.15",
|
"govoplan-core>=0.1.18",
|
||||||
"govoplan-identity>=0.1.15",
|
"govoplan-identity>=0.1.18",
|
||||||
"govoplan-organizations>=0.1.15",
|
"govoplan-organizations>=0.1.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ router = APIRouter(prefix="/idm", tags=["idm"])
|
|||||||
ORGANIZATION_IDENTITY_READ_SCOPES = (
|
ORGANIZATION_IDENTITY_READ_SCOPES = (
|
||||||
"idm:organization_identity:read",
|
"idm:organization_identity:read",
|
||||||
"idm:organization_assignment:write",
|
"idm:organization_assignment:write",
|
||||||
|
"idm:relationship:write",
|
||||||
"organizations:function:assign",
|
"organizations:function:assign",
|
||||||
"admin:users:read",
|
"admin:users:read",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,749 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import or_
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.dsar import (
|
||||||
|
DsarErasureActionRef,
|
||||||
|
DsarExecutionResultRef,
|
||||||
|
DsarRecordRef,
|
||||||
|
DsarSubjectRef,
|
||||||
|
dsar_capability_name,
|
||||||
|
)
|
||||||
|
from govoplan_idm.backend.db.models import (
|
||||||
|
IdmFunctionAssignmentChange,
|
||||||
|
IdmFunctionAssignmentChangeEvent,
|
||||||
|
IdmIdentityRelationship,
|
||||||
|
IdmOrganizationFunctionAssignment,
|
||||||
|
IdmTypedGroup,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
IDM_DSAR_CAPABILITY = dsar_capability_name("idm")
|
||||||
|
_MAX_RECORDS = 5_000
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _SubjectSelectors:
|
||||||
|
account_id: str | None
|
||||||
|
identity_id: str | None
|
||||||
|
references: Mapping[str, str]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_canonical_selector(self) -> bool:
|
||||||
|
return bool(self.account_id or self.identity_id)
|
||||||
|
|
||||||
|
|
||||||
|
class IdmDsarProvider:
|
||||||
|
provider_id = "idm"
|
||||||
|
module_id = "idm"
|
||||||
|
|
||||||
|
def search_subject(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
) -> Sequence[DsarRecordRef]:
|
||||||
|
db = _session(session)
|
||||||
|
selectors = _subject_selectors(subject)
|
||||||
|
if selectors is None:
|
||||||
|
return ()
|
||||||
|
|
||||||
|
assignments = _matching_assignments(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
selectors=selectors,
|
||||||
|
)
|
||||||
|
relationships = _matching_relationships(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
selectors=selectors,
|
||||||
|
)
|
||||||
|
changes = _matching_changes(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
selectors=selectors,
|
||||||
|
)
|
||||||
|
if _direct_reference_conflicts(
|
||||||
|
selectors,
|
||||||
|
assignments=assignments,
|
||||||
|
relationships=relationships,
|
||||||
|
changes=changes,
|
||||||
|
):
|
||||||
|
return ()
|
||||||
|
|
||||||
|
records: list[DsarRecordRef] = []
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
|
||||||
|
def append(record: DsarRecordRef) -> None:
|
||||||
|
key = (record.resource_type, record.resource_id)
|
||||||
|
if key in seen:
|
||||||
|
return
|
||||||
|
if len(records) >= _MAX_RECORDS:
|
||||||
|
raise ValueError(
|
||||||
|
"IDM DSAR result limit exceeded; narrow the subject selectors."
|
||||||
|
)
|
||||||
|
seen.add(key)
|
||||||
|
records.append(record)
|
||||||
|
|
||||||
|
for assignment in assignments:
|
||||||
|
match_fields = _assignment_match_fields(assignment, selectors)
|
||||||
|
append(
|
||||||
|
_record(
|
||||||
|
"idm_function_assignment",
|
||||||
|
assignment.id,
|
||||||
|
"institutional_function_fact",
|
||||||
|
"IDM organization-function assignment",
|
||||||
|
{
|
||||||
|
"match_fields": match_fields,
|
||||||
|
"identity_id": (
|
||||||
|
assignment.identity_id
|
||||||
|
if "identity_id" in match_fields
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"account_id": (
|
||||||
|
assignment.account_id
|
||||||
|
if "account_id" in match_fields
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"function_id": assignment.function_id,
|
||||||
|
"organization_unit_id": assignment.organization_unit_id,
|
||||||
|
"applies_to_subunits": assignment.applies_to_subunits,
|
||||||
|
"source": assignment.source,
|
||||||
|
"has_delegated_source": bool(
|
||||||
|
assignment.delegated_from_assignment_id
|
||||||
|
),
|
||||||
|
"acting_for_account_id": (
|
||||||
|
assignment.acting_for_account_id
|
||||||
|
if "acting_for_account_id" in match_fields
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"valid_from": _iso(assignment.valid_from),
|
||||||
|
"valid_until": _iso(assignment.valid_until),
|
||||||
|
"expired_event_at": _iso(assignment.expired_event_at),
|
||||||
|
"is_active": assignment.is_active,
|
||||||
|
},
|
||||||
|
observed_at=assignment.updated_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
group_ids = {
|
||||||
|
row.target_group_id
|
||||||
|
for row in relationships
|
||||||
|
if row.target_group_id is not None
|
||||||
|
}
|
||||||
|
groups = {
|
||||||
|
row.id: row
|
||||||
|
for row in _rows_by_ids(
|
||||||
|
db,
|
||||||
|
IdmTypedGroup,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
ids=group_ids,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for group in groups.values():
|
||||||
|
append(
|
||||||
|
_record(
|
||||||
|
"idm_typed_group_context",
|
||||||
|
group.id,
|
||||||
|
"typed_relationship_context",
|
||||||
|
"IDM typed-group relationship context",
|
||||||
|
{
|
||||||
|
"key": group.key,
|
||||||
|
"name": _bounded_text(group.name, 255),
|
||||||
|
"group_type": group.group_type,
|
||||||
|
"status": group.status,
|
||||||
|
"source_provider": group.source_provider,
|
||||||
|
"revision": group.revision,
|
||||||
|
},
|
||||||
|
observed_at=group.updated_at,
|
||||||
|
immutable=True,
|
||||||
|
retention_reason=(
|
||||||
|
"The minimized typed-group definition is retained as context "
|
||||||
|
"for the subject's effective-dated relationship evidence."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for relationship in relationships:
|
||||||
|
match_fields = _relationship_match_fields(relationship, selectors)
|
||||||
|
group = groups.get(relationship.target_group_id or "")
|
||||||
|
append(
|
||||||
|
_record(
|
||||||
|
"idm_identity_relationship",
|
||||||
|
relationship.id,
|
||||||
|
"typed_identity_relationship",
|
||||||
|
"IDM typed identity relationship",
|
||||||
|
{
|
||||||
|
"match_fields": match_fields,
|
||||||
|
"relationship_kind": relationship.relationship_kind,
|
||||||
|
"subject_identity_id": (
|
||||||
|
relationship.subject_identity_id
|
||||||
|
if "subject_identity_id" in match_fields
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"target_group_id": relationship.target_group_id,
|
||||||
|
"target_group_key": group.key if group else None,
|
||||||
|
"target_group_name": _bounded_text(
|
||||||
|
group.name if group else None,
|
||||||
|
255,
|
||||||
|
),
|
||||||
|
"target_group_type": group.group_type if group else None,
|
||||||
|
"related_identity_id": (
|
||||||
|
relationship.related_identity_id
|
||||||
|
if "related_identity_id" in match_fields
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"role": _bounded_text(relationship.role, 120),
|
||||||
|
"valid_from": _iso(relationship.valid_from),
|
||||||
|
"valid_until": _iso(relationship.valid_until),
|
||||||
|
"status": relationship.status,
|
||||||
|
"revoked_at": _iso(relationship.revoked_at),
|
||||||
|
"revoked_by": (
|
||||||
|
relationship.revoked_by
|
||||||
|
if relationship.revoked_by == selectors.account_id
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"revocation_reason": _bounded_text(
|
||||||
|
relationship.revocation_reason,
|
||||||
|
2_000,
|
||||||
|
),
|
||||||
|
"expired_event_at": _iso(relationship.expired_event_at),
|
||||||
|
"source_provider": relationship.source_provider,
|
||||||
|
"revision": relationship.revision,
|
||||||
|
},
|
||||||
|
observed_at=relationship.updated_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
change_ids: set[str] = set()
|
||||||
|
for change in changes:
|
||||||
|
change_ids.add(change.id)
|
||||||
|
match_fields = _change_match_fields(change, selectors)
|
||||||
|
append(
|
||||||
|
_record(
|
||||||
|
"idm_function_assignment_change",
|
||||||
|
change.id,
|
||||||
|
"function_assignment_governance_evidence",
|
||||||
|
"IDM governed function-assignment change",
|
||||||
|
{
|
||||||
|
"match_fields": match_fields,
|
||||||
|
"kind": change.kind,
|
||||||
|
"state": change.state,
|
||||||
|
"profile": change.profile,
|
||||||
|
"function_id": change.function_id,
|
||||||
|
"organization_unit_id": change.organization_unit_id,
|
||||||
|
"candidate_identity_id": (
|
||||||
|
change.candidate_identity_id
|
||||||
|
if "candidate_identity_id" in match_fields
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"candidate_account_id": (
|
||||||
|
change.candidate_account_id
|
||||||
|
if "candidate_account_id" in match_fields
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"initiator_account_id": (
|
||||||
|
change.initiator_account_id
|
||||||
|
if "initiator_account_id" in match_fields
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"initiator_identity_id": (
|
||||||
|
change.initiator_identity_id
|
||||||
|
if "initiator_identity_id" in match_fields
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"has_represented_assignment": bool(
|
||||||
|
change.represented_assignment_id
|
||||||
|
),
|
||||||
|
"requested_valid_from": _iso(change.requested_valid_from),
|
||||||
|
"requested_valid_until": _iso(change.requested_valid_until),
|
||||||
|
"applies_to_subunits": change.applies_to_subunits,
|
||||||
|
"assignment_source": change.assignment_source,
|
||||||
|
"resulting_assignment_id": change.resulting_assignment_id,
|
||||||
|
"expires_at": _iso(change.expires_at),
|
||||||
|
"resource_revision": change.resource_revision,
|
||||||
|
},
|
||||||
|
observed_at=change.updated_at,
|
||||||
|
immutable=True,
|
||||||
|
retention_reason=(
|
||||||
|
"Governed assignment requests and grants retain their state, "
|
||||||
|
"subject linkage, and outcome as institutional decision evidence."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for event in _matching_change_events(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
selectors=selectors,
|
||||||
|
change_ids=change_ids,
|
||||||
|
):
|
||||||
|
match_fields = []
|
||||||
|
if event.change_id in change_ids:
|
||||||
|
match_fields.append("change_id")
|
||||||
|
if event.actor_account_id == selectors.account_id:
|
||||||
|
match_fields.append("actor_account_id")
|
||||||
|
if event.actor_identity_id == selectors.identity_id:
|
||||||
|
match_fields.append("actor_identity_id")
|
||||||
|
append(
|
||||||
|
_record(
|
||||||
|
"idm_function_assignment_change_event",
|
||||||
|
event.id,
|
||||||
|
"function_assignment_governance_evidence",
|
||||||
|
"IDM function-assignment lifecycle event",
|
||||||
|
{
|
||||||
|
"match_fields": match_fields,
|
||||||
|
"change_id": event.change_id,
|
||||||
|
"sequence": event.sequence,
|
||||||
|
"action": event.action,
|
||||||
|
"from_state": event.from_state,
|
||||||
|
"to_state": event.to_state,
|
||||||
|
"actor_account_id": (
|
||||||
|
event.actor_account_id
|
||||||
|
if "actor_account_id" in match_fields
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"actor_identity_id": (
|
||||||
|
event.actor_identity_id
|
||||||
|
if "actor_identity_id" in match_fields
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"actor_assignment_id": (
|
||||||
|
event.actor_assignment_id
|
||||||
|
if (
|
||||||
|
"actor_account_id" in match_fields
|
||||||
|
or "actor_identity_id" in match_fields
|
||||||
|
)
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"created_at": _iso(event.created_at),
|
||||||
|
},
|
||||||
|
observed_at=event.created_at,
|
||||||
|
immutable=True,
|
||||||
|
retention_reason=(
|
||||||
|
"Assignment lifecycle events are immutable decision and "
|
||||||
|
"accountability evidence."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return tuple(records)
|
||||||
|
|
||||||
|
def plan_erasure(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
records: Sequence[DsarRecordRef],
|
||||||
|
) -> Sequence[DsarErasureActionRef]:
|
||||||
|
del tenant_id
|
||||||
|
_session(session)
|
||||||
|
if _subject_selectors(subject) is None:
|
||||||
|
raise ValueError("IDM DSAR subject selectors conflict.")
|
||||||
|
actions: list[DsarErasureActionRef] = []
|
||||||
|
for record in records:
|
||||||
|
_validate_record(record)
|
||||||
|
if record.immutable_evidence:
|
||||||
|
kind = "retain"
|
||||||
|
rationale = record.retention_reason or (
|
||||||
|
"IDM governance evidence must be retained."
|
||||||
|
)
|
||||||
|
title = f"Retain {record.title}"
|
||||||
|
else:
|
||||||
|
kind = "manual_review"
|
||||||
|
rationale = (
|
||||||
|
"Function assignments and typed relationships are effective-dated "
|
||||||
|
"institutional facts. An authorized IDM operator must correct, "
|
||||||
|
"revoke, deactivate, or expire them through the governed lifecycle "
|
||||||
|
"after reviewing organizational and third-party consequences."
|
||||||
|
)
|
||||||
|
title = f"Review {record.title}"
|
||||||
|
actions.append(
|
||||||
|
DsarErasureActionRef(
|
||||||
|
action_id=f"idm:{kind}:{record.resource_type}:{record.resource_id}",
|
||||||
|
provider_id=self.provider_id,
|
||||||
|
module_id=self.module_id,
|
||||||
|
kind=kind,
|
||||||
|
resource_type=record.resource_type,
|
||||||
|
resource_id=record.resource_id,
|
||||||
|
title=title,
|
||||||
|
rationale=rationale,
|
||||||
|
executable=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(actions)
|
||||||
|
|
||||||
|
def execute_erasure(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
actions: Sequence[DsarErasureActionRef],
|
||||||
|
request_id: str,
|
||||||
|
) -> Sequence[DsarExecutionResultRef]:
|
||||||
|
del tenant_id
|
||||||
|
_session(session)
|
||||||
|
if _subject_selectors(subject) is None:
|
||||||
|
raise ValueError("IDM DSAR subject selectors conflict.")
|
||||||
|
results: list[DsarExecutionResultRef] = []
|
||||||
|
for action in actions:
|
||||||
|
_validate_action(action)
|
||||||
|
if action.executable:
|
||||||
|
raise ValueError(
|
||||||
|
"IDM DSAR does not publish executable erasure actions."
|
||||||
|
)
|
||||||
|
results.append(
|
||||||
|
DsarExecutionResultRef(
|
||||||
|
action_id=action.action_id,
|
||||||
|
status="blocked",
|
||||||
|
summary=(
|
||||||
|
"Use the governed IDM assignment or relationship lifecycle "
|
||||||
|
"after organizational, evidence, and third-party review."
|
||||||
|
),
|
||||||
|
evidence={"request_id": request_id},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(results)
|
||||||
|
|
||||||
|
|
||||||
|
def _matching_assignments(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
selectors: _SubjectSelectors,
|
||||||
|
) -> list[IdmOrganizationFunctionAssignment]:
|
||||||
|
conditions = []
|
||||||
|
if selectors.identity_id:
|
||||||
|
conditions.append(
|
||||||
|
IdmOrganizationFunctionAssignment.identity_id == selectors.identity_id
|
||||||
|
)
|
||||||
|
if selectors.account_id:
|
||||||
|
conditions.extend(
|
||||||
|
(
|
||||||
|
IdmOrganizationFunctionAssignment.account_id == selectors.account_id,
|
||||||
|
IdmOrganizationFunctionAssignment.acting_for_account_id
|
||||||
|
== selectors.account_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if reference := selectors.references.get("assignment"):
|
||||||
|
conditions.append(IdmOrganizationFunctionAssignment.id == reference)
|
||||||
|
return _query_conditions(
|
||||||
|
session,
|
||||||
|
IdmOrganizationFunctionAssignment,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
conditions=conditions,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _matching_relationships(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
selectors: _SubjectSelectors,
|
||||||
|
) -> list[IdmIdentityRelationship]:
|
||||||
|
conditions = []
|
||||||
|
if selectors.identity_id:
|
||||||
|
conditions.extend(
|
||||||
|
(
|
||||||
|
IdmIdentityRelationship.subject_identity_id == selectors.identity_id,
|
||||||
|
IdmIdentityRelationship.related_identity_id == selectors.identity_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if reference := selectors.references.get("relationship"):
|
||||||
|
conditions.append(IdmIdentityRelationship.id == reference)
|
||||||
|
return _query_conditions(
|
||||||
|
session,
|
||||||
|
IdmIdentityRelationship,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
conditions=conditions,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _matching_changes(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
selectors: _SubjectSelectors,
|
||||||
|
) -> list[IdmFunctionAssignmentChange]:
|
||||||
|
conditions = []
|
||||||
|
if selectors.identity_id:
|
||||||
|
conditions.extend(
|
||||||
|
(
|
||||||
|
IdmFunctionAssignmentChange.candidate_identity_id
|
||||||
|
== selectors.identity_id,
|
||||||
|
IdmFunctionAssignmentChange.initiator_identity_id
|
||||||
|
== selectors.identity_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if selectors.account_id:
|
||||||
|
conditions.extend(
|
||||||
|
(
|
||||||
|
IdmFunctionAssignmentChange.candidate_account_id
|
||||||
|
== selectors.account_id,
|
||||||
|
IdmFunctionAssignmentChange.initiator_account_id
|
||||||
|
== selectors.account_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if reference := selectors.references.get("assignment_change"):
|
||||||
|
conditions.append(IdmFunctionAssignmentChange.id == reference)
|
||||||
|
return _query_conditions(
|
||||||
|
session,
|
||||||
|
IdmFunctionAssignmentChange,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
conditions=conditions,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _matching_change_events(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
selectors: _SubjectSelectors,
|
||||||
|
change_ids: set[str],
|
||||||
|
) -> list[IdmFunctionAssignmentChangeEvent]:
|
||||||
|
conditions = []
|
||||||
|
if change_ids:
|
||||||
|
conditions.append(IdmFunctionAssignmentChangeEvent.change_id.in_(change_ids))
|
||||||
|
if selectors.account_id:
|
||||||
|
conditions.append(
|
||||||
|
IdmFunctionAssignmentChangeEvent.actor_account_id == selectors.account_id
|
||||||
|
)
|
||||||
|
if selectors.identity_id:
|
||||||
|
conditions.append(
|
||||||
|
IdmFunctionAssignmentChangeEvent.actor_identity_id == selectors.identity_id
|
||||||
|
)
|
||||||
|
return _query_conditions(
|
||||||
|
session,
|
||||||
|
IdmFunctionAssignmentChangeEvent,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
conditions=conditions,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _direct_reference_conflicts(
|
||||||
|
selectors: _SubjectSelectors,
|
||||||
|
*,
|
||||||
|
assignments: Sequence[IdmOrganizationFunctionAssignment],
|
||||||
|
relationships: Sequence[IdmIdentityRelationship],
|
||||||
|
changes: Sequence[IdmFunctionAssignmentChange],
|
||||||
|
) -> bool:
|
||||||
|
if not selectors.has_canonical_selector:
|
||||||
|
return False
|
||||||
|
checks = (
|
||||||
|
(
|
||||||
|
"assignment",
|
||||||
|
assignments,
|
||||||
|
lambda row: _assignment_match_fields(row, selectors),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"relationship",
|
||||||
|
relationships,
|
||||||
|
lambda row: _relationship_match_fields(row, selectors),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"assignment_change",
|
||||||
|
changes,
|
||||||
|
lambda row: _change_match_fields(row, selectors),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for kind, rows, match in checks:
|
||||||
|
reference = selectors.references.get(kind)
|
||||||
|
if not reference:
|
||||||
|
continue
|
||||||
|
row = next((item for item in rows if item.id == reference), None)
|
||||||
|
if row is None or not [field for field in match(row) if field != "reference"]:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _assignment_match_fields(
|
||||||
|
row: IdmOrganizationFunctionAssignment,
|
||||||
|
selectors: _SubjectSelectors,
|
||||||
|
) -> list[str]:
|
||||||
|
fields = []
|
||||||
|
if row.identity_id == selectors.identity_id:
|
||||||
|
fields.append("identity_id")
|
||||||
|
if selectors.account_id and row.account_id == selectors.account_id:
|
||||||
|
fields.append("account_id")
|
||||||
|
if selectors.account_id and row.acting_for_account_id == selectors.account_id:
|
||||||
|
fields.append("acting_for_account_id")
|
||||||
|
if row.id == selectors.references.get("assignment"):
|
||||||
|
fields.append("reference")
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
def _relationship_match_fields(
|
||||||
|
row: IdmIdentityRelationship,
|
||||||
|
selectors: _SubjectSelectors,
|
||||||
|
) -> list[str]:
|
||||||
|
fields = []
|
||||||
|
if row.subject_identity_id == selectors.identity_id:
|
||||||
|
fields.append("subject_identity_id")
|
||||||
|
if selectors.identity_id and row.related_identity_id == selectors.identity_id:
|
||||||
|
fields.append("related_identity_id")
|
||||||
|
if row.revoked_by == selectors.account_id:
|
||||||
|
fields.append("revoked_by")
|
||||||
|
if row.id == selectors.references.get("relationship"):
|
||||||
|
fields.append("reference")
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
def _change_match_fields(
|
||||||
|
row: IdmFunctionAssignmentChange,
|
||||||
|
selectors: _SubjectSelectors,
|
||||||
|
) -> list[str]:
|
||||||
|
fields = []
|
||||||
|
for field, expected in (
|
||||||
|
("candidate_identity_id", selectors.identity_id),
|
||||||
|
("candidate_account_id", selectors.account_id),
|
||||||
|
("initiator_identity_id", selectors.identity_id),
|
||||||
|
("initiator_account_id", selectors.account_id),
|
||||||
|
):
|
||||||
|
if expected and getattr(row, field) == expected:
|
||||||
|
fields.append(field)
|
||||||
|
if row.id == selectors.references.get("assignment_change"):
|
||||||
|
fields.append("reference")
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None:
|
||||||
|
groups = {
|
||||||
|
"account_id": (
|
||||||
|
subject.account_id,
|
||||||
|
subject.external_references.get("idm.account"),
|
||||||
|
subject.external_references.get("access.account"),
|
||||||
|
),
|
||||||
|
"identity_id": (
|
||||||
|
subject.identity_id,
|
||||||
|
subject.external_references.get("idm.identity"),
|
||||||
|
subject.external_references.get("identity.id"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
normalized: dict[str, str | None] = {}
|
||||||
|
for key, values in groups.items():
|
||||||
|
distinct = {value for item in values if (value := _normalized_id(item))}
|
||||||
|
if len(distinct) > 1:
|
||||||
|
return None
|
||||||
|
normalized[key] = next(iter(distinct), None)
|
||||||
|
|
||||||
|
aliases = {
|
||||||
|
"idm.assignment": "assignment",
|
||||||
|
"idm.relationship": "relationship",
|
||||||
|
"idm.assignment_change": "assignment_change",
|
||||||
|
}
|
||||||
|
references = {
|
||||||
|
target: value
|
||||||
|
for source, target in aliases.items()
|
||||||
|
if (value := _normalized_id(subject.external_references.get(source)))
|
||||||
|
}
|
||||||
|
return _SubjectSelectors(references=references, **normalized)
|
||||||
|
|
||||||
|
|
||||||
|
def _rows_by_ids(
|
||||||
|
session: Session,
|
||||||
|
model: type,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
ids: set[str],
|
||||||
|
) -> list[object]:
|
||||||
|
if not ids:
|
||||||
|
return []
|
||||||
|
return _bounded_rows(
|
||||||
|
session.query(model)
|
||||||
|
.filter(model.tenant_id == tenant_id, model.id.in_(ids))
|
||||||
|
.order_by(model.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _query_conditions(
|
||||||
|
session: Session,
|
||||||
|
model: type,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
conditions: Sequence[object],
|
||||||
|
) -> list[object]:
|
||||||
|
if not conditions:
|
||||||
|
return []
|
||||||
|
return _bounded_rows(
|
||||||
|
session.query(model)
|
||||||
|
.filter(model.tenant_id == tenant_id, or_(*conditions))
|
||||||
|
.order_by(model.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_record(record: DsarRecordRef) -> None:
|
||||||
|
if record.provider_id != "idm" or record.module_id != "idm":
|
||||||
|
raise ValueError("IDM DSAR received a foreign provider record.")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_action(action: DsarErasureActionRef) -> None:
|
||||||
|
if action.provider_id != "idm" or action.module_id != "idm":
|
||||||
|
raise ValueError("IDM DSAR received a foreign provider action.")
|
||||||
|
|
||||||
|
|
||||||
|
def _record(
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
category: str,
|
||||||
|
title: str,
|
||||||
|
data: Mapping[str, object],
|
||||||
|
*,
|
||||||
|
observed_at: datetime | None,
|
||||||
|
immutable: bool = False,
|
||||||
|
retention_reason: str | None = None,
|
||||||
|
) -> DsarRecordRef:
|
||||||
|
return DsarRecordRef(
|
||||||
|
provider_id="idm",
|
||||||
|
module_id="idm",
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
category=category,
|
||||||
|
title=title,
|
||||||
|
data=data,
|
||||||
|
observed_at=observed_at,
|
||||||
|
immutable_evidence=immutable,
|
||||||
|
retention_reason=retention_reason,
|
||||||
|
source_path="/idm",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _session(value: object) -> Session:
|
||||||
|
if not isinstance(value, Session):
|
||||||
|
raise TypeError("IDM DSAR provider requires a SQLAlchemy session.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_rows(query: object) -> list[object]:
|
||||||
|
rows = query.limit(_MAX_RECORDS + 1).all() # type: ignore[attr-defined]
|
||||||
|
if len(rows) > _MAX_RECORDS:
|
||||||
|
raise ValueError("IDM DSAR match limit exceeded; narrow the subject selectors.")
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_text(value: str | None, limit: int) -> str | None:
|
||||||
|
return value[:limit] if value else None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_id(value: object) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
value = str(value).strip()
|
||||||
|
return value or None
|
||||||
|
|
||||||
|
|
||||||
|
def _iso(value: datetime | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if value.tzinfo is None:
|
||||||
|
value = value.replace(tzinfo=timezone.utc)
|
||||||
|
return value.isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["IDM_DSAR_CAPABILITY", "IdmDsarProvider"]
|
||||||
@@ -29,6 +29,7 @@ from govoplan_core.core.organizations import (
|
|||||||
from govoplan_core.core.views import ViewSurface
|
from govoplan_core.core.views import ViewSurface
|
||||||
from govoplan_core.core.module_guards import persistent_table_uninstall_guard
|
from govoplan_core.core.module_guards import persistent_table_uninstall_guard
|
||||||
from govoplan_core.core.modules import (
|
from govoplan_core.core.modules import (
|
||||||
|
CapabilityDocumentation,
|
||||||
DocumentationCondition,
|
DocumentationCondition,
|
||||||
DocumentationLink,
|
DocumentationLink,
|
||||||
DocumentationTopic,
|
DocumentationTopic,
|
||||||
@@ -41,19 +42,21 @@ from govoplan_core.core.modules import (
|
|||||||
ModuleManifest,
|
ModuleManifest,
|
||||||
NavItem,
|
NavItem,
|
||||||
PermissionDefinition,
|
PermissionDefinition,
|
||||||
|
ProductAreaContribution,
|
||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.search import SearchSourceProviderRegistration
|
from govoplan_core.core.search import SearchSourceProviderRegistration
|
||||||
from govoplan_core.core.provider_governance import declared_module_architecture
|
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||||
from govoplan_core.db.base import Base
|
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.db import models as idm_models # noqa: F401 - populate metadata
|
||||||
|
from govoplan_idm.backend.dsar_provider import IDM_DSAR_CAPABILITY
|
||||||
from govoplan_idm.backend.workflow_definitions import (
|
from govoplan_idm.backend.workflow_definitions import (
|
||||||
function_assignment_workflow_definitions,
|
function_assignment_workflow_definitions,
|
||||||
)
|
)
|
||||||
from govoplan_idm.backend.search_source import create_idm_search_source
|
from govoplan_idm.backend.search_source import create_idm_search_source
|
||||||
|
|
||||||
|
|
||||||
MODULE_VERSION = "0.1.15"
|
MODULE_VERSION = "0.1.19"
|
||||||
|
|
||||||
IDM_READ_SCOPES = (
|
IDM_READ_SCOPES = (
|
||||||
"idm:organization_assignment:read",
|
"idm:organization_assignment:read",
|
||||||
@@ -216,6 +219,13 @@ def _relationship_directory(context: ModuleContext) -> object:
|
|||||||
return SqlIdmRelationshipDirectory(identities=identities)
|
return SqlIdmRelationshipDirectory(identities=identities)
|
||||||
|
|
||||||
|
|
||||||
|
def _idm_dsar_provider(context: ModuleContext) -> object:
|
||||||
|
del context
|
||||||
|
from govoplan_idm.backend.dsar_provider import IdmDsarProvider
|
||||||
|
|
||||||
|
return IdmDsarProvider()
|
||||||
|
|
||||||
|
|
||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id="idm",
|
id="idm",
|
||||||
name="IDM",
|
name="IDM",
|
||||||
@@ -258,6 +268,10 @@ manifest = ModuleManifest(
|
|||||||
name="idm.function_assignment_changes",
|
name="idm.function_assignment_changes",
|
||||||
version="1.0.0",
|
version="1.0.0",
|
||||||
),
|
),
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name=IDM_DSAR_CAPABILITY,
|
||||||
|
version="0.1.0",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
requires_interfaces=(
|
requires_interfaces=(
|
||||||
ModuleInterfaceRequirement(
|
ModuleInterfaceRequirement(
|
||||||
@@ -282,6 +296,17 @@ manifest = ModuleManifest(
|
|||||||
package_name="@govoplan/idm-webui",
|
package_name="@govoplan/idm-webui",
|
||||||
routes=(FrontendRoute(path="/idm", component="IdmPage", required_any=IDM_READ_SCOPES, order=72),),
|
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),),
|
nav_items=(NavItem(path="/idm", label="IDM", icon="users", required_any=IDM_READ_SCOPES, order=72),),
|
||||||
|
product_areas=(
|
||||||
|
ProductAreaContribution(
|
||||||
|
id="people-responsibility",
|
||||||
|
module_id="idm",
|
||||||
|
label="i18n:govoplan-core.product_area.people_responsibility",
|
||||||
|
icon="users",
|
||||||
|
description="i18n:govoplan-core.product_area.people_responsibility_description",
|
||||||
|
surface_ids=("idm.nav.idm", "idm.route.idm"),
|
||||||
|
order=70,
|
||||||
|
),
|
||||||
|
),
|
||||||
view_surfaces=(
|
view_surfaces=(
|
||||||
ViewSurface(
|
ViewSurface(
|
||||||
id="idm.action.view-function-assignments",
|
id="idm.action.view-function-assignments",
|
||||||
@@ -313,11 +338,90 @@ manifest = ModuleManifest(
|
|||||||
CAPABILITY_IDM_DIRECTORY: _idm_directory,
|
CAPABILITY_IDM_DIRECTORY: _idm_directory,
|
||||||
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS: _idm_directory,
|
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS: _idm_directory,
|
||||||
CAPABILITY_IDM_RELATIONSHIPS: _relationship_directory,
|
CAPABILITY_IDM_RELATIONSHIPS: _relationship_directory,
|
||||||
|
IDM_DSAR_CAPABILITY: _idm_dsar_provider,
|
||||||
|
},
|
||||||
|
capability_documentation={
|
||||||
|
IDM_DSAR_CAPABILITY: CapabilityDocumentation(
|
||||||
|
label="IDM data-subject request provider",
|
||||||
|
summary=(
|
||||||
|
"Finds tenant-scoped function assignments, typed relationships, "
|
||||||
|
"governed changes, and lifecycle evidence with third-party and opaque "
|
||||||
|
"payload minimization."
|
||||||
|
),
|
||||||
|
contract_version="0.1.0",
|
||||||
|
documentation_types=("admin",),
|
||||||
|
audience=("privacy_officer", "idm_admin", "records_manager"),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
workflow_definitions=function_assignment_workflow_definitions(
|
workflow_definitions=function_assignment_workflow_definitions(
|
||||||
module_version=MODULE_VERSION,
|
module_version=MODULE_VERSION,
|
||||||
),
|
),
|
||||||
documentation=(
|
documentation=(
|
||||||
|
DocumentationTopic(
|
||||||
|
id="idm.privacy.data-subject-requests",
|
||||||
|
title="Review IDM data in a data-subject request",
|
||||||
|
summary=(
|
||||||
|
"Collect tenant-scoped institutional function and relationship facts "
|
||||||
|
"while preserving governed decision evidence."
|
||||||
|
),
|
||||||
|
body=(
|
||||||
|
"IDM searches corroborated account and identity selectors plus "
|
||||||
|
"namespaced assignment, relationship, and assignment-change references. "
|
||||||
|
"Results include effective-dated organization-function assignments, typed "
|
||||||
|
"identity relationships with minimized group context, governed assignment "
|
||||||
|
"requests or grants, and related lifecycle events. When a record concerns "
|
||||||
|
"another candidate or actor, their identity and account identifiers are "
|
||||||
|
"removed from the automated export. Settings, group properties, external "
|
||||||
|
"source references, provenance, justifications, evidence arrays, policy "
|
||||||
|
"decisions, workflow internals, idempotency keys, request digests, opaque "
|
||||||
|
"metadata, event comments and details, unrelated records, and other tenants "
|
||||||
|
"are excluded. Assignments and relationships are effective institutional "
|
||||||
|
"facts, so correction, revocation, deactivation, or expiry requires an "
|
||||||
|
"authorized IDM lifecycle review. Governed change and event records retain "
|
||||||
|
"explicit decision-evidence reasons. Identity owns the person record, "
|
||||||
|
"Organizations owns functions and units, and Access owns the authority "
|
||||||
|
"derived from accepted IDM facts."
|
||||||
|
),
|
||||||
|
layer="static",
|
||||||
|
documentation_types=("admin",),
|
||||||
|
audience=(
|
||||||
|
"privacy_officer",
|
||||||
|
"idm_admin",
|
||||||
|
"records_manager",
|
||||||
|
"operator",
|
||||||
|
),
|
||||||
|
related_modules=(
|
||||||
|
"access",
|
||||||
|
"audit",
|
||||||
|
"identity",
|
||||||
|
"organizations",
|
||||||
|
"records",
|
||||||
|
),
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(required_modules=("idm", "access")),
|
||||||
|
),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Data-subject requests",
|
||||||
|
href="/admin?section=tenant-data-subject-requests",
|
||||||
|
kind="runtime",
|
||||||
|
),
|
||||||
|
DocumentationLink(
|
||||||
|
label="IDM assignments",
|
||||||
|
href="/idm",
|
||||||
|
kind="runtime",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
metadata={
|
||||||
|
"kind": "guide",
|
||||||
|
"help_contexts": [
|
||||||
|
"idm.route.assignments",
|
||||||
|
"idm.action.view-function-assignments",
|
||||||
|
"idm.function-change.request",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
order=24,
|
||||||
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="idm.search.directory",
|
id="idm.search.directory",
|
||||||
title="Search authorized IDM records",
|
title="Search authorized IDM records",
|
||||||
@@ -396,17 +500,113 @@ manifest = ModuleManifest(
|
|||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="idm.reference.typed-relationships",
|
id="idm.reference.typed-relationships",
|
||||||
title="Typed groups and effective relationships",
|
title="Typed groups and effective relationships",
|
||||||
summary="IDM keeps business group membership separate from identity lifecycle status.",
|
summary="Create tenant-scoped business groups, maintain effective-dated identity links, inspect membership decisions, and retain source provenance without turning membership into access authority.",
|
||||||
body=(
|
body=(
|
||||||
"Typed groups and identity relationships are tenant-scoped, effective-dated facts. "
|
"Typed groups and identity relationships are tenant-scoped institutional facts. Administrators use stable group keys and types, searchable identity and group selectors, effective dates, source references, typed properties, and provenance to record why a relationship exists. "
|
||||||
"Current, future, expired, and revoked links remain explainable, including external "
|
"A future relationship is scheduled but not yet effective. An expired relationship no longer contributes to membership, and revocation stops membership immediately while retaining the actor, time, reason, source, and revision as evidence. Revoked relationships cannot be edited or reactivated; create a replacement when the fact becomes valid again. "
|
||||||
"directory source revisions and provenance. Consumers such as Distribution Lists use "
|
"The membership inspector evaluates a selected group, time, and relationship kind through the same Core capability used by downstream consumers. Included and excluded decisions remain visible with reason codes and identity lifecycle state. Business membership never grants application permissions; Access evaluates roles and rights separately."
|
||||||
"the IDM relationship capability and never infer application permissions from membership."
|
|
||||||
),
|
),
|
||||||
layer="configured",
|
layer="configured",
|
||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
audience=("tenant_admin", "operator", "module_admin"),
|
audience=("tenant_admin", "operator", "module_admin"),
|
||||||
related_modules=("identity", "organizations", "dist_lists"),
|
related_modules=("identity", "organizations", "dist_lists"),
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
required_modules=("idm",),
|
||||||
|
any_scopes=("idm:relationship:read", "idm:relationship:write"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(label="IDM relationship administration", href="/idm", kind="runtime"),
|
||||||
|
DocumentationLink(label="Typed groups API", href="/api/v1/idm/typed-groups", kind="api"),
|
||||||
|
DocumentationLink(label="Identity relationships API", href="/api/v1/idm/relationships", kind="api"),
|
||||||
|
DocumentationLink(label="Typed relationship contract", href="docs/TYPED_RELATIONSHIPS.md", kind="repository"),
|
||||||
|
),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Typisierte Gruppen und wirksame Beziehungen",
|
||||||
|
"summary": "Erstellen Sie mandantenbezogene Fachgruppen, pflegen Sie zeitlich wirksame Identitätsbeziehungen, prüfen Sie Mitgliedschaftsentscheidungen und bewahren Sie die Herkunft auf, ohne Mitgliedschaft mit Zugriffsrechten gleichzusetzen.",
|
||||||
|
"body": "Typisierte Gruppen und Identitätsbeziehungen sind mandantenbezogene institutionelle Tatsachen. Administrierende erfassen mit stabilen Gruppenschlüsseln und -typen, durchsuchbaren Identitäts- und Gruppenauswahlen, Wirksamkeitsdaten, Quellreferenzen, typisierten Eigenschaften und Herkunftsnachweisen, warum eine Beziehung besteht. Eine zukünftige Beziehung ist geplant, aber noch nicht wirksam. Eine abgelaufene Beziehung trägt nicht mehr zur Mitgliedschaft bei. Ein Widerruf beendet die Mitgliedschaft sofort, bewahrt jedoch handelnde Person, Zeitpunkt, Grund, Quelle und Revision als Nachweis. Widerrufene Beziehungen können weder bearbeitet noch reaktiviert werden; wird die Tatsache erneut gültig, ist eine neue Beziehung anzulegen. Die Mitgliedschaftsprüfung wertet eine gewählte Gruppe, einen Zeitpunkt und eine Beziehungsart über dieselbe Core-Fähigkeit aus, die nachgelagerte Verbraucher verwenden. Einbezogene und ausgeschlossene Entscheidungen bleiben mit Begründungscode und Identitätsstatus sichtbar. Eine fachliche Mitgliedschaft erteilt niemals Anwendungsberechtigungen; Access bewertet Rollen und Rechte getrennt.",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
|
"route": "/idm",
|
||||||
|
"screen": "Typed groups and identity relationships",
|
||||||
|
"help_contexts": [
|
||||||
|
"idm.relationships.page",
|
||||||
|
"idm.typed-groups.action.reload",
|
||||||
|
"idm.typed-groups.action.create",
|
||||||
|
"idm.typed-groups.action.edit",
|
||||||
|
"idm.typed-groups.action.save",
|
||||||
|
"idm.typed-groups.action.inspect-memberships",
|
||||||
|
"idm.typed-groups.action.resolve-memberships",
|
||||||
|
"idm.typed-groups.editor",
|
||||||
|
"idm.typed-groups.membership-resolution",
|
||||||
|
"idm.typed-groups.field.show-inactive",
|
||||||
|
"idm.typed-groups.field.key",
|
||||||
|
"idm.typed-groups.field.name",
|
||||||
|
"idm.typed-groups.field.type",
|
||||||
|
"idm.typed-groups.field.status",
|
||||||
|
"idm.typed-groups.field.description",
|
||||||
|
"idm.typed-groups.field.source-provider",
|
||||||
|
"idm.typed-groups.field.source-resource-type",
|
||||||
|
"idm.typed-groups.field.source-resource-id",
|
||||||
|
"idm.typed-groups.field.source-revision",
|
||||||
|
"idm.typed-groups.field.properties",
|
||||||
|
"idm.typed-groups.field.provenance",
|
||||||
|
"idm.typed-groups.field.membership-effective-at",
|
||||||
|
"idm.typed-groups.field.membership-kinds",
|
||||||
|
"idm.relationships.action.reload",
|
||||||
|
"idm.relationships.action.create",
|
||||||
|
"idm.relationships.action.edit",
|
||||||
|
"idm.relationships.action.save",
|
||||||
|
"idm.relationships.action.revoke",
|
||||||
|
"idm.relationships.action.confirm-revoke",
|
||||||
|
"idm.relationships.editor",
|
||||||
|
"idm.relationships.confirm-revoke",
|
||||||
|
"idm.relationships.field.show-revoked",
|
||||||
|
"idm.relationships.field.kind",
|
||||||
|
"idm.relationships.field.role",
|
||||||
|
"idm.relationships.field.subject-identity",
|
||||||
|
"idm.relationships.field.target-type",
|
||||||
|
"idm.relationships.field.target-group",
|
||||||
|
"idm.relationships.field.related-identity",
|
||||||
|
"idm.relationships.field.valid-from",
|
||||||
|
"idm.relationships.field.valid-until",
|
||||||
|
"idm.relationships.field.source-provider",
|
||||||
|
"idm.relationships.field.source-resource-type",
|
||||||
|
"idm.relationships.field.source-resource-id",
|
||||||
|
"idm.relationships.field.source-revision",
|
||||||
|
"idm.relationships.field.properties",
|
||||||
|
"idm.relationships.field.provenance",
|
||||||
|
"idm.relationships.field.revocation-reason",
|
||||||
|
],
|
||||||
|
"prerequisites": [
|
||||||
|
"The identities exist in the tenant identity directory.",
|
||||||
|
"The actor has relationship read permission and write permission for mutations.",
|
||||||
|
"The accountable source, effective window, relationship kind, and business purpose are known.",
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
"Create or select a typed group with a stable key, type, and source provenance.",
|
||||||
|
"Create a relationship with searchable subject and target references and the intended validity window.",
|
||||||
|
"Inspect effective memberships at the relevant time and review every included or excluded decision.",
|
||||||
|
"Revoke a relationship with a retained reason when the fact must stop before its scheduled end.",
|
||||||
|
],
|
||||||
|
"outcome": "The tenant has explainable, effective-dated business membership facts that downstream consumers can resolve without importing IDM internals or inferring Access rights.",
|
||||||
|
"limitations": [
|
||||||
|
"Membership resolution is tenant-scoped and rejects cross-tenant group references.",
|
||||||
|
"A revoked relationship is immutable and requires a replacement for later reuse.",
|
||||||
|
"Membership alone never activates an identity or grants an application permission.",
|
||||||
|
],
|
||||||
|
"consequences": [
|
||||||
|
"A future start delays membership until the selected instant.",
|
||||||
|
"Expiry removes the relationship from effective resolution while retaining evidence.",
|
||||||
|
"Revocation immediately removes the relationship from effective resolution and cannot be undone.",
|
||||||
|
"Changing an externally sourced fact without matching provenance can break reconciliation accountability.",
|
||||||
|
],
|
||||||
|
"verification": "Reload both directories, confirm the record revision and source fields, then resolve the target group's memberships at times before, during, and after the validity window. Verify that Access permissions remain unchanged.",
|
||||||
|
},
|
||||||
order=28,
|
order=28,
|
||||||
),
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
@@ -418,6 +618,7 @@ manifest = ModuleManifest(
|
|||||||
"Then create the assignment in IDM. Direct assignments state who holds the function. Delegated assignments require a source assignment and a delegable function. "
|
"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. "
|
"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",
|
layer="configured",
|
||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
@@ -462,7 +663,10 @@ manifest = ModuleManifest(
|
|||||||
"function and unit are owned by Organizations. Source distinguishes "
|
"function and unit are owned by Organizations. Source distinguishes "
|
||||||
"direct, delegated, acting-for, directory, governance, and system facts. "
|
"direct, delegated, acting-for, directory, governance, and system facts. "
|
||||||
"Delegation and acting-for require a valid source assignment and the "
|
"Delegation and acting-for require a valid source assignment and the "
|
||||||
"corresponding function permission. Subunit scope broadens the fact's "
|
"corresponding Organizations function permission. A delegate acts as themself; "
|
||||||
|
"acting-for additionally requires Access to select the exact representation "
|
||||||
|
"context before it contributes authority. Source and derived assignments must "
|
||||||
|
"remain current, active, tenant-local, and function-compatible. Subunit scope broadens the fact's "
|
||||||
"organizational reach. Deactivation and expiry preserve provenance while "
|
"organizational reach. Deactivation and expiry preserve provenance while "
|
||||||
"removing the assignment from effective resolution. Governed request and "
|
"removing the assignment from effective resolution. Governed request and "
|
||||||
"grant decisions retain actor, policy, workflow revision, comments, and "
|
"grant decisions retain actor, policy, workflow revision, comments, and "
|
||||||
|
|||||||
@@ -99,6 +99,26 @@ class AssignmentWorkflowTests(unittest.TestCase):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_delegated_assignment_rejects_function_that_forbids_delegation(self) -> None:
|
||||||
|
base = assignment(id="source-1")
|
||||||
|
item = assignment(
|
||||||
|
id="assignment-2",
|
||||||
|
identity_id="identity-2",
|
||||||
|
account_id="account-2",
|
||||||
|
source="delegated",
|
||||||
|
delegated_from_assignment_id="source-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assert_invalid(
|
||||||
|
"This organization function does not allow delegation.",
|
||||||
|
lambda: validate_assignment_source_rules( # type: ignore[arg-type]
|
||||||
|
item,
|
||||||
|
function=function(delegable=False),
|
||||||
|
base=base,
|
||||||
|
account_linked_to_identity=lambda _identity_id, _account_id: False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
def test_acting_for_assignment_accepts_identity_linked_account(self) -> None:
|
def test_acting_for_assignment_accepts_identity_linked_account(self) -> None:
|
||||||
base = assignment(id="source-1", identity_id="identity-1", account_id=None)
|
base = assignment(id="source-1", identity_id="identity-1", account_id=None)
|
||||||
item = assignment(
|
item = assignment(
|
||||||
@@ -136,6 +156,27 @@ class AssignmentWorkflowTests(unittest.TestCase):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_acting_for_assignment_rejects_function_that_forbids_representation(self) -> None:
|
||||||
|
base = assignment(id="source-1")
|
||||||
|
item = assignment(
|
||||||
|
id="assignment-2",
|
||||||
|
identity_id="identity-2",
|
||||||
|
account_id="acting-account",
|
||||||
|
source="acting_for",
|
||||||
|
delegated_from_assignment_id="source-1",
|
||||||
|
acting_for_account_id="account-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assert_invalid(
|
||||||
|
"This organization function does not allow acting in place.",
|
||||||
|
lambda: validate_assignment_source_rules( # type: ignore[arg-type]
|
||||||
|
item,
|
||||||
|
function=function(act_in_place_allowed=False),
|
||||||
|
base=base,
|
||||||
|
account_linked_to_identity=lambda _identity_id, _account_id: False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
def test_update_plan_does_not_mutate_until_applied(self) -> None:
|
def test_update_plan_does_not_mutate_until_applied(self) -> None:
|
||||||
item = assignment()
|
item = assignment()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,560 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from govoplan_core.core.dsar import (
|
||||||
|
DsarErasureActionRef,
|
||||||
|
DsarProvider,
|
||||||
|
DsarSubjectRef,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.privacy.dsar_workflow import (
|
||||||
|
create_data_subject_request,
|
||||||
|
search_data_subject_request,
|
||||||
|
)
|
||||||
|
from govoplan_identity.backend.db.models import CanonicalIdentity
|
||||||
|
from govoplan_idm.backend.db.models import (
|
||||||
|
IdmFunctionAssignmentChange,
|
||||||
|
IdmFunctionAssignmentChangeEvent,
|
||||||
|
IdmIdentityRelationship,
|
||||||
|
IdmOrganizationFunctionAssignment,
|
||||||
|
IdmTypedGroup,
|
||||||
|
)
|
||||||
|
from govoplan_idm.backend.dsar_provider import IDM_DSAR_CAPABILITY, IdmDsarProvider
|
||||||
|
from govoplan_idm.backend.manifest import manifest
|
||||||
|
from govoplan_organizations.backend.db.models import (
|
||||||
|
OrganizationFunction,
|
||||||
|
OrganizationUnit,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
def __init__(self, provider: IdmDsarProvider, *, idm_active: bool = True) -> None:
|
||||||
|
self.provider = provider
|
||||||
|
self.idm_active = idm_active
|
||||||
|
|
||||||
|
def capability_names(self):
|
||||||
|
return (IDM_DSAR_CAPABILITY,)
|
||||||
|
|
||||||
|
def capability_owner(self, name):
|
||||||
|
self._assert_capability(name)
|
||||||
|
return "idm"
|
||||||
|
|
||||||
|
def tenant_entitlement_resolver(self):
|
||||||
|
idm_active = self.idm_active
|
||||||
|
|
||||||
|
class _Resolver:
|
||||||
|
@staticmethod
|
||||||
|
def resolve(session, tenant_id):
|
||||||
|
del session, tenant_id
|
||||||
|
return type(
|
||||||
|
"State",
|
||||||
|
(),
|
||||||
|
{"effective_modules": ("idm",) if idm_active else ()},
|
||||||
|
)()
|
||||||
|
|
||||||
|
return _Resolver()
|
||||||
|
|
||||||
|
def require_tenant_capability(self, name, session, **kwargs):
|
||||||
|
del session, kwargs
|
||||||
|
self._assert_capability(name)
|
||||||
|
return self.provider
|
||||||
|
|
||||||
|
def manifests(self):
|
||||||
|
return (type("Manifest", (), {"id": "idm"})(),)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _assert_capability(name: str) -> None:
|
||||||
|
if name != IDM_DSAR_CAPABILITY:
|
||||||
|
raise KeyError(name)
|
||||||
|
|
||||||
|
|
||||||
|
class IdmDsarProviderTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite:///:memory:", future=True)
|
||||||
|
Base.metadata.create_all(bind=self.engine)
|
||||||
|
self.session = sessionmaker(bind=self.engine, future=True)()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
self.identity = CanonicalIdentity(
|
||||||
|
id="identity-1",
|
||||||
|
display_name="Subject",
|
||||||
|
settings={"secret": "identity-settings-do-not-export"},
|
||||||
|
)
|
||||||
|
other_identity = CanonicalIdentity(
|
||||||
|
id="identity-other",
|
||||||
|
display_name="Unrelated Person",
|
||||||
|
settings={"secret": "other-identity-settings-do-not-export"},
|
||||||
|
)
|
||||||
|
unit = OrganizationUnit(
|
||||||
|
id="unit-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
slug="residents",
|
||||||
|
name="Residents Office",
|
||||||
|
)
|
||||||
|
function = OrganizationFunction(
|
||||||
|
id="function-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
organization_unit_id=unit.id,
|
||||||
|
slug="case-worker",
|
||||||
|
name="Case worker",
|
||||||
|
)
|
||||||
|
acting_function = OrganizationFunction(
|
||||||
|
id="function-2",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
organization_unit_id=unit.id,
|
||||||
|
slug="acting-case-worker",
|
||||||
|
name="Acting case worker",
|
||||||
|
)
|
||||||
|
self.assignment = IdmOrganizationFunctionAssignment(
|
||||||
|
id="assignment-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
identity_id=self.identity.id,
|
||||||
|
account_id="account-1",
|
||||||
|
function_id=function.id,
|
||||||
|
organization_unit_id=unit.id,
|
||||||
|
source="direct",
|
||||||
|
valid_from=now,
|
||||||
|
settings={"secret": "assignment-settings-do-not-export"},
|
||||||
|
)
|
||||||
|
acting_assignment = IdmOrganizationFunctionAssignment(
|
||||||
|
id="assignment-acting",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
identity_id=other_identity.id,
|
||||||
|
account_id="account-other",
|
||||||
|
function_id=acting_function.id,
|
||||||
|
organization_unit_id=unit.id,
|
||||||
|
source="acting_for",
|
||||||
|
delegated_from_assignment_id=self.assignment.id,
|
||||||
|
acting_for_account_id="account-1",
|
||||||
|
valid_from=now,
|
||||||
|
settings={"secret": "acting-settings-do-not-export"},
|
||||||
|
)
|
||||||
|
self.unrelated_assignment = IdmOrganizationFunctionAssignment(
|
||||||
|
id="assignment-unrelated",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
identity_id=other_identity.id,
|
||||||
|
account_id="account-other",
|
||||||
|
function_id=function.id,
|
||||||
|
organization_unit_id=unit.id,
|
||||||
|
source="direct",
|
||||||
|
settings={"secret": "unrelated-assignment-do-not-export"},
|
||||||
|
)
|
||||||
|
tenant_two_assignment = IdmOrganizationFunctionAssignment(
|
||||||
|
id="assignment-tenant-2",
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
identity_id=self.identity.id,
|
||||||
|
account_id="account-1",
|
||||||
|
function_id=function.id,
|
||||||
|
organization_unit_id=unit.id,
|
||||||
|
source="directory",
|
||||||
|
settings={"secret": "other-tenant-assignment-do-not-export"},
|
||||||
|
)
|
||||||
|
self.group = IdmTypedGroup(
|
||||||
|
id="group-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
key="residents",
|
||||||
|
name="Residents",
|
||||||
|
group_type="business_group",
|
||||||
|
source_provider="ldap",
|
||||||
|
source_resource_id="private-group-ref-do-not-export",
|
||||||
|
properties={"secret": "group-properties-do-not-export"},
|
||||||
|
provenance={"secret": "group-provenance-do-not-export"},
|
||||||
|
)
|
||||||
|
self.relationship = IdmIdentityRelationship(
|
||||||
|
id="relationship-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
relationship_kind="member_of",
|
||||||
|
subject_identity_id=self.identity.id,
|
||||||
|
target_group_id=self.group.id,
|
||||||
|
role="member",
|
||||||
|
valid_from=now,
|
||||||
|
source_provider="ldap",
|
||||||
|
source_resource_id="private-relationship-ref-do-not-export",
|
||||||
|
source_revision="private-source-revision-do-not-export",
|
||||||
|
properties={"secret": "relationship-properties-do-not-export"},
|
||||||
|
provenance={"secret": "relationship-provenance-do-not-export"},
|
||||||
|
)
|
||||||
|
related_relationship = IdmIdentityRelationship(
|
||||||
|
id="relationship-related",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
relationship_kind="representative_for",
|
||||||
|
subject_identity_id=other_identity.id,
|
||||||
|
related_identity_id=self.identity.id,
|
||||||
|
role="representative",
|
||||||
|
properties={"secret": "related-properties-do-not-export"},
|
||||||
|
provenance={"secret": "related-provenance-do-not-export"},
|
||||||
|
)
|
||||||
|
unrelated_relationship = IdmIdentityRelationship(
|
||||||
|
id="relationship-unrelated",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
relationship_kind="member_of",
|
||||||
|
subject_identity_id=other_identity.id,
|
||||||
|
target_group_id=self.group.id,
|
||||||
|
role="member",
|
||||||
|
)
|
||||||
|
tenant_two_relationship = IdmIdentityRelationship(
|
||||||
|
id="relationship-tenant-2",
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
relationship_kind="member_of",
|
||||||
|
subject_identity_id=self.identity.id,
|
||||||
|
target_group_id=self.group.id,
|
||||||
|
role="member",
|
||||||
|
properties={"secret": "other-tenant-relationship-do-not-export"},
|
||||||
|
)
|
||||||
|
self.change = IdmFunctionAssignmentChange(
|
||||||
|
id="change-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
kind="request",
|
||||||
|
state="approved",
|
||||||
|
profile="self_request",
|
||||||
|
function_id=function.id,
|
||||||
|
organization_unit_id=unit.id,
|
||||||
|
candidate_identity_id=self.identity.id,
|
||||||
|
candidate_account_id="account-1",
|
||||||
|
initiator_account_id="account-other",
|
||||||
|
initiator_identity_id=other_identity.id,
|
||||||
|
justification="private-justification-do-not-export",
|
||||||
|
evidence=["private-evidence-do-not-export"],
|
||||||
|
requested_valid_from=now,
|
||||||
|
required_steps=["approval-secret-do-not-export"],
|
||||||
|
completed_steps=["approval-secret-do-not-export"],
|
||||||
|
policy_decision={"secret": "policy-decision-do-not-export"},
|
||||||
|
workflow_definition_id="workflow-secret-do-not-export",
|
||||||
|
workflow_instance_id="workflow-instance-do-not-export",
|
||||||
|
idempotency_key="idempotency-key-do-not-export",
|
||||||
|
outcome_reason="outcome-reason-do-not-export",
|
||||||
|
metadata_={"secret": "change-metadata-do-not-export"},
|
||||||
|
)
|
||||||
|
initiated_change = IdmFunctionAssignmentChange(
|
||||||
|
id="change-initiated",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
kind="grant",
|
||||||
|
state="pending",
|
||||||
|
profile="authority_grant",
|
||||||
|
function_id=acting_function.id,
|
||||||
|
organization_unit_id=unit.id,
|
||||||
|
candidate_identity_id=other_identity.id,
|
||||||
|
candidate_account_id="account-other",
|
||||||
|
initiator_account_id="account-1",
|
||||||
|
initiator_identity_id=self.identity.id,
|
||||||
|
justification="third-party-justification-do-not-export",
|
||||||
|
evidence=["third-party-evidence-do-not-export"],
|
||||||
|
idempotency_key="initiated-change-key-do-not-export",
|
||||||
|
metadata_={"secret": "initiated-metadata-do-not-export"},
|
||||||
|
)
|
||||||
|
unrelated_change = IdmFunctionAssignmentChange(
|
||||||
|
id="change-unrelated",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
kind="grant",
|
||||||
|
state="pending",
|
||||||
|
profile="authority_grant",
|
||||||
|
function_id=function.id,
|
||||||
|
organization_unit_id=unit.id,
|
||||||
|
candidate_identity_id=other_identity.id,
|
||||||
|
candidate_account_id="account-other",
|
||||||
|
initiator_account_id="account-other",
|
||||||
|
initiator_identity_id=other_identity.id,
|
||||||
|
justification="unrelated-change-do-not-export",
|
||||||
|
idempotency_key="unrelated-change-key",
|
||||||
|
)
|
||||||
|
tenant_two_change = IdmFunctionAssignmentChange(
|
||||||
|
id="change-tenant-2",
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
kind="request",
|
||||||
|
state="pending",
|
||||||
|
profile="self_request",
|
||||||
|
function_id=function.id,
|
||||||
|
organization_unit_id=unit.id,
|
||||||
|
candidate_identity_id=self.identity.id,
|
||||||
|
candidate_account_id="account-1",
|
||||||
|
initiator_account_id="account-1",
|
||||||
|
initiator_identity_id=self.identity.id,
|
||||||
|
justification="other-tenant-change-do-not-export",
|
||||||
|
idempotency_key="tenant-two-key",
|
||||||
|
)
|
||||||
|
self.event = IdmFunctionAssignmentChangeEvent(
|
||||||
|
id="event-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
change_id=self.change.id,
|
||||||
|
sequence=1,
|
||||||
|
action="approved",
|
||||||
|
from_state="pending",
|
||||||
|
to_state="approved",
|
||||||
|
actor_account_id="account-other",
|
||||||
|
actor_identity_id=other_identity.id,
|
||||||
|
actor_assignment_id=self.unrelated_assignment.id,
|
||||||
|
comment="private-event-comment-do-not-export",
|
||||||
|
evidence=["private-event-evidence-do-not-export"],
|
||||||
|
policy_decision={"secret": "event-policy-do-not-export"},
|
||||||
|
workflow_step_id="workflow-step-do-not-export",
|
||||||
|
details={"secret": "event-details-do-not-export"},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
actor_event = IdmFunctionAssignmentChangeEvent(
|
||||||
|
id="event-actor",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
change_id=unrelated_change.id,
|
||||||
|
sequence=1,
|
||||||
|
action="reviewed",
|
||||||
|
from_state="pending",
|
||||||
|
to_state="pending",
|
||||||
|
actor_account_id="account-1",
|
||||||
|
actor_identity_id=self.identity.id,
|
||||||
|
actor_assignment_id=self.assignment.id,
|
||||||
|
comment="actor-comment-do-not-export",
|
||||||
|
evidence=["actor-evidence-do-not-export"],
|
||||||
|
policy_decision={"secret": "actor-policy-do-not-export"},
|
||||||
|
workflow_step_id="actor-workflow-step-do-not-export",
|
||||||
|
details={"secret": "actor-details-do-not-export"},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
tenant_two_event = IdmFunctionAssignmentChangeEvent(
|
||||||
|
id="event-tenant-2",
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
change_id=tenant_two_change.id,
|
||||||
|
sequence=1,
|
||||||
|
action="requested",
|
||||||
|
to_state="pending",
|
||||||
|
actor_account_id="account-1",
|
||||||
|
actor_identity_id=self.identity.id,
|
||||||
|
details={"secret": "other-tenant-event-do-not-export"},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
self.session.add_all(
|
||||||
|
[
|
||||||
|
self.identity,
|
||||||
|
other_identity,
|
||||||
|
unit,
|
||||||
|
function,
|
||||||
|
acting_function,
|
||||||
|
self.assignment,
|
||||||
|
acting_assignment,
|
||||||
|
self.unrelated_assignment,
|
||||||
|
tenant_two_assignment,
|
||||||
|
self.group,
|
||||||
|
self.relationship,
|
||||||
|
related_relationship,
|
||||||
|
unrelated_relationship,
|
||||||
|
tenant_two_relationship,
|
||||||
|
self.change,
|
||||||
|
initiated_change,
|
||||||
|
unrelated_change,
|
||||||
|
tenant_two_change,
|
||||||
|
self.event,
|
||||||
|
actor_event,
|
||||||
|
tenant_two_event,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
self.provider = IdmDsarProvider()
|
||||||
|
self.subject = DsarSubjectRef(
|
||||||
|
account_id="account-1",
|
||||||
|
identity_id=self.identity.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def test_manifest_publishes_protocol_conforming_provider(self) -> None:
|
||||||
|
self.assertIn(
|
||||||
|
IDM_DSAR_CAPABILITY,
|
||||||
|
{item.name for item in manifest.provides_interfaces},
|
||||||
|
)
|
||||||
|
provider = manifest.capability_factories[IDM_DSAR_CAPABILITY](None)
|
||||||
|
self.assertIsInstance(provider, DsarProvider)
|
||||||
|
|
||||||
|
def test_search_is_tenant_scoped_third_party_safe_and_minimized(self) -> None:
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=self.subject,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(
|
||||||
|
{
|
||||||
|
"idm_function_assignment",
|
||||||
|
"idm_identity_relationship",
|
||||||
|
"idm_typed_group_context",
|
||||||
|
"idm_function_assignment_change",
|
||||||
|
"idm_function_assignment_change_event",
|
||||||
|
}.issubset({record.resource_type for record in records})
|
||||||
|
)
|
||||||
|
serialized = repr([record.to_dict() for record in records])
|
||||||
|
self.assertIn("assignment-acting", serialized)
|
||||||
|
self.assertIn("relationship-related", serialized)
|
||||||
|
self.assertIn("change-initiated", serialized)
|
||||||
|
self.assertIn("event-actor", serialized)
|
||||||
|
excluded = (
|
||||||
|
"identity-other",
|
||||||
|
"account-other",
|
||||||
|
"assignment-settings-do-not-export",
|
||||||
|
"private-group-ref-do-not-export",
|
||||||
|
"group-properties-do-not-export",
|
||||||
|
"group-provenance-do-not-export",
|
||||||
|
"private-relationship-ref-do-not-export",
|
||||||
|
"private-source-revision-do-not-export",
|
||||||
|
"relationship-properties-do-not-export",
|
||||||
|
"relationship-provenance-do-not-export",
|
||||||
|
"private-justification-do-not-export",
|
||||||
|
"private-evidence-do-not-export",
|
||||||
|
"approval-secret-do-not-export",
|
||||||
|
"policy-decision-do-not-export",
|
||||||
|
"workflow-secret-do-not-export",
|
||||||
|
"workflow-instance-do-not-export",
|
||||||
|
"idempotency-key-do-not-export",
|
||||||
|
"outcome-reason-do-not-export",
|
||||||
|
"change-metadata-do-not-export",
|
||||||
|
"private-event-comment-do-not-export",
|
||||||
|
"private-event-evidence-do-not-export",
|
||||||
|
"event-policy-do-not-export",
|
||||||
|
"workflow-step-do-not-export",
|
||||||
|
"event-details-do-not-export",
|
||||||
|
"unrelated-change-do-not-export",
|
||||||
|
"other-tenant-assignment-do-not-export",
|
||||||
|
"other-tenant-relationship-do-not-export",
|
||||||
|
"other-tenant-change-do-not-export",
|
||||||
|
"other-tenant-event-do-not-export",
|
||||||
|
)
|
||||||
|
for value in excluded:
|
||||||
|
self.assertNotIn(value, serialized)
|
||||||
|
|
||||||
|
def test_conflicting_and_uncorroborated_direct_selectors_fail_closed(self) -> None:
|
||||||
|
conflict = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
identity_id=self.identity.id,
|
||||||
|
external_references={"idm.identity": "identity-other"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
direct_conflict = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
identity_id=self.identity.id,
|
||||||
|
external_references={
|
||||||
|
"idm.assignment": self.unrelated_assignment.id,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual((), conflict)
|
||||||
|
self.assertEqual((), direct_conflict)
|
||||||
|
|
||||||
|
def test_plan_retains_evidence_and_routes_facts_to_manual_review(self) -> None:
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=self.subject,
|
||||||
|
)
|
||||||
|
actions = self.provider.plan_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=self.subject,
|
||||||
|
records=records,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
{"manual_review", "retain"},
|
||||||
|
{action.kind for action in actions},
|
||||||
|
)
|
||||||
|
self.assertFalse(any(action.executable for action in actions))
|
||||||
|
results = self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=self.subject,
|
||||||
|
actions=actions,
|
||||||
|
request_id="dsar-idm-1",
|
||||||
|
)
|
||||||
|
self.assertEqual({"blocked"}, {result.status for result in results})
|
||||||
|
self.assertIsNotNone(
|
||||||
|
self.session.get(IdmOrganizationFunctionAssignment, self.assignment.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_execution_rejects_foreign_and_forged_executable_actions(self) -> None:
|
||||||
|
actions = (
|
||||||
|
DsarErasureActionRef(
|
||||||
|
action_id="identity:delete:assignment:assignment-1",
|
||||||
|
provider_id="identity",
|
||||||
|
module_id="identity",
|
||||||
|
kind="delete",
|
||||||
|
resource_type="idm_function_assignment",
|
||||||
|
resource_id=self.assignment.id,
|
||||||
|
title="Foreign action",
|
||||||
|
rationale="Must be rejected",
|
||||||
|
executable=True,
|
||||||
|
),
|
||||||
|
DsarErasureActionRef(
|
||||||
|
action_id="idm:delete:assignment:assignment-1",
|
||||||
|
provider_id="idm",
|
||||||
|
module_id="idm",
|
||||||
|
kind="delete",
|
||||||
|
resource_type="idm_function_assignment",
|
||||||
|
resource_id=self.assignment.id,
|
||||||
|
title="Forged action",
|
||||||
|
rationale="Must be rejected",
|
||||||
|
executable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for action in actions:
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=self.subject,
|
||||||
|
actions=(action,),
|
||||||
|
request_id="dsar-idm-2",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_core_workflow_discovers_active_and_inactive_provider(self) -> None:
|
||||||
|
request = create_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
reference="DSAR-IDM-1",
|
||||||
|
request_kind="access",
|
||||||
|
subject=self.subject,
|
||||||
|
purpose="Respond to an authorized privacy request.",
|
||||||
|
legal_basis="Article 15 GDPR",
|
||||||
|
due_at=None,
|
||||||
|
requested_by_account_id="privacy-officer",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
search_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
registry=_Registry(self.provider),
|
||||||
|
row=request,
|
||||||
|
expected_revision=1,
|
||||||
|
)
|
||||||
|
self.assertEqual(["idm"], request.coverage["covered_modules"])
|
||||||
|
|
||||||
|
disabled = create_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
reference="DSAR-IDM-DISABLED",
|
||||||
|
request_kind="access",
|
||||||
|
subject=self.subject,
|
||||||
|
purpose="Verify disabled-module coverage.",
|
||||||
|
legal_basis="Article 15 GDPR",
|
||||||
|
due_at=None,
|
||||||
|
requested_by_account_id="privacy-officer",
|
||||||
|
)
|
||||||
|
search_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
registry=_Registry(self.provider, idm_active=False),
|
||||||
|
row=disabled,
|
||||||
|
expected_revision=1,
|
||||||
|
)
|
||||||
|
self.assertEqual(0, disabled.search_result["record_count"])
|
||||||
|
self.assertEqual(
|
||||||
|
[IDM_DSAR_CAPABILITY],
|
||||||
|
disabled.coverage["inactive_provider_capabilities"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from govoplan_idm.backend.manifest import manifest
|
from govoplan_idm.backend.manifest import manifest
|
||||||
|
from govoplan_idm.backend.api.v1.routes import ORGANIZATION_IDENTITY_READ_SCOPES
|
||||||
|
|
||||||
|
|
||||||
class IdmInterfaceDocumentationContractTests(unittest.TestCase):
|
class IdmInterfaceDocumentationContractTests(unittest.TestCase):
|
||||||
@@ -24,6 +25,7 @@ class IdmInterfaceDocumentationContractTests(unittest.TestCase):
|
|||||||
self.assertIn("idm.workflow.assign-function-to-identity", topics)
|
self.assertIn("idm.workflow.assign-function-to-identity", topics)
|
||||||
self.assertIn("idm.reference.assignment-governance", topics)
|
self.assertIn("idm.reference.assignment-governance", topics)
|
||||||
self.assertIn("idm.reference.fields-and-consequences", topics)
|
self.assertIn("idm.reference.fields-and-consequences", topics)
|
||||||
|
self.assertIn("idm.reference.typed-relationships", topics)
|
||||||
|
|
||||||
workflow = topics["idm.workflow.assign-function-to-identity"]
|
workflow = topics["idm.workflow.assign-function-to-identity"]
|
||||||
self.assertIn("idm.blocker.permission", workflow.metadata["help_contexts"])
|
self.assertIn("idm.blocker.permission", workflow.metadata["help_contexts"])
|
||||||
@@ -33,7 +35,30 @@ class IdmInterfaceDocumentationContractTests(unittest.TestCase):
|
|||||||
self.assertIn("idm.field.acting-for", reference.metadata["help_contexts"])
|
self.assertIn("idm.field.acting-for", reference.metadata["help_contexts"])
|
||||||
self.assertIn("deactivate_or_expire", reference.metadata["consequence_classes"])
|
self.assertIn("deactivate_or_expire", reference.metadata["consequence_classes"])
|
||||||
|
|
||||||
|
relationships = topics["idm.reference.typed-relationships"]
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"title",
|
||||||
|
"summary",
|
||||||
|
"body",
|
||||||
|
},
|
||||||
|
set(relationships.translations["de"]),
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"idm.relationships.field.revocation-reason",
|
||||||
|
relationships.metadata["help_contexts"],
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"idm.typed-groups.action.resolve-memberships",
|
||||||
|
relationships.metadata["help_contexts"],
|
||||||
|
)
|
||||||
|
self.assertIn("Revocation immediately", relationships.metadata["consequences"][2])
|
||||||
|
self.assertIn("Access permissions remain unchanged", relationships.metadata["verification"])
|
||||||
|
|
||||||
|
def test_relationship_writers_may_use_identity_search_selectors(self) -> None:
|
||||||
|
self.assertIn("idm:relationship:write", ORGANIZATION_IDENTITY_READ_SCOPES)
|
||||||
|
self.assertNotIn("idm:relationship:read", ORGANIZATION_IDENTITY_READ_SCOPES)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/idm-webui",
|
"name": "@govoplan/idm-webui",
|
||||||
"version": "0.1.15",
|
"version": "0.1.19",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
"./styles/idm.css": "./src/styles/idm.css"
|
"./styles/idm.css": "./src/styles/idm.css"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.15",
|
"@govoplan/core-webui": "^0.1.18",
|
||||||
"@vitejs/plugin-react": "^5.2.0",
|
"@vitejs/plugin-react": "^5.2.0",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": ">=19.2.7 <20",
|
"react": ">=19.2.7 <20",
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ function assert(condition, message) {
|
|||||||
|
|
||||||
const page = source("../src/features/IdmPage.tsx");
|
const page = source("../src/features/IdmPage.tsx");
|
||||||
const changes = source("../src/features/FunctionAssignmentChangesPanel.tsx");
|
const changes = source("../src/features/FunctionAssignmentChangesPanel.tsx");
|
||||||
|
const relationships = source("../src/features/TypedRelationshipsPanel.tsx");
|
||||||
|
const api = source("../src/api/idm.ts");
|
||||||
const patterns = source("../src/features/interfacePatterns.ts");
|
const patterns = source("../src/features/interfacePatterns.ts");
|
||||||
const moduleSource = source("../src/module.ts");
|
const moduleSource = source("../src/module.ts");
|
||||||
const translations = source("../src/i18n/generatedTranslations.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("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("assignmentBaseline") && page.includes("draftKey(assignmentDraft) !== draftKey(assignmentBaseline)"), "Existing assignments compare against their loaded draft baseline");
|
||||||
@@ -24,6 +27,16 @@ assert(patterns.includes('topicId: "idm.reference.fields-and-consequences"'), "I
|
|||||||
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(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(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(!page.includes("window.confirm") && !changes.includes("window.confirm"), "IDM does not use browser-native consequential confirmation");
|
||||||
|
assert(styles.includes(".idm-page") && !/\.idm-page\s*\{[^}]*max-width/s.test(styles), "The IDM workspace uses the full shared application width");
|
||||||
|
assert(page.includes("TypedRelationshipsPanel") && page.includes("<TypedRelationshipsPanel settings={settings} auth={auth} />"), "The IDM workspace exposes typed-group and relationship administration");
|
||||||
|
assert(relationships.includes("SearchableSelect") && relationships.includes('aria-label="Subject identity"') && relationships.includes('aria-label="Target typed group"') && relationships.includes('aria-label="Related identity"'), "Identity and group references use searchable selectors");
|
||||||
|
assert(relationships.includes('"future" | "active" | "expired" | "revoked"') && relationships.includes("relationshipState(row)"), "Relationship lifecycle states remain visually distinct");
|
||||||
|
assert(relationships.includes("revocationReason.trim()") && relationships.includes('helpContextId="idm.relationships.confirm-revoke"'), "Relationship revocation requires a reason and explicit governed confirmation");
|
||||||
|
assert(relationships.includes("resolveTypedGroupMemberships") && relationships.includes('id="idm-typed-group-membership-resolution"'), "Effective group membership is inspectable through the shared resolver");
|
||||||
|
assert(relationships.includes("sourceResourceId") && relationships.includes("sourceRevision") && relationships.includes("provenance"), "Relationship administration retains source and provenance evidence");
|
||||||
|
assert(api.includes("/api/v1/idm/typed-groups") && api.includes("/api/v1/idm/relationships") && api.includes("/memberships?"), "The WebUI API uses the implemented relationship lifecycle endpoints");
|
||||||
|
assert(moduleSource.includes('"idm:relationship:read"') && moduleSource.includes('"idm:relationship:write"'), "Relationship-only administrators can enter the IDM product surface");
|
||||||
|
assert(translations.includes('"Typed groups and identity relationships": "Typisierte Gruppen und Identitätsbeziehungen"') && translations.includes('"Revoked": "Widerrufen"'), "The relationship administration vocabulary has German reference translations");
|
||||||
|
assert(!relationships.includes("window.confirm"), "Relationship administration does not use browser-native consequential confirmation");
|
||||||
|
|
||||||
console.log("IDM surfaces satisfy the recorded interface pattern-language contract.");
|
console.log("IDM surfaces satisfy the recorded interface pattern-language contract.");
|
||||||
|
|
||||||
|
|||||||
+194
-2
@@ -49,6 +49,117 @@ export type IdentityListResponse = {
|
|||||||
identities: IdentityOption[];
|
identities: IdentityOption[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TypedGroupItem = {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
group_type: string;
|
||||||
|
description?: string | null;
|
||||||
|
status: "active" | "inactive";
|
||||||
|
source_provider: string;
|
||||||
|
source_resource_type?: string | null;
|
||||||
|
source_resource_id?: string | null;
|
||||||
|
source_revision?: string | null;
|
||||||
|
properties: Record<string, unknown>;
|
||||||
|
provenance: Record<string, unknown>;
|
||||||
|
revision: number;
|
||||||
|
created_at?: string | null;
|
||||||
|
updated_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TypedGroupList = {
|
||||||
|
groups: TypedGroupItem[];
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TypedGroupPayload = {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
group_type: string;
|
||||||
|
description?: string | null;
|
||||||
|
source_provider?: string;
|
||||||
|
source_resource_type?: string | null;
|
||||||
|
source_resource_id?: string | null;
|
||||||
|
source_revision?: string | null;
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
provenance?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TypedGroupUpdatePayload = Partial<TypedGroupPayload> & {
|
||||||
|
base_revision: number;
|
||||||
|
status?: "active" | "inactive";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IdentityRelationshipItem = {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
relationship_kind: string;
|
||||||
|
subject_identity_id: string;
|
||||||
|
target_group_id?: string | null;
|
||||||
|
related_identity_id?: string | null;
|
||||||
|
role?: string | null;
|
||||||
|
valid_from?: string | null;
|
||||||
|
valid_until?: string | null;
|
||||||
|
status: "active" | "revoked";
|
||||||
|
revoked_at?: string | null;
|
||||||
|
revoked_by?: string | null;
|
||||||
|
revocation_reason?: string | null;
|
||||||
|
expired_event_at?: string | null;
|
||||||
|
source_provider: string;
|
||||||
|
source_resource_type?: string | null;
|
||||||
|
source_resource_id?: string | null;
|
||||||
|
source_revision?: string | null;
|
||||||
|
properties: Record<string, unknown>;
|
||||||
|
provenance: Record<string, unknown>;
|
||||||
|
revision: number;
|
||||||
|
created_at?: string | null;
|
||||||
|
updated_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IdentityRelationshipList = {
|
||||||
|
relationships: IdentityRelationshipItem[];
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IdentityRelationshipPayload = {
|
||||||
|
relationship_kind: string;
|
||||||
|
subject_identity_id: string;
|
||||||
|
target_group_id?: string | null;
|
||||||
|
related_identity_id?: string | null;
|
||||||
|
role?: string | null;
|
||||||
|
valid_from?: string | null;
|
||||||
|
valid_until?: string | null;
|
||||||
|
source_provider?: string;
|
||||||
|
source_resource_type?: string | null;
|
||||||
|
source_resource_id?: string | null;
|
||||||
|
source_revision?: string | null;
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
provenance?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IdentityRelationshipUpdatePayload = Omit<
|
||||||
|
Partial<IdentityRelationshipPayload>,
|
||||||
|
"subject_identity_id"
|
||||||
|
> & {
|
||||||
|
base_revision: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IdentityRelationshipDecisionItem = {
|
||||||
|
relationship: IdentityRelationshipItem;
|
||||||
|
included: boolean;
|
||||||
|
code: string;
|
||||||
|
explanation: string;
|
||||||
|
identity_status?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TypedGroupMembershipResolution = {
|
||||||
|
group: TypedGroupItem;
|
||||||
|
effective_at: string;
|
||||||
|
decisions: IdentityRelationshipDecisionItem[];
|
||||||
|
identity_ids: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export type OrganizationFunctionAssignmentItem = {
|
export type OrganizationFunctionAssignmentItem = {
|
||||||
id: string;
|
id: string;
|
||||||
tenant_id: string;
|
tenant_id: string;
|
||||||
@@ -218,12 +329,93 @@ export function patchIdmSettings(settings: ApiSettings, payload: IdmSettingsPayl
|
|||||||
return apiPatchJson(settings, "/api/v1/idm/settings", payload);
|
return apiPatchJson(settings, "/api/v1/idm/settings", payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function searchOrganizationIdentityOptions(settings: ApiSettings, query = "", limit = 50): Promise<IdentityListResponse> {
|
export function searchOrganizationIdentityOptions(
|
||||||
|
settings: ApiSettings,
|
||||||
|
query = "",
|
||||||
|
limit = 50,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<IdentityListResponse> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
const trimmed = query.trim();
|
const trimmed = query.trim();
|
||||||
if (trimmed) params.set("query", trimmed);
|
if (trimmed) params.set("query", trimmed);
|
||||||
params.set("limit", String(limit));
|
params.set("limit", String(limit));
|
||||||
return apiFetch<IdentityListResponse>(settings, `/api/v1/idm/organization-identities?${params.toString()}`);
|
return apiFetch<IdentityListResponse>(settings, `/api/v1/idm/organization-identities?${params.toString()}`, { signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTypedGroups(
|
||||||
|
settings: ApiSettings,
|
||||||
|
options: { query?: string; includeInactive?: boolean; limit?: number; signal?: AbortSignal } = {}
|
||||||
|
): Promise<TypedGroupList> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (options.query?.trim()) params.set("query", options.query.trim());
|
||||||
|
if (options.includeInactive) params.set("include_inactive", "true");
|
||||||
|
params.set("limit", String(options.limit ?? 1000));
|
||||||
|
return apiFetch<TypedGroupList>(settings, `/api/v1/idm/typed-groups?${params.toString()}`, { signal: options.signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTypedGroup(settings: ApiSettings, payload: TypedGroupPayload): Promise<TypedGroupItem> {
|
||||||
|
return apiPostJson(settings, "/api/v1/idm/typed-groups", payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function patchTypedGroup(
|
||||||
|
settings: ApiSettings,
|
||||||
|
groupId: string,
|
||||||
|
payload: TypedGroupUpdatePayload
|
||||||
|
): Promise<TypedGroupItem> {
|
||||||
|
return apiPatchJson(settings, `/api/v1/idm/typed-groups/${encodeURIComponent(groupId)}`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIdentityRelationships(
|
||||||
|
settings: ApiSettings,
|
||||||
|
options: { includeRevoked?: boolean; identityId?: string; groupId?: string; relationshipKind?: string; limit?: number } = {}
|
||||||
|
): Promise<IdentityRelationshipList> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (options.includeRevoked) params.set("include_revoked", "true");
|
||||||
|
if (options.identityId) params.set("identity_id", options.identityId);
|
||||||
|
if (options.groupId) params.set("group_id", options.groupId);
|
||||||
|
if (options.relationshipKind) params.set("relationship_kind", options.relationshipKind);
|
||||||
|
params.set("limit", String(options.limit ?? 1000));
|
||||||
|
return apiFetch<IdentityRelationshipList>(settings, `/api/v1/idm/relationships?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createIdentityRelationship(
|
||||||
|
settings: ApiSettings,
|
||||||
|
payload: IdentityRelationshipPayload
|
||||||
|
): Promise<IdentityRelationshipItem> {
|
||||||
|
return apiPostJson(settings, "/api/v1/idm/relationships", payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function patchIdentityRelationship(
|
||||||
|
settings: ApiSettings,
|
||||||
|
relationshipId: string,
|
||||||
|
payload: IdentityRelationshipUpdatePayload
|
||||||
|
): Promise<IdentityRelationshipItem> {
|
||||||
|
return apiPatchJson(settings, `/api/v1/idm/relationships/${encodeURIComponent(relationshipId)}`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function revokeIdentityRelationship(
|
||||||
|
settings: ApiSettings,
|
||||||
|
relationship: Pick<IdentityRelationshipItem, "id" | "revision">,
|
||||||
|
reason: string
|
||||||
|
): Promise<IdentityRelationshipItem> {
|
||||||
|
return apiPostJson(settings, `/api/v1/idm/relationships/${encodeURIComponent(relationship.id)}/revoke`, {
|
||||||
|
base_revision: relationship.revision,
|
||||||
|
reason
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTypedGroupMemberships(
|
||||||
|
settings: ApiSettings,
|
||||||
|
groupId: string,
|
||||||
|
options: { effectiveAt?: string; relationshipKinds?: string[] } = {}
|
||||||
|
): Promise<TypedGroupMembershipResolution> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (options.effectiveAt) params.set("effective_at", options.effectiveAt);
|
||||||
|
for (const kind of options.relationshipKinds ?? ["member"]) params.append("relationship_kind", kind);
|
||||||
|
return apiFetch<TypedGroupMembershipResolution>(
|
||||||
|
settings,
|
||||||
|
`/api/v1/idm/typed-groups/${encodeURIComponent(groupId)}/memberships?${params.toString()}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createOrganizationFunctionAssignment(
|
export function createOrganizationFunctionAssignment(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
||||||
import { Check, Eye, Plus, RotateCcw, Undo2, X } from "lucide-react";
|
import { Check, Eye, Plus, RotateCcw, Undo2, X } from "lucide-react";
|
||||||
import {
|
import { FormLayout,
|
||||||
AdminIconButton,
|
AdminIconButton,
|
||||||
ApiError,
|
ApiError,
|
||||||
Button,
|
Button,
|
||||||
@@ -360,15 +360,15 @@ export default function FunctionAssignmentChangesPanel({ settings, auth, model,
|
|||||||
</LoadingFrame>
|
</LoadingFrame>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Dialog
|
<Dialog variant="administration" size="wide"
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
title="Start governed function change"
|
title="Start governed function change"
|
||||||
className="admin-dialog admin-dialog-wide idm-change-dialog"
|
className="idm-change-dialog"
|
||||||
onClose={closeCreate}
|
onClose={closeCreate}
|
||||||
closeDisabled={busy}
|
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></>}
|
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)}>
|
<FormLayout columns={2} gap="small" collapseAt="workspace" id="idm-change-create" className="" onSubmit={(event) => void submit(event)}>
|
||||||
<div className="wide">
|
<div className="wide">
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
ariaLabel="Function change kind"
|
ariaLabel="Function change kind"
|
||||||
@@ -414,10 +414,10 @@ export default function FunctionAssignmentChangesPanel({ settings, auth, model,
|
|||||||
<textarea rows={3} value={draft.evidence} onChange={(event) => setDraft((current) => ({ ...current, evidence: event.target.value }))} disabled={busy} />
|
<textarea rows={3} value={draft.evidence} onChange={(event) => setDraft((current) => ({ ...current, evidence: event.target.value }))} disabled={busy} />
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</FormLayout>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog
|
<Dialog variant="administration" size="wide"
|
||||||
open={detailOpen && selected !== null}
|
open={detailOpen && selected !== null}
|
||||||
title={selected
|
title={selected
|
||||||
? i18nMessage(
|
? i18nMessage(
|
||||||
@@ -427,7 +427,7 @@ export default function FunctionAssignmentChangesPanel({ settings, auth, model,
|
|||||||
{ function: functionById.get(selected.function_id)?.name ?? selected.function_id }
|
{ function: functionById.get(selected.function_id)?.name ?? selected.function_id }
|
||||||
)
|
)
|
||||||
: "Function change"}
|
: "Function change"}
|
||||||
className="admin-dialog admin-dialog-wide idm-change-dialog"
|
className="idm-change-dialog"
|
||||||
onClose={() => !busy && setDetailOpen(false)}
|
onClose={() => !busy && setDetailOpen(false)}
|
||||||
closeDisabled={busy}
|
closeDisabled={busy}
|
||||||
footer={<Button type="button" onClick={() => setDetailOpen(false)} disabled={busy} disabledReason={idmDisabledReason(false, busy)}>Close</Button>}
|
footer={<Button type="button" onClick={() => setDetailOpen(false)} disabled={busy} disabledReason={idmDisabledReason(false, busy)}>Close</Button>}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
||||||
import { Edit3, Plus, RefreshCw } from "lucide-react";
|
import { Edit3, Plus, RefreshCw } from "lucide-react";
|
||||||
import {
|
import { FormLayout, ActionToolbar,
|
||||||
ActionBlockerHint,
|
ActionBlockerHint,
|
||||||
AdminIconButton,
|
AdminIconButton,
|
||||||
ApiError,
|
ApiError,
|
||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
type OrganizationUnitItem
|
type OrganizationUnitItem
|
||||||
} from "../api/idm";
|
} from "../api/idm";
|
||||||
import FunctionAssignmentChangesPanel from "./FunctionAssignmentChangesPanel";
|
import FunctionAssignmentChangesPanel from "./FunctionAssignmentChangesPanel";
|
||||||
|
import TypedRelationshipsPanel from "./TypedRelationshipsPanel";
|
||||||
import {
|
import {
|
||||||
IDM_DOCUMENTATION,
|
IDM_DOCUMENTATION,
|
||||||
IDM_FIELD_DOCUMENTATION,
|
IDM_FIELD_DOCUMENTATION,
|
||||||
@@ -277,7 +278,10 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
|||||||
const appliedInitialQueryRef = useRef(false);
|
const appliedInitialQueryRef = useRef(false);
|
||||||
const { requestDiscard } = useUnsavedChanges();
|
const { requestDiscard } = useUnsavedChanges();
|
||||||
|
|
||||||
|
const canReadAssignments = hasScope(auth, "idm:organization_assignment:read") || hasScope(auth, "idm:organization_assignment:write") || hasScope(auth, "organizations:function:assign");
|
||||||
const canManage = hasScope(auth, "idm:organization_assignment:write") || hasScope(auth, "organizations:function:assign");
|
const canManage = hasScope(auth, "idm:organization_assignment:write") || hasScope(auth, "organizations:function:assign");
|
||||||
|
const canUseFunctionChanges = hasScope(auth, "idm:function_change:read") || hasScope(auth, "idm:function_request:create") || hasScope(auth, "idm:function_grant:create") || hasScope(auth, "idm:function_change:decide") || hasScope(auth, "idm:function_change:admin");
|
||||||
|
const canUseAssignmentWorkspace = canReadAssignments || canUseFunctionChanges;
|
||||||
const canSearchIdentities = canManage || hasScope(auth, "idm:organization_identity:read") || hasScope(auth, "admin:users:read");
|
const canSearchIdentities = canManage || hasScope(auth, "idm:organization_identity:read") || hasScope(auth, "admin:users:read");
|
||||||
const canReadSettings = hasScope(auth, "idm:settings:read") || hasScope(auth, "idm:settings:write") || hasScope(auth, "idm:organization_assignment:read") || hasScope(auth, "idm:organization_assignment:write");
|
const canReadSettings = hasScope(auth, "idm:settings:read") || hasScope(auth, "idm:settings:write") || hasScope(auth, "idm:organization_assignment:read") || hasScope(auth, "idm:organization_assignment:write");
|
||||||
const canManageSettings = hasScope(auth, "idm:settings:write");
|
const canManageSettings = hasScope(auth, "idm:settings:write");
|
||||||
@@ -336,8 +340,8 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
|||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const [nextModel, nextAssignments, nextSettings] = await Promise.all([
|
const [nextModel, nextAssignments, nextSettings] = await Promise.all([
|
||||||
getOrganizationModel(settings),
|
canUseAssignmentWorkspace ? getOrganizationModel(settings) : Promise.resolve(EMPTY_MODEL),
|
||||||
getOrganizationFunctionAssignments(settings),
|
canReadAssignments ? getOrganizationFunctionAssignments(settings) : Promise.resolve({ assignments: [], total: 0, page: 1, page_size: 0, pages: 1 }),
|
||||||
canReadSettings ? getIdmSettings(settings).catch(() => null) : Promise.resolve(null)
|
canReadSettings ? getIdmSettings(settings).catch(() => null) : Promise.resolve(null)
|
||||||
]);
|
]);
|
||||||
setModel(nextModel);
|
setModel(nextModel);
|
||||||
@@ -371,7 +375,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [canReadSettings, canSearchIdentities, initialFunctionFilter, initialQuery.assignmentId, settings]);
|
}, [canReadAssignments, canReadSettings, canSearchIdentities, canUseAssignmentWorkspace, initialQuery.assignmentId, settings]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadData();
|
void loadData();
|
||||||
@@ -624,7 +628,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
|||||||
<PageTitle loading={loading}>i18n:govoplan-idm.idm.61f4a7a2</PageTitle>
|
<PageTitle loading={loading}>i18n:govoplan-idm.idm.61f4a7a2</PageTitle>
|
||||||
<p>i18n:govoplan-idm.identity_links_intro.45fed9dd</p>
|
<p>i18n:govoplan-idm.identity_links_intro.45fed9dd</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="idm-toolbar">
|
<ActionToolbar justify="end" className="idm-toolbar">
|
||||||
<DocumentationHelpLink reference={IDM_DOCUMENTATION} />
|
<DocumentationHelpLink reference={IDM_DOCUMENTATION} />
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -635,12 +639,12 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
|||||||
>
|
>
|
||||||
<RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-idm.reload.870ca3ec
|
<RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-idm.reload.870ca3ec
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</ActionToolbar>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||||
{success && !error && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
|
{success && !error && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
|
||||||
{!canManage && (
|
{canReadAssignments && !canManage && (
|
||||||
<ActionBlockerHint
|
<ActionBlockerHint
|
||||||
reason={{
|
reason={{
|
||||||
summary: IDM_INTERFACE_I18N.writeReason,
|
summary: IDM_INTERFACE_I18N.writeReason,
|
||||||
@@ -656,7 +660,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
|||||||
documentation={IDM_DOCUMENTATION}
|
documentation={IDM_DOCUMENTATION}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!model.functions.length && !loading && (
|
{canUseAssignmentWorkspace && !model.functions.length && !loading && (
|
||||||
<ActionBlockerHint
|
<ActionBlockerHint
|
||||||
reason={{
|
reason={{
|
||||||
summary: IDM_INTERFACE_I18N.noFunctions,
|
summary: IDM_INTERFACE_I18N.noFunctions,
|
||||||
@@ -682,7 +686,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
|||||||
collapseKey="idm.governance"
|
collapseKey="idm.governance"
|
||||||
actions={<DocumentationHelpLink reference={IDM_GOVERNANCE_DOCUMENTATION} />}
|
actions={<DocumentationHelpLink reference={IDM_GOVERNANCE_DOCUMENTATION} />}
|
||||||
>
|
>
|
||||||
<form className="admin-form-grid two-columns" onSubmit={(event) => { event.preventDefault(); void submitSettings(); }}>
|
<FormLayout columns={2} gap="small" collapseAt="workspace" className="" onSubmit={(event) => { event.preventDefault(); void submitSettings(); }}>
|
||||||
<div className="idm-check-list wide">
|
<div className="idm-check-list wide">
|
||||||
<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 })} />
|
<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>
|
</div>
|
||||||
@@ -716,18 +720,22 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
|||||||
i18n:govoplan-idm.save_settings.4602c430
|
i18n:govoplan-idm.save_settings.4602c430
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</FormLayout>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{canUseFunctionChanges && (
|
||||||
<FunctionAssignmentChangesPanel
|
<FunctionAssignmentChangesPanel
|
||||||
settings={settings}
|
settings={settings}
|
||||||
auth={auth}
|
auth={auth}
|
||||||
model={model}
|
model={model}
|
||||||
identities={identityOptions}
|
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} />}>
|
<TypedRelationshipsPanel settings={settings} auth={auth} />
|
||||||
|
|
||||||
|
{canReadAssignments && <Card title="i18n:govoplan-idm.assignments.a0d19ec5" collapsible collapseKey="idm.assignments" actions={<AdminIconButton label="i18n:govoplan-idm.add_assignment.08f2a0d5" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canManage || busy || !model.functions.length} disabledReason={idmDisabledReason(false, busy, canManage) ?? (!model.functions.length ? IDM_INTERFACE_I18N.noFunctions : undefined)} onClick={openCreateAssignment} />}>
|
||||||
<DataGrid
|
<DataGrid
|
||||||
id="idm-organization-function-assignments"
|
id="idm-organization-function-assignments"
|
||||||
rows={assignments}
|
rows={assignments}
|
||||||
@@ -737,7 +745,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
|||||||
initialFilters={initialFunctionFilter ? { function: initialFunctionFilter } : undefined}
|
initialFilters={initialFunctionFilter ? { function: initialFunctionFilter } : undefined}
|
||||||
initialFit="container"
|
initialFit="container"
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>}
|
||||||
</div>
|
</div>
|
||||||
</LoadingFrame>
|
</LoadingFrame>
|
||||||
{renderAssignmentDialog()}
|
{renderAssignmentDialog()}
|
||||||
@@ -748,12 +756,12 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
|||||||
function renderAssignmentDialog() {
|
function renderAssignmentDialog() {
|
||||||
const formId = "idm-assignment-editor";
|
const formId = "idm-assignment-editor";
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog variant="administration" size="wide"
|
||||||
open={assignmentEditorOpen}
|
open={assignmentEditorOpen}
|
||||||
title={editingAssignmentId ? "i18n:govoplan-idm.update_assignment.e20f52aa" : "i18n:govoplan-idm.add_assignment.08f2a0d5"}
|
title={editingAssignmentId ? "i18n:govoplan-idm.update_assignment.e20f52aa" : "i18n:govoplan-idm.add_assignment.08f2a0d5"}
|
||||||
onClose={closeAssignmentEditor}
|
onClose={closeAssignmentEditor}
|
||||||
closeDisabled={busy}
|
closeDisabled={busy}
|
||||||
className="admin-dialog admin-dialog-wide idm-editor-dialog"
|
className="idm-editor-dialog"
|
||||||
footer={(
|
footer={(
|
||||||
<>
|
<>
|
||||||
<Button type="button" onClick={closeAssignmentEditor} disabled={busy} disabledReason={idmDisabledReason(false, busy)}>i18n:govoplan-idm.cancel_edit.ea4781e0</Button>
|
<Button type="button" onClick={closeAssignmentEditor} disabled={busy} disabledReason={idmDisabledReason(false, busy)}>i18n:govoplan-idm.cancel_edit.ea4781e0</Button>
|
||||||
@@ -769,7 +777,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<form id={formId} className="admin-form-grid two-columns" onSubmit={(event) => void submitAssignment(event)}>
|
<FormLayout columns={2} gap="small" collapseAt="workspace" id={formId} className="" onSubmit={(event) => void submitAssignment(event)}>
|
||||||
<FormField label="i18n:govoplan-idm.identity_search.d3460fcf" documentation={IDM_FIELD_DOCUMENTATION}>
|
<FormField label="i18n:govoplan-idm.identity_search.d3460fcf" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||||
<input
|
<input
|
||||||
value={identitySearch}
|
value={identitySearch}
|
||||||
@@ -886,7 +894,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
|||||||
{identityLoading && <p className="idm-muted wide">i18n:govoplan-idm.loading_identities.f3b84693</p>}
|
{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>}
|
{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>}
|
{!model.functions.length && <p className="idm-muted wide">i18n:govoplan-idm.no_functions_available.51ba08eb</p>}
|
||||||
</form>
|
</FormLayout>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,972 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Eye, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
ActionBlockerHint,
|
||||||
|
ActionToolbar,
|
||||||
|
AdminIconButton,
|
||||||
|
ApiError,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
DataGrid,
|
||||||
|
DateTimeField,
|
||||||
|
Dialog,
|
||||||
|
DismissibleAlert,
|
||||||
|
DocumentationHelpLink,
|
||||||
|
FormField,
|
||||||
|
FormLayout,
|
||||||
|
LoadingFrame,
|
||||||
|
SearchableSelect,
|
||||||
|
StatusBadge,
|
||||||
|
TableActionGroup,
|
||||||
|
ToggleSwitch,
|
||||||
|
hasScope,
|
||||||
|
i18nMessage,
|
||||||
|
usePlatformLanguage,
|
||||||
|
useUnsavedChanges,
|
||||||
|
useUnsavedDraftGuard,
|
||||||
|
type ApiSettings,
|
||||||
|
type AuthInfo,
|
||||||
|
type DataGridColumn,
|
||||||
|
type SearchableSelectOption
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
createIdentityRelationship,
|
||||||
|
createTypedGroup,
|
||||||
|
getIdentityRelationships,
|
||||||
|
getTypedGroups,
|
||||||
|
patchIdentityRelationship,
|
||||||
|
patchTypedGroup,
|
||||||
|
resolveTypedGroupMemberships,
|
||||||
|
revokeIdentityRelationship,
|
||||||
|
searchOrganizationIdentityOptions,
|
||||||
|
type IdentityOption,
|
||||||
|
type IdentityRelationshipDecisionItem,
|
||||||
|
type IdentityRelationshipItem,
|
||||||
|
type IdentityRelationshipPayload,
|
||||||
|
type TypedGroupItem,
|
||||||
|
type TypedGroupMembershipResolution,
|
||||||
|
type TypedGroupPayload
|
||||||
|
} from "../api/idm";
|
||||||
|
import { IDM_RELATIONSHIP_DOCUMENTATION } from "./interfacePatterns";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
settings: ApiSettings;
|
||||||
|
auth: AuthInfo;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GroupDraft = {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
groupType: string;
|
||||||
|
description: string;
|
||||||
|
status: "active" | "inactive";
|
||||||
|
sourceProvider: string;
|
||||||
|
sourceResourceType: string;
|
||||||
|
sourceResourceId: string;
|
||||||
|
sourceRevision: string;
|
||||||
|
properties: string;
|
||||||
|
provenance: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RelationshipDraft = {
|
||||||
|
relationshipKind: string;
|
||||||
|
subjectIdentityId: string;
|
||||||
|
targetType: "group" | "identity";
|
||||||
|
targetGroupId: string;
|
||||||
|
relatedIdentityId: string;
|
||||||
|
role: string;
|
||||||
|
validFrom: string;
|
||||||
|
validUntil: string;
|
||||||
|
sourceProvider: string;
|
||||||
|
sourceResourceType: string;
|
||||||
|
sourceResourceId: string;
|
||||||
|
sourceRevision: string;
|
||||||
|
properties: string;
|
||||||
|
provenance: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_GROUP_DRAFT: GroupDraft = {
|
||||||
|
key: "",
|
||||||
|
name: "",
|
||||||
|
groupType: "business_group",
|
||||||
|
description: "",
|
||||||
|
status: "active",
|
||||||
|
sourceProvider: "local",
|
||||||
|
sourceResourceType: "",
|
||||||
|
sourceResourceId: "",
|
||||||
|
sourceRevision: "",
|
||||||
|
properties: "{}",
|
||||||
|
provenance: "{}"
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_RELATIONSHIP_DRAFT: RelationshipDraft = {
|
||||||
|
relationshipKind: "member",
|
||||||
|
subjectIdentityId: "",
|
||||||
|
targetType: "group",
|
||||||
|
targetGroupId: "",
|
||||||
|
relatedIdentityId: "",
|
||||||
|
role: "",
|
||||||
|
validFrom: "",
|
||||||
|
validUntil: "",
|
||||||
|
sourceProvider: "local",
|
||||||
|
sourceResourceType: "",
|
||||||
|
sourceResourceId: "",
|
||||||
|
sourceRevision: "",
|
||||||
|
properties: "{}",
|
||||||
|
provenance: "{}"
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TypedRelationshipsPanel({ settings, auth }: Props) {
|
||||||
|
const [groups, setGroups] = useState<TypedGroupItem[]>([]);
|
||||||
|
const [relationships, setRelationships] = useState<IdentityRelationshipItem[]>([]);
|
||||||
|
const [identities, setIdentities] = useState<IdentityOption[]>([]);
|
||||||
|
const [showInactiveGroups, setShowInactiveGroups] = useState(false);
|
||||||
|
const [showRevokedRelationships, setShowRevokedRelationships] = useState(false);
|
||||||
|
const [groupEditor, setGroupEditor] = useState<TypedGroupItem | "create" | null>(null);
|
||||||
|
const [groupDraft, setGroupDraft] = useState<GroupDraft>({ ...EMPTY_GROUP_DRAFT });
|
||||||
|
const [groupBaseline, setGroupBaseline] = useState<GroupDraft>({ ...EMPTY_GROUP_DRAFT });
|
||||||
|
const [relationshipEditor, setRelationshipEditor] = useState<IdentityRelationshipItem | "create" | null>(null);
|
||||||
|
const [relationshipDraft, setRelationshipDraft] = useState<RelationshipDraft>({ ...EMPTY_RELATIONSHIP_DRAFT });
|
||||||
|
const [relationshipBaseline, setRelationshipBaseline] = useState<RelationshipDraft>({ ...EMPTY_RELATIONSHIP_DRAFT });
|
||||||
|
const [revokeTarget, setRevokeTarget] = useState<IdentityRelationshipItem | null>(null);
|
||||||
|
const [revocationReason, setRevocationReason] = useState("");
|
||||||
|
const [membershipGroup, setMembershipGroup] = useState<TypedGroupItem | null>(null);
|
||||||
|
const [membershipEffectiveAt, setMembershipEffectiveAt] = useState("");
|
||||||
|
const [membershipKinds, setMembershipKinds] = useState("member");
|
||||||
|
const [membershipResolution, setMembershipResolution] = useState<TypedGroupMembershipResolution | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [membershipLoading, setMembershipLoading] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [success, setSuccess] = useState("");
|
||||||
|
const appliedDeepLink = useRef(false);
|
||||||
|
const { language } = usePlatformLanguage();
|
||||||
|
const { requestDiscard } = useUnsavedChanges();
|
||||||
|
|
||||||
|
const canRead = hasScope(auth, "idm:relationship:read") || hasScope(auth, "idm:relationship:write");
|
||||||
|
const canWrite = hasScope(auth, "idm:relationship:write");
|
||||||
|
const canSearchIdentities = canWrite
|
||||||
|
|| hasScope(auth, "idm:organization_identity:read")
|
||||||
|
|| hasScope(auth, "idm:organization_assignment:write")
|
||||||
|
|| hasScope(auth, "admin:users:read");
|
||||||
|
const groupById = useMemo(() => new Map(groups.map((item) => [item.id, item])), [groups]);
|
||||||
|
const visibleGroups = useMemo(
|
||||||
|
() => showInactiveGroups ? groups : groups.filter((item) => item.status === "active"),
|
||||||
|
[groups, showInactiveGroups]
|
||||||
|
);
|
||||||
|
const identityById = useMemo(() => new Map(identities.map((item) => [item.id, item])), [identities]);
|
||||||
|
const groupOptions = useMemo<SearchableSelectOption[]>(
|
||||||
|
() => groups.filter((item) => item.status === "active").map(groupOption),
|
||||||
|
[groups]
|
||||||
|
);
|
||||||
|
const dirty = groupEditor
|
||||||
|
? draftKey(groupDraft) !== draftKey(groupBaseline)
|
||||||
|
: relationshipEditor
|
||||||
|
? draftKey(relationshipDraft) !== draftKey(relationshipBaseline)
|
||||||
|
: false;
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!canRead) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const [groupResponse, relationshipResponse, identityResponse] = await Promise.all([
|
||||||
|
getTypedGroups(settings, { includeInactive: true }),
|
||||||
|
getIdentityRelationships(settings, { includeRevoked: showRevokedRelationships }),
|
||||||
|
canSearchIdentities
|
||||||
|
? searchOrganizationIdentityOptions(settings, "", 100).catch(() => ({ identities: [] }))
|
||||||
|
: Promise.resolve({ identities: [] })
|
||||||
|
]);
|
||||||
|
setGroups(groupResponse.groups);
|
||||||
|
setRelationships(relationshipResponse.relationships);
|
||||||
|
setIdentities(identityResponse.identities);
|
||||||
|
if (!appliedDeepLink.current && typeof window !== "undefined") {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const groupId = params.get("groupId");
|
||||||
|
const relationshipId = params.get("relationshipId");
|
||||||
|
const linkedGroup = groupResponse.groups.find((item) => item.id === groupId);
|
||||||
|
const linkedRelationship = relationshipResponse.relationships.find((item) => item.id === relationshipId);
|
||||||
|
if (linkedGroup) {
|
||||||
|
appliedDeepLink.current = true;
|
||||||
|
openMembership(linkedGroup);
|
||||||
|
} else if (linkedRelationship) {
|
||||||
|
appliedDeepLink.current = true;
|
||||||
|
openRelationshipEditor(linkedRelationship);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (caught) {
|
||||||
|
setError(apiErrorMessage(caught));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [canRead, canSearchIdentities, settings, showRevokedRelationships]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const loadIdentityOptions = useCallback(async (
|
||||||
|
query: string,
|
||||||
|
options: { limit: number; signal: AbortSignal }
|
||||||
|
): Promise<SearchableSelectOption[]> => {
|
||||||
|
const response = await searchOrganizationIdentityOptions(settings, query, options.limit, options.signal);
|
||||||
|
if (!options.signal.aborted) {
|
||||||
|
setIdentities((current) => mergeIdentities(current, response.identities));
|
||||||
|
}
|
||||||
|
return response.identities.map(identityOption);
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
useUnsavedDraftGuard({
|
||||||
|
dirty,
|
||||||
|
onSave: async () => groupEditor ? saveGroup() : saveRelationship(),
|
||||||
|
onDiscard: closeEditors,
|
||||||
|
title: "Unsaved relationship administration",
|
||||||
|
message: "Save or discard the typed-group or relationship draft before leaving this surface."
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupColumns = useMemo<DataGridColumn<TypedGroupItem>[]>(() => [
|
||||||
|
{
|
||||||
|
id: "name",
|
||||||
|
header: "Name",
|
||||||
|
minWidth: 210,
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
value: (row) => row.name,
|
||||||
|
render: (row) => <div><strong>{row.name}</strong><div className="idm-id">{row.key}</div></div>
|
||||||
|
},
|
||||||
|
{ id: "type", header: "Group type", minWidth: 170, sortable: true, filterable: true, value: (row) => row.group_type },
|
||||||
|
{ id: "source", header: "Source", minWidth: 160, sortable: true, value: (row) => row.source_provider, render: (row) => sourceSummary(row) },
|
||||||
|
{ id: "status", header: "Status", width: 120, sortable: true, value: (row) => row.status, render: (row) => <StatusBadge status={row.status} label={groupStatusLabel(row.status)} /> },
|
||||||
|
{ id: "revision", header: "Revision", width: 100, sortable: true, value: (row) => row.revision },
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: "Actions",
|
||||||
|
width: 108,
|
||||||
|
sticky: "end",
|
||||||
|
render: (row) => <TableActionGroup actions={[
|
||||||
|
{
|
||||||
|
id: "memberships",
|
||||||
|
label: i18nMessage("i18n:govoplan-idm.inspect_memberships_value", { value0: row.name }),
|
||||||
|
icon: <Eye size={16} aria-hidden="true" />,
|
||||||
|
helpContextId: "idm.typed-groups.action.inspect-memberships",
|
||||||
|
helpModuleId: "idm",
|
||||||
|
onClick: () => openMembership(row)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "edit",
|
||||||
|
label: i18nMessage("i18n:govoplan-idm.edit_group_value", { value0: row.name }),
|
||||||
|
icon: <Pencil size={16} aria-hidden="true" />,
|
||||||
|
helpContextId: "idm.typed-groups.action.edit",
|
||||||
|
helpModuleId: "idm",
|
||||||
|
disabled: !canWrite || busy,
|
||||||
|
disabledReason: !canWrite ? "Typed-group write permission is required." : undefined,
|
||||||
|
onClick: () => openGroupEditor(row)
|
||||||
|
}
|
||||||
|
]} />
|
||||||
|
}
|
||||||
|
], [busy, canWrite]);
|
||||||
|
|
||||||
|
const relationshipColumns = useMemo<DataGridColumn<IdentityRelationshipItem>[]>(() => [
|
||||||
|
{
|
||||||
|
id: "subject",
|
||||||
|
header: "Subject identity",
|
||||||
|
minWidth: 220,
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
filterValue: (row) => identityLabel(identityById.get(row.subject_identity_id), row.subject_identity_id),
|
||||||
|
render: (row) => identityDisplay(identityById.get(row.subject_identity_id), row.subject_identity_id)
|
||||||
|
},
|
||||||
|
{ id: "kind", header: "Relationship", minWidth: 170, sortable: true, filterable: true, value: (row) => row.relationship_kind, render: (row) => row.role ? `${row.relationship_kind} · ${row.role}` : row.relationship_kind },
|
||||||
|
{
|
||||||
|
id: "target",
|
||||||
|
header: "Target",
|
||||||
|
minWidth: 220,
|
||||||
|
filterable: true,
|
||||||
|
filterValue: (row) => relationshipTargetLabel(row, groupById, identityById),
|
||||||
|
render: (row) => relationshipTarget(row, groupById, identityById)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "effective_status",
|
||||||
|
header: "Effective state",
|
||||||
|
width: 145,
|
||||||
|
sortable: true,
|
||||||
|
value: (row) => relationshipState(row),
|
||||||
|
render: (row) => {
|
||||||
|
const state = relationshipState(row);
|
||||||
|
return <StatusBadge status={stateStatus(state)} label={relationshipStateLabel(state)} />;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ id: "window", header: "Effective window", minWidth: 220, value: (row) => `${row.valid_from ?? ""} ${row.valid_until ?? ""}`, render: (row) => effectiveWindow(row, language) },
|
||||||
|
{ id: "source", header: "Source", minWidth: 150, value: (row) => row.source_provider, render: (row) => sourceSummary(row) },
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: "Actions",
|
||||||
|
width: 108,
|
||||||
|
sticky: "end",
|
||||||
|
render: (row) => <TableActionGroup actions={[
|
||||||
|
{
|
||||||
|
id: "edit",
|
||||||
|
label: "Edit relationship",
|
||||||
|
icon: <Pencil size={16} aria-hidden="true" />,
|
||||||
|
helpContextId: "idm.relationships.action.edit",
|
||||||
|
helpModuleId: "idm",
|
||||||
|
disabled: !canWrite || busy || row.status === "revoked",
|
||||||
|
disabledReason: !canWrite ? "Relationship write permission is required." : row.status === "revoked" ? "Revoked relationships are retained as immutable evidence." : undefined,
|
||||||
|
onClick: () => openRelationshipEditor(row)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "revoke",
|
||||||
|
label: "Revoke relationship",
|
||||||
|
icon: <Trash2 size={16} aria-hidden="true" />,
|
||||||
|
variant: "danger",
|
||||||
|
helpContextId: "idm.relationships.action.revoke",
|
||||||
|
helpModuleId: "idm",
|
||||||
|
applicable: row.status !== "revoked",
|
||||||
|
disabled: !canWrite || busy,
|
||||||
|
disabledReason: !canWrite ? "Relationship write permission is required." : undefined,
|
||||||
|
onClick: () => {
|
||||||
|
setRevokeTarget(row);
|
||||||
|
setRevocationReason("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]} />
|
||||||
|
}
|
||||||
|
], [busy, canWrite, groupById, identityById, language]);
|
||||||
|
|
||||||
|
const membershipColumns = useMemo<DataGridColumn<IdentityRelationshipDecisionItem>[]>(() => [
|
||||||
|
{
|
||||||
|
id: "identity",
|
||||||
|
header: "Identity",
|
||||||
|
minWidth: 220,
|
||||||
|
value: (row) => row.relationship.subject_identity_id,
|
||||||
|
render: (row) => identityDisplay(identityById.get(row.relationship.subject_identity_id), row.relationship.subject_identity_id)
|
||||||
|
},
|
||||||
|
{ id: "kind", header: "Relationship", minWidth: 150, value: (row) => row.relationship.relationship_kind },
|
||||||
|
{ id: "decision", header: "Resolution", minWidth: 150, value: (row) => row.code, render: (row) => <StatusBadge status={row.included ? "success" : "inactive"} label={row.code} /> },
|
||||||
|
{ id: "identity_status", header: "Identity state", minWidth: 130, value: (row) => row.identity_status ?? "", render: (row) => row.identity_status ?? "Not available" },
|
||||||
|
{ id: "explanation", header: "Explanation", minWidth: 280, fill: true, value: (row) => row.explanation }
|
||||||
|
], [identityById]);
|
||||||
|
|
||||||
|
if (!canRead) {
|
||||||
|
return (
|
||||||
|
<Card title="Typed groups and identity relationships" collapsible collapseKey="idm.typed-relationships">
|
||||||
|
<ActionBlockerHint
|
||||||
|
reason={{
|
||||||
|
summary: "You do not have permission to view typed identity relationships.",
|
||||||
|
requiredAction: "Ask for typed-relationship read permission.",
|
||||||
|
actor: "An Access or tenant administrator",
|
||||||
|
target: "Access role assignments"
|
||||||
|
}}
|
||||||
|
labels={{ requiredAction: "Required action", actor: "Responsible actor", target: "Destination" }}
|
||||||
|
documentation={IDM_RELATIONSHIP_DOCUMENTATION}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="idm-relationship-stack" data-help-context-id="idm.relationships.page" data-help-module-id="idm">
|
||||||
|
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||||
|
{success && !error && <DismissibleAlert tone="success" resetKey={success}>{success}</DismissibleAlert>}
|
||||||
|
{!canWrite && (
|
||||||
|
<ActionBlockerHint
|
||||||
|
reason={{
|
||||||
|
summary: "You may inspect relationship evidence but not change it.",
|
||||||
|
requiredAction: "Ask for typed-relationship write permission before creating, editing, or revoking records.",
|
||||||
|
actor: "An Access or tenant administrator",
|
||||||
|
target: "Access role assignments"
|
||||||
|
}}
|
||||||
|
labels={{ requiredAction: "Required action", actor: "Responsible actor", target: "Destination" }}
|
||||||
|
documentation={IDM_RELATIONSHIP_DOCUMENTATION}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<LoadingFrame loading={loading} label="Loading typed groups and relationships">
|
||||||
|
<Card
|
||||||
|
title="Typed groups"
|
||||||
|
collapsible
|
||||||
|
collapseKey="idm.typed-groups"
|
||||||
|
actions={(
|
||||||
|
<ActionToolbar justify="end">
|
||||||
|
<DocumentationHelpLink reference={IDM_RELATIONSHIP_DOCUMENTATION} />
|
||||||
|
<ToggleSwitch
|
||||||
|
label="Show inactive groups"
|
||||||
|
checked={showInactiveGroups}
|
||||||
|
helpContextId="idm.typed-groups.field.show-inactive"
|
||||||
|
helpModuleId="idm"
|
||||||
|
onChange={setShowInactiveGroups}
|
||||||
|
/>
|
||||||
|
<Button helpContextId="idm.typed-groups.action.reload" helpModuleId="idm" onClick={() => void load()} disabled={loading || busy}>
|
||||||
|
<RefreshCw size={16} aria-hidden="true" /> Reload
|
||||||
|
</Button>
|
||||||
|
<AdminIconButton
|
||||||
|
label="Create typed group"
|
||||||
|
icon={<Plus size={16} aria-hidden="true" />}
|
||||||
|
variant="primary"
|
||||||
|
helpContextId="idm.typed-groups.action.create"
|
||||||
|
helpModuleId="idm"
|
||||||
|
disabled={!canWrite || busy}
|
||||||
|
disabledReason={!canWrite ? "Typed-group write permission is required." : undefined}
|
||||||
|
onClick={() => openGroupEditor("create")}
|
||||||
|
/>
|
||||||
|
</ActionToolbar>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<DataGrid id="idm-typed-groups" rows={visibleGroups} columns={groupColumns} getRowKey={(row) => row.id} emptyText="No typed groups found." initialFit="container" />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title="Effective identity relationships"
|
||||||
|
collapsible
|
||||||
|
collapseKey="idm.identity-relationships"
|
||||||
|
actions={(
|
||||||
|
<ActionToolbar justify="end">
|
||||||
|
<DocumentationHelpLink reference={IDM_RELATIONSHIP_DOCUMENTATION} />
|
||||||
|
<ToggleSwitch
|
||||||
|
label="Show revoked relationships"
|
||||||
|
checked={showRevokedRelationships}
|
||||||
|
helpContextId="idm.relationships.field.show-revoked"
|
||||||
|
helpModuleId="idm"
|
||||||
|
onChange={setShowRevokedRelationships}
|
||||||
|
/>
|
||||||
|
<Button helpContextId="idm.relationships.action.reload" helpModuleId="idm" onClick={() => void load()} disabled={loading || busy}>
|
||||||
|
<RefreshCw size={16} aria-hidden="true" /> Reload
|
||||||
|
</Button>
|
||||||
|
<AdminIconButton
|
||||||
|
label="Create relationship"
|
||||||
|
icon={<Plus size={16} aria-hidden="true" />}
|
||||||
|
variant="primary"
|
||||||
|
helpContextId="idm.relationships.action.create"
|
||||||
|
helpModuleId="idm"
|
||||||
|
disabled={!canWrite || busy || groups.every((item) => item.status !== "active")}
|
||||||
|
disabledReason={!canWrite ? "Relationship write permission is required." : groups.every((item) => item.status !== "active") ? "Create an active typed group first." : undefined}
|
||||||
|
onClick={() => openRelationshipEditor("create")}
|
||||||
|
/>
|
||||||
|
</ActionToolbar>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<DataGrid id="idm-identity-relationships" rows={relationships} columns={relationshipColumns} getRowKey={(row) => row.id} emptyText="No identity relationships found." initialFit="container" />
|
||||||
|
<p className="idm-muted idm-card-note">Business membership is an institutional fact. It does not grant application permissions; Access evaluates authority separately.</p>
|
||||||
|
</Card>
|
||||||
|
</LoadingFrame>
|
||||||
|
|
||||||
|
{renderGroupEditor()}
|
||||||
|
{renderRelationshipEditor()}
|
||||||
|
{renderRevokeDialog()}
|
||||||
|
{renderMembershipDialog()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
function openGroupEditor(item: TypedGroupItem | "create") {
|
||||||
|
const next = item === "create" ? { ...EMPTY_GROUP_DRAFT } : groupDraftFrom(item);
|
||||||
|
setGroupEditor(item);
|
||||||
|
setGroupDraft(next);
|
||||||
|
setGroupBaseline(next);
|
||||||
|
setRelationshipEditor(null);
|
||||||
|
setError("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRelationshipEditor(item: IdentityRelationshipItem | "create") {
|
||||||
|
const next = item === "create" ? { ...EMPTY_RELATIONSHIP_DRAFT } : relationshipDraftFrom(item);
|
||||||
|
setRelationshipEditor(item);
|
||||||
|
setRelationshipDraft(next);
|
||||||
|
setRelationshipBaseline(next);
|
||||||
|
setGroupEditor(null);
|
||||||
|
setError("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditors() {
|
||||||
|
setGroupEditor(null);
|
||||||
|
setRelationshipEditor(null);
|
||||||
|
setGroupDraft({ ...EMPTY_GROUP_DRAFT });
|
||||||
|
setGroupBaseline({ ...EMPTY_GROUP_DRAFT });
|
||||||
|
setRelationshipDraft({ ...EMPTY_RELATIONSHIP_DRAFT });
|
||||||
|
setRelationshipBaseline({ ...EMPTY_RELATIONSHIP_DRAFT });
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestCloseEditors() {
|
||||||
|
if (busy) return;
|
||||||
|
if (dirty) requestDiscard(closeEditors);
|
||||||
|
else closeEditors();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveGroup(): Promise<boolean> {
|
||||||
|
if (!groupEditor || !canWrite) return false;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const payload = groupPayload(groupDraft);
|
||||||
|
if (groupEditor === "create") {
|
||||||
|
await createTypedGroup(settings, payload);
|
||||||
|
setSuccess("Typed group created.");
|
||||||
|
} else {
|
||||||
|
await patchTypedGroup(settings, groupEditor.id, {
|
||||||
|
...payload,
|
||||||
|
base_revision: groupEditor.revision,
|
||||||
|
status: groupDraft.status
|
||||||
|
});
|
||||||
|
setSuccess("Typed group updated.");
|
||||||
|
}
|
||||||
|
closeEditors();
|
||||||
|
await load();
|
||||||
|
return true;
|
||||||
|
} catch (caught) {
|
||||||
|
setError(apiErrorMessage(caught));
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRelationship(): Promise<boolean> {
|
||||||
|
if (!relationshipEditor || !canWrite) return false;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const payload = relationshipPayload(relationshipDraft);
|
||||||
|
if (relationshipEditor === "create") {
|
||||||
|
await createIdentityRelationship(settings, payload);
|
||||||
|
setSuccess("Identity relationship created.");
|
||||||
|
} else {
|
||||||
|
const { subject_identity_id: _subject, ...update } = payload;
|
||||||
|
await patchIdentityRelationship(settings, relationshipEditor.id, {
|
||||||
|
...update,
|
||||||
|
base_revision: relationshipEditor.revision
|
||||||
|
});
|
||||||
|
setSuccess("Identity relationship updated.");
|
||||||
|
}
|
||||||
|
closeEditors();
|
||||||
|
await load();
|
||||||
|
return true;
|
||||||
|
} catch (caught) {
|
||||||
|
setError(apiErrorMessage(caught));
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeRelationship(): Promise<void> {
|
||||||
|
if (!revokeTarget || !revocationReason.trim() || !canWrite) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await revokeIdentityRelationship(settings, revokeTarget, revocationReason.trim());
|
||||||
|
setSuccess("Identity relationship revoked. Effective membership and downstream business resolution stop immediately.");
|
||||||
|
setRevokeTarget(null);
|
||||||
|
setRevocationReason("");
|
||||||
|
await load();
|
||||||
|
} catch (caught) {
|
||||||
|
setError(apiErrorMessage(caught));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMembership(group: TypedGroupItem) {
|
||||||
|
setMembershipGroup(group);
|
||||||
|
setMembershipEffectiveAt("");
|
||||||
|
setMembershipKinds("member");
|
||||||
|
setMembershipResolution(null);
|
||||||
|
void loadMembership(group, "", "member");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMembership(group = membershipGroup, effectiveAt = membershipEffectiveAt, kinds = membershipKinds): Promise<void> {
|
||||||
|
if (!group) return;
|
||||||
|
setMembershipLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const resolution = await resolveTypedGroupMemberships(settings, group.id, {
|
||||||
|
effectiveAt: optionalDateTime(effectiveAt) ?? undefined,
|
||||||
|
relationshipKinds: kinds.split(",").map((item) => item.trim()).filter(Boolean)
|
||||||
|
});
|
||||||
|
setMembershipResolution(resolution);
|
||||||
|
} catch (caught) {
|
||||||
|
setError(apiErrorMessage(caught));
|
||||||
|
} finally {
|
||||||
|
setMembershipLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGroupEditor() {
|
||||||
|
const formId = "idm-typed-group-editor";
|
||||||
|
const editing = groupEditor && groupEditor !== "create" ? groupEditor : null;
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
variant="administration"
|
||||||
|
size="wide"
|
||||||
|
open={Boolean(groupEditor)}
|
||||||
|
title={editing ? "Edit typed group" : "Create typed group"}
|
||||||
|
helpContextId="idm.typed-groups.editor"
|
||||||
|
helpModuleId="idm"
|
||||||
|
onClose={requestCloseEditors}
|
||||||
|
closeDisabled={busy}
|
||||||
|
className=""
|
||||||
|
footer={<><Button onClick={requestCloseEditors} disabled={busy}>Cancel</Button><Button type="submit" form={formId} variant="primary" helpContextId="idm.typed-groups.action.save" helpModuleId="idm" disabled={!canWrite || busy || !groupDraft.key.trim() || !groupDraft.name.trim() || !groupDraft.groupType.trim()}>{busy ? "Saving..." : "Save group"}</Button></>}
|
||||||
|
>
|
||||||
|
<FormLayout id={formId} columns={2} gap="small" collapseAt="workspace" className="" onSubmit={(event) => { event.preventDefault(); void saveGroup(); }}>
|
||||||
|
<FormField label="Key" helpContextId="idm.typed-groups.field.key" helpModuleId="idm"><input required value={groupDraft.key} disabled={busy} onChange={(event) => setGroupDraft({ ...groupDraft, key: event.target.value })} /></FormField>
|
||||||
|
<FormField label="Name" helpContextId="idm.typed-groups.field.name" helpModuleId="idm"><input required value={groupDraft.name} disabled={busy} onChange={(event) => setGroupDraft({ ...groupDraft, name: event.target.value })} /></FormField>
|
||||||
|
<FormField label="Group type" helpContextId="idm.typed-groups.field.type" helpModuleId="idm"><input required value={groupDraft.groupType} disabled={busy} onChange={(event) => setGroupDraft({ ...groupDraft, groupType: event.target.value })} /></FormField>
|
||||||
|
{editing && <FormField label="Status" helpContextId="idm.typed-groups.field.status" helpModuleId="idm"><select value={groupDraft.status} disabled={busy} onChange={(event) => setGroupDraft({ ...groupDraft, status: event.target.value as GroupDraft["status"] })}><option value="active">Active</option><option value="inactive">Inactive</option></select></FormField>}
|
||||||
|
<FormField label="Description" helpContextId="idm.typed-groups.field.description" helpModuleId="idm" className="wide"><textarea rows={3} value={groupDraft.description} disabled={busy} onChange={(event) => setGroupDraft({ ...groupDraft, description: event.target.value })} /></FormField>
|
||||||
|
<FormField label="Source provider" helpContextId="idm.typed-groups.field.source-provider" helpModuleId="idm"><input required value={groupDraft.sourceProvider} disabled={busy} onChange={(event) => setGroupDraft({ ...groupDraft, sourceProvider: event.target.value })} /></FormField>
|
||||||
|
<FormField label="Source resource type" helpContextId="idm.typed-groups.field.source-resource-type" helpModuleId="idm"><input value={groupDraft.sourceResourceType} disabled={busy} onChange={(event) => setGroupDraft({ ...groupDraft, sourceResourceType: event.target.value })} /></FormField>
|
||||||
|
<FormField label="Source resource ID" helpContextId="idm.typed-groups.field.source-resource-id" helpModuleId="idm"><input value={groupDraft.sourceResourceId} disabled={busy} onChange={(event) => setGroupDraft({ ...groupDraft, sourceResourceId: event.target.value })} /></FormField>
|
||||||
|
<FormField label="Source revision" helpContextId="idm.typed-groups.field.source-revision" helpModuleId="idm"><input value={groupDraft.sourceRevision} disabled={busy} onChange={(event) => setGroupDraft({ ...groupDraft, sourceRevision: event.target.value })} /></FormField>
|
||||||
|
<FormField label="Properties (JSON object)" helpContextId="idm.typed-groups.field.properties" helpModuleId="idm" className="wide"><textarea rows={4} value={groupDraft.properties} disabled={busy} onChange={(event) => setGroupDraft({ ...groupDraft, properties: event.target.value })} /></FormField>
|
||||||
|
<FormField label="Provenance (JSON object)" helpContextId="idm.typed-groups.field.provenance" helpModuleId="idm" className="wide"><textarea rows={4} value={groupDraft.provenance} disabled={busy} onChange={(event) => setGroupDraft({ ...groupDraft, provenance: event.target.value })} /></FormField>
|
||||||
|
{editing && <p className="idm-muted wide">{i18nMessage("i18n:govoplan-idm.group_revision_help", { value0: editing.revision })}</p>}
|
||||||
|
</FormLayout>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRelationshipEditor() {
|
||||||
|
const formId = "idm-identity-relationship-editor";
|
||||||
|
const editing = relationshipEditor && relationshipEditor !== "create" ? relationshipEditor : null;
|
||||||
|
const subjectOption = selectedIdentityOption(relationshipDraft.subjectIdentityId, identityById);
|
||||||
|
const relatedOption = selectedIdentityOption(relationshipDraft.relatedIdentityId, identityById);
|
||||||
|
const targetGroupOption = relationshipDraft.targetGroupId ? groupOption(groupById.get(relationshipDraft.targetGroupId) ?? fallbackGroup(relationshipDraft.targetGroupId)) : null;
|
||||||
|
const complete = Boolean(
|
||||||
|
relationshipDraft.relationshipKind.trim()
|
||||||
|
&& relationshipDraft.subjectIdentityId
|
||||||
|
&& (relationshipDraft.targetType === "group" ? relationshipDraft.targetGroupId : relationshipDraft.relatedIdentityId)
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
variant="administration"
|
||||||
|
size="wide"
|
||||||
|
open={Boolean(relationshipEditor)}
|
||||||
|
title={editing ? "Edit identity relationship" : "Create identity relationship"}
|
||||||
|
helpContextId="idm.relationships.editor"
|
||||||
|
helpModuleId="idm"
|
||||||
|
onClose={requestCloseEditors}
|
||||||
|
closeDisabled={busy}
|
||||||
|
className=""
|
||||||
|
footer={<><Button onClick={requestCloseEditors} disabled={busy}>Cancel</Button><Button type="submit" form={formId} variant="primary" helpContextId="idm.relationships.action.save" helpModuleId="idm" disabled={!canWrite || busy || !complete}>{busy ? "Saving..." : "Save relationship"}</Button></>}
|
||||||
|
>
|
||||||
|
<FormLayout id={formId} columns={2} gap="small" collapseAt="workspace" className="" onSubmit={(event) => { event.preventDefault(); void saveRelationship(); }}>
|
||||||
|
<FormField label="Relationship kind" helpContextId="idm.relationships.field.kind" helpModuleId="idm"><input required value={relationshipDraft.relationshipKind} disabled={busy} onChange={(event) => setRelationshipDraft({ ...relationshipDraft, relationshipKind: event.target.value })} /></FormField>
|
||||||
|
<FormField label="Role" helpContextId="idm.relationships.field.role" helpModuleId="idm"><input value={relationshipDraft.role} disabled={busy} onChange={(event) => setRelationshipDraft({ ...relationshipDraft, role: event.target.value })} /></FormField>
|
||||||
|
<FormField label="Subject identity" helpContextId="idm.relationships.field.subject-identity" helpModuleId="idm">
|
||||||
|
<SearchableSelect
|
||||||
|
value={relationshipDraft.subjectIdentityId}
|
||||||
|
selectedOption={subjectOption}
|
||||||
|
loadOptions={loadIdentityOptions}
|
||||||
|
aria-label="Subject identity"
|
||||||
|
placeholder="Search identities"
|
||||||
|
minQueryLength={0}
|
||||||
|
required
|
||||||
|
disabled={busy || Boolean(editing)}
|
||||||
|
helpContextId="idm.relationships.field.subject-identity"
|
||||||
|
helpModuleId="idm"
|
||||||
|
onChange={(subjectIdentityId) => setRelationshipDraft({ ...relationshipDraft, subjectIdentityId })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Target type" helpContextId="idm.relationships.field.target-type" helpModuleId="idm"><select value={relationshipDraft.targetType} disabled={busy} onChange={(event) => setRelationshipDraft({ ...relationshipDraft, targetType: event.target.value as RelationshipDraft["targetType"], targetGroupId: "", relatedIdentityId: "" })}><option value="group">Typed group</option><option value="identity">Related identity</option></select></FormField>
|
||||||
|
{relationshipDraft.targetType === "group" ? (
|
||||||
|
<FormField label="Target group" helpContextId="idm.relationships.field.target-group" helpModuleId="idm">
|
||||||
|
<SearchableSelect
|
||||||
|
value={relationshipDraft.targetGroupId}
|
||||||
|
selectedOption={targetGroupOption}
|
||||||
|
options={groupOptions}
|
||||||
|
aria-label="Target typed group"
|
||||||
|
placeholder="Search typed groups"
|
||||||
|
required
|
||||||
|
disabled={busy}
|
||||||
|
helpContextId="idm.relationships.field.target-group"
|
||||||
|
helpModuleId="idm"
|
||||||
|
onChange={(targetGroupId) => setRelationshipDraft({ ...relationshipDraft, targetGroupId })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
) : (
|
||||||
|
<FormField label="Related identity" helpContextId="idm.relationships.field.related-identity" helpModuleId="idm">
|
||||||
|
<SearchableSelect
|
||||||
|
value={relationshipDraft.relatedIdentityId}
|
||||||
|
selectedOption={relatedOption}
|
||||||
|
loadOptions={loadIdentityOptions}
|
||||||
|
aria-label="Related identity"
|
||||||
|
placeholder="Search identities"
|
||||||
|
minQueryLength={0}
|
||||||
|
required
|
||||||
|
disabled={busy}
|
||||||
|
helpContextId="idm.relationships.field.related-identity"
|
||||||
|
helpModuleId="idm"
|
||||||
|
onChange={(relatedIdentityId) => setRelationshipDraft({ ...relationshipDraft, relatedIdentityId })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
<FormField label="Valid from" helpContextId="idm.relationships.field.valid-from" helpModuleId="idm"><DateTimeField value={relationshipDraft.validFrom} disabled={busy} helpContextId="idm.relationships.field.valid-from" helpModuleId="idm" onChange={(validFrom) => setRelationshipDraft({ ...relationshipDraft, validFrom })} /></FormField>
|
||||||
|
<FormField label="Valid until" helpContextId="idm.relationships.field.valid-until" helpModuleId="idm"><DateTimeField value={relationshipDraft.validUntil} disabled={busy} helpContextId="idm.relationships.field.valid-until" helpModuleId="idm" onChange={(validUntil) => setRelationshipDraft({ ...relationshipDraft, validUntil })} /></FormField>
|
||||||
|
<FormField label="Source provider" helpContextId="idm.relationships.field.source-provider" helpModuleId="idm"><input required value={relationshipDraft.sourceProvider} disabled={busy} onChange={(event) => setRelationshipDraft({ ...relationshipDraft, sourceProvider: event.target.value })} /></FormField>
|
||||||
|
<FormField label="Source resource type" helpContextId="idm.relationships.field.source-resource-type" helpModuleId="idm"><input value={relationshipDraft.sourceResourceType} disabled={busy} onChange={(event) => setRelationshipDraft({ ...relationshipDraft, sourceResourceType: event.target.value })} /></FormField>
|
||||||
|
<FormField label="Source resource ID" helpContextId="idm.relationships.field.source-resource-id" helpModuleId="idm"><input value={relationshipDraft.sourceResourceId} disabled={busy} onChange={(event) => setRelationshipDraft({ ...relationshipDraft, sourceResourceId: event.target.value })} /></FormField>
|
||||||
|
<FormField label="Source revision" helpContextId="idm.relationships.field.source-revision" helpModuleId="idm"><input value={relationshipDraft.sourceRevision} disabled={busy} onChange={(event) => setRelationshipDraft({ ...relationshipDraft, sourceRevision: event.target.value })} /></FormField>
|
||||||
|
<FormField label="Properties (JSON object)" helpContextId="idm.relationships.field.properties" helpModuleId="idm" className="wide"><textarea rows={4} value={relationshipDraft.properties} disabled={busy} onChange={(event) => setRelationshipDraft({ ...relationshipDraft, properties: event.target.value })} /></FormField>
|
||||||
|
<FormField label="Provenance (JSON object)" helpContextId="idm.relationships.field.provenance" helpModuleId="idm" className="wide"><textarea rows={4} value={relationshipDraft.provenance} disabled={busy} onChange={(event) => setRelationshipDraft({ ...relationshipDraft, provenance: event.target.value })} /></FormField>
|
||||||
|
<p className="idm-muted wide">Future dates schedule the fact without granting current membership. Expiry and revocation remove it from effective resolution while retaining source and decision evidence. Membership never grants Access permissions by itself.</p>
|
||||||
|
{editing && <p className="idm-muted wide">{i18nMessage("i18n:govoplan-idm.relationship_revision_help", { value0: editing.revision })}</p>}
|
||||||
|
</FormLayout>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRevokeDialog() {
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
variant="administration"
|
||||||
|
size="large"
|
||||||
|
open={Boolean(revokeTarget)}
|
||||||
|
title="Revoke identity relationship"
|
||||||
|
helpContextId="idm.relationships.confirm-revoke"
|
||||||
|
helpModuleId="idm"
|
||||||
|
onClose={() => {
|
||||||
|
if (busy) return;
|
||||||
|
const close = () => { setRevokeTarget(null); setRevocationReason(""); };
|
||||||
|
if (revocationReason.trim()) requestDiscard(close);
|
||||||
|
else close();
|
||||||
|
}}
|
||||||
|
closeDisabled={busy}
|
||||||
|
className=""
|
||||||
|
footer={<><Button onClick={() => { setRevokeTarget(null); setRevocationReason(""); }} disabled={busy}>Cancel</Button><Button variant="danger" helpContextId="idm.relationships.action.confirm-revoke" helpModuleId="idm" disabled={!canWrite || busy || !revocationReason.trim()} onClick={() => void revokeRelationship()}>{busy ? "Revoking..." : "Revoke relationship"}</Button></>}
|
||||||
|
>
|
||||||
|
<p>Revocation takes effect immediately for membership resolution and downstream business consumers. The record, actor, time, source, and reason remain as evidence; a revoked relationship cannot be edited or reactivated.</p>
|
||||||
|
<FormField label="Revocation reason" helpContextId="idm.relationships.field.revocation-reason" helpModuleId="idm"><textarea rows={4} required value={revocationReason} disabled={busy} onChange={(event) => setRevocationReason(event.target.value)} /></FormField>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMembershipDialog() {
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
variant="administration"
|
||||||
|
size="wide"
|
||||||
|
open={Boolean(membershipGroup)}
|
||||||
|
title={membershipGroup ? i18nMessage("i18n:govoplan-idm.effective_memberships_value", { value0: membershipGroup.name }) : "Effective memberships"}
|
||||||
|
helpContextId="idm.typed-groups.membership-resolution"
|
||||||
|
helpModuleId="idm"
|
||||||
|
onClose={() => !membershipLoading && setMembershipGroup(null)}
|
||||||
|
closeDisabled={membershipLoading}
|
||||||
|
className=""
|
||||||
|
footer={<Button variant="primary" onClick={() => setMembershipGroup(null)} disabled={membershipLoading}>Close</Button>}
|
||||||
|
>
|
||||||
|
<FormLayout columns={2} gap="small" collapseAt="workspace" className="" onSubmit={(event) => { event.preventDefault(); void loadMembership(); }}>
|
||||||
|
<FormField label="Effective at" helpContextId="idm.typed-groups.field.membership-effective-at" helpModuleId="idm"><DateTimeField value={membershipEffectiveAt} disabled={membershipLoading} helpContextId="idm.typed-groups.field.membership-effective-at" helpModuleId="idm" onChange={setMembershipEffectiveAt} /></FormField>
|
||||||
|
<FormField label="Relationship kinds" helpContextId="idm.typed-groups.field.membership-kinds" helpModuleId="idm"><input value={membershipKinds} disabled={membershipLoading} placeholder="member" onChange={(event) => setMembershipKinds(event.target.value)} /></FormField>
|
||||||
|
<div className="wide button-row compact-actions"><Button type="submit" helpContextId="idm.typed-groups.action.resolve-memberships" helpModuleId="idm" disabled={membershipLoading || !membershipKinds.trim()}><RefreshCw size={16} aria-hidden="true" /> Resolve memberships</Button></div>
|
||||||
|
</FormLayout>
|
||||||
|
<LoadingFrame loading={membershipLoading} label="Resolving effective memberships">
|
||||||
|
{membershipResolution && (
|
||||||
|
<>
|
||||||
|
<p className="idm-muted">{i18nMessage("i18n:govoplan-idm.memberships_resolved_summary", { value0: new Date(membershipResolution.effective_at).toLocaleString(language), value1: membershipResolution.identity_ids.length })}</p>
|
||||||
|
<DataGrid id="idm-typed-group-membership-resolution" rows={membershipResolution.decisions} columns={membershipColumns} getRowKey={(row) => row.relationship.id} emptyText="No membership relationships were evaluated." initialFit="container" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</LoadingFrame>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupDraftFrom(item: TypedGroupItem): GroupDraft {
|
||||||
|
return {
|
||||||
|
key: item.key,
|
||||||
|
name: item.name,
|
||||||
|
groupType: item.group_type,
|
||||||
|
description: item.description ?? "",
|
||||||
|
status: item.status,
|
||||||
|
sourceProvider: item.source_provider,
|
||||||
|
sourceResourceType: item.source_resource_type ?? "",
|
||||||
|
sourceResourceId: item.source_resource_id ?? "",
|
||||||
|
sourceRevision: item.source_revision ?? "",
|
||||||
|
properties: prettyJson(item.properties),
|
||||||
|
provenance: prettyJson(item.provenance)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupPayload(draft: GroupDraft): TypedGroupPayload {
|
||||||
|
return {
|
||||||
|
key: draft.key.trim(),
|
||||||
|
name: draft.name.trim(),
|
||||||
|
group_type: draft.groupType.trim(),
|
||||||
|
description: optionalText(draft.description),
|
||||||
|
source_provider: draft.sourceProvider.trim(),
|
||||||
|
source_resource_type: optionalText(draft.sourceResourceType),
|
||||||
|
source_resource_id: optionalText(draft.sourceResourceId),
|
||||||
|
source_revision: optionalText(draft.sourceRevision),
|
||||||
|
properties: parseJsonObject(draft.properties, "Properties"),
|
||||||
|
provenance: parseJsonObject(draft.provenance, "Provenance")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function relationshipDraftFrom(item: IdentityRelationshipItem): RelationshipDraft {
|
||||||
|
return {
|
||||||
|
relationshipKind: item.relationship_kind,
|
||||||
|
subjectIdentityId: item.subject_identity_id,
|
||||||
|
targetType: item.target_group_id ? "group" : "identity",
|
||||||
|
targetGroupId: item.target_group_id ?? "",
|
||||||
|
relatedIdentityId: item.related_identity_id ?? "",
|
||||||
|
role: item.role ?? "",
|
||||||
|
validFrom: item.valid_from ?? "",
|
||||||
|
validUntil: item.valid_until ?? "",
|
||||||
|
sourceProvider: item.source_provider,
|
||||||
|
sourceResourceType: item.source_resource_type ?? "",
|
||||||
|
sourceResourceId: item.source_resource_id ?? "",
|
||||||
|
sourceRevision: item.source_revision ?? "",
|
||||||
|
properties: prettyJson(item.properties),
|
||||||
|
provenance: prettyJson(item.provenance)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function relationshipPayload(draft: RelationshipDraft): IdentityRelationshipPayload {
|
||||||
|
return {
|
||||||
|
relationship_kind: draft.relationshipKind.trim(),
|
||||||
|
subject_identity_id: draft.subjectIdentityId,
|
||||||
|
target_group_id: draft.targetType === "group" ? draft.targetGroupId || null : null,
|
||||||
|
related_identity_id: draft.targetType === "identity" ? draft.relatedIdentityId || null : null,
|
||||||
|
role: optionalText(draft.role),
|
||||||
|
valid_from: optionalDateTime(draft.validFrom),
|
||||||
|
valid_until: optionalDateTime(draft.validUntil),
|
||||||
|
source_provider: draft.sourceProvider.trim(),
|
||||||
|
source_resource_type: optionalText(draft.sourceResourceType),
|
||||||
|
source_resource_id: optionalText(draft.sourceResourceId),
|
||||||
|
source_revision: optionalText(draft.sourceRevision),
|
||||||
|
properties: parseJsonObject(draft.properties, "Properties"),
|
||||||
|
provenance: parseJsonObject(draft.provenance, "Provenance")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function relationshipState(item: IdentityRelationshipItem): "future" | "active" | "expired" | "revoked" {
|
||||||
|
if (item.status === "revoked") return "revoked";
|
||||||
|
const now = Date.now();
|
||||||
|
if (item.valid_from && new Date(item.valid_from).getTime() > now) return "future";
|
||||||
|
if (item.valid_until && new Date(item.valid_until).getTime() <= now) return "expired";
|
||||||
|
return "active";
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateStatus(state: ReturnType<typeof relationshipState>): string {
|
||||||
|
if (state === "future") return "pending";
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function relationshipStateLabel(state: ReturnType<typeof relationshipState>): string {
|
||||||
|
return state === "future" ? "Future" : state === "active" ? "Active" : state === "expired" ? "Expired" : "Revoked";
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupStatusLabel(status: TypedGroupItem["status"]): string {
|
||||||
|
return status === "active" ? "Active" : "Inactive";
|
||||||
|
}
|
||||||
|
|
||||||
|
function identityOption(item: IdentityOption): SearchableSelectOption {
|
||||||
|
const label = identityLabel(item, item.id);
|
||||||
|
return { value: item.id, label, description: item.external_subject ?? item.id, searchText: `${item.id} ${item.account_ids.join(" ")}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedIdentityOption(id: string, identityById: ReadonlyMap<string, IdentityOption>): SearchableSelectOption | null {
|
||||||
|
if (!id) return null;
|
||||||
|
const item = identityById.get(id);
|
||||||
|
return item ? identityOption(item) : { value: id, label: id, description: "Identity reference" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function identityLabel(item: IdentityOption | undefined, fallback: string): string {
|
||||||
|
return item?.display_name || item?.external_subject || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function identityDisplay(item: IdentityOption | undefined, fallback: string): JSX.Element {
|
||||||
|
return <div><strong>{identityLabel(item, fallback)}</strong><div className="idm-id">{fallback}</div></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupOption(item: TypedGroupItem): SearchableSelectOption {
|
||||||
|
return { value: item.id, label: item.name, description: `${item.group_type} · ${item.key}`, searchText: `${item.id} ${item.key}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackGroup(id: string): TypedGroupItem {
|
||||||
|
return { id, tenant_id: "", key: id, name: id, group_type: "unknown", status: "inactive", source_provider: "unknown", properties: {}, provenance: {}, revision: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceSummary(item: Pick<TypedGroupItem, "source_provider" | "source_revision">): JSX.Element {
|
||||||
|
return <div><span>{item.source_provider}</span>{item.source_revision && <div className="idm-id">{item.source_revision}</div>}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function relationshipTargetLabel(
|
||||||
|
item: IdentityRelationshipItem,
|
||||||
|
groupById: ReadonlyMap<string, TypedGroupItem>,
|
||||||
|
identityById: ReadonlyMap<string, IdentityOption>
|
||||||
|
): string {
|
||||||
|
if (item.target_group_id) return groupById.get(item.target_group_id)?.name ?? item.target_group_id;
|
||||||
|
return identityLabel(identityById.get(item.related_identity_id ?? ""), item.related_identity_id ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function relationshipTarget(
|
||||||
|
item: IdentityRelationshipItem,
|
||||||
|
groupById: ReadonlyMap<string, TypedGroupItem>,
|
||||||
|
identityById: ReadonlyMap<string, IdentityOption>
|
||||||
|
): JSX.Element {
|
||||||
|
if (item.target_group_id) {
|
||||||
|
const group = groupById.get(item.target_group_id);
|
||||||
|
return <div><strong>{group?.name ?? item.target_group_id}</strong><div className="idm-id">{group ? `${group.group_type} · ${group.key}` : item.target_group_id}</div></div>;
|
||||||
|
}
|
||||||
|
return identityDisplay(identityById.get(item.related_identity_id ?? ""), item.related_identity_id ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function effectiveWindow(item: IdentityRelationshipItem, language: string): JSX.Element {
|
||||||
|
return <div><div>{item.valid_from ? new Date(item.valid_from).toLocaleString(language) : "No start limit"}</div><div className="idm-muted">{item.valid_until ? new Date(item.valid_until).toLocaleString(language) : "No end limit"}</div></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeIdentities(current: IdentityOption[], incoming: IdentityOption[]): IdentityOption[] {
|
||||||
|
const merged = new Map(current.map((item) => [item.id, item]));
|
||||||
|
for (const item of incoming) merged.set(item.id, item);
|
||||||
|
return Array.from(merged.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalText(value: string): string | null {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalDateTime(value: string): string | null {
|
||||||
|
if (!value.trim()) return null;
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (Number.isNaN(parsed.getTime())) throw new Error("Enter a valid date and time.");
|
||||||
|
return parsed.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonObject(value: string, label: string): Record<string, unknown> {
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(value || "{}");
|
||||||
|
} catch {
|
||||||
|
throw new Error(`${label} must contain valid JSON.`);
|
||||||
|
}
|
||||||
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||||
|
throw new Error(`${label} must be a JSON object.`);
|
||||||
|
}
|
||||||
|
return parsed as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prettyJson(value: Record<string, unknown>): string {
|
||||||
|
return JSON.stringify(value, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function draftKey(value: unknown): string {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(error.body) as { detail?: string | { message?: string } };
|
||||||
|
if (typeof parsed.detail === "string") return parsed.detail;
|
||||||
|
if (parsed.detail && typeof parsed.detail.message === "string") return parsed.detail.message;
|
||||||
|
} catch {
|
||||||
|
// Fall back to the transport message.
|
||||||
|
}
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
if (error instanceof Error) return error.message;
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
@@ -15,6 +15,11 @@ export const IDM_FIELD_DOCUMENTATION = {
|
|||||||
documentationType: "admin"
|
documentationType: "admin"
|
||||||
} satisfies DocumentationHelpReference;
|
} satisfies DocumentationHelpReference;
|
||||||
|
|
||||||
|
export const IDM_RELATIONSHIP_DOCUMENTATION = {
|
||||||
|
topicId: "idm.reference.typed-relationships",
|
||||||
|
documentationType: "admin"
|
||||||
|
} satisfies DocumentationHelpReference;
|
||||||
|
|
||||||
export const IDM_INTERFACE_I18N = {
|
export const IDM_INTERFACE_I18N = {
|
||||||
loading: "i18n:govoplan-idm.loading_reason",
|
loading: "i18n:govoplan-idm.loading_reason",
|
||||||
busy: "i18n:govoplan-idm.busy_reason",
|
busy: "i18n:govoplan-idm.busy_reason",
|
||||||
@@ -45,4 +50,3 @@ export function idmDisabledReason(
|
|||||||
if (!permitted) return IDM_INTERFACE_I18N.writeReason;
|
if (!permitted) return IDM_INTERFACE_I18N.writeReason;
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-idm.governance.b989a277": "governance",
|
"i18n:govoplan-idm.governance.b989a277": "governance",
|
||||||
"i18n:govoplan-idm.identity.544a8347": "Identity",
|
"i18n:govoplan-idm.identity.544a8347": "Identity",
|
||||||
"i18n:govoplan-idm.identity_is_required.6ad4ee23": "Identity is required.",
|
"i18n:govoplan-idm.identity_is_required.6ad4ee23": "Identity is required.",
|
||||||
"i18n:govoplan-idm.identity_links_intro.45fed9dd": "Link identities to organization functions. Organizations defines the functions; IDM owns who holds them.",
|
"i18n:govoplan-idm.identity_links_intro.45fed9dd": "Manage organization-function assignments, typed business groups, and effective identity relationships. IDM records institutional facts; Access evaluates application authority separately.",
|
||||||
"i18n:govoplan-idm.identity_lookup_unavailable.b76f7714": "Identity lookup is unavailable.",
|
"i18n:govoplan-idm.identity_lookup_unavailable.b76f7714": "Identity lookup is unavailable.",
|
||||||
"i18n:govoplan-idm.identity_search.d3460fcf": "Identity search",
|
"i18n:govoplan-idm.identity_search.d3460fcf": "Identity search",
|
||||||
"i18n:govoplan-idm.idm_governance.6e4f3251": "IDM governance",
|
"i18n:govoplan-idm.idm_governance.6e4f3251": "IDM governance",
|
||||||
@@ -151,7 +151,108 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"History": "History",
|
"History": "History",
|
||||||
"Confirm function decision": "Confirm function decision",
|
"Confirm function decision": "Confirm function decision",
|
||||||
"Confirm": "Confirm",
|
"Confirm": "Confirm",
|
||||||
"None": "None"
|
"None": "None",
|
||||||
|
"i18n:govoplan-idm.edit_group_value": "Edit {value0}",
|
||||||
|
"i18n:govoplan-idm.inspect_memberships_value": "Inspect memberships for {value0}",
|
||||||
|
"i18n:govoplan-idm.effective_memberships_value": "Effective memberships: {value0}",
|
||||||
|
"i18n:govoplan-idm.memberships_resolved_summary": "Resolved at {value0}. {value1} identities are effective; excluded decisions remain visible for explanation.",
|
||||||
|
"i18n:govoplan-idm.group_revision_help": "Revision {value0} is used for optimistic concurrency. If another administrator saves first, reload before applying your change.",
|
||||||
|
"i18n:govoplan-idm.relationship_revision_help": "Revision {value0} is used for optimistic concurrency. The subject identity is immutable; replace the relationship if the subject is wrong.",
|
||||||
|
"Access role assignments": "Access role assignments",
|
||||||
|
"Active": "Active",
|
||||||
|
"An Access or tenant administrator": "An Access or tenant administrator",
|
||||||
|
"Ask for typed-relationship read permission.": "Ask for typed-relationship read permission.",
|
||||||
|
"Ask for typed-relationship write permission before creating, editing, or revoking records.": "Ask for typed-relationship write permission before creating, editing, or revoking records.",
|
||||||
|
"Business membership is an institutional fact. It does not grant application permissions; Access evaluates authority separately.": "Business membership is an institutional fact. It does not grant application permissions; Access evaluates authority separately.",
|
||||||
|
"Create an active typed group first.": "Create an active typed group first.",
|
||||||
|
"Create identity relationship": "Create identity relationship",
|
||||||
|
"Create relationship": "Create relationship",
|
||||||
|
"Create typed group": "Create typed group",
|
||||||
|
"Description": "Description",
|
||||||
|
"Destination": "Destination",
|
||||||
|
"Edit identity relationship": "Edit identity relationship",
|
||||||
|
"Edit relationship": "Edit relationship",
|
||||||
|
"Edit typed group": "Edit typed group",
|
||||||
|
"Effective at": "Effective at",
|
||||||
|
"Effective identity relationships": "Effective identity relationships",
|
||||||
|
"Effective memberships": "Effective memberships",
|
||||||
|
"Effective state": "Effective state",
|
||||||
|
"Effective window": "Effective window",
|
||||||
|
"Enter a valid date and time.": "Enter a valid date and time.",
|
||||||
|
"Expired": "Expired",
|
||||||
|
"Future": "Future",
|
||||||
|
"Future dates schedule the fact without granting current membership. Expiry and revocation remove it from effective resolution while retaining source and decision evidence. Membership never grants Access permissions by itself.": "Future dates schedule the fact without granting current membership. Expiry and revocation remove it from effective resolution while retaining source and decision evidence. Membership never grants Access permissions by itself.",
|
||||||
|
"Group type": "Group type",
|
||||||
|
"Identity": "Identity",
|
||||||
|
"Identity reference": "Identity reference",
|
||||||
|
"Identity relationship created.": "Identity relationship created.",
|
||||||
|
"Identity relationship revoked. Effective membership and downstream business resolution stop immediately.": "Identity relationship revoked. Effective membership and downstream business resolution stop immediately.",
|
||||||
|
"Identity relationship updated.": "Identity relationship updated.",
|
||||||
|
"Identity state": "Identity state",
|
||||||
|
"Inactive": "Inactive",
|
||||||
|
"Key": "Key",
|
||||||
|
"Loading typed groups and relationships": "Loading typed groups and relationships",
|
||||||
|
"Name": "Name",
|
||||||
|
"No end limit": "No end limit",
|
||||||
|
"No identity relationships found.": "No identity relationships found.",
|
||||||
|
"No membership relationships were evaluated.": "No membership relationships were evaluated.",
|
||||||
|
"No start limit": "No start limit",
|
||||||
|
"No typed groups found.": "No typed groups found.",
|
||||||
|
"Not available": "Not available",
|
||||||
|
"Properties (JSON object)": "Properties (JSON object)",
|
||||||
|
"Properties must be a JSON object.": "Properties must be a JSON object.",
|
||||||
|
"Properties must contain valid JSON.": "Properties must contain valid JSON.",
|
||||||
|
"Provenance (JSON object)": "Provenance (JSON object)",
|
||||||
|
"Provenance must be a JSON object.": "Provenance must be a JSON object.",
|
||||||
|
"Provenance must contain valid JSON.": "Provenance must contain valid JSON.",
|
||||||
|
"Related identity": "Related identity",
|
||||||
|
"Reload": "Reload",
|
||||||
|
"Relationship": "Relationship",
|
||||||
|
"Relationship kind": "Relationship kind",
|
||||||
|
"Relationship kinds": "Relationship kinds",
|
||||||
|
"Relationship write permission is required.": "Relationship write permission is required.",
|
||||||
|
"Required action": "Required action",
|
||||||
|
"Resolution": "Resolution",
|
||||||
|
"Resolve memberships": "Resolve memberships",
|
||||||
|
"Resolving effective memberships": "Resolving effective memberships",
|
||||||
|
"Responsible actor": "Responsible actor",
|
||||||
|
"Revision": "Revision",
|
||||||
|
"Revocation reason": "Revocation reason",
|
||||||
|
"Revocation takes effect immediately for membership resolution and downstream business consumers. The record, actor, time, source, and reason remain as evidence; a revoked relationship cannot be edited or reactivated.": "Revocation takes effect immediately for membership resolution and downstream business consumers. The record, actor, time, source, and reason remain as evidence; a revoked relationship cannot be edited or reactivated.",
|
||||||
|
"Revoke identity relationship": "Revoke identity relationship",
|
||||||
|
"Revoke relationship": "Revoke relationship",
|
||||||
|
"Revoked": "Revoked",
|
||||||
|
"Revoked relationships are retained as immutable evidence.": "Revoked relationships are retained as immutable evidence.",
|
||||||
|
"Revoking...": "Revoking...",
|
||||||
|
"Role": "Role",
|
||||||
|
"Save group": "Save group",
|
||||||
|
"Save or discard the typed-group or relationship draft before leaving this surface.": "Save or discard the typed-group or relationship draft before leaving this surface.",
|
||||||
|
"Save relationship": "Save relationship",
|
||||||
|
"Saving...": "Saving...",
|
||||||
|
"Search identities": "Search identities",
|
||||||
|
"Search typed groups": "Search typed groups",
|
||||||
|
"Show inactive groups": "Show inactive groups",
|
||||||
|
"Show revoked relationships": "Show revoked relationships",
|
||||||
|
"Source": "Source",
|
||||||
|
"Source provider": "Source provider",
|
||||||
|
"Source resource ID": "Source resource ID",
|
||||||
|
"Source resource type": "Source resource type",
|
||||||
|
"Source revision": "Source revision",
|
||||||
|
"Status": "Status",
|
||||||
|
"Subject identity": "Subject identity",
|
||||||
|
"Target": "Target",
|
||||||
|
"Target group": "Target group",
|
||||||
|
"Target type": "Target type",
|
||||||
|
"Target typed group": "Target typed group",
|
||||||
|
"Typed group": "Typed group",
|
||||||
|
"Typed group created.": "Typed group created.",
|
||||||
|
"Typed group updated.": "Typed group updated.",
|
||||||
|
"Typed groups": "Typed groups",
|
||||||
|
"Typed groups and identity relationships": "Typed groups and identity relationships",
|
||||||
|
"Typed-group write permission is required.": "Typed-group write permission is required.",
|
||||||
|
"Unsaved relationship administration": "Unsaved relationship administration",
|
||||||
|
"You do not have permission to view typed identity relationships.": "You do not have permission to view typed identity relationships.",
|
||||||
|
"You may inspect relationship evidence but not change it.": "You may inspect relationship evidence but not change it."
|
||||||
},
|
},
|
||||||
de: {
|
de: {
|
||||||
"i18n:govoplan-idm.account.2b2936f8": "Konto",
|
"i18n:govoplan-idm.account.2b2936f8": "Konto",
|
||||||
@@ -183,7 +284,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-idm.governance.b989a277": "Governance",
|
"i18n:govoplan-idm.governance.b989a277": "Governance",
|
||||||
"i18n:govoplan-idm.identity.544a8347": "Identität",
|
"i18n:govoplan-idm.identity.544a8347": "Identität",
|
||||||
"i18n:govoplan-idm.identity_is_required.6ad4ee23": "Identität ist erforderlich.",
|
"i18n:govoplan-idm.identity_is_required.6ad4ee23": "Identität ist erforderlich.",
|
||||||
"i18n:govoplan-idm.identity_links_intro.45fed9dd": "Verknüpfe Identitäten mit Organisationsfunktionen. Organisationen definiert die Funktionen; IDM verwaltet, wer sie innehat.",
|
"i18n:govoplan-idm.identity_links_intro.45fed9dd": "Verwalten Sie Organisationsfunktionszuordnungen, typisierte Fachgruppen und zeitlich wirksame Identitätsbeziehungen. IDM erfasst institutionelle Tatsachen; Access bewertet Anwendungsbefugnisse getrennt.",
|
||||||
"i18n:govoplan-idm.identity_lookup_unavailable.b76f7714": "Identitätssuche ist nicht verfügbar.",
|
"i18n:govoplan-idm.identity_lookup_unavailable.b76f7714": "Identitätssuche ist nicht verfügbar.",
|
||||||
"i18n:govoplan-idm.identity_search.d3460fcf": "Identitätssuche",
|
"i18n:govoplan-idm.identity_search.d3460fcf": "Identitätssuche",
|
||||||
"i18n:govoplan-idm.idm_governance.6e4f3251": "IDM-Governance",
|
"i18n:govoplan-idm.idm_governance.6e4f3251": "IDM-Governance",
|
||||||
@@ -303,6 +404,107 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"History": "Verlauf",
|
"History": "Verlauf",
|
||||||
"Confirm function decision": "Funktionsentscheidung bestätigen",
|
"Confirm function decision": "Funktionsentscheidung bestätigen",
|
||||||
"Confirm": "Bestätigen",
|
"Confirm": "Bestätigen",
|
||||||
"None": "Keine"
|
"None": "Keine",
|
||||||
|
"i18n:govoplan-idm.edit_group_value": "{value0} bearbeiten",
|
||||||
|
"i18n:govoplan-idm.inspect_memberships_value": "Mitgliedschaften von {value0} prüfen",
|
||||||
|
"i18n:govoplan-idm.effective_memberships_value": "Wirksame Mitgliedschaften: {value0}",
|
||||||
|
"i18n:govoplan-idm.memberships_resolved_summary": "Aufgelöst zum Zeitpunkt {value0}. {value1} Identitäten sind wirksam; ausgeschlossene Entscheidungen bleiben zur Erläuterung sichtbar.",
|
||||||
|
"i18n:govoplan-idm.group_revision_help": "Revision {value0} schützt vor dem Überschreiben paralleler Änderungen. Falls eine andere Administration zuerst speichert, laden Sie vor Ihrer Änderung neu.",
|
||||||
|
"i18n:govoplan-idm.relationship_revision_help": "Revision {value0} schützt vor dem Überschreiben paralleler Änderungen. Die Ausgangsidentität ist unveränderbar; ersetzen Sie die Beziehung, wenn sie falsch ist.",
|
||||||
|
"Access role assignments": "Zuweisungen von Zugriffsrollen",
|
||||||
|
"Active": "Aktiv",
|
||||||
|
"An Access or tenant administrator": "Access- oder Mandantenadministration",
|
||||||
|
"Ask for typed-relationship read permission.": "Fordern Sie die Leseberechtigung für typisierte Beziehungen an.",
|
||||||
|
"Ask for typed-relationship write permission before creating, editing, or revoking records.": "Fordern Sie vor dem Erstellen, Bearbeiten oder Widerrufen die Schreibberechtigung für typisierte Beziehungen an.",
|
||||||
|
"Business membership is an institutional fact. It does not grant application permissions; Access evaluates authority separately.": "Eine fachliche Mitgliedschaft ist eine institutionelle Tatsache. Sie erteilt keine Anwendungsberechtigungen; Access bewertet Befugnisse getrennt.",
|
||||||
|
"Create an active typed group first.": "Erstellen Sie zuerst eine aktive typisierte Gruppe.",
|
||||||
|
"Create identity relationship": "Identitätsbeziehung erstellen",
|
||||||
|
"Create relationship": "Beziehung erstellen",
|
||||||
|
"Create typed group": "Typisierte Gruppe erstellen",
|
||||||
|
"Description": "Beschreibung",
|
||||||
|
"Destination": "Ziel",
|
||||||
|
"Edit identity relationship": "Identitätsbeziehung bearbeiten",
|
||||||
|
"Edit relationship": "Beziehung bearbeiten",
|
||||||
|
"Edit typed group": "Typisierte Gruppe bearbeiten",
|
||||||
|
"Effective at": "Wirksam zum Zeitpunkt",
|
||||||
|
"Effective identity relationships": "Wirksame Identitätsbeziehungen",
|
||||||
|
"Effective memberships": "Wirksame Mitgliedschaften",
|
||||||
|
"Effective state": "Wirksamkeitsstatus",
|
||||||
|
"Effective window": "Wirksamkeitszeitraum",
|
||||||
|
"Enter a valid date and time.": "Geben Sie ein gültiges Datum mit Uhrzeit ein.",
|
||||||
|
"Expired": "Abgelaufen",
|
||||||
|
"Future": "Zukünftig",
|
||||||
|
"Future dates schedule the fact without granting current membership. Expiry and revocation remove it from effective resolution while retaining source and decision evidence. Membership never grants Access permissions by itself.": "Ein zukünftiger Beginn plant die Tatsache, ohne eine aktuelle Mitgliedschaft zu erzeugen. Ablauf und Widerruf entfernen sie aus der wirksamen Auflösung, während Quellen- und Entscheidungsnachweise erhalten bleiben. Eine Mitgliedschaft erteilt niemals selbstständig Access-Berechtigungen.",
|
||||||
|
"Group type": "Gruppentyp",
|
||||||
|
"Identity": "Identität",
|
||||||
|
"Identity reference": "Identitätsreferenz",
|
||||||
|
"Identity relationship created.": "Identitätsbeziehung erstellt.",
|
||||||
|
"Identity relationship revoked. Effective membership and downstream business resolution stop immediately.": "Identitätsbeziehung widerrufen. Wirksame Mitgliedschaft und nachgelagerte fachliche Auflösungen enden sofort.",
|
||||||
|
"Identity relationship updated.": "Identitätsbeziehung aktualisiert.",
|
||||||
|
"Identity state": "Identitätsstatus",
|
||||||
|
"Inactive": "Inaktiv",
|
||||||
|
"Key": "Schlüssel",
|
||||||
|
"Loading typed groups and relationships": "Typisierte Gruppen und Beziehungen werden geladen",
|
||||||
|
"Name": "Name",
|
||||||
|
"No end limit": "Kein Endzeitpunkt",
|
||||||
|
"No identity relationships found.": "Keine Identitätsbeziehungen gefunden.",
|
||||||
|
"No membership relationships were evaluated.": "Es wurden keine Mitgliedschaftsbeziehungen ausgewertet.",
|
||||||
|
"No start limit": "Kein Startzeitpunkt",
|
||||||
|
"No typed groups found.": "Keine typisierten Gruppen gefunden.",
|
||||||
|
"Not available": "Nicht verfügbar",
|
||||||
|
"Properties (JSON object)": "Eigenschaften (JSON-Objekt)",
|
||||||
|
"Properties must be a JSON object.": "Eigenschaften müssen ein JSON-Objekt sein.",
|
||||||
|
"Properties must contain valid JSON.": "Eigenschaften müssen gültiges JSON enthalten.",
|
||||||
|
"Provenance (JSON object)": "Herkunftsnachweis (JSON-Objekt)",
|
||||||
|
"Provenance must be a JSON object.": "Der Herkunftsnachweis muss ein JSON-Objekt sein.",
|
||||||
|
"Provenance must contain valid JSON.": "Der Herkunftsnachweis muss gültiges JSON enthalten.",
|
||||||
|
"Related identity": "Verknüpfte Identität",
|
||||||
|
"Reload": "Neu laden",
|
||||||
|
"Relationship": "Beziehung",
|
||||||
|
"Relationship kind": "Beziehungsart",
|
||||||
|
"Relationship kinds": "Beziehungsarten",
|
||||||
|
"Relationship write permission is required.": "Die Schreibberechtigung für Beziehungen ist erforderlich.",
|
||||||
|
"Required action": "Erforderliche Aktion",
|
||||||
|
"Resolution": "Auflösung",
|
||||||
|
"Resolve memberships": "Mitgliedschaften auflösen",
|
||||||
|
"Resolving effective memberships": "Wirksame Mitgliedschaften werden aufgelöst",
|
||||||
|
"Responsible actor": "Verantwortliche Stelle",
|
||||||
|
"Revision": "Revision",
|
||||||
|
"Revocation reason": "Widerrufsgrund",
|
||||||
|
"Revocation takes effect immediately for membership resolution and downstream business consumers. The record, actor, time, source, and reason remain as evidence; a revoked relationship cannot be edited or reactivated.": "Der Widerruf wirkt sofort auf die Mitgliedschaftsauflösung und nachgelagerte fachliche Verbraucher. Datensatz, handelnde Person, Zeitpunkt, Quelle und Grund bleiben als Nachweis erhalten; eine widerrufene Beziehung kann weder bearbeitet noch reaktiviert werden.",
|
||||||
|
"Revoke identity relationship": "Identitätsbeziehung widerrufen",
|
||||||
|
"Revoke relationship": "Beziehung widerrufen",
|
||||||
|
"Revoked": "Widerrufen",
|
||||||
|
"Revoked relationships are retained as immutable evidence.": "Widerrufene Beziehungen bleiben als unveränderbarer Nachweis erhalten.",
|
||||||
|
"Revoking...": "Wird widerrufen …",
|
||||||
|
"Role": "Rolle",
|
||||||
|
"Save group": "Gruppe speichern",
|
||||||
|
"Save or discard the typed-group or relationship draft before leaving this surface.": "Speichern oder verwerfen Sie den Entwurf der typisierten Gruppe oder Beziehung, bevor Sie diesen Bereich verlassen.",
|
||||||
|
"Save relationship": "Beziehung speichern",
|
||||||
|
"Saving...": "Wird gespeichert …",
|
||||||
|
"Search identities": "Identitäten durchsuchen",
|
||||||
|
"Search typed groups": "Typisierte Gruppen durchsuchen",
|
||||||
|
"Show inactive groups": "Inaktive Gruppen anzeigen",
|
||||||
|
"Show revoked relationships": "Widerrufene Beziehungen anzeigen",
|
||||||
|
"Source": "Quelle",
|
||||||
|
"Source provider": "Quellanbieter",
|
||||||
|
"Source resource ID": "Quellressourcen-ID",
|
||||||
|
"Source resource type": "Quellressourcentyp",
|
||||||
|
"Source revision": "Quellrevision",
|
||||||
|
"Status": "Status",
|
||||||
|
"Subject identity": "Ausgangsidentität",
|
||||||
|
"Target": "Ziel",
|
||||||
|
"Target group": "Zielgruppe",
|
||||||
|
"Target type": "Zielart",
|
||||||
|
"Target typed group": "Typisierte Zielgruppe",
|
||||||
|
"Typed group": "Typisierte Gruppe",
|
||||||
|
"Typed group created.": "Typisierte Gruppe erstellt.",
|
||||||
|
"Typed group updated.": "Typisierte Gruppe aktualisiert.",
|
||||||
|
"Typed groups": "Typisierte Gruppen",
|
||||||
|
"Typed groups and identity relationships": "Typisierte Gruppen und Identitätsbeziehungen",
|
||||||
|
"Typed-group write permission is required.": "Die Schreibberechtigung für typisierte Gruppen ist erforderlich.",
|
||||||
|
"Unsaved relationship administration": "Ungespeicherte Beziehungsverwaltung",
|
||||||
|
"You do not have permission to view typed identity relationships.": "Sie haben keine Berechtigung, typisierte Identitätsbeziehungen anzuzeigen.",
|
||||||
|
"You may inspect relationship evidence but not change it.": "Sie können Beziehungsnachweise prüfen, aber nicht ändern."
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ const IdmPage = lazy(() => import("./features/IdmPage"));
|
|||||||
const idmReadScopes = [
|
const idmReadScopes = [
|
||||||
"idm:organization_assignment:read",
|
"idm:organization_assignment:read",
|
||||||
"idm:organization_assignment:write",
|
"idm:organization_assignment:write",
|
||||||
|
"idm:relationship:read",
|
||||||
|
"idm:relationship:write",
|
||||||
"organizations:function:assign"
|
"organizations:function:assign"
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -2,21 +2,12 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1480px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.idm-heading {
|
.idm-heading {
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.idm-toolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.idm-table-stack {
|
.idm-table-stack {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
@@ -27,6 +18,16 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.idm-relationship-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idm-card-note {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.idm-check-list {
|
.idm-check-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|||||||
Reference in New Issue
Block a user