From dc99a4038409751c1f527436289f32a1c42e2bb2 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 19 Aug 2026 22:16:18 +0200 Subject: [PATCH] docs(idm): complete delegation lifecycle guidance --- docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md | 28 ++++++++++++++++++ src/govoplan_idm/backend/manifest.py | 5 +++- tests/test_assignment_workflow.py | 41 +++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md b/docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md index ee36a5c..41451a6 100644 --- a/docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md +++ b/docs/FUNCTION_ASSIGNMENT_WORKFLOWS.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 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 The first policy profiles are: diff --git a/src/govoplan_idm/backend/manifest.py b/src/govoplan_idm/backend/manifest.py index 3984953..8cdfb8a 100644 --- a/src/govoplan_idm/backend/manifest.py +++ b/src/govoplan_idm/backend/manifest.py @@ -475,7 +475,10 @@ manifest = ModuleManifest( "function and unit are owned by Organizations. Source distinguishes " "direct, delegated, acting-for, directory, governance, and system facts. " "Delegation and acting-for require a valid source assignment and the " - "corresponding function permission. Subunit scope broadens the fact's " + "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 " "removing the assignment from effective resolution. Governed request and " "grant decisions retain actor, policy, workflow revision, comments, and " diff --git a/tests/test_assignment_workflow.py b/tests/test_assignment_workflow.py index 601905b..5fc09fb 100644 --- a/tests/test_assignment_workflow.py +++ b/tests/test_assignment_workflow.py @@ -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: base = assignment(id="source-1", identity_id="identity-1", account_id=None) 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: item = assignment()