save checkpoint: save features
This commit is contained in:
@@ -272,14 +272,118 @@ internal sealed class ActionExecutor : IActionExecutor
|
||||
executionId, proposal, context, ActionAuditOutcome.ApprovalRequested,
|
||||
decision, null, cancellationToken, approvalRequest.RequestId);
|
||||
|
||||
if (!_options.AwaitApprovalCompletion)
|
||||
{
|
||||
return new ActionExecutionResult
|
||||
{
|
||||
ExecutionId = executionId,
|
||||
Outcome = ActionExecutionOutcome.PendingApproval,
|
||||
Message = $"Approval required from: {string.Join(", ", decision.RequiredApprovers.Select(a => a.Identifier))}",
|
||||
StartedAt = startedAt,
|
||||
CompletedAt = null, // Not completed yet
|
||||
CanRollback = false,
|
||||
OutputData = new Dictionary<string, string>
|
||||
{
|
||||
["approvalRequestId"] = approvalRequest.RequestId,
|
||||
["approvalWorkflowId"] = approvalRequest.WorkflowId
|
||||
}.ToImmutableDictionary()
|
||||
};
|
||||
}
|
||||
|
||||
var approvalResult = await _approvalAdapter.WaitForApprovalAsync(
|
||||
approvalRequest.RequestId,
|
||||
_options.ApprovalWaitTimeout,
|
||||
cancellationToken);
|
||||
|
||||
if (approvalResult.Approved)
|
||||
{
|
||||
await RecordAuditEntryAsync(
|
||||
executionId,
|
||||
proposal,
|
||||
context,
|
||||
ActionAuditOutcome.Approved,
|
||||
decision,
|
||||
null,
|
||||
cancellationToken,
|
||||
approvalRequest.RequestId,
|
||||
approvalResult.ApproverId);
|
||||
|
||||
var executedResult = await ExecuteImmediatelyAsync(
|
||||
executionId,
|
||||
proposal,
|
||||
context,
|
||||
decision,
|
||||
startedAt,
|
||||
cancellationToken);
|
||||
|
||||
return executedResult with
|
||||
{
|
||||
OutputData = executedResult.OutputData
|
||||
.SetItem("approvalRequestId", approvalRequest.RequestId)
|
||||
.SetItem("approvalWorkflowId", approvalRequest.WorkflowId)
|
||||
.SetItem("approvedBy", approvalResult.ApproverId ?? string.Empty)
|
||||
};
|
||||
}
|
||||
|
||||
if (approvalResult.TimedOut)
|
||||
{
|
||||
await RecordAuditEntryAsync(
|
||||
executionId,
|
||||
proposal,
|
||||
context,
|
||||
ActionAuditOutcome.ApprovalTimedOut,
|
||||
decision,
|
||||
approvalResult.DenialReason,
|
||||
cancellationToken,
|
||||
approvalRequest.RequestId);
|
||||
|
||||
return new ActionExecutionResult
|
||||
{
|
||||
ExecutionId = executionId,
|
||||
Outcome = ActionExecutionOutcome.Timeout,
|
||||
Message = $"Approval timed out: {approvalResult.DenialReason}",
|
||||
StartedAt = startedAt,
|
||||
CompletedAt = _timeProvider.GetUtcNow(),
|
||||
CanRollback = false,
|
||||
Error = new ActionError
|
||||
{
|
||||
Code = "APPROVAL_TIMED_OUT",
|
||||
Message = approvalResult.DenialReason ?? "Timed out waiting for approval",
|
||||
IsRetryable = true
|
||||
},
|
||||
OutputData = new Dictionary<string, string>
|
||||
{
|
||||
["approvalRequestId"] = approvalRequest.RequestId,
|
||||
["approvalWorkflowId"] = approvalRequest.WorkflowId
|
||||
}.ToImmutableDictionary()
|
||||
};
|
||||
}
|
||||
|
||||
await RecordAuditEntryAsync(
|
||||
executionId,
|
||||
proposal,
|
||||
context,
|
||||
ActionAuditOutcome.ApprovalDenied,
|
||||
decision,
|
||||
approvalResult.DenialReason,
|
||||
cancellationToken,
|
||||
approvalRequest.RequestId,
|
||||
approvalResult.ApproverId);
|
||||
|
||||
return new ActionExecutionResult
|
||||
{
|
||||
ExecutionId = executionId,
|
||||
Outcome = ActionExecutionOutcome.PendingApproval,
|
||||
Message = $"Approval required from: {string.Join(", ", decision.RequiredApprovers.Select(a => a.Identifier))}",
|
||||
Outcome = ActionExecutionOutcome.Failed,
|
||||
Message = $"Approval denied: {approvalResult.DenialReason}",
|
||||
StartedAt = startedAt,
|
||||
CompletedAt = null, // Not completed yet
|
||||
CompletedAt = _timeProvider.GetUtcNow(),
|
||||
CanRollback = false,
|
||||
Error = new ActionError
|
||||
{
|
||||
Code = "APPROVAL_DENIED",
|
||||
Message = approvalResult.DenialReason ?? "Action approval denied",
|
||||
IsRetryable = false
|
||||
},
|
||||
OutputData = new Dictionary<string, string>
|
||||
{
|
||||
["approvalRequestId"] = approvalRequest.RequestId,
|
||||
@@ -382,7 +486,8 @@ internal sealed class ActionExecutor : IActionExecutor
|
||||
ActionPolicyDecision? decision,
|
||||
string? errorMessage,
|
||||
CancellationToken cancellationToken,
|
||||
string? approvalRequestId = null)
|
||||
string? approvalRequestId = null,
|
||||
string? approverId = null)
|
||||
{
|
||||
var entry = new ActionAuditEntry
|
||||
{
|
||||
@@ -399,6 +504,7 @@ internal sealed class ActionExecutor : IActionExecutor
|
||||
PolicyId = decision?.PolicyId,
|
||||
PolicyResult = decision?.Decision,
|
||||
ApprovalRequestId = approvalRequestId,
|
||||
ApproverId = approverId,
|
||||
Parameters = proposal.Parameters,
|
||||
ExecutionId = executionId,
|
||||
ErrorMessage = errorMessage
|
||||
@@ -454,4 +560,14 @@ public sealed class ActionExecutorOptions
|
||||
/// Default timeout for action execution.
|
||||
/// </summary>
|
||||
public TimeSpan DefaultTimeout { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Whether ExecuteAsync should wait for approval and continue execution when approved.
|
||||
/// </summary>
|
||||
public bool AwaitApprovalCompletion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum wait time when awaiting approval completion.
|
||||
/// </summary>
|
||||
public TimeSpan ApprovalWaitTimeout { get; set; } = TimeSpan.FromMinutes(5);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
// <copyright file="ActionWorkflowIntegrationTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.AdvisoryAI.Actions;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the full action policy gate workflow.
|
||||
/// </summary>
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
public sealed class ActionWorkflowIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_AwaitApprovalEnabled_ApprovesExecutesAndAudits()
|
||||
{
|
||||
var harness = CreateHarness(awaitApprovalCompletion: true);
|
||||
var proposal = CreateApproveProposal(harness.TimeProvider.GetUtcNow());
|
||||
var context = CreateActionContext();
|
||||
var decision = await harness.PolicyGate.EvaluateAsync(proposal, context, CancellationToken.None);
|
||||
|
||||
decision.Decision.Should().Be(PolicyDecisionKind.AllowWithApproval);
|
||||
|
||||
var executeTask = harness.Executor.ExecuteAsync(proposal, decision, context, CancellationToken.None);
|
||||
await WaitForApprovalRequestAsync(
|
||||
harness.ApprovalAdapter,
|
||||
harness.ExpectedApprovalRequestId,
|
||||
CancellationToken.None);
|
||||
|
||||
harness.ApprovalAdapter.RecordApproval(
|
||||
harness.ExpectedApprovalRequestId,
|
||||
approverId: "security-lead-1",
|
||||
approved: true,
|
||||
comments: "approved for release");
|
||||
|
||||
var result = await executeTask;
|
||||
|
||||
result.Outcome.Should().Be(ActionExecutionOutcome.Success);
|
||||
result.OutputData.Should().ContainKey("approvalRequestId");
|
||||
result.OutputData["approvalRequestId"].Should().Be(harness.ExpectedApprovalRequestId);
|
||||
result.OutputData["approvedBy"].Should().Be("security-lead-1");
|
||||
|
||||
var entries = await harness.AuditLedger.GetByRunAsync(context.RunId!, CancellationToken.None);
|
||||
var outcomes = entries.Select(e => e.Outcome).ToArray();
|
||||
outcomes.Should().Contain(ActionAuditOutcome.ApprovalRequested);
|
||||
outcomes.Should().Contain(ActionAuditOutcome.Approved);
|
||||
outcomes.Should().Contain(ActionAuditOutcome.Executed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_RepeatAfterApprovedExecution_ReturnsIdempotentResultAndAuditsSkip()
|
||||
{
|
||||
var harness = CreateHarness(awaitApprovalCompletion: true);
|
||||
var proposal = CreateApproveProposal(harness.TimeProvider.GetUtcNow());
|
||||
var context = CreateActionContext();
|
||||
var decision = await harness.PolicyGate.EvaluateAsync(proposal, context, CancellationToken.None);
|
||||
|
||||
var firstExecutionTask = harness.Executor.ExecuteAsync(proposal, decision, context, CancellationToken.None);
|
||||
await WaitForApprovalRequestAsync(
|
||||
harness.ApprovalAdapter,
|
||||
harness.ExpectedApprovalRequestId,
|
||||
CancellationToken.None);
|
||||
harness.ApprovalAdapter.RecordApproval(
|
||||
harness.ExpectedApprovalRequestId,
|
||||
approverId: "security-lead-1",
|
||||
approved: true,
|
||||
comments: "approved");
|
||||
var firstResult = await firstExecutionTask;
|
||||
|
||||
var secondResult = await harness.Executor.ExecuteAsync(proposal, decision, context, CancellationToken.None);
|
||||
|
||||
secondResult.ExecutionId.Should().Be(firstResult.ExecutionId);
|
||||
secondResult.Outcome.Should().Be(ActionExecutionOutcome.Success);
|
||||
|
||||
var entries = await harness.AuditLedger.GetByRunAsync(context.RunId!, CancellationToken.None);
|
||||
entries.Select(e => e.Outcome).Should().Contain(ActionAuditOutcome.IdempotentSkipped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_PolicyDenied_FailsAndAuditsDenial()
|
||||
{
|
||||
var harness = CreateHarness(awaitApprovalCompletion: false);
|
||||
var proposal = CreateApproveProposal(harness.TimeProvider.GetUtcNow());
|
||||
var context = CreateActionContext(userRoles: ["viewer"]);
|
||||
var decision = await harness.PolicyGate.EvaluateAsync(proposal, context, CancellationToken.None);
|
||||
|
||||
decision.Decision.Should().Be(PolicyDecisionKind.Deny);
|
||||
|
||||
var result = await harness.Executor.ExecuteAsync(proposal, decision, context, CancellationToken.None);
|
||||
|
||||
result.Outcome.Should().Be(ActionExecutionOutcome.Failed);
|
||||
result.Error.Should().NotBeNull();
|
||||
result.Error!.Code.Should().Be("POLICY_DENIED");
|
||||
|
||||
var entries = await harness.AuditLedger.GetByRunAsync(context.RunId!, CancellationToken.None);
|
||||
entries.Select(e => e.Outcome).Should().Contain(ActionAuditOutcome.DeniedByPolicy);
|
||||
}
|
||||
|
||||
private static ActionProposal CreateApproveProposal(DateTimeOffset createdAt)
|
||||
{
|
||||
return new ActionProposal
|
||||
{
|
||||
ProposalId = "proposal-approve-cve",
|
||||
ActionType = "approve",
|
||||
Label = "Approve CVE risk",
|
||||
Parameters = new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2024-1234",
|
||||
["justification"] = "Reachability confirms non-exploitable path"
|
||||
}.ToImmutableDictionary(),
|
||||
CreatedAt = createdAt,
|
||||
IdempotencyKey = "approve-cve-2024-1234"
|
||||
};
|
||||
}
|
||||
|
||||
private static ActionContext CreateActionContext(ImmutableArray<string>? userRoles = null)
|
||||
{
|
||||
return new ActionContext
|
||||
{
|
||||
TenantId = "tenant-qa",
|
||||
UserId = "analyst-1",
|
||||
UserRoles = userRoles ?? ["security-analyst"],
|
||||
Environment = "development",
|
||||
RunId = "run-action-policy",
|
||||
CveId = "CVE-2024-1234"
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task WaitForApprovalRequestAsync(
|
||||
ApprovalWorkflowAdapter approvalAdapter,
|
||||
string requestId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
for (var attempt = 0; attempt < 50; attempt++)
|
||||
{
|
||||
var status = await approvalAdapter.GetApprovalStatusAsync(requestId, cancellationToken);
|
||||
if (status is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.Delay(20, cancellationToken);
|
||||
}
|
||||
|
||||
throw new TimeoutException($"Approval request {requestId} was not created in time.");
|
||||
}
|
||||
|
||||
private static ActionHarness CreateHarness(bool awaitApprovalCompletion)
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 2, 11, 12, 0, 0, TimeSpan.Zero));
|
||||
var guidGenerator = new SequenceGuidGenerator(
|
||||
"00000000-0000-0000-0000-000000000001",
|
||||
"00000000-0000-0000-0000-000000000002");
|
||||
|
||||
var registry = new ActionRegistry();
|
||||
var policyGate = new ActionPolicyGate(
|
||||
registry,
|
||||
timeProvider,
|
||||
Options.Create(new ActionPolicyOptions()),
|
||||
NullLogger<ActionPolicyGate>.Instance);
|
||||
var idempotencyHandler = new IdempotencyHandler(
|
||||
timeProvider,
|
||||
Options.Create(new IdempotencyOptions { TtlDays = 30 }),
|
||||
NullLogger<IdempotencyHandler>.Instance);
|
||||
var approvalAdapter = new ApprovalWorkflowAdapter(
|
||||
timeProvider,
|
||||
guidGenerator,
|
||||
NullLogger<ApprovalWorkflowAdapter>.Instance);
|
||||
var auditLedger = new ActionAuditLedger(
|
||||
Options.Create(new AuditLedgerOptions()),
|
||||
NullLogger<ActionAuditLedger>.Instance);
|
||||
var executor = new ActionExecutor(
|
||||
policyGate,
|
||||
registry,
|
||||
idempotencyHandler,
|
||||
approvalAdapter,
|
||||
auditLedger,
|
||||
timeProvider,
|
||||
guidGenerator,
|
||||
Options.Create(new ActionExecutorOptions
|
||||
{
|
||||
EnableIdempotency = true,
|
||||
EnableAuditLogging = true,
|
||||
AwaitApprovalCompletion = awaitApprovalCompletion,
|
||||
ApprovalWaitTimeout = TimeSpan.FromSeconds(2)
|
||||
}),
|
||||
NullLogger<ActionExecutor>.Instance);
|
||||
|
||||
return new ActionHarness(
|
||||
Executor: executor,
|
||||
PolicyGate: policyGate,
|
||||
ApprovalAdapter: approvalAdapter,
|
||||
AuditLedger: auditLedger,
|
||||
TimeProvider: timeProvider,
|
||||
ExpectedApprovalRequestId: "00000000-0000-0000-0000-000000000002");
|
||||
}
|
||||
|
||||
private sealed record ActionHarness(
|
||||
ActionExecutor Executor,
|
||||
ActionPolicyGate PolicyGate,
|
||||
ApprovalWorkflowAdapter ApprovalAdapter,
|
||||
ActionAuditLedger AuditLedger,
|
||||
FakeTimeProvider TimeProvider,
|
||||
string ExpectedApprovalRequestId);
|
||||
|
||||
private sealed class SequenceGuidGenerator : IGuidGenerator
|
||||
{
|
||||
private readonly Queue<Guid> _seededGuids;
|
||||
private int _counter;
|
||||
|
||||
public SequenceGuidGenerator(params string[] seededGuids)
|
||||
{
|
||||
_seededGuids = new Queue<Guid>(seededGuids.Select(Guid.Parse));
|
||||
}
|
||||
|
||||
public Guid NewGuid()
|
||||
{
|
||||
if (_seededGuids.Count > 0)
|
||||
{
|
||||
return _seededGuids.Dequeue();
|
||||
}
|
||||
|
||||
return new Guid($"00000000-0000-0000-0000-{_counter++:D12}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user