Skip to content

Commit c76c330

Browse files
committed
Feat: Implement governance approval workflow
1 parent ab6cb8f commit c76c330

53 files changed

Lines changed: 1178 additions & 114 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Docs/Decision/Adr/ADR_025_Governance_Approval_Workflow.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
#adr_025
55

66
## Status
7-
Proposed
7+
Accepted
88

99
## Date
1010
2026-06-22
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<OutputType>Exe</OutputType>
5+
<TargetFramework>net10.0</TargetFramework>
6+
<ImplicitUsings>enable</ImplicitUsings>
7+
<Nullable>enable</Nullable>
8+
</PropertyGroup>
9+
10+
<ItemGroup>
11+
<ProjectReference Include="..\..\..\src\ModularityKit.Mutator.Governance.csproj" />
12+
</ItemGroup>
13+
14+
</Project>
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
using ApprovalWorkflow.Scenarios;
2+
3+
await GovernanceApprovalWorkflowScenario.Run();
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Governance ApprovalWorkflow
2+
3+
This example shows the governance approval workflow built on top of `MutationRequest.PendingApproval(...)` and `MutationRequestApprovalWorkflowManager`.
4+
5+
It demonstrates:
6+
7+
- mapping `PolicyRequirement` into request-level approval requirements
8+
- multi-actor approvals in the same step
9+
- ordered approval steps
10+
- transition from `Pending` to `Approved` after the final approval
11+
12+
## Key files
13+
14+
- [`Program.cs`](Program.cs)
15+
- [`Scenarios/GovernanceApprovalWorkflowScenario.cs`](Scenarios/GovernanceApprovalWorkflowScenario.cs)
16+
- [`src/Governance/Runtime/Approval/MutationRequestApprovalWorkflowManager.cs`](../../../src/Governance/Runtime/Approval/MutationRequestApprovalWorkflowManager.cs)
17+
- [`src/Governance/Abstractions/Approval/MutationApprovalRequirement.cs`](../../../src/Governance/Abstractions/Approval/MutationApprovalRequirement.cs)
18+
19+
## Run
20+
21+
```bash
22+
dotnet run --project Examples/Governance/ApprovalWorkflow/ApprovalWorkflow.csproj
23+
```
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
using ModularityKit.Mutator.Abstractions.Context;
2+
using ModularityKit.Mutator.Abstractions.Intent;
3+
using ModularityKit.Mutator.Abstractions.Policies;
4+
using ModularityKit.Mutator.Governance.Abstractions.Requests.Model;
5+
using ModularityKit.Mutator.Governance.Runtime.Approval.Execution;
6+
using ModularityKit.Mutator.Governance.Runtime.Storage;
7+
8+
namespace ApprovalWorkflow.Scenarios;
9+
10+
internal static class GovernanceApprovalWorkflowScenario
11+
{
12+
public static async Task Run()
13+
{
14+
var store = new InMemoryMutationRequestStore();
15+
var manager = new MutationRequestApprovalWorkflowManager(store);
16+
17+
PrintSection("Submit Pending Approval Request");
18+
var request = await store.Create(CreateApprovalRequest());
19+
PrintRequest(request);
20+
21+
PrintSection("Approve Step 1");
22+
var aliceApproval = request.ApprovalRequirements.Single(requirement => requirement.ApproverId == "alice");
23+
var afterAlice = await manager.ApproveRequirement(
24+
request.RequestId,
25+
aliceApproval.ApprovalId,
26+
MutationContext.User("alice", "Alice", "Manager approved"));
27+
PrintRequest(afterAlice);
28+
29+
PrintSection("Approve Step 1 - Second Actor");
30+
var bobApproval = afterAlice.ApprovalRequirements.Single(requirement => requirement.ApproverId == "bob");
31+
var afterBob = await manager.ApproveRequirement(
32+
request.RequestId,
33+
bobApproval.ApprovalId,
34+
MutationContext.User("bob", "Bob", "Security approved"));
35+
PrintRequest(afterBob);
36+
37+
PrintSection("Approve Step 2");
38+
var carolApproval = afterBob.ApprovalRequirements.Single(requirement => requirement.ApproverId == "carol");
39+
var afterCarol = await manager.ApproveRequirement(
40+
request.RequestId,
41+
carolApproval.ApprovalId,
42+
MutationContext.User("carol", "Carol", "Finance approved"));
43+
PrintRequest(afterCarol);
44+
}
45+
46+
private static MutationRequest CreateApprovalRequest()
47+
{
48+
return MutationRequest.PendingApproval(
49+
stateId: "tenant-42:roles",
50+
stateType: "IamRoleState",
51+
mutationType: "GrantRoleMutation",
52+
intent: new MutationIntent
53+
{
54+
OperationName = "GrantRole",
55+
Category = "Security",
56+
Description = "Grant elevated role to tenant operator"
57+
},
58+
context: MutationContext.User("requester", "Requester", "Need elevated access for incident"),
59+
requirements:
60+
[
61+
PolicyRequirement.Approval("alice", "Manager approval"),
62+
new PolicyRequirement
63+
{
64+
Type = "Approval",
65+
Description = "Security review",
66+
Data = new
67+
{
68+
Approver = "bob",
69+
StepOrder = 1,
70+
Reason = "Security sign-off"
71+
}
72+
},
73+
new PolicyRequirement
74+
{
75+
Type = "Approval",
76+
Description = "Finance review",
77+
Data = new
78+
{
79+
Approver = "carol",
80+
StepOrder = 2,
81+
Reason = "Budget sign-off"
82+
}
83+
}
84+
],
85+
expectedStateVersion: "v10");
86+
}
87+
88+
private static void PrintSection(string title)
89+
{
90+
Console.WriteLine();
91+
Console.WriteLine($"=== {title} ===");
92+
}
93+
94+
private static void PrintRequest(MutationRequest request)
95+
{
96+
Console.WriteLine($"Request status: {request.Status}");
97+
Console.WriteLine($"Pending reason: {request.PendingReason?.ToString() ?? "-"}");
98+
Console.WriteLine($"Revision: {request.Revision}");
99+
Console.WriteLine("Approval requirements:");
100+
101+
foreach (var requirement in request.ApprovalRequirements.OrderBy(requirement => requirement.StepOrder).ThenBy(requirement => requirement.ApproverId))
102+
{
103+
Console.WriteLine(
104+
$" - Step {requirement.StepOrder}: {requirement.ApproverId} => {requirement.Status}");
105+
}
106+
107+
var lastDecision = request.Decisions[^1];
108+
Console.WriteLine($"Last decision: {lastDecision.Type} by {lastDecision.Context.ActorId ?? "system"}");
109+
Console.WriteLine($"Reason: {lastDecision.Reason ?? "-"}");
110+
}
111+
}

Examples/Governance/RequestLifecycle/Scenarios/GovernanceRequestLifecycleScenario.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
using ModularityKit.Mutator.Abstractions.Context;
22
using ModularityKit.Mutator.Abstractions.Intent;
33
using ModularityKit.Mutator.Abstractions.Policies;
4-
using ModularityKit.Mutator.Governance.Abstractions.Lifecycle;
5-
using ModularityKit.Mutator.Governance.Abstractions.Requests;
6-
using ModularityKit.Mutator.Governance.Runtime.Lifecycle;
4+
using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model;
5+
using ModularityKit.Mutator.Governance.Abstractions.Requests.Model;
6+
using ModularityKit.Mutator.Governance.Runtime.Lifecycle.Execution;
77
using ModularityKit.Mutator.Governance.Runtime.Storage;
88

99
namespace RequestLifecycle.Scenarios;

Examples/Governance/VersionedResolution/Scenarios/GovernanceVersionedResolutionScenario.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
using ModularityKit.Mutator.Abstractions.Context;
22
using ModularityKit.Mutator.Abstractions.Intent;
3-
using ModularityKit.Mutator.Governance.Abstractions.Requests;
4-
using ModularityKit.Mutator.Governance.Abstractions.Resolution;
5-
using ModularityKit.Mutator.Governance.Runtime.Resolution;
3+
using ModularityKit.Mutator.Governance.Abstractions.Requests.Model;
4+
using ModularityKit.Mutator.Governance.Abstractions.Resolution.Model;
5+
using ModularityKit.Mutator.Governance.Abstractions.Resolution.Strategies;
6+
using ModularityKit.Mutator.Governance.Runtime.Resolution.Execution;
67
using ModularityKit.Mutator.Governance.Runtime.Storage;
78

89
namespace VersionedResolution.Scenarios;
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
using ModularityKit.Mutator.Abstractions.Context;
2+
using ModularityKit.Mutator.Abstractions.Intent;
3+
using ModularityKit.Mutator.Abstractions.Policies;
4+
using ModularityKit.Mutator.Governance.Abstractions.Approval.Model;
5+
using ModularityKit.Mutator.Governance.Abstractions.Exceptions.Approval;
6+
using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model;
7+
using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions;
8+
using ModularityKit.Mutator.Governance.Abstractions.Requests.Model;
9+
using ModularityKit.Mutator.Governance.Runtime.Approval.Execution;
10+
using ModularityKit.Mutator.Governance.Runtime.Storage;
11+
using Xunit;
12+
13+
namespace ModularityKit.Mutator.Governance.Tests.Approval;
14+
15+
public sealed class MutationRequestApprovalWorkflowTests
16+
{
17+
[Fact]
18+
public void PendingApproval_maps_policy_requirements_into_visible_request_approval_requirements()
19+
{
20+
var request = MutationRequest.PendingApproval(
21+
stateId: "tenant-42:roles",
22+
stateType: "IamRoleState",
23+
mutationType: "GrantRoleMutation",
24+
intent: CreateIntent(),
25+
context: MutationContext.User("requester", "Requester", "Needs privileged access"),
26+
requirements:
27+
[
28+
PolicyRequirement.Approval("alice", "Manager approval"),
29+
new PolicyRequirement
30+
{
31+
Type = "Approval",
32+
Description = "Security and finance approval",
33+
Data = new
34+
{
35+
Approvers = new[] { "bob", "carol" },
36+
StepOrder = 2,
37+
Reason = "Cross-functional sign-off"
38+
}
39+
}
40+
],
41+
expectedStateVersion: "v10");
42+
43+
Assert.Equal(MutationRequestStatus.Pending, request.Status);
44+
Assert.Equal(PendingMutationReason.Approval, request.PendingReason);
45+
Assert.Equal(3, request.ApprovalRequirements.Count);
46+
Assert.Collection(
47+
request.ApprovalRequirements.OrderBy(requirement => requirement.StepOrder).ThenBy(requirement => requirement.ApproverId),
48+
first =>
49+
{
50+
Assert.Equal("alice", first.ApproverId);
51+
Assert.Equal(1, first.StepOrder);
52+
Assert.Equal(MutationApprovalRequirementStatus.Pending, first.Status);
53+
},
54+
second =>
55+
{
56+
Assert.Equal("bob", second.ApproverId);
57+
Assert.Equal(2, second.StepOrder);
58+
},
59+
third =>
60+
{
61+
Assert.Equal("carol", third.ApproverId);
62+
Assert.Equal(2, third.StepOrder);
63+
});
64+
}
65+
66+
[Fact]
67+
public async Task ApproveRequirement_enforces_step_order_and_marks_request_approved_after_final_approval()
68+
{
69+
var store = new InMemoryMutationRequestStore();
70+
var manager = new MutationRequestApprovalWorkflowManager(store);
71+
var request = await store.Create(CreateMultiStepApprovalRequest());
72+
73+
var stepTwoApproval = request.ApprovalRequirements.Single(requirement => requirement.ApproverId == "carol");
74+
var invalidStep = await Assert.ThrowsAsync<InvalidMutationApprovalActionException>(() =>
75+
manager.ApproveRequirement(
76+
request.RequestId,
77+
stepTwoApproval.ApprovalId,
78+
MutationContext.User("carol", "Carol", "Approve too early")));
79+
80+
Assert.Equal(stepTwoApproval.ApprovalId, invalidStep.ApprovalId);
81+
82+
var aliceApproval = request.ApprovalRequirements.Single(requirement => requirement.ApproverId == "alice");
83+
var afterAlice = await manager.ApproveRequirement(
84+
request.RequestId,
85+
aliceApproval.ApprovalId,
86+
MutationContext.User("alice", "Alice", "Manager approved"));
87+
88+
Assert.Equal(MutationRequestStatus.Pending, afterAlice.Status);
89+
Assert.Equal(MutationApprovalRequirementStatus.Approved, afterAlice.ApprovalRequirements.Single(requirement => requirement.ApproverId == "alice").Status);
90+
91+
var bobApproval = afterAlice.ApprovalRequirements.Single(requirement => requirement.ApproverId == "bob");
92+
var afterBob = await manager.ApproveRequirement(
93+
request.RequestId,
94+
bobApproval.ApprovalId,
95+
MutationContext.User("bob", "Bob", "Security approved"));
96+
97+
Assert.Equal(MutationRequestStatus.Pending, afterBob.Status);
98+
99+
var finalCarolApproval = afterBob.ApprovalRequirements.Single(requirement => requirement.ApproverId == "carol");
100+
var afterCarol = await manager.ApproveRequirement(
101+
request.RequestId,
102+
finalCarolApproval.ApprovalId,
103+
MutationContext.User("carol", "Carol", "Finance approved"));
104+
105+
Assert.Equal(MutationRequestStatus.Approved, afterCarol.Status);
106+
Assert.Null(afterCarol.PendingReason);
107+
Assert.All(afterCarol.ApprovalRequirements, requirement => Assert.Equal(MutationApprovalRequirementStatus.Approved, requirement.Status));
108+
Assert.Equal(
109+
MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Approved),
110+
afterCarol.Decisions[^1].Type);
111+
Assert.Contains(
112+
afterCarol.Decisions,
113+
decision => decision.Type == MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.Granted));
114+
}
115+
116+
[Fact]
117+
public async Task RejectRequirement_marks_request_rejected_and_records_explicit_history()
118+
{
119+
var store = new InMemoryMutationRequestStore();
120+
var manager = new MutationRequestApprovalWorkflowManager(store);
121+
var request = await store.Create(CreateMultiStepApprovalRequest());
122+
var aliceApproval = request.ApprovalRequirements.Single(requirement => requirement.ApproverId == "alice");
123+
124+
var rejected = await manager.RejectRequirement(
125+
request.RequestId,
126+
aliceApproval.ApprovalId,
127+
MutationContext.User("alice", "Alice", "Manager rejected"),
128+
reason: "Insufficient justification");
129+
130+
Assert.Equal(MutationRequestStatus.Rejected, rejected.Status);
131+
Assert.Null(rejected.PendingReason);
132+
Assert.Equal(MutationApprovalRequirementStatus.Rejected, rejected.ApprovalRequirements.Single(requirement => requirement.ApprovalId == aliceApproval.ApprovalId).Status);
133+
Assert.Equal(
134+
MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Rejected),
135+
rejected.Decisions[^1].Type);
136+
Assert.Contains(
137+
rejected.Decisions,
138+
decision => decision.Type == MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.Rejected));
139+
Assert.Contains(rejected.Decisions, decision => decision.Reason == "Insufficient justification");
140+
}
141+
142+
private static MutationRequest CreateMultiStepApprovalRequest()
143+
{
144+
return MutationRequest.PendingApproval(
145+
stateId: "tenant-42:roles",
146+
stateType: "IamRoleState",
147+
mutationType: "GrantRoleMutation",
148+
intent: CreateIntent(),
149+
context: MutationContext.User("requester", "Requester", "Needs privileged access"),
150+
requirements:
151+
[
152+
PolicyRequirement.Approval("alice", "Manager approval"),
153+
new PolicyRequirement
154+
{
155+
Type = "Approval",
156+
Description = "Security review",
157+
Data = new
158+
{
159+
Approver = "bob",
160+
StepOrder = 1,
161+
Reason = "Security sign-off"
162+
}
163+
},
164+
new PolicyRequirement
165+
{
166+
Type = "Approval",
167+
Description = "Finance review",
168+
Data = new
169+
{
170+
Approver = "carol",
171+
StepOrder = 2,
172+
Reason = "Budget sign-off"
173+
}
174+
}
175+
],
176+
expectedStateVersion: "v10");
177+
}
178+
179+
private static MutationIntent CreateIntent()
180+
{
181+
return new MutationIntent
182+
{
183+
OperationName = "GrantRole",
184+
Category = "Security",
185+
Description = "Grant elevated role to tenant operator"
186+
};
187+
}
188+
}

Tests/ModularityKit.Mutator.Governance.Tests/Lifecycle/MutationRequestLifecycleAtomicityTests.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
using ModularityKit.Mutator.Abstractions.Context;
2-
using ModularityKit.Mutator.Governance.Abstractions.Exceptions;
3-
using ModularityKit.Mutator.Governance.Abstractions.Lifecycle;
4-
using ModularityKit.Mutator.Governance.Runtime.Lifecycle;
2+
using ModularityKit.Mutator.Governance.Abstractions.Exceptions.Storage;
3+
using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model;
4+
using ModularityKit.Mutator.Governance.Runtime.Lifecycle.Execution;
55
using ModularityKit.Mutator.Governance.Tests.TestSupport;
66
using Xunit;
77

0 commit comments

Comments
 (0)