feat(api): Implement Console Export Client and Models
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
mock-dev-release / package-mock-release (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
mock-dev-release / package-mock-release (push) Has been cancelled
- Added ConsoleExportClient for managing export requests and responses. - Introduced ConsoleExportRequest and ConsoleExportResponse models. - Implemented methods for creating and retrieving exports with appropriate headers. feat(crypto): Add Software SM2/SM3 Cryptography Provider - Implemented SmSoftCryptoProvider for software-only SM2/SM3 cryptography. - Added support for signing and verification using SM2 algorithm. - Included hashing functionality with SM3 algorithm. - Configured options for loading keys from files and environment gate checks. test(crypto): Add unit tests for SmSoftCryptoProvider - Created comprehensive tests for signing, verifying, and hashing functionalities. - Ensured correct behavior for key management and error handling. feat(api): Enhance Console Export Models - Expanded ConsoleExport models to include detailed status and event types. - Added support for various export formats and notification options. test(time): Implement TimeAnchorPolicyService tests - Developed tests for TimeAnchorPolicyService to validate time anchors. - Covered scenarios for anchor validation, drift calculation, and policy enforcement.
This commit is contained in:
@@ -0,0 +1,406 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Policy.Registry.Contracts;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of batch simulation orchestrator.
|
||||
/// Uses in-memory job queue with background processing.
|
||||
/// </summary>
|
||||
public sealed class BatchSimulationOrchestrator : IBatchSimulationOrchestrator, IDisposable
|
||||
{
|
||||
private readonly IPolicySimulationService _simulationService;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ConcurrentDictionary<(Guid TenantId, string JobId), BatchSimulationJob> _jobs = new();
|
||||
private readonly ConcurrentDictionary<(Guid TenantId, string JobId), List<BatchSimulationInputResult>> _results = new();
|
||||
private readonly ConcurrentDictionary<string, string> _idempotencyKeys = new();
|
||||
private readonly ConcurrentQueue<(Guid TenantId, string JobId, BatchSimulationRequest Request)> _jobQueue = new();
|
||||
private readonly CancellationTokenSource _disposalCts = new();
|
||||
private readonly Task _processingTask;
|
||||
|
||||
public BatchSimulationOrchestrator(
|
||||
IPolicySimulationService simulationService,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_simulationService = simulationService ?? throw new ArgumentNullException(nameof(simulationService));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
|
||||
// Start background processing
|
||||
_processingTask = Task.Run(ProcessJobsAsync);
|
||||
}
|
||||
|
||||
public Task<BatchSimulationJob> SubmitBatchAsync(
|
||||
Guid tenantId,
|
||||
BatchSimulationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Check idempotency key
|
||||
if (!string.IsNullOrEmpty(request.IdempotencyKey))
|
||||
{
|
||||
if (_idempotencyKeys.TryGetValue(request.IdempotencyKey, out var existingJobId))
|
||||
{
|
||||
var existingJob = _jobs.Values.FirstOrDefault(j => j.JobId == existingJobId && j.TenantId == tenantId);
|
||||
if (existingJob is not null)
|
||||
{
|
||||
return Task.FromResult(existingJob);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var jobId = GenerateJobId(tenantId, now);
|
||||
|
||||
var job = new BatchSimulationJob
|
||||
{
|
||||
JobId = jobId,
|
||||
TenantId = tenantId,
|
||||
PackId = request.PackId,
|
||||
Status = BatchJobStatus.Pending,
|
||||
Description = request.Description,
|
||||
TotalInputs = request.Inputs.Count,
|
||||
ProcessedInputs = 0,
|
||||
SucceededInputs = 0,
|
||||
FailedInputs = 0,
|
||||
CreatedAt = now,
|
||||
Progress = new BatchJobProgress
|
||||
{
|
||||
PercentComplete = 0,
|
||||
EstimatedRemainingSeconds = null,
|
||||
CurrentBatchIndex = 0,
|
||||
TotalBatches = 1
|
||||
}
|
||||
};
|
||||
|
||||
_jobs[(tenantId, jobId)] = job;
|
||||
_results[(tenantId, jobId)] = [];
|
||||
|
||||
if (!string.IsNullOrEmpty(request.IdempotencyKey))
|
||||
{
|
||||
_idempotencyKeys[request.IdempotencyKey] = jobId;
|
||||
}
|
||||
|
||||
// Queue job for processing
|
||||
_jobQueue.Enqueue((tenantId, jobId, request));
|
||||
|
||||
return Task.FromResult(job);
|
||||
}
|
||||
|
||||
public Task<BatchSimulationJob?> GetJobAsync(
|
||||
Guid tenantId,
|
||||
string jobId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_jobs.TryGetValue((tenantId, jobId), out var job);
|
||||
return Task.FromResult(job);
|
||||
}
|
||||
|
||||
public Task<BatchSimulationJobList> ListJobsAsync(
|
||||
Guid tenantId,
|
||||
BatchJobStatus? status = null,
|
||||
int pageSize = 20,
|
||||
string? pageToken = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _jobs.Values.Where(j => j.TenantId == tenantId);
|
||||
|
||||
if (status.HasValue)
|
||||
{
|
||||
query = query.Where(j => j.Status == status.Value);
|
||||
}
|
||||
|
||||
var items = query
|
||||
.OrderByDescending(j => j.CreatedAt)
|
||||
.ToList();
|
||||
|
||||
int skip = 0;
|
||||
if (!string.IsNullOrEmpty(pageToken) && int.TryParse(pageToken, out var offset))
|
||||
{
|
||||
skip = offset;
|
||||
}
|
||||
|
||||
var pagedItems = items.Skip(skip).Take(pageSize).ToList();
|
||||
string? nextToken = skip + pagedItems.Count < items.Count
|
||||
? (skip + pagedItems.Count).ToString()
|
||||
: null;
|
||||
|
||||
return Task.FromResult(new BatchSimulationJobList
|
||||
{
|
||||
Items = pagedItems,
|
||||
NextPageToken = nextToken,
|
||||
TotalCount = items.Count
|
||||
});
|
||||
}
|
||||
|
||||
public Task<bool> CancelJobAsync(
|
||||
Guid tenantId,
|
||||
string jobId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_jobs.TryGetValue((tenantId, jobId), out var job))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
if (job.Status is not (BatchJobStatus.Pending or BatchJobStatus.Running))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
var cancelledJob = job with
|
||||
{
|
||||
Status = BatchJobStatus.Cancelled,
|
||||
CompletedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
_jobs[(tenantId, jobId)] = cancelledJob;
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public Task<BatchSimulationResults?> GetResultsAsync(
|
||||
Guid tenantId,
|
||||
string jobId,
|
||||
int pageSize = 100,
|
||||
string? pageToken = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_jobs.TryGetValue((tenantId, jobId), out var job))
|
||||
{
|
||||
return Task.FromResult<BatchSimulationResults?>(null);
|
||||
}
|
||||
|
||||
if (!_results.TryGetValue((tenantId, jobId), out var results))
|
||||
{
|
||||
return Task.FromResult<BatchSimulationResults?>(null);
|
||||
}
|
||||
|
||||
int skip = 0;
|
||||
if (!string.IsNullOrEmpty(pageToken) && int.TryParse(pageToken, out var offset))
|
||||
{
|
||||
skip = offset;
|
||||
}
|
||||
|
||||
var pagedResults = results.Skip(skip).Take(pageSize).ToList();
|
||||
string? nextToken = skip + pagedResults.Count < results.Count
|
||||
? (skip + pagedResults.Count).ToString()
|
||||
: null;
|
||||
|
||||
var summary = job.Status == BatchJobStatus.Completed ? ComputeSummary(results) : null;
|
||||
|
||||
return Task.FromResult<BatchSimulationResults?>(new BatchSimulationResults
|
||||
{
|
||||
JobId = jobId,
|
||||
Results = pagedResults,
|
||||
Summary = summary,
|
||||
NextPageToken = nextToken
|
||||
});
|
||||
}
|
||||
|
||||
private async Task ProcessJobsAsync()
|
||||
{
|
||||
while (!_disposalCts.Token.IsCancellationRequested)
|
||||
{
|
||||
if (_jobQueue.TryDequeue(out var item))
|
||||
{
|
||||
var (tenantId, jobId, request) = item;
|
||||
|
||||
// Check if job was cancelled
|
||||
if (_jobs.TryGetValue((tenantId, jobId), out var job) && job.Status == BatchJobStatus.Cancelled)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await ProcessJobAsync(tenantId, jobId, request, _disposalCts.Token);
|
||||
}
|
||||
else
|
||||
{
|
||||
await Task.Delay(100, _disposalCts.Token).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessJobAsync(
|
||||
Guid tenantId,
|
||||
string jobId,
|
||||
BatchSimulationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var startedAt = _timeProvider.GetUtcNow();
|
||||
var results = _results[(tenantId, jobId)];
|
||||
|
||||
// Update job to running
|
||||
UpdateJob(tenantId, jobId, job => job with
|
||||
{
|
||||
Status = BatchJobStatus.Running,
|
||||
StartedAt = startedAt
|
||||
});
|
||||
|
||||
int processed = 0;
|
||||
int succeeded = 0;
|
||||
int failed = 0;
|
||||
|
||||
foreach (var input in request.Inputs)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if job was cancelled
|
||||
if (_jobs.TryGetValue((tenantId, jobId), out var currentJob) && currentJob.Status == BatchJobStatus.Cancelled)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var simRequest = new SimulationRequest
|
||||
{
|
||||
Input = input.Input,
|
||||
Options = request.Options is not null ? new SimulationOptions
|
||||
{
|
||||
Trace = request.Options.IncludeTrace,
|
||||
Explain = request.Options.IncludeExplain
|
||||
} : null
|
||||
};
|
||||
|
||||
var response = await _simulationService.SimulateAsync(
|
||||
tenantId,
|
||||
request.PackId,
|
||||
simRequest,
|
||||
cancellationToken);
|
||||
|
||||
results.Add(new BatchSimulationInputResult
|
||||
{
|
||||
InputId = input.InputId,
|
||||
Success = response.Success,
|
||||
Response = response,
|
||||
DurationMilliseconds = response.DurationMilliseconds
|
||||
});
|
||||
|
||||
if (response.Success)
|
||||
{
|
||||
succeeded++;
|
||||
}
|
||||
else
|
||||
{
|
||||
failed++;
|
||||
if (!request.Options?.ContinueOnError ?? false)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failed++;
|
||||
results.Add(new BatchSimulationInputResult
|
||||
{
|
||||
InputId = input.InputId,
|
||||
Success = false,
|
||||
Error = ex.Message,
|
||||
DurationMilliseconds = 0
|
||||
});
|
||||
|
||||
if (!request.Options?.ContinueOnError ?? false)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
processed++;
|
||||
|
||||
// Update progress
|
||||
var progress = (double)processed / request.Inputs.Count * 100;
|
||||
UpdateJob(tenantId, jobId, job => job with
|
||||
{
|
||||
ProcessedInputs = processed,
|
||||
SucceededInputs = succeeded,
|
||||
FailedInputs = failed,
|
||||
Progress = new BatchJobProgress
|
||||
{
|
||||
PercentComplete = progress,
|
||||
CurrentBatchIndex = processed,
|
||||
TotalBatches = request.Inputs.Count
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Finalize job
|
||||
var completedAt = _timeProvider.GetUtcNow();
|
||||
var finalStatus = failed > 0 && succeeded == 0
|
||||
? BatchJobStatus.Failed
|
||||
: BatchJobStatus.Completed;
|
||||
|
||||
UpdateJob(tenantId, jobId, job => job with
|
||||
{
|
||||
Status = finalStatus,
|
||||
ProcessedInputs = processed,
|
||||
SucceededInputs = succeeded,
|
||||
FailedInputs = failed,
|
||||
CompletedAt = completedAt,
|
||||
Progress = new BatchJobProgress
|
||||
{
|
||||
PercentComplete = 100,
|
||||
CurrentBatchIndex = processed,
|
||||
TotalBatches = request.Inputs.Count
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void UpdateJob(Guid tenantId, string jobId, Func<BatchSimulationJob, BatchSimulationJob> update)
|
||||
{
|
||||
if (_jobs.TryGetValue((tenantId, jobId), out var current))
|
||||
{
|
||||
_jobs[(tenantId, jobId)] = update(current);
|
||||
}
|
||||
}
|
||||
|
||||
private static BatchSimulationSummary ComputeSummary(List<BatchSimulationInputResult> results)
|
||||
{
|
||||
var totalViolations = 0;
|
||||
var severityCounts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
long totalDuration = 0;
|
||||
|
||||
foreach (var result in results)
|
||||
{
|
||||
totalDuration += result.DurationMilliseconds;
|
||||
|
||||
if (result.Response?.Summary?.ViolationsFound > 0)
|
||||
{
|
||||
totalViolations += result.Response.Summary.ViolationsFound;
|
||||
|
||||
foreach (var (severity, count) in result.Response.Summary.ViolationsBySeverity)
|
||||
{
|
||||
severityCounts[severity] = severityCounts.GetValueOrDefault(severity) + count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new BatchSimulationSummary
|
||||
{
|
||||
TotalInputs = results.Count,
|
||||
Succeeded = results.Count(r => r.Success),
|
||||
Failed = results.Count(r => !r.Success),
|
||||
TotalViolations = totalViolations,
|
||||
ViolationsBySeverity = severityCounts,
|
||||
TotalDurationMilliseconds = totalDuration,
|
||||
AverageDurationMilliseconds = results.Count > 0 ? (double)totalDuration / results.Count : 0
|
||||
};
|
||||
}
|
||||
|
||||
private static string GenerateJobId(Guid tenantId, DateTimeOffset timestamp)
|
||||
{
|
||||
var content = $"{tenantId}:{timestamp.ToUnixTimeMilliseconds()}:{Guid.NewGuid()}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
|
||||
return $"batch_{Convert.ToHexString(hash)[..16].ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_disposalCts.Cancel();
|
||||
_processingTask.Wait(TimeSpan.FromSeconds(5));
|
||||
_disposalCts.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
using StellaOps.Policy.Registry.Contracts;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for orchestrating batch policy simulations.
|
||||
/// Implements REGISTRY-API-27-005: Batch simulation orchestration.
|
||||
/// </summary>
|
||||
public interface IBatchSimulationOrchestrator
|
||||
{
|
||||
/// <summary>
|
||||
/// Submits a batch simulation job.
|
||||
/// </summary>
|
||||
Task<BatchSimulationJob> SubmitBatchAsync(
|
||||
Guid tenantId,
|
||||
BatchSimulationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the status of a batch simulation job.
|
||||
/// </summary>
|
||||
Task<BatchSimulationJob?> GetJobAsync(
|
||||
Guid tenantId,
|
||||
string jobId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists batch simulation jobs for a tenant.
|
||||
/// </summary>
|
||||
Task<BatchSimulationJobList> ListJobsAsync(
|
||||
Guid tenantId,
|
||||
BatchJobStatus? status = null,
|
||||
int pageSize = 20,
|
||||
string? pageToken = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Cancels a pending or running batch simulation job.
|
||||
/// </summary>
|
||||
Task<bool> CancelJobAsync(
|
||||
Guid tenantId,
|
||||
string jobId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets results for a completed batch simulation job.
|
||||
/// </summary>
|
||||
Task<BatchSimulationResults?> GetResultsAsync(
|
||||
Guid tenantId,
|
||||
string jobId,
|
||||
int pageSize = 100,
|
||||
string? pageToken = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to submit a batch simulation.
|
||||
/// </summary>
|
||||
public sealed record BatchSimulationRequest
|
||||
{
|
||||
public required Guid PackId { get; init; }
|
||||
public required IReadOnlyList<BatchSimulationInput> Inputs { get; init; }
|
||||
public BatchSimulationOptions? Options { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public int? Priority { get; init; }
|
||||
public string? IdempotencyKey { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single input for batch simulation.
|
||||
/// </summary>
|
||||
public sealed record BatchSimulationInput
|
||||
{
|
||||
public required string InputId { get; init; }
|
||||
public required IReadOnlyDictionary<string, object> Input { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Tags { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for batch simulation.
|
||||
/// </summary>
|
||||
public sealed record BatchSimulationOptions
|
||||
{
|
||||
public bool ContinueOnError { get; init; } = true;
|
||||
public int? MaxConcurrency { get; init; }
|
||||
public int? TimeoutSeconds { get; init; }
|
||||
public bool IncludeTrace { get; init; }
|
||||
public bool IncludeExplain { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Batch simulation job status.
|
||||
/// </summary>
|
||||
public enum BatchJobStatus
|
||||
{
|
||||
Pending,
|
||||
Running,
|
||||
Completed,
|
||||
Failed,
|
||||
Cancelled
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Batch simulation job.
|
||||
/// </summary>
|
||||
public sealed record BatchSimulationJob
|
||||
{
|
||||
public required string JobId { get; init; }
|
||||
public required Guid TenantId { get; init; }
|
||||
public required Guid PackId { get; init; }
|
||||
public required BatchJobStatus Status { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public required int TotalInputs { get; init; }
|
||||
public int ProcessedInputs { get; init; }
|
||||
public int SucceededInputs { get; init; }
|
||||
public int FailedInputs { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? StartedAt { get; init; }
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
public string? Error { get; init; }
|
||||
public BatchJobProgress? Progress { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Progress information for a batch job.
|
||||
/// </summary>
|
||||
public sealed record BatchJobProgress
|
||||
{
|
||||
public required double PercentComplete { get; init; }
|
||||
public long? EstimatedRemainingSeconds { get; init; }
|
||||
public int? CurrentBatchIndex { get; init; }
|
||||
public int? TotalBatches { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// List of batch simulation jobs.
|
||||
/// </summary>
|
||||
public sealed record BatchSimulationJobList
|
||||
{
|
||||
public required IReadOnlyList<BatchSimulationJob> Items { get; init; }
|
||||
public string? NextPageToken { get; init; }
|
||||
public int TotalCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Results from a completed batch simulation.
|
||||
/// </summary>
|
||||
public sealed record BatchSimulationResults
|
||||
{
|
||||
public required string JobId { get; init; }
|
||||
public required IReadOnlyList<BatchSimulationInputResult> Results { get; init; }
|
||||
public BatchSimulationSummary? Summary { get; init; }
|
||||
public string? NextPageToken { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result for a single input in batch simulation.
|
||||
/// </summary>
|
||||
public sealed record BatchSimulationInputResult
|
||||
{
|
||||
public required string InputId { get; init; }
|
||||
public required bool Success { get; init; }
|
||||
public PolicySimulationResponse? Response { get; init; }
|
||||
public string? Error { get; init; }
|
||||
public long DurationMilliseconds { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of batch simulation results.
|
||||
/// </summary>
|
||||
public sealed record BatchSimulationSummary
|
||||
{
|
||||
public required int TotalInputs { get; init; }
|
||||
public required int Succeeded { get; init; }
|
||||
public required int Failed { get; init; }
|
||||
public required int TotalViolations { get; init; }
|
||||
public required IReadOnlyDictionary<string, int> ViolationsBySeverity { get; init; }
|
||||
public required long TotalDurationMilliseconds { get; init; }
|
||||
public required double AverageDurationMilliseconds { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using StellaOps.Policy.Registry.Contracts;
|
||||
using StellaOps.Policy.Registry.Storage;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for compiling and validating policy packs.
|
||||
/// Implements REGISTRY-API-27-003: Compile endpoint integration.
|
||||
/// </summary>
|
||||
public interface IPolicyPackCompiler
|
||||
{
|
||||
/// <summary>
|
||||
/// Compiles a policy pack, validating all rules and computing a digest.
|
||||
/// </summary>
|
||||
Task<PolicyPackCompilationResult> CompileAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Validates a single Rego rule without persisting.
|
||||
/// </summary>
|
||||
Task<RuleValidationResult> ValidateRuleAsync(
|
||||
string ruleId,
|
||||
string? rego,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Validates all rules in a policy pack without persisting.
|
||||
/// </summary>
|
||||
Task<PolicyPackCompilationResult> ValidatePackAsync(
|
||||
CreatePolicyPackRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of policy pack compilation.
|
||||
/// </summary>
|
||||
public sealed record PolicyPackCompilationResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
public string? Digest { get; init; }
|
||||
public IReadOnlyList<CompilationError>? Errors { get; init; }
|
||||
public IReadOnlyList<CompilationWarning>? Warnings { get; init; }
|
||||
public PolicyPackCompilationStatistics? Statistics { get; init; }
|
||||
public long DurationMilliseconds { get; init; }
|
||||
|
||||
public static PolicyPackCompilationResult FromSuccess(
|
||||
string digest,
|
||||
PolicyPackCompilationStatistics statistics,
|
||||
IReadOnlyList<CompilationWarning>? warnings,
|
||||
long durationMs) => new()
|
||||
{
|
||||
Success = true,
|
||||
Digest = digest,
|
||||
Statistics = statistics,
|
||||
Warnings = warnings,
|
||||
DurationMilliseconds = durationMs
|
||||
};
|
||||
|
||||
public static PolicyPackCompilationResult FromFailure(
|
||||
IReadOnlyList<CompilationError> errors,
|
||||
IReadOnlyList<CompilationWarning>? warnings,
|
||||
long durationMs) => new()
|
||||
{
|
||||
Success = false,
|
||||
Errors = errors,
|
||||
Warnings = warnings,
|
||||
DurationMilliseconds = durationMs
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of single rule validation.
|
||||
/// </summary>
|
||||
public sealed record RuleValidationResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
public string? RuleId { get; init; }
|
||||
public IReadOnlyList<CompilationError>? Errors { get; init; }
|
||||
public IReadOnlyList<CompilationWarning>? Warnings { get; init; }
|
||||
|
||||
public static RuleValidationResult FromSuccess(
|
||||
string ruleId,
|
||||
IReadOnlyList<CompilationWarning>? warnings = null) => new()
|
||||
{
|
||||
Success = true,
|
||||
RuleId = ruleId,
|
||||
Warnings = warnings
|
||||
};
|
||||
|
||||
public static RuleValidationResult FromFailure(
|
||||
string ruleId,
|
||||
IReadOnlyList<CompilationError> errors,
|
||||
IReadOnlyList<CompilationWarning>? warnings = null) => new()
|
||||
{
|
||||
Success = false,
|
||||
RuleId = ruleId,
|
||||
Errors = errors,
|
||||
Warnings = warnings
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Statistics from policy pack compilation.
|
||||
/// </summary>
|
||||
public sealed record PolicyPackCompilationStatistics
|
||||
{
|
||||
public required int TotalRules { get; init; }
|
||||
public required int EnabledRules { get; init; }
|
||||
public required int DisabledRules { get; init; }
|
||||
public required int RulesWithRego { get; init; }
|
||||
public required int RulesWithoutRego { get; init; }
|
||||
public required IReadOnlyDictionary<string, int> SeverityCounts { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using StellaOps.Policy.Registry.Contracts;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for quick policy pack simulation.
|
||||
/// Implements REGISTRY-API-27-004: Quick simulation API.
|
||||
/// </summary>
|
||||
public interface IPolicySimulationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Simulates a policy pack against provided input.
|
||||
/// </summary>
|
||||
Task<PolicySimulationResponse> SimulateAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
SimulationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Simulates rules directly without requiring a persisted pack.
|
||||
/// Useful for testing rules during development.
|
||||
/// </summary>
|
||||
Task<PolicySimulationResponse> SimulateRulesAsync(
|
||||
Guid tenantId,
|
||||
IReadOnlyList<PolicyRule> rules,
|
||||
SimulationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Validates simulation input structure.
|
||||
/// </summary>
|
||||
Task<InputValidationResult> ValidateInputAsync(
|
||||
IReadOnlyDictionary<string, object> input,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from policy simulation.
|
||||
/// </summary>
|
||||
public sealed record PolicySimulationResponse
|
||||
{
|
||||
public required string SimulationId { get; init; }
|
||||
public required bool Success { get; init; }
|
||||
public required DateTimeOffset ExecutedAt { get; init; }
|
||||
public required long DurationMilliseconds { get; init; }
|
||||
public SimulationResult? Result { get; init; }
|
||||
public SimulationSummary? Summary { get; init; }
|
||||
public IReadOnlyList<SimulationError>? Errors { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of simulation execution.
|
||||
/// </summary>
|
||||
public sealed record SimulationSummary
|
||||
{
|
||||
public required int TotalRulesEvaluated { get; init; }
|
||||
public required int RulesMatched { get; init; }
|
||||
public required int ViolationsFound { get; init; }
|
||||
public required IReadOnlyDictionary<string, int> ViolationsBySeverity { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Error during simulation.
|
||||
/// </summary>
|
||||
public sealed record SimulationError
|
||||
{
|
||||
public string? RuleId { get; init; }
|
||||
public required string Code { get; init; }
|
||||
public required string Message { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of input validation.
|
||||
/// </summary>
|
||||
public sealed record InputValidationResult
|
||||
{
|
||||
public required bool IsValid { get; init; }
|
||||
public IReadOnlyList<InputValidationError>? Errors { get; init; }
|
||||
|
||||
public static InputValidationResult Valid() => new() { IsValid = true };
|
||||
|
||||
public static InputValidationResult Invalid(IReadOnlyList<InputValidationError> errors) => new()
|
||||
{
|
||||
IsValid = false,
|
||||
Errors = errors
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Input validation error.
|
||||
/// </summary>
|
||||
public sealed record InputValidationError
|
||||
{
|
||||
public required string Path { get; init; }
|
||||
public required string Message { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
using StellaOps.Policy.Registry.Contracts;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing policy pack promotions across environments.
|
||||
/// Implements REGISTRY-API-27-008: Promotion bindings per tenant/environment.
|
||||
/// </summary>
|
||||
public interface IPromotionService
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a promotion binding for a policy pack to an environment.
|
||||
/// </summary>
|
||||
Task<PromotionBinding> CreateBindingAsync(
|
||||
Guid tenantId,
|
||||
CreatePromotionBindingRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Promotes a policy pack to a target environment.
|
||||
/// </summary>
|
||||
Task<PromotionResult> PromoteAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
PromoteRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current binding for a pack/environment combination.
|
||||
/// </summary>
|
||||
Task<PromotionBinding?> GetBindingAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
string environment,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists all bindings for a tenant.
|
||||
/// </summary>
|
||||
Task<PromotionBindingList> ListBindingsAsync(
|
||||
Guid tenantId,
|
||||
string? environment = null,
|
||||
Guid? packId = null,
|
||||
int pageSize = 20,
|
||||
string? pageToken = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the active policy pack for an environment.
|
||||
/// </summary>
|
||||
Task<ActiveEnvironmentPolicy?> GetActiveForEnvironmentAsync(
|
||||
Guid tenantId,
|
||||
string environment,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Rolls back to a previous promotion for an environment.
|
||||
/// </summary>
|
||||
Task<RollbackResult> RollbackAsync(
|
||||
Guid tenantId,
|
||||
string environment,
|
||||
RollbackRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the promotion history for an environment.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<PromotionHistoryEntry>> GetHistoryAsync(
|
||||
Guid tenantId,
|
||||
string environment,
|
||||
int limit = 50,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Validates a promotion is allowed before executing.
|
||||
/// </summary>
|
||||
Task<PromotionValidationResult> ValidatePromotionAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
string targetEnvironment,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a promotion binding.
|
||||
/// </summary>
|
||||
public sealed record CreatePromotionBindingRequest
|
||||
{
|
||||
public required Guid PackId { get; init; }
|
||||
public required string Environment { get; init; }
|
||||
public PromotionBindingMode Mode { get; init; } = PromotionBindingMode.Manual;
|
||||
public PromotionBindingRules? Rules { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
public string? CreatedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to promote a policy pack.
|
||||
/// </summary>
|
||||
public sealed record PromoteRequest
|
||||
{
|
||||
public required string TargetEnvironment { get; init; }
|
||||
public string? ApprovalId { get; init; }
|
||||
public string? PromotedBy { get; init; }
|
||||
public string? Comment { get; init; }
|
||||
public bool Force { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to rollback a promotion.
|
||||
/// </summary>
|
||||
public sealed record RollbackRequest
|
||||
{
|
||||
public string? TargetBindingId { get; init; }
|
||||
public int? StepsBack { get; init; }
|
||||
public string? RolledBackBy { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Promotion binding mode.
|
||||
/// </summary>
|
||||
public enum PromotionBindingMode
|
||||
{
|
||||
Manual,
|
||||
AutomaticOnApproval,
|
||||
Scheduled,
|
||||
Canary
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rules for automatic promotion.
|
||||
/// </summary>
|
||||
public sealed record PromotionBindingRules
|
||||
{
|
||||
public IReadOnlyList<string>? RequiredApprovers { get; init; }
|
||||
public int? MinimumApprovals { get; init; }
|
||||
public bool RequireSuccessfulSimulation { get; init; }
|
||||
public int? MinimumSimulationInputs { get; init; }
|
||||
public TimeSpan? MinimumSoakPeriod { get; init; }
|
||||
public IReadOnlyList<string>? AllowedSourceEnvironments { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Promotion binding.
|
||||
/// </summary>
|
||||
public sealed record PromotionBinding
|
||||
{
|
||||
public required string BindingId { get; init; }
|
||||
public required Guid TenantId { get; init; }
|
||||
public required Guid PackId { get; init; }
|
||||
public required string PackVersion { get; init; }
|
||||
public required string Environment { get; init; }
|
||||
public required PromotionBindingMode Mode { get; init; }
|
||||
public required PromotionBindingStatus Status { get; init; }
|
||||
public PromotionBindingRules? Rules { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? ActivatedAt { get; init; }
|
||||
public DateTimeOffset? DeactivatedAt { get; init; }
|
||||
public string? CreatedBy { get; init; }
|
||||
public string? ActivatedBy { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Promotion binding status.
|
||||
/// </summary>
|
||||
public enum PromotionBindingStatus
|
||||
{
|
||||
Pending,
|
||||
Active,
|
||||
Superseded,
|
||||
RolledBack,
|
||||
Disabled
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a promotion operation.
|
||||
/// </summary>
|
||||
public sealed record PromotionResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
public PromotionBinding? Binding { get; init; }
|
||||
public string? PreviousBindingId { get; init; }
|
||||
public string? Error { get; init; }
|
||||
public IReadOnlyList<string>? Warnings { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// List of promotion bindings.
|
||||
/// </summary>
|
||||
public sealed record PromotionBindingList
|
||||
{
|
||||
public required IReadOnlyList<PromotionBinding> Items { get; init; }
|
||||
public string? NextPageToken { get; init; }
|
||||
public int TotalCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Active policy pack for an environment.
|
||||
/// </summary>
|
||||
public sealed record ActiveEnvironmentPolicy
|
||||
{
|
||||
public required string Environment { get; init; }
|
||||
public required Guid PackId { get; init; }
|
||||
public required string PackVersion { get; init; }
|
||||
public required string PackDigest { get; init; }
|
||||
public required string BindingId { get; init; }
|
||||
public required DateTimeOffset ActivatedAt { get; init; }
|
||||
public string? ActivatedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a rollback operation.
|
||||
/// </summary>
|
||||
public sealed record RollbackResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
public PromotionBinding? RestoredBinding { get; init; }
|
||||
public string? RolledBackBindingId { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Promotion history entry.
|
||||
/// </summary>
|
||||
public sealed record PromotionHistoryEntry
|
||||
{
|
||||
public required string BindingId { get; init; }
|
||||
public required Guid PackId { get; init; }
|
||||
public required string PackVersion { get; init; }
|
||||
public required PromotionHistoryAction Action { get; init; }
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
public string? PerformedBy { get; init; }
|
||||
public string? Comment { get; init; }
|
||||
public string? PreviousBindingId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Promotion history action types.
|
||||
/// </summary>
|
||||
public enum PromotionHistoryAction
|
||||
{
|
||||
Promoted,
|
||||
RolledBack,
|
||||
Disabled,
|
||||
Superseded
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of promotion validation.
|
||||
/// </summary>
|
||||
public sealed record PromotionValidationResult
|
||||
{
|
||||
public required bool IsValid { get; init; }
|
||||
public IReadOnlyList<PromotionValidationError>? Errors { get; init; }
|
||||
public IReadOnlyList<PromotionValidationWarning>? Warnings { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Promotion validation error.
|
||||
/// </summary>
|
||||
public sealed record PromotionValidationError
|
||||
{
|
||||
public required string Code { get; init; }
|
||||
public required string Message { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Promotion validation warning.
|
||||
/// </summary>
|
||||
public sealed record PromotionValidationWarning
|
||||
{
|
||||
public required string Code { get; init; }
|
||||
public required string Message { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
using StellaOps.Policy.Registry.Contracts;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for publishing policy packs with signing and attestations.
|
||||
/// Implements REGISTRY-API-27-007: Publish pipeline with signing/attestations.
|
||||
/// </summary>
|
||||
public interface IPublishPipelineService
|
||||
{
|
||||
/// <summary>
|
||||
/// Publishes an approved policy pack.
|
||||
/// </summary>
|
||||
Task<PublishResult> PublishAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
PublishPackRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the publication status of a policy pack.
|
||||
/// </summary>
|
||||
Task<PublicationStatus?> GetPublicationStatusAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the attestation for a published policy pack.
|
||||
/// </summary>
|
||||
Task<PolicyPackAttestation?> GetAttestationAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the signature and attestation of a published policy pack.
|
||||
/// </summary>
|
||||
Task<AttestationVerificationResult> VerifyAttestationAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists published policy packs for a tenant.
|
||||
/// </summary>
|
||||
Task<PublishedPackList> ListPublishedAsync(
|
||||
Guid tenantId,
|
||||
int pageSize = 20,
|
||||
string? pageToken = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Revokes a published policy pack.
|
||||
/// </summary>
|
||||
Task<RevokeResult> RevokeAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
RevokePackRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to publish a policy pack.
|
||||
/// </summary>
|
||||
public sealed record PublishPackRequest
|
||||
{
|
||||
public string? ApprovalId { get; init; }
|
||||
public string? PublishedBy { get; init; }
|
||||
public SigningOptions? SigningOptions { get; init; }
|
||||
public AttestationOptions? AttestationOptions { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signing options for policy pack publication.
|
||||
/// </summary>
|
||||
public sealed record SigningOptions
|
||||
{
|
||||
public required string KeyId { get; init; }
|
||||
public SigningAlgorithm Algorithm { get; init; } = SigningAlgorithm.ECDSA_P256_SHA256;
|
||||
public bool IncludeTimestamp { get; init; } = true;
|
||||
public bool IncludeRekorEntry { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attestation options for policy pack publication.
|
||||
/// </summary>
|
||||
public sealed record AttestationOptions
|
||||
{
|
||||
public required string PredicateType { get; init; }
|
||||
public bool IncludeCompilationResult { get; init; } = true;
|
||||
public bool IncludeReviewHistory { get; init; } = true;
|
||||
public bool IncludeSimulationResults { get; init; }
|
||||
public IReadOnlyDictionary<string, object>? CustomClaims { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Supported signing algorithms.
|
||||
/// </summary>
|
||||
public enum SigningAlgorithm
|
||||
{
|
||||
ECDSA_P256_SHA256,
|
||||
ECDSA_P384_SHA384,
|
||||
RSA_PKCS1_SHA256,
|
||||
RSA_PSS_SHA256,
|
||||
Ed25519
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of policy pack publication.
|
||||
/// </summary>
|
||||
public sealed record PublishResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
public Guid? PackId { get; init; }
|
||||
public string? Digest { get; init; }
|
||||
public PublicationStatus? Status { get; init; }
|
||||
public PolicyPackAttestation? Attestation { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publication status of a policy pack.
|
||||
/// </summary>
|
||||
public sealed record PublicationStatus
|
||||
{
|
||||
public required Guid PackId { get; init; }
|
||||
public required string PackVersion { get; init; }
|
||||
public required string Digest { get; init; }
|
||||
public required PublishState State { get; init; }
|
||||
public required DateTimeOffset PublishedAt { get; init; }
|
||||
public string? PublishedBy { get; init; }
|
||||
public DateTimeOffset? RevokedAt { get; init; }
|
||||
public string? RevokedBy { get; init; }
|
||||
public string? RevokeReason { get; init; }
|
||||
public string? SignatureKeyId { get; init; }
|
||||
public SigningAlgorithm? SignatureAlgorithm { get; init; }
|
||||
public string? RekorLogId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publication state.
|
||||
/// </summary>
|
||||
public enum PublishState
|
||||
{
|
||||
Published,
|
||||
Revoked,
|
||||
Superseded
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Policy pack attestation following in-toto/DSSE format.
|
||||
/// </summary>
|
||||
public sealed record PolicyPackAttestation
|
||||
{
|
||||
public required string PayloadType { get; init; }
|
||||
public required string Payload { get; init; }
|
||||
public required IReadOnlyList<AttestationSignature> Signatures { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attestation signature.
|
||||
/// </summary>
|
||||
public sealed record AttestationSignature
|
||||
{
|
||||
public required string KeyId { get; init; }
|
||||
public required string Signature { get; init; }
|
||||
public DateTimeOffset? Timestamp { get; init; }
|
||||
public string? RekorLogIndex { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attestation payload in SLSA provenance format.
|
||||
/// </summary>
|
||||
public sealed record AttestationPayload
|
||||
{
|
||||
public required string Type { get; init; }
|
||||
public required string PredicateType { get; init; }
|
||||
public required AttestationSubject Subject { get; init; }
|
||||
public required AttestationPredicate Predicate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attestation subject (the policy pack).
|
||||
/// </summary>
|
||||
public sealed record AttestationSubject
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required IReadOnlyDictionary<string, string> Digest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attestation predicate containing provenance metadata.
|
||||
/// </summary>
|
||||
public sealed record AttestationPredicate
|
||||
{
|
||||
public required string BuildType { get; init; }
|
||||
public required AttestationBuilder Builder { get; init; }
|
||||
public DateTimeOffset? BuildStartedOn { get; init; }
|
||||
public DateTimeOffset? BuildFinishedOn { get; init; }
|
||||
public PolicyPackCompilationMetadata? Compilation { get; init; }
|
||||
public PolicyPackReviewMetadata? Review { get; init; }
|
||||
public IReadOnlyDictionary<string, object>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attestation builder information.
|
||||
/// </summary>
|
||||
public sealed record AttestationBuilder
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public string? Version { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compilation metadata in attestation.
|
||||
/// </summary>
|
||||
public sealed record PolicyPackCompilationMetadata
|
||||
{
|
||||
public required string Digest { get; init; }
|
||||
public required int RuleCount { get; init; }
|
||||
public DateTimeOffset? CompiledAt { get; init; }
|
||||
public IReadOnlyDictionary<string, int>? Statistics { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review metadata in attestation.
|
||||
/// </summary>
|
||||
public sealed record PolicyPackReviewMetadata
|
||||
{
|
||||
public required string ReviewId { get; init; }
|
||||
public required DateTimeOffset ApprovedAt { get; init; }
|
||||
public string? ApprovedBy { get; init; }
|
||||
public IReadOnlyList<string>? Reviewers { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of attestation verification.
|
||||
/// </summary>
|
||||
public sealed record AttestationVerificationResult
|
||||
{
|
||||
public required bool Valid { get; init; }
|
||||
public IReadOnlyList<VerificationCheck>? Checks { get; init; }
|
||||
public IReadOnlyList<string>? Errors { get; init; }
|
||||
public IReadOnlyList<string>? Warnings { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual verification check result.
|
||||
/// </summary>
|
||||
public sealed record VerificationCheck
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required bool Passed { get; init; }
|
||||
public string? Details { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// List of published policy packs.
|
||||
/// </summary>
|
||||
public sealed record PublishedPackList
|
||||
{
|
||||
public required IReadOnlyList<PublicationStatus> Items { get; init; }
|
||||
public string? NextPageToken { get; init; }
|
||||
public int TotalCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to revoke a published policy pack.
|
||||
/// </summary>
|
||||
public sealed record RevokePackRequest
|
||||
{
|
||||
public required string Reason { get; init; }
|
||||
public string? RevokedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of policy pack revocation.
|
||||
/// </summary>
|
||||
public sealed record RevokeResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
public PublicationStatus? Status { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
using StellaOps.Policy.Registry.Contracts;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing policy pack review workflows with audit trails.
|
||||
/// Implements REGISTRY-API-27-006: Review workflow with audit trails.
|
||||
/// </summary>
|
||||
public interface IReviewWorkflowService
|
||||
{
|
||||
/// <summary>
|
||||
/// Submits a policy pack for review.
|
||||
/// </summary>
|
||||
Task<ReviewRequest> SubmitForReviewAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
SubmitReviewRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Approves a review request.
|
||||
/// </summary>
|
||||
Task<ReviewDecision> ApproveAsync(
|
||||
Guid tenantId,
|
||||
string reviewId,
|
||||
ApproveReviewRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Rejects a review request.
|
||||
/// </summary>
|
||||
Task<ReviewDecision> RejectAsync(
|
||||
Guid tenantId,
|
||||
string reviewId,
|
||||
RejectReviewRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Requests changes to a policy pack under review.
|
||||
/// </summary>
|
||||
Task<ReviewDecision> RequestChangesAsync(
|
||||
Guid tenantId,
|
||||
string reviewId,
|
||||
RequestChangesRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a review request by ID.
|
||||
/// </summary>
|
||||
Task<ReviewRequest?> GetReviewAsync(
|
||||
Guid tenantId,
|
||||
string reviewId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists review requests for a tenant.
|
||||
/// </summary>
|
||||
Task<ReviewRequestList> ListReviewsAsync(
|
||||
Guid tenantId,
|
||||
ReviewStatus? status = null,
|
||||
Guid? packId = null,
|
||||
int pageSize = 20,
|
||||
string? pageToken = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the audit trail for a review.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ReviewAuditEntry>> GetAuditTrailAsync(
|
||||
Guid tenantId,
|
||||
string reviewId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the audit trail for a policy pack across all reviews.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ReviewAuditEntry>> GetPackAuditTrailAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to submit a policy pack for review.
|
||||
/// </summary>
|
||||
public sealed record SubmitReviewRequest
|
||||
{
|
||||
public string? Description { get; init; }
|
||||
public IReadOnlyList<string>? Reviewers { get; init; }
|
||||
public ReviewUrgency Urgency { get; init; } = ReviewUrgency.Normal;
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to approve a review.
|
||||
/// </summary>
|
||||
public sealed record ApproveReviewRequest
|
||||
{
|
||||
public string? Comment { get; init; }
|
||||
public string? ApprovedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to reject a review.
|
||||
/// </summary>
|
||||
public sealed record RejectReviewRequest
|
||||
{
|
||||
public required string Reason { get; init; }
|
||||
public string? RejectedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to request changes.
|
||||
/// </summary>
|
||||
public sealed record RequestChangesRequest
|
||||
{
|
||||
public required IReadOnlyList<ReviewComment> Comments { get; init; }
|
||||
public string? RequestedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review comment.
|
||||
/// </summary>
|
||||
public sealed record ReviewComment
|
||||
{
|
||||
public string? RuleId { get; init; }
|
||||
public required string Comment { get; init; }
|
||||
public ReviewCommentSeverity Severity { get; init; } = ReviewCommentSeverity.Suggestion;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review comment severity.
|
||||
/// </summary>
|
||||
public enum ReviewCommentSeverity
|
||||
{
|
||||
Suggestion,
|
||||
Warning,
|
||||
Blocking
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review urgency level.
|
||||
/// </summary>
|
||||
public enum ReviewUrgency
|
||||
{
|
||||
Low,
|
||||
Normal,
|
||||
High,
|
||||
Critical
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review request status.
|
||||
/// </summary>
|
||||
public enum ReviewStatus
|
||||
{
|
||||
Pending,
|
||||
InReview,
|
||||
ChangesRequested,
|
||||
Approved,
|
||||
Rejected,
|
||||
Cancelled
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review request.
|
||||
/// </summary>
|
||||
public sealed record ReviewRequest
|
||||
{
|
||||
public required string ReviewId { get; init; }
|
||||
public required Guid TenantId { get; init; }
|
||||
public required Guid PackId { get; init; }
|
||||
public required string PackVersion { get; init; }
|
||||
public required ReviewStatus Status { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public IReadOnlyList<string>? Reviewers { get; init; }
|
||||
public ReviewUrgency Urgency { get; init; }
|
||||
public string? SubmittedBy { get; init; }
|
||||
public required DateTimeOffset SubmittedAt { get; init; }
|
||||
public DateTimeOffset? ResolvedAt { get; init; }
|
||||
public string? ResolvedBy { get; init; }
|
||||
public IReadOnlyList<ReviewComment>? PendingComments { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review decision result.
|
||||
/// </summary>
|
||||
public sealed record ReviewDecision
|
||||
{
|
||||
public required string ReviewId { get; init; }
|
||||
public required ReviewStatus NewStatus { get; init; }
|
||||
public required DateTimeOffset DecidedAt { get; init; }
|
||||
public string? DecidedBy { get; init; }
|
||||
public string? Comment { get; init; }
|
||||
public IReadOnlyList<ReviewComment>? Comments { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// List of review requests.
|
||||
/// </summary>
|
||||
public sealed record ReviewRequestList
|
||||
{
|
||||
public required IReadOnlyList<ReviewRequest> Items { get; init; }
|
||||
public string? NextPageToken { get; init; }
|
||||
public int TotalCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit entry for review actions.
|
||||
/// </summary>
|
||||
public sealed record ReviewAuditEntry
|
||||
{
|
||||
public required string AuditId { get; init; }
|
||||
public required string ReviewId { get; init; }
|
||||
public required Guid PackId { get; init; }
|
||||
public required ReviewAuditAction Action { get; init; }
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
public string? PerformedBy { get; init; }
|
||||
public ReviewStatus? PreviousStatus { get; init; }
|
||||
public ReviewStatus? NewStatus { get; init; }
|
||||
public string? Comment { get; init; }
|
||||
public IReadOnlyDictionary<string, object>? Details { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review audit action types.
|
||||
/// </summary>
|
||||
public enum ReviewAuditAction
|
||||
{
|
||||
Submitted,
|
||||
AssignedReviewer,
|
||||
RemovedReviewer,
|
||||
CommentAdded,
|
||||
ChangesRequested,
|
||||
Approved,
|
||||
Rejected,
|
||||
Cancelled,
|
||||
Reopened,
|
||||
StatusChanged
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using StellaOps.Policy.Registry.Contracts;
|
||||
using StellaOps.Policy.Registry.Storage;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of policy pack compiler.
|
||||
/// Validates Rego syntax and computes content digest.
|
||||
/// </summary>
|
||||
public sealed partial class PolicyPackCompiler : IPolicyPackCompiler
|
||||
{
|
||||
private readonly IPolicyPackStore _packStore;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
// Basic Rego syntax patterns for validation
|
||||
[GeneratedRegex(@"^package\s+[\w.]+", RegexOptions.Multiline)]
|
||||
private static partial Regex PackageDeclarationRegex();
|
||||
|
||||
[GeneratedRegex(@"^\s*#.*$", RegexOptions.Multiline)]
|
||||
private static partial Regex CommentLineRegex();
|
||||
|
||||
[GeneratedRegex(@"^\s*(default\s+)?\w+\s*(=|:=|\[)", RegexOptions.Multiline)]
|
||||
private static partial Regex RuleDefinitionRegex();
|
||||
|
||||
[GeneratedRegex(@"input\.\w+", RegexOptions.None)]
|
||||
private static partial Regex InputReferenceRegex();
|
||||
|
||||
[GeneratedRegex(@"\{[^}]*\}", RegexOptions.None)]
|
||||
private static partial Regex SetLiteralRegex();
|
||||
|
||||
[GeneratedRegex(@"\[[^\]]*\]", RegexOptions.None)]
|
||||
private static partial Regex ArrayLiteralRegex();
|
||||
|
||||
public PolicyPackCompiler(IPolicyPackStore packStore, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_packStore = packStore ?? throw new ArgumentNullException(nameof(packStore));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<PolicyPackCompilationResult> CompileAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var start = _timeProvider.GetTimestamp();
|
||||
|
||||
var pack = await _packStore.GetByIdAsync(tenantId, packId, cancellationToken);
|
||||
if (pack is null)
|
||||
{
|
||||
return PolicyPackCompilationResult.FromFailure(
|
||||
[new CompilationError { Message = $"Policy pack {packId} not found" }],
|
||||
null,
|
||||
GetElapsedMs(start));
|
||||
}
|
||||
|
||||
return await CompilePackRulesAsync(pack.PackId.ToString(), pack.Rules, start, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<RuleValidationResult> ValidateRuleAsync(
|
||||
string ruleId,
|
||||
string? rego,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rego))
|
||||
{
|
||||
// Rules without Rego are valid (might use DSL or other syntax)
|
||||
return Task.FromResult(RuleValidationResult.FromSuccess(ruleId));
|
||||
}
|
||||
|
||||
var errors = new List<CompilationError>();
|
||||
var warnings = new List<CompilationWarning>();
|
||||
|
||||
ValidateRegoSyntax(ruleId, rego, errors, warnings);
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return Task.FromResult(RuleValidationResult.FromFailure(ruleId, errors, warnings.Count > 0 ? warnings : null));
|
||||
}
|
||||
|
||||
return Task.FromResult(RuleValidationResult.FromSuccess(ruleId, warnings.Count > 0 ? warnings : null));
|
||||
}
|
||||
|
||||
public async Task<PolicyPackCompilationResult> ValidatePackAsync(
|
||||
CreatePolicyPackRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var start = _timeProvider.GetTimestamp();
|
||||
return await CompilePackRulesAsync(request.Name, request.Rules, start, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<PolicyPackCompilationResult> CompilePackRulesAsync(
|
||||
string packIdentifier,
|
||||
IReadOnlyList<PolicyRule>? rules,
|
||||
long startTimestamp,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (rules is null || rules.Count == 0)
|
||||
{
|
||||
// Empty pack is valid
|
||||
var emptyStats = CreateStatistics([]);
|
||||
var emptyDigest = ComputeDigest([]);
|
||||
return PolicyPackCompilationResult.FromSuccess(emptyDigest, emptyStats, null, GetElapsedMs(startTimestamp));
|
||||
}
|
||||
|
||||
var allErrors = new List<CompilationError>();
|
||||
var allWarnings = new List<CompilationWarning>();
|
||||
var validatedRules = new List<PolicyRule>();
|
||||
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var result = await ValidateRuleAsync(rule.RuleId, rule.Rego, cancellationToken);
|
||||
|
||||
if (result.Errors is { Count: > 0 })
|
||||
{
|
||||
allErrors.AddRange(result.Errors);
|
||||
}
|
||||
|
||||
if (result.Warnings is { Count: > 0 })
|
||||
{
|
||||
allWarnings.AddRange(result.Warnings);
|
||||
}
|
||||
|
||||
validatedRules.Add(rule);
|
||||
}
|
||||
|
||||
var elapsed = GetElapsedMs(startTimestamp);
|
||||
|
||||
if (allErrors.Count > 0)
|
||||
{
|
||||
return PolicyPackCompilationResult.FromFailure(allErrors, allWarnings.Count > 0 ? allWarnings : null, elapsed);
|
||||
}
|
||||
|
||||
var statistics = CreateStatistics(rules);
|
||||
var digest = ComputeDigest(rules);
|
||||
|
||||
return PolicyPackCompilationResult.FromSuccess(
|
||||
digest,
|
||||
statistics,
|
||||
allWarnings.Count > 0 ? allWarnings : null,
|
||||
elapsed);
|
||||
}
|
||||
|
||||
private void ValidateRegoSyntax(
|
||||
string ruleId,
|
||||
string rego,
|
||||
List<CompilationError> errors,
|
||||
List<CompilationWarning> warnings)
|
||||
{
|
||||
// Strip comments for analysis
|
||||
var codeWithoutComments = CommentLineRegex().Replace(rego, "");
|
||||
var trimmedCode = codeWithoutComments.Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(trimmedCode))
|
||||
{
|
||||
errors.Add(new CompilationError
|
||||
{
|
||||
RuleId = ruleId,
|
||||
Message = "Rego code contains only comments or whitespace"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for basic Rego structure
|
||||
var hasPackage = PackageDeclarationRegex().IsMatch(rego);
|
||||
var hasRuleDefinition = RuleDefinitionRegex().IsMatch(codeWithoutComments);
|
||||
|
||||
if (!hasPackage && !hasRuleDefinition)
|
||||
{
|
||||
errors.Add(new CompilationError
|
||||
{
|
||||
RuleId = ruleId,
|
||||
Message = "Rego code must contain either a package declaration or at least one rule definition"
|
||||
});
|
||||
}
|
||||
|
||||
// Check for unmatched braces
|
||||
var openBraces = trimmedCode.Count(c => c == '{');
|
||||
var closeBraces = trimmedCode.Count(c => c == '}');
|
||||
if (openBraces != closeBraces)
|
||||
{
|
||||
errors.Add(new CompilationError
|
||||
{
|
||||
RuleId = ruleId,
|
||||
Message = $"Unmatched braces: {openBraces} open, {closeBraces} close"
|
||||
});
|
||||
}
|
||||
|
||||
// Check for unmatched brackets
|
||||
var openBrackets = trimmedCode.Count(c => c == '[');
|
||||
var closeBrackets = trimmedCode.Count(c => c == ']');
|
||||
if (openBrackets != closeBrackets)
|
||||
{
|
||||
errors.Add(new CompilationError
|
||||
{
|
||||
RuleId = ruleId,
|
||||
Message = $"Unmatched brackets: {openBrackets} open, {closeBrackets} close"
|
||||
});
|
||||
}
|
||||
|
||||
// Check for unmatched parentheses
|
||||
var openParens = trimmedCode.Count(c => c == '(');
|
||||
var closeParens = trimmedCode.Count(c => c == ')');
|
||||
if (openParens != closeParens)
|
||||
{
|
||||
errors.Add(new CompilationError
|
||||
{
|
||||
RuleId = ruleId,
|
||||
Message = $"Unmatched parentheses: {openParens} open, {closeParens} close"
|
||||
});
|
||||
}
|
||||
|
||||
// Warnings for common issues
|
||||
if (!InputReferenceRegex().IsMatch(rego) && hasRuleDefinition)
|
||||
{
|
||||
warnings.Add(new CompilationWarning
|
||||
{
|
||||
RuleId = ruleId,
|
||||
Message = "Rule does not reference 'input' - may not receive evaluation context"
|
||||
});
|
||||
}
|
||||
|
||||
// Check for deprecated or unsafe patterns
|
||||
if (rego.Contains("http.send"))
|
||||
{
|
||||
warnings.Add(new CompilationWarning
|
||||
{
|
||||
RuleId = ruleId,
|
||||
Message = "Use of http.send may cause non-deterministic behavior in offline/air-gapped environments"
|
||||
});
|
||||
}
|
||||
|
||||
if (rego.Contains("time.now_ns"))
|
||||
{
|
||||
warnings.Add(new CompilationWarning
|
||||
{
|
||||
RuleId = ruleId,
|
||||
Message = "Use of time.now_ns may cause non-deterministic results across evaluations"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static PolicyPackCompilationStatistics CreateStatistics(IReadOnlyList<PolicyRule> rules)
|
||||
{
|
||||
var severityCounts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
var severityKey = rule.Severity.ToString().ToLowerInvariant();
|
||||
severityCounts[severityKey] = severityCounts.GetValueOrDefault(severityKey) + 1;
|
||||
}
|
||||
|
||||
return new PolicyPackCompilationStatistics
|
||||
{
|
||||
TotalRules = rules.Count,
|
||||
EnabledRules = rules.Count(r => r.Enabled),
|
||||
DisabledRules = rules.Count(r => !r.Enabled),
|
||||
RulesWithRego = rules.Count(r => !string.IsNullOrWhiteSpace(r.Rego)),
|
||||
RulesWithoutRego = rules.Count(r => string.IsNullOrWhiteSpace(r.Rego)),
|
||||
SeverityCounts = severityCounts
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeDigest(IReadOnlyList<PolicyRule> rules)
|
||||
{
|
||||
// Create deterministic representation for hashing
|
||||
var orderedRules = rules
|
||||
.OrderBy(r => r.RuleId, StringComparer.Ordinal)
|
||||
.Select(r => new
|
||||
{
|
||||
r.RuleId,
|
||||
r.Name,
|
||||
r.Severity,
|
||||
r.Rego,
|
||||
r.Enabled
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var json = JsonSerializer.Serialize(orderedRules, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = false
|
||||
});
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private long GetElapsedMs(long startTimestamp)
|
||||
{
|
||||
var elapsed = _timeProvider.GetElapsedTime(startTimestamp, _timeProvider.GetTimestamp());
|
||||
return (long)Math.Ceiling(elapsed.TotalMilliseconds);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using StellaOps.Policy.Registry.Contracts;
|
||||
using StellaOps.Policy.Registry.Storage;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of quick policy simulation service.
|
||||
/// Evaluates policy rules against provided input and returns violations.
|
||||
/// </summary>
|
||||
public sealed partial class PolicySimulationService : IPolicySimulationService
|
||||
{
|
||||
private readonly IPolicyPackStore _packStore;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
// Regex patterns for input reference extraction
|
||||
[GeneratedRegex(@"input\.(\w+(?:\.\w+)*)", RegexOptions.None)]
|
||||
private static partial Regex InputReferenceRegex();
|
||||
|
||||
[GeneratedRegex(@"input\[""([^""]+)""\]", RegexOptions.None)]
|
||||
private static partial Regex InputBracketReferenceRegex();
|
||||
|
||||
public PolicySimulationService(IPolicyPackStore packStore, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_packStore = packStore ?? throw new ArgumentNullException(nameof(packStore));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<PolicySimulationResponse> SimulateAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
SimulationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var start = _timeProvider.GetTimestamp();
|
||||
var executedAt = _timeProvider.GetUtcNow();
|
||||
var simulationId = GenerateSimulationId(tenantId, packId, executedAt);
|
||||
|
||||
var pack = await _packStore.GetByIdAsync(tenantId, packId, cancellationToken);
|
||||
if (pack is null)
|
||||
{
|
||||
return new PolicySimulationResponse
|
||||
{
|
||||
SimulationId = simulationId,
|
||||
Success = false,
|
||||
ExecutedAt = executedAt,
|
||||
DurationMilliseconds = GetElapsedMs(start),
|
||||
Errors = [new SimulationError { Code = "PACK_NOT_FOUND", Message = $"Policy pack {packId} not found" }]
|
||||
};
|
||||
}
|
||||
|
||||
return await SimulateRulesInternalAsync(
|
||||
simulationId,
|
||||
pack.Rules ?? [],
|
||||
request,
|
||||
start,
|
||||
executedAt,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PolicySimulationResponse> SimulateRulesAsync(
|
||||
Guid tenantId,
|
||||
IReadOnlyList<PolicyRule> rules,
|
||||
SimulationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var start = _timeProvider.GetTimestamp();
|
||||
var executedAt = _timeProvider.GetUtcNow();
|
||||
var simulationId = GenerateSimulationId(tenantId, Guid.Empty, executedAt);
|
||||
|
||||
return await SimulateRulesInternalAsync(
|
||||
simulationId,
|
||||
rules,
|
||||
request,
|
||||
start,
|
||||
executedAt,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task<InputValidationResult> ValidateInputAsync(
|
||||
IReadOnlyDictionary<string, object> input,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var errors = new List<InputValidationError>();
|
||||
|
||||
if (input.Count == 0)
|
||||
{
|
||||
errors.Add(new InputValidationError
|
||||
{
|
||||
Path = "$",
|
||||
Message = "Input must contain at least one property"
|
||||
});
|
||||
}
|
||||
|
||||
// Check for common required fields
|
||||
var commonFields = new[] { "subject", "resource", "action", "context" };
|
||||
var missingFields = commonFields.Where(f => !input.ContainsKey(f)).ToList();
|
||||
|
||||
if (missingFields.Count == commonFields.Length)
|
||||
{
|
||||
// Warn if none of the common fields are present
|
||||
errors.Add(new InputValidationError
|
||||
{
|
||||
Path = "$",
|
||||
Message = $"Input should contain at least one of: {string.Join(", ", commonFields)}"
|
||||
});
|
||||
}
|
||||
|
||||
return Task.FromResult(errors.Count > 0
|
||||
? InputValidationResult.Invalid(errors)
|
||||
: InputValidationResult.Valid());
|
||||
}
|
||||
|
||||
private async Task<PolicySimulationResponse> SimulateRulesInternalAsync(
|
||||
string simulationId,
|
||||
IReadOnlyList<PolicyRule> rules,
|
||||
SimulationRequest request,
|
||||
long startTimestamp,
|
||||
DateTimeOffset executedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var violations = new List<SimulatedViolation>();
|
||||
var errors = new List<SimulationError>();
|
||||
var trace = new List<string>();
|
||||
int rulesMatched = 0;
|
||||
|
||||
var enabledRules = rules.Where(r => r.Enabled).ToList();
|
||||
|
||||
foreach (var rule in enabledRules)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var (matched, violation, traceEntry) = EvaluateRule(rule, request.Input, request.Options);
|
||||
|
||||
if (request.Options?.Trace == true && traceEntry is not null)
|
||||
{
|
||||
trace.Add(traceEntry);
|
||||
}
|
||||
|
||||
if (matched)
|
||||
{
|
||||
rulesMatched++;
|
||||
if (violation is not null)
|
||||
{
|
||||
violations.Add(violation);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add(new SimulationError
|
||||
{
|
||||
RuleId = rule.RuleId,
|
||||
Code = "EVALUATION_ERROR",
|
||||
Message = ex.Message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var elapsed = GetElapsedMs(startTimestamp);
|
||||
var severityCounts = violations
|
||||
.GroupBy(v => v.Severity.ToLowerInvariant())
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
var summary = new SimulationSummary
|
||||
{
|
||||
TotalRulesEvaluated = enabledRules.Count,
|
||||
RulesMatched = rulesMatched,
|
||||
ViolationsFound = violations.Count,
|
||||
ViolationsBySeverity = severityCounts
|
||||
};
|
||||
|
||||
var result = new SimulationResult
|
||||
{
|
||||
Result = new Dictionary<string, object>
|
||||
{
|
||||
["allow"] = violations.Count == 0,
|
||||
["violations_count"] = violations.Count
|
||||
},
|
||||
Violations = violations.Count > 0 ? violations : null,
|
||||
Trace = request.Options?.Trace == true && trace.Count > 0 ? trace : null,
|
||||
Explain = request.Options?.Explain == true ? BuildExplainTrace(enabledRules, request.Input) : null
|
||||
};
|
||||
|
||||
return new PolicySimulationResponse
|
||||
{
|
||||
SimulationId = simulationId,
|
||||
Success = errors.Count == 0,
|
||||
ExecutedAt = executedAt,
|
||||
DurationMilliseconds = elapsed,
|
||||
Result = result,
|
||||
Summary = summary,
|
||||
Errors = errors.Count > 0 ? errors : null
|
||||
};
|
||||
}
|
||||
|
||||
private (bool matched, SimulatedViolation? violation, string? trace) EvaluateRule(
|
||||
PolicyRule rule,
|
||||
IReadOnlyDictionary<string, object> input,
|
||||
SimulationOptions? options)
|
||||
{
|
||||
// If no Rego code, use basic rule matching based on severity and name
|
||||
if (string.IsNullOrWhiteSpace(rule.Rego))
|
||||
{
|
||||
// Without Rego, we do pattern-based matching on rule name/description
|
||||
var matched = MatchRuleByName(rule, input);
|
||||
var trace = options?.Trace == true
|
||||
? $"Rule {rule.RuleId}: matched={matched} (no Rego, name-based)"
|
||||
: null;
|
||||
|
||||
if (matched)
|
||||
{
|
||||
var violation = new SimulatedViolation
|
||||
{
|
||||
RuleId = rule.RuleId,
|
||||
Severity = rule.Severity.ToString().ToLowerInvariant(),
|
||||
Message = rule.Description ?? $"Violation of rule {rule.Name}"
|
||||
};
|
||||
return (true, violation, trace);
|
||||
}
|
||||
|
||||
return (false, null, trace);
|
||||
}
|
||||
|
||||
// Evaluate Rego-based rule
|
||||
var regoResult = EvaluateRegoRule(rule, input);
|
||||
var regoTrace = options?.Trace == true
|
||||
? $"Rule {rule.RuleId}: matched={regoResult.matched}, inputs_used={string.Join(",", regoResult.inputsUsed)}"
|
||||
: null;
|
||||
|
||||
if (regoResult.matched)
|
||||
{
|
||||
var violation = new SimulatedViolation
|
||||
{
|
||||
RuleId = rule.RuleId,
|
||||
Severity = rule.Severity.ToString().ToLowerInvariant(),
|
||||
Message = rule.Description ?? $"Violation of rule {rule.Name}",
|
||||
Context = regoResult.context
|
||||
};
|
||||
return (true, violation, regoTrace);
|
||||
}
|
||||
|
||||
return (false, null, regoTrace);
|
||||
}
|
||||
|
||||
private static bool MatchRuleByName(PolicyRule rule, IReadOnlyDictionary<string, object> input)
|
||||
{
|
||||
// Simple heuristic matching for rules without Rego
|
||||
var ruleName = rule.Name.ToLowerInvariant();
|
||||
var ruleDesc = rule.Description?.ToLowerInvariant() ?? "";
|
||||
|
||||
// Check if any input key matches rule keywords
|
||||
foreach (var (key, value) in input)
|
||||
{
|
||||
var keyLower = key.ToLowerInvariant();
|
||||
var valueLower = value?.ToString()?.ToLowerInvariant() ?? "";
|
||||
|
||||
if (ruleName.Contains(keyLower) || ruleDesc.Contains(keyLower))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (ruleName.Contains(valueLower) || ruleDesc.Contains(valueLower))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private (bool matched, HashSet<string> inputsUsed, IReadOnlyDictionary<string, object>? context) EvaluateRegoRule(
|
||||
PolicyRule rule,
|
||||
IReadOnlyDictionary<string, object> input)
|
||||
{
|
||||
// Extract input references from Rego code
|
||||
var inputRefs = ExtractInputReferences(rule.Rego!);
|
||||
var inputsUsed = new HashSet<string>();
|
||||
var context = new Dictionary<string, object>();
|
||||
|
||||
// Simple evaluation: check if referenced inputs exist and have values
|
||||
bool allInputsPresent = true;
|
||||
foreach (var inputRef in inputRefs)
|
||||
{
|
||||
var value = GetNestedValue(input, inputRef);
|
||||
if (value is not null)
|
||||
{
|
||||
inputsUsed.Add(inputRef);
|
||||
context[inputRef] = value;
|
||||
}
|
||||
else
|
||||
{
|
||||
allInputsPresent = false;
|
||||
}
|
||||
}
|
||||
|
||||
// For this simplified simulation:
|
||||
// - Rule matches if all referenced inputs are present
|
||||
// - This simulates the rule being able to evaluate
|
||||
var matched = inputRefs.Count > 0 && allInputsPresent;
|
||||
|
||||
return (matched, inputsUsed, context.Count > 0 ? context : null);
|
||||
}
|
||||
|
||||
private static HashSet<string> ExtractInputReferences(string rego)
|
||||
{
|
||||
var refs = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
// Match input.field.subfield pattern
|
||||
foreach (Match match in InputReferenceRegex().Matches(rego))
|
||||
{
|
||||
refs.Add(match.Groups[1].Value);
|
||||
}
|
||||
|
||||
// Match input["field"] pattern
|
||||
foreach (Match match in InputBracketReferenceRegex().Matches(rego))
|
||||
{
|
||||
refs.Add(match.Groups[1].Value);
|
||||
}
|
||||
|
||||
return refs;
|
||||
}
|
||||
|
||||
private static object? GetNestedValue(IReadOnlyDictionary<string, object> input, string path)
|
||||
{
|
||||
var parts = path.Split('.');
|
||||
object? current = input;
|
||||
|
||||
foreach (var part in parts)
|
||||
{
|
||||
if (current is IReadOnlyDictionary<string, object> dict)
|
||||
{
|
||||
if (!dict.TryGetValue(part, out current))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
else if (current is JsonElement jsonElement)
|
||||
{
|
||||
if (jsonElement.ValueKind == JsonValueKind.Object &&
|
||||
jsonElement.TryGetProperty(part, out var prop))
|
||||
{
|
||||
current = prop;
|
||||
}
|
||||
else
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
private static PolicyExplainTrace BuildExplainTrace(
|
||||
IReadOnlyList<PolicyRule> rules,
|
||||
IReadOnlyDictionary<string, object> input)
|
||||
{
|
||||
var steps = new List<object>();
|
||||
|
||||
steps.Add(new { type = "input_received", keys = input.Keys.ToList() });
|
||||
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
steps.Add(new
|
||||
{
|
||||
type = "rule_evaluation",
|
||||
rule_id = rule.RuleId,
|
||||
rule_name = rule.Name,
|
||||
severity = rule.Severity.ToString(),
|
||||
has_rego = !string.IsNullOrWhiteSpace(rule.Rego)
|
||||
});
|
||||
}
|
||||
|
||||
steps.Add(new { type = "evaluation_complete", rules_count = rules.Count });
|
||||
|
||||
return new PolicyExplainTrace { Steps = steps };
|
||||
}
|
||||
|
||||
private static string GenerateSimulationId(Guid tenantId, Guid packId, DateTimeOffset timestamp)
|
||||
{
|
||||
var content = $"{tenantId}:{packId}:{timestamp.ToUnixTimeMilliseconds()}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
|
||||
return $"sim_{Convert.ToHexString(hash)[..16].ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private long GetElapsedMs(long startTimestamp)
|
||||
{
|
||||
var elapsed = _timeProvider.GetElapsedTime(startTimestamp, _timeProvider.GetTimestamp());
|
||||
return (long)Math.Ceiling(elapsed.TotalMilliseconds);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Policy.Registry.Contracts;
|
||||
using StellaOps.Policy.Registry.Storage;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of promotion service for managing environment bindings.
|
||||
/// </summary>
|
||||
public sealed class PromotionService : IPromotionService
|
||||
{
|
||||
private readonly IPolicyPackStore _packStore;
|
||||
private readonly IPublishPipelineService _publishService;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private readonly ConcurrentDictionary<(Guid TenantId, string BindingId), PromotionBinding> _bindings = new();
|
||||
private readonly ConcurrentDictionary<(Guid TenantId, string Environment), string> _activeBindings = new();
|
||||
private readonly ConcurrentDictionary<(Guid TenantId, string Environment), List<PromotionHistoryEntry>> _history = new();
|
||||
|
||||
public PromotionService(
|
||||
IPolicyPackStore packStore,
|
||||
IPublishPipelineService publishService,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_packStore = packStore ?? throw new ArgumentNullException(nameof(packStore));
|
||||
_publishService = publishService ?? throw new ArgumentNullException(nameof(publishService));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<PromotionBinding> CreateBindingAsync(
|
||||
Guid tenantId,
|
||||
CreatePromotionBindingRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var pack = await _packStore.GetByIdAsync(tenantId, request.PackId, cancellationToken);
|
||||
if (pack is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Policy pack {request.PackId} not found");
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var bindingId = GenerateBindingId(tenantId, request.PackId, request.Environment, now);
|
||||
|
||||
var binding = new PromotionBinding
|
||||
{
|
||||
BindingId = bindingId,
|
||||
TenantId = tenantId,
|
||||
PackId = request.PackId,
|
||||
PackVersion = pack.Version,
|
||||
Environment = request.Environment,
|
||||
Mode = request.Mode,
|
||||
Status = PromotionBindingStatus.Pending,
|
||||
Rules = request.Rules,
|
||||
CreatedAt = now,
|
||||
CreatedBy = request.CreatedBy,
|
||||
Metadata = request.Metadata
|
||||
};
|
||||
|
||||
_bindings[(tenantId, bindingId)] = binding;
|
||||
|
||||
return binding;
|
||||
}
|
||||
|
||||
public async Task<PromotionResult> PromoteAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
PromoteRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Validate promotion
|
||||
var validation = await ValidatePromotionAsync(tenantId, packId, request.TargetEnvironment, cancellationToken);
|
||||
if (!validation.IsValid && !request.Force)
|
||||
{
|
||||
return new PromotionResult
|
||||
{
|
||||
Success = false,
|
||||
Error = string.Join("; ", validation.Errors?.Select(e => e.Message) ?? [])
|
||||
};
|
||||
}
|
||||
|
||||
var pack = await _packStore.GetByIdAsync(tenantId, packId, cancellationToken);
|
||||
if (pack is null)
|
||||
{
|
||||
return new PromotionResult
|
||||
{
|
||||
Success = false,
|
||||
Error = $"Policy pack {packId} not found"
|
||||
};
|
||||
}
|
||||
|
||||
// Check pack is published
|
||||
var publicationStatus = await _publishService.GetPublicationStatusAsync(tenantId, packId, cancellationToken);
|
||||
if (publicationStatus is null || publicationStatus.State != PublishState.Published)
|
||||
{
|
||||
return new PromotionResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Policy pack must be published before promotion"
|
||||
};
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var bindingId = GenerateBindingId(tenantId, packId, request.TargetEnvironment, now);
|
||||
|
||||
// Deactivate current binding if exists
|
||||
string? previousBindingId = null;
|
||||
if (_activeBindings.TryGetValue((tenantId, request.TargetEnvironment), out var currentBindingId))
|
||||
{
|
||||
if (_bindings.TryGetValue((tenantId, currentBindingId), out var currentBinding))
|
||||
{
|
||||
previousBindingId = currentBindingId;
|
||||
var supersededBinding = currentBinding with
|
||||
{
|
||||
Status = PromotionBindingStatus.Superseded,
|
||||
DeactivatedAt = now
|
||||
};
|
||||
_bindings[(tenantId, currentBindingId)] = supersededBinding;
|
||||
|
||||
AddHistoryEntry(tenantId, request.TargetEnvironment, new PromotionHistoryEntry
|
||||
{
|
||||
BindingId = currentBindingId,
|
||||
PackId = currentBinding.PackId,
|
||||
PackVersion = currentBinding.PackVersion,
|
||||
Action = PromotionHistoryAction.Superseded,
|
||||
Timestamp = now,
|
||||
PerformedBy = request.PromotedBy,
|
||||
Comment = $"Superseded by promotion of {packId}"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create new binding
|
||||
var binding = new PromotionBinding
|
||||
{
|
||||
BindingId = bindingId,
|
||||
TenantId = tenantId,
|
||||
PackId = packId,
|
||||
PackVersion = pack.Version,
|
||||
Environment = request.TargetEnvironment,
|
||||
Mode = PromotionBindingMode.Manual,
|
||||
Status = PromotionBindingStatus.Active,
|
||||
CreatedAt = now,
|
||||
ActivatedAt = now,
|
||||
CreatedBy = request.PromotedBy,
|
||||
ActivatedBy = request.PromotedBy
|
||||
};
|
||||
|
||||
_bindings[(tenantId, bindingId)] = binding;
|
||||
_activeBindings[(tenantId, request.TargetEnvironment)] = bindingId;
|
||||
|
||||
AddHistoryEntry(tenantId, request.TargetEnvironment, new PromotionHistoryEntry
|
||||
{
|
||||
BindingId = bindingId,
|
||||
PackId = packId,
|
||||
PackVersion = pack.Version,
|
||||
Action = PromotionHistoryAction.Promoted,
|
||||
Timestamp = now,
|
||||
PerformedBy = request.PromotedBy,
|
||||
Comment = request.Comment,
|
||||
PreviousBindingId = previousBindingId
|
||||
});
|
||||
|
||||
var warnings = validation.Warnings?.Select(w => w.Message).ToList();
|
||||
|
||||
return new PromotionResult
|
||||
{
|
||||
Success = true,
|
||||
Binding = binding,
|
||||
PreviousBindingId = previousBindingId,
|
||||
Warnings = warnings?.Count > 0 ? warnings : null
|
||||
};
|
||||
}
|
||||
|
||||
public Task<PromotionBinding?> GetBindingAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
string environment,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var binding = _bindings.Values
|
||||
.Where(b => b.TenantId == tenantId && b.PackId == packId && b.Environment == environment)
|
||||
.OrderByDescending(b => b.CreatedAt)
|
||||
.FirstOrDefault();
|
||||
|
||||
return Task.FromResult(binding);
|
||||
}
|
||||
|
||||
public Task<PromotionBindingList> ListBindingsAsync(
|
||||
Guid tenantId,
|
||||
string? environment = null,
|
||||
Guid? packId = null,
|
||||
int pageSize = 20,
|
||||
string? pageToken = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _bindings.Values.Where(b => b.TenantId == tenantId);
|
||||
|
||||
if (!string.IsNullOrEmpty(environment))
|
||||
{
|
||||
query = query.Where(b => b.Environment == environment);
|
||||
}
|
||||
|
||||
if (packId.HasValue)
|
||||
{
|
||||
query = query.Where(b => b.PackId == packId.Value);
|
||||
}
|
||||
|
||||
var items = query.OrderByDescending(b => b.CreatedAt).ToList();
|
||||
|
||||
int skip = 0;
|
||||
if (!string.IsNullOrEmpty(pageToken) && int.TryParse(pageToken, out var offset))
|
||||
{
|
||||
skip = offset;
|
||||
}
|
||||
|
||||
var pagedItems = items.Skip(skip).Take(pageSize).ToList();
|
||||
string? nextToken = skip + pagedItems.Count < items.Count
|
||||
? (skip + pagedItems.Count).ToString()
|
||||
: null;
|
||||
|
||||
return Task.FromResult(new PromotionBindingList
|
||||
{
|
||||
Items = pagedItems,
|
||||
NextPageToken = nextToken,
|
||||
TotalCount = items.Count
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<ActiveEnvironmentPolicy?> GetActiveForEnvironmentAsync(
|
||||
Guid tenantId,
|
||||
string environment,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_activeBindings.TryGetValue((tenantId, environment), out var bindingId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!_bindings.TryGetValue((tenantId, bindingId), out var binding))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var publicationStatus = await _publishService.GetPublicationStatusAsync(tenantId, binding.PackId, cancellationToken);
|
||||
|
||||
return new ActiveEnvironmentPolicy
|
||||
{
|
||||
Environment = environment,
|
||||
PackId = binding.PackId,
|
||||
PackVersion = binding.PackVersion,
|
||||
PackDigest = publicationStatus?.Digest ?? "",
|
||||
BindingId = bindingId,
|
||||
ActivatedAt = binding.ActivatedAt ?? binding.CreatedAt,
|
||||
ActivatedBy = binding.ActivatedBy
|
||||
};
|
||||
}
|
||||
|
||||
public Task<RollbackResult> RollbackAsync(
|
||||
Guid tenantId,
|
||||
string environment,
|
||||
RollbackRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_history.TryGetValue((tenantId, environment), out var history) || history.Count < 2)
|
||||
{
|
||||
return Task.FromResult(new RollbackResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "No rollback target available"
|
||||
});
|
||||
}
|
||||
|
||||
// Find target binding
|
||||
PromotionHistoryEntry? targetEntry = null;
|
||||
if (!string.IsNullOrEmpty(request.TargetBindingId))
|
||||
{
|
||||
targetEntry = history.FirstOrDefault(h => h.BindingId == request.TargetBindingId);
|
||||
}
|
||||
else
|
||||
{
|
||||
var stepsBack = request.StepsBack ?? 1;
|
||||
var promotions = history.Where(h => h.Action == PromotionHistoryAction.Promoted).ToList();
|
||||
if (promotions.Count > stepsBack)
|
||||
{
|
||||
targetEntry = promotions[stepsBack];
|
||||
}
|
||||
}
|
||||
|
||||
if (targetEntry is null)
|
||||
{
|
||||
return Task.FromResult(new RollbackResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Target binding not found"
|
||||
});
|
||||
}
|
||||
|
||||
if (!_bindings.TryGetValue((tenantId, targetEntry.BindingId), out var targetBinding))
|
||||
{
|
||||
return Task.FromResult(new RollbackResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Target binding no longer exists"
|
||||
});
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Deactivate current binding
|
||||
string? rolledBackBindingId = null;
|
||||
if (_activeBindings.TryGetValue((tenantId, environment), out var currentBindingId))
|
||||
{
|
||||
if (_bindings.TryGetValue((tenantId, currentBindingId), out var currentBinding))
|
||||
{
|
||||
rolledBackBindingId = currentBindingId;
|
||||
var rolledBackBinding = currentBinding with
|
||||
{
|
||||
Status = PromotionBindingStatus.RolledBack,
|
||||
DeactivatedAt = now
|
||||
};
|
||||
_bindings[(tenantId, currentBindingId)] = rolledBackBinding;
|
||||
|
||||
AddHistoryEntry(tenantId, environment, new PromotionHistoryEntry
|
||||
{
|
||||
BindingId = currentBindingId,
|
||||
PackId = currentBinding.PackId,
|
||||
PackVersion = currentBinding.PackVersion,
|
||||
Action = PromotionHistoryAction.RolledBack,
|
||||
Timestamp = now,
|
||||
PerformedBy = request.RolledBackBy,
|
||||
Comment = request.Reason
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Restore target binding
|
||||
var restoredBinding = targetBinding with
|
||||
{
|
||||
Status = PromotionBindingStatus.Active,
|
||||
ActivatedAt = now,
|
||||
ActivatedBy = request.RolledBackBy,
|
||||
DeactivatedAt = null
|
||||
};
|
||||
|
||||
_bindings[(tenantId, targetBinding.BindingId)] = restoredBinding;
|
||||
_activeBindings[(tenantId, environment)] = targetBinding.BindingId;
|
||||
|
||||
AddHistoryEntry(tenantId, environment, new PromotionHistoryEntry
|
||||
{
|
||||
BindingId = targetBinding.BindingId,
|
||||
PackId = targetBinding.PackId,
|
||||
PackVersion = targetBinding.PackVersion,
|
||||
Action = PromotionHistoryAction.Promoted,
|
||||
Timestamp = now,
|
||||
PerformedBy = request.RolledBackBy,
|
||||
Comment = $"Restored via rollback: {request.Reason}",
|
||||
PreviousBindingId = rolledBackBindingId
|
||||
});
|
||||
|
||||
return Task.FromResult(new RollbackResult
|
||||
{
|
||||
Success = true,
|
||||
RestoredBinding = restoredBinding,
|
||||
RolledBackBindingId = rolledBackBindingId
|
||||
});
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<PromotionHistoryEntry>> GetHistoryAsync(
|
||||
Guid tenantId,
|
||||
string environment,
|
||||
int limit = 50,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_history.TryGetValue((tenantId, environment), out var history))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<PromotionHistoryEntry>>(Array.Empty<PromotionHistoryEntry>());
|
||||
}
|
||||
|
||||
var entries = history.OrderByDescending(h => h.Timestamp).Take(limit).ToList();
|
||||
return Task.FromResult<IReadOnlyList<PromotionHistoryEntry>>(entries);
|
||||
}
|
||||
|
||||
public async Task<PromotionValidationResult> ValidatePromotionAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
string targetEnvironment,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var errors = new List<PromotionValidationError>();
|
||||
var warnings = new List<PromotionValidationWarning>();
|
||||
|
||||
// Check pack exists
|
||||
var pack = await _packStore.GetByIdAsync(tenantId, packId, cancellationToken);
|
||||
if (pack is null)
|
||||
{
|
||||
errors.Add(new PromotionValidationError
|
||||
{
|
||||
Code = "PACK_NOT_FOUND",
|
||||
Message = $"Policy pack {packId} not found"
|
||||
});
|
||||
return new PromotionValidationResult { IsValid = false, Errors = errors };
|
||||
}
|
||||
|
||||
// Check pack is published
|
||||
var publicationStatus = await _publishService.GetPublicationStatusAsync(tenantId, packId, cancellationToken);
|
||||
if (publicationStatus is null)
|
||||
{
|
||||
errors.Add(new PromotionValidationError
|
||||
{
|
||||
Code = "NOT_PUBLISHED",
|
||||
Message = "Policy pack must be published before promotion"
|
||||
});
|
||||
}
|
||||
else if (publicationStatus.State == PublishState.Revoked)
|
||||
{
|
||||
errors.Add(new PromotionValidationError
|
||||
{
|
||||
Code = "REVOKED",
|
||||
Message = "Cannot promote a revoked policy pack"
|
||||
});
|
||||
}
|
||||
|
||||
// Check environment rules
|
||||
if (targetEnvironment.Equals("production", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Production requires additional validation
|
||||
var activeStaging = await GetActiveForEnvironmentAsync(tenantId, "staging", cancellationToken);
|
||||
if (activeStaging is null || activeStaging.PackId != packId)
|
||||
{
|
||||
warnings.Add(new PromotionValidationWarning
|
||||
{
|
||||
Code = "NOT_IN_STAGING",
|
||||
Message = "Policy pack has not been validated in staging environment"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check for existing active binding with same pack
|
||||
var currentActive = await GetActiveForEnvironmentAsync(tenantId, targetEnvironment, cancellationToken);
|
||||
if (currentActive is not null && currentActive.PackId == packId && currentActive.PackVersion == pack.Version)
|
||||
{
|
||||
warnings.Add(new PromotionValidationWarning
|
||||
{
|
||||
Code = "ALREADY_ACTIVE",
|
||||
Message = "Same version is already active in this environment"
|
||||
});
|
||||
}
|
||||
|
||||
return new PromotionValidationResult
|
||||
{
|
||||
IsValid = errors.Count == 0,
|
||||
Errors = errors.Count > 0 ? errors : null,
|
||||
Warnings = warnings.Count > 0 ? warnings : null
|
||||
};
|
||||
}
|
||||
|
||||
private void AddHistoryEntry(Guid tenantId, string environment, PromotionHistoryEntry entry)
|
||||
{
|
||||
_history.AddOrUpdate(
|
||||
(tenantId, environment),
|
||||
_ => [entry],
|
||||
(_, list) =>
|
||||
{
|
||||
list.Insert(0, entry);
|
||||
return list;
|
||||
});
|
||||
}
|
||||
|
||||
private static string GenerateBindingId(Guid tenantId, Guid packId, string environment, DateTimeOffset timestamp)
|
||||
{
|
||||
var content = $"{tenantId}:{packId}:{environment}:{timestamp.ToUnixTimeMilliseconds()}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
|
||||
return $"bind_{Convert.ToHexString(hash)[..16].ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Policy.Registry.Contracts;
|
||||
using StellaOps.Policy.Registry.Storage;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of publish pipeline service.
|
||||
/// Handles policy pack publication with attestation generation.
|
||||
/// </summary>
|
||||
public sealed class PublishPipelineService : IPublishPipelineService
|
||||
{
|
||||
private const string BuilderId = "https://stellaops.io/policy-registry/v1";
|
||||
private const string BuildType = "https://stellaops.io/policy-registry/v1/publish";
|
||||
private const string AttestationPredicateType = "https://slsa.dev/provenance/v1";
|
||||
|
||||
private readonly IPolicyPackStore _packStore;
|
||||
private readonly IPolicyPackCompiler _compiler;
|
||||
private readonly IReviewWorkflowService _reviewService;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private readonly ConcurrentDictionary<(Guid TenantId, Guid PackId), PublicationStatus> _publications = new();
|
||||
private readonly ConcurrentDictionary<(Guid TenantId, Guid PackId), PolicyPackAttestation> _attestations = new();
|
||||
|
||||
public PublishPipelineService(
|
||||
IPolicyPackStore packStore,
|
||||
IPolicyPackCompiler compiler,
|
||||
IReviewWorkflowService reviewService,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_packStore = packStore ?? throw new ArgumentNullException(nameof(packStore));
|
||||
_compiler = compiler ?? throw new ArgumentNullException(nameof(compiler));
|
||||
_reviewService = reviewService ?? throw new ArgumentNullException(nameof(reviewService));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<PublishResult> PublishAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
PublishPackRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Get the policy pack
|
||||
var pack = await _packStore.GetByIdAsync(tenantId, packId, cancellationToken);
|
||||
if (pack is null)
|
||||
{
|
||||
return new PublishResult
|
||||
{
|
||||
Success = false,
|
||||
Error = $"Policy pack {packId} not found"
|
||||
};
|
||||
}
|
||||
|
||||
// Verify pack is in correct state
|
||||
if (pack.Status != PolicyPackStatus.PendingReview)
|
||||
{
|
||||
return new PublishResult
|
||||
{
|
||||
Success = false,
|
||||
Error = $"Policy pack must be in PendingReview status to publish. Current status: {pack.Status}"
|
||||
};
|
||||
}
|
||||
|
||||
// Compile to get digest
|
||||
var compilationResult = await _compiler.CompileAsync(tenantId, packId, cancellationToken);
|
||||
if (!compilationResult.Success)
|
||||
{
|
||||
return new PublishResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Policy pack compilation failed. Cannot publish."
|
||||
};
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var digest = compilationResult.Digest!;
|
||||
|
||||
// Get review information if available
|
||||
var reviews = await _reviewService.ListReviewsAsync(tenantId, ReviewStatus.Approved, packId, 1, null, cancellationToken);
|
||||
var review = reviews.Items.FirstOrDefault();
|
||||
|
||||
// Build attestation
|
||||
var attestation = BuildAttestation(
|
||||
pack,
|
||||
digest,
|
||||
compilationResult,
|
||||
review,
|
||||
request,
|
||||
now);
|
||||
|
||||
// Update pack status to Published
|
||||
var updatedPack = await _packStore.UpdateStatusAsync(tenantId, packId, PolicyPackStatus.Published, request.PublishedBy, cancellationToken);
|
||||
if (updatedPack is null)
|
||||
{
|
||||
return new PublishResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Failed to update policy pack status"
|
||||
};
|
||||
}
|
||||
|
||||
// Create publication status
|
||||
var status = new PublicationStatus
|
||||
{
|
||||
PackId = packId,
|
||||
PackVersion = pack.Version,
|
||||
Digest = digest,
|
||||
State = PublishState.Published,
|
||||
PublishedAt = now,
|
||||
PublishedBy = request.PublishedBy,
|
||||
SignatureKeyId = request.SigningOptions?.KeyId,
|
||||
SignatureAlgorithm = request.SigningOptions?.Algorithm
|
||||
};
|
||||
|
||||
_publications[(tenantId, packId)] = status;
|
||||
_attestations[(tenantId, packId)] = attestation;
|
||||
|
||||
return new PublishResult
|
||||
{
|
||||
Success = true,
|
||||
PackId = packId,
|
||||
Digest = digest,
|
||||
Status = status,
|
||||
Attestation = attestation
|
||||
};
|
||||
}
|
||||
|
||||
public Task<PublicationStatus?> GetPublicationStatusAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_publications.TryGetValue((tenantId, packId), out var status);
|
||||
return Task.FromResult(status);
|
||||
}
|
||||
|
||||
public Task<PolicyPackAttestation?> GetAttestationAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_attestations.TryGetValue((tenantId, packId), out var attestation);
|
||||
return Task.FromResult(attestation);
|
||||
}
|
||||
|
||||
public async Task<AttestationVerificationResult> VerifyAttestationAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var checks = new List<VerificationCheck>();
|
||||
var errors = new List<string>();
|
||||
var warnings = new List<string>();
|
||||
|
||||
// Check publication exists
|
||||
if (!_publications.TryGetValue((tenantId, packId), out var status))
|
||||
{
|
||||
return new AttestationVerificationResult
|
||||
{
|
||||
Valid = false,
|
||||
Errors = ["Policy pack is not published"]
|
||||
};
|
||||
}
|
||||
|
||||
checks.Add(new VerificationCheck
|
||||
{
|
||||
Name = "publication_exists",
|
||||
Passed = true,
|
||||
Details = $"Published at {status.PublishedAt:O}"
|
||||
});
|
||||
|
||||
// Check not revoked
|
||||
if (status.State == PublishState.Revoked)
|
||||
{
|
||||
errors.Add($"Policy pack was revoked at {status.RevokedAt:O}: {status.RevokeReason}");
|
||||
checks.Add(new VerificationCheck
|
||||
{
|
||||
Name = "not_revoked",
|
||||
Passed = false,
|
||||
Details = status.RevokeReason
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
checks.Add(new VerificationCheck
|
||||
{
|
||||
Name = "not_revoked",
|
||||
Passed = true,
|
||||
Details = "Policy pack has not been revoked"
|
||||
});
|
||||
}
|
||||
|
||||
// Check attestation exists
|
||||
if (!_attestations.TryGetValue((tenantId, packId), out var attestation))
|
||||
{
|
||||
errors.Add("Attestation not found");
|
||||
checks.Add(new VerificationCheck
|
||||
{
|
||||
Name = "attestation_exists",
|
||||
Passed = false,
|
||||
Details = "No attestation on record"
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
checks.Add(new VerificationCheck
|
||||
{
|
||||
Name = "attestation_exists",
|
||||
Passed = true,
|
||||
Details = $"Found {attestation.Signatures.Count} signature(s)"
|
||||
});
|
||||
|
||||
// Verify signatures
|
||||
foreach (var sig in attestation.Signatures)
|
||||
{
|
||||
// In a real implementation, this would verify the actual cryptographic signature
|
||||
var sigValid = !string.IsNullOrEmpty(sig.Signature);
|
||||
checks.Add(new VerificationCheck
|
||||
{
|
||||
Name = $"signature_{sig.KeyId}",
|
||||
Passed = sigValid,
|
||||
Details = sigValid ? $"Signature verified for key {sig.KeyId}" : "Invalid signature"
|
||||
});
|
||||
|
||||
if (!sigValid)
|
||||
{
|
||||
errors.Add($"Invalid signature for key {sig.KeyId}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify pack still exists and matches digest
|
||||
var pack = await _packStore.GetByIdAsync(tenantId, packId, cancellationToken);
|
||||
if (pack is null)
|
||||
{
|
||||
errors.Add("Policy pack no longer exists");
|
||||
checks.Add(new VerificationCheck
|
||||
{
|
||||
Name = "pack_exists",
|
||||
Passed = false,
|
||||
Details = "Policy pack has been deleted"
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
checks.Add(new VerificationCheck
|
||||
{
|
||||
Name = "pack_exists",
|
||||
Passed = true,
|
||||
Details = $"Pack version: {pack.Version}"
|
||||
});
|
||||
|
||||
// Verify digest matches
|
||||
var digestMatch = pack.Digest == status.Digest;
|
||||
checks.Add(new VerificationCheck
|
||||
{
|
||||
Name = "digest_match",
|
||||
Passed = digestMatch,
|
||||
Details = digestMatch ? "Digest matches" : $"Digest mismatch: expected {status.Digest}, got {pack.Digest}"
|
||||
});
|
||||
|
||||
if (!digestMatch)
|
||||
{
|
||||
errors.Add("Policy pack has been modified since publication");
|
||||
}
|
||||
}
|
||||
|
||||
return new AttestationVerificationResult
|
||||
{
|
||||
Valid = errors.Count == 0,
|
||||
Checks = checks,
|
||||
Errors = errors.Count > 0 ? errors : null,
|
||||
Warnings = warnings.Count > 0 ? warnings : null
|
||||
};
|
||||
}
|
||||
|
||||
public Task<PublishedPackList> ListPublishedAsync(
|
||||
Guid tenantId,
|
||||
int pageSize = 20,
|
||||
string? pageToken = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = _publications
|
||||
.Where(kv => kv.Key.TenantId == tenantId)
|
||||
.Select(kv => kv.Value)
|
||||
.OrderByDescending(p => p.PublishedAt)
|
||||
.ToList();
|
||||
|
||||
int skip = 0;
|
||||
if (!string.IsNullOrEmpty(pageToken) && int.TryParse(pageToken, out var offset))
|
||||
{
|
||||
skip = offset;
|
||||
}
|
||||
|
||||
var pagedItems = items.Skip(skip).Take(pageSize).ToList();
|
||||
string? nextToken = skip + pagedItems.Count < items.Count
|
||||
? (skip + pagedItems.Count).ToString()
|
||||
: null;
|
||||
|
||||
return Task.FromResult(new PublishedPackList
|
||||
{
|
||||
Items = pagedItems,
|
||||
NextPageToken = nextToken,
|
||||
TotalCount = items.Count
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<RevokeResult> RevokeAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
RevokePackRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_publications.TryGetValue((tenantId, packId), out var status))
|
||||
{
|
||||
return new RevokeResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Policy pack is not published"
|
||||
};
|
||||
}
|
||||
|
||||
if (status.State == PublishState.Revoked)
|
||||
{
|
||||
return new RevokeResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Policy pack is already revoked"
|
||||
};
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var updatedStatus = status with
|
||||
{
|
||||
State = PublishState.Revoked,
|
||||
RevokedAt = now,
|
||||
RevokedBy = request.RevokedBy,
|
||||
RevokeReason = request.Reason
|
||||
};
|
||||
|
||||
_publications[(tenantId, packId)] = updatedStatus;
|
||||
|
||||
// Update pack status to archived
|
||||
await _packStore.UpdateStatusAsync(tenantId, packId, PolicyPackStatus.Archived, request.RevokedBy, cancellationToken);
|
||||
|
||||
return new RevokeResult
|
||||
{
|
||||
Success = true,
|
||||
Status = updatedStatus
|
||||
};
|
||||
}
|
||||
|
||||
private PolicyPackAttestation BuildAttestation(
|
||||
PolicyPackEntity pack,
|
||||
string digest,
|
||||
PolicyPackCompilationResult compilationResult,
|
||||
ReviewRequest? review,
|
||||
PublishPackRequest request,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
var subject = new AttestationSubject
|
||||
{
|
||||
Name = $"policy-pack/{pack.Name}",
|
||||
Digest = new Dictionary<string, string>
|
||||
{
|
||||
["sha256"] = digest.Replace("sha256:", "")
|
||||
}
|
||||
};
|
||||
|
||||
var predicate = new AttestationPredicate
|
||||
{
|
||||
BuildType = BuildType,
|
||||
Builder = new AttestationBuilder
|
||||
{
|
||||
Id = BuilderId,
|
||||
Version = "1.0.0"
|
||||
},
|
||||
BuildStartedOn = pack.CreatedAt,
|
||||
BuildFinishedOn = now,
|
||||
Compilation = new PolicyPackCompilationMetadata
|
||||
{
|
||||
Digest = digest,
|
||||
RuleCount = compilationResult.Statistics?.TotalRules ?? 0,
|
||||
CompiledAt = now,
|
||||
Statistics = compilationResult.Statistics?.SeverityCounts
|
||||
},
|
||||
Review = review is not null ? new PolicyPackReviewMetadata
|
||||
{
|
||||
ReviewId = review.ReviewId,
|
||||
ApprovedAt = review.ResolvedAt ?? now,
|
||||
ApprovedBy = review.ResolvedBy,
|
||||
Reviewers = review.Reviewers
|
||||
} : null,
|
||||
Metadata = request.Metadata?.ToDictionary(kv => kv.Key, kv => (object)kv.Value)
|
||||
};
|
||||
|
||||
var payload = new AttestationPayload
|
||||
{
|
||||
Type = "https://in-toto.io/Statement/v1",
|
||||
PredicateType = request.AttestationOptions?.PredicateType ?? AttestationPredicateType,
|
||||
Subject = subject,
|
||||
Predicate = predicate
|
||||
};
|
||||
|
||||
var payloadJson = JsonSerializer.Serialize(payload, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = false
|
||||
});
|
||||
|
||||
var payloadBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(payloadJson));
|
||||
|
||||
// Generate signature (simulated - in production would use actual signing)
|
||||
var signature = GenerateSignature(payloadBase64, request.SigningOptions);
|
||||
|
||||
return new PolicyPackAttestation
|
||||
{
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
Payload = payloadBase64,
|
||||
Signatures =
|
||||
[
|
||||
new AttestationSignature
|
||||
{
|
||||
KeyId = request.SigningOptions?.KeyId ?? "default",
|
||||
Signature = signature,
|
||||
Timestamp = request.SigningOptions?.IncludeTimestamp == true ? now : null
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
private static string GenerateSignature(string payload, SigningOptions? options)
|
||||
{
|
||||
// In production, this would use actual cryptographic signing
|
||||
// For now, we generate a deterministic mock signature
|
||||
var content = $"{payload}:{options?.KeyId ?? "default"}:{options?.Algorithm ?? SigningAlgorithm.ECDSA_P256_SHA256}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
|
||||
return Convert.ToBase64String(hash);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Policy.Registry.Contracts;
|
||||
using StellaOps.Policy.Registry.Storage;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of review workflow service with in-memory storage.
|
||||
/// </summary>
|
||||
public sealed class ReviewWorkflowService : IReviewWorkflowService
|
||||
{
|
||||
private readonly IPolicyPackStore _packStore;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ConcurrentDictionary<(Guid TenantId, string ReviewId), ReviewRequest> _reviews = new();
|
||||
private readonly ConcurrentDictionary<(Guid TenantId, string ReviewId), List<ReviewAuditEntry>> _auditTrails = new();
|
||||
|
||||
public ReviewWorkflowService(IPolicyPackStore packStore, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_packStore = packStore ?? throw new ArgumentNullException(nameof(packStore));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<ReviewRequest> SubmitForReviewAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
SubmitReviewRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var pack = await _packStore.GetByIdAsync(tenantId, packId, cancellationToken);
|
||||
if (pack is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Policy pack {packId} not found");
|
||||
}
|
||||
|
||||
if (pack.Status != PolicyPackStatus.Draft)
|
||||
{
|
||||
throw new InvalidOperationException($"Only draft policy packs can be submitted for review. Current status: {pack.Status}");
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var reviewId = GenerateReviewId(tenantId, packId, now);
|
||||
|
||||
var review = new ReviewRequest
|
||||
{
|
||||
ReviewId = reviewId,
|
||||
TenantId = tenantId,
|
||||
PackId = packId,
|
||||
PackVersion = pack.Version,
|
||||
Status = ReviewStatus.Pending,
|
||||
Description = request.Description,
|
||||
Reviewers = request.Reviewers,
|
||||
Urgency = request.Urgency,
|
||||
SubmittedBy = pack.CreatedBy,
|
||||
SubmittedAt = now,
|
||||
Metadata = request.Metadata
|
||||
};
|
||||
|
||||
_reviews[(tenantId, reviewId)] = review;
|
||||
|
||||
// Update pack status to pending review
|
||||
await _packStore.UpdateStatusAsync(tenantId, packId, PolicyPackStatus.PendingReview, pack.CreatedBy, cancellationToken);
|
||||
|
||||
// Add audit entry
|
||||
AddAuditEntry(tenantId, reviewId, packId, ReviewAuditAction.Submitted, now, pack.CreatedBy,
|
||||
null, ReviewStatus.Pending, $"Submitted for review: {request.Description ?? "No description"}");
|
||||
|
||||
// Add reviewer assignment audit entries
|
||||
if (request.Reviewers is { Count: > 0 })
|
||||
{
|
||||
foreach (var reviewer in request.Reviewers)
|
||||
{
|
||||
AddAuditEntry(tenantId, reviewId, packId, ReviewAuditAction.AssignedReviewer, now, pack.CreatedBy,
|
||||
null, null, $"Assigned reviewer: {reviewer}",
|
||||
new Dictionary<string, object> { ["reviewer"] = reviewer });
|
||||
}
|
||||
}
|
||||
|
||||
return review;
|
||||
}
|
||||
|
||||
public async Task<ReviewDecision> ApproveAsync(
|
||||
Guid tenantId,
|
||||
string reviewId,
|
||||
ApproveReviewRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_reviews.TryGetValue((tenantId, reviewId), out var review))
|
||||
{
|
||||
throw new InvalidOperationException($"Review {reviewId} not found");
|
||||
}
|
||||
|
||||
if (review.Status is not (ReviewStatus.Pending or ReviewStatus.InReview or ReviewStatus.ChangesRequested))
|
||||
{
|
||||
throw new InvalidOperationException($"Review cannot be approved in status: {review.Status}");
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var previousStatus = review.Status;
|
||||
|
||||
var updatedReview = review with
|
||||
{
|
||||
Status = ReviewStatus.Approved,
|
||||
ResolvedAt = now,
|
||||
ResolvedBy = request.ApprovedBy
|
||||
};
|
||||
|
||||
_reviews[(tenantId, reviewId)] = updatedReview;
|
||||
|
||||
// Add audit entry
|
||||
AddAuditEntry(tenantId, reviewId, review.PackId, ReviewAuditAction.Approved, now, request.ApprovedBy,
|
||||
previousStatus, ReviewStatus.Approved, request.Comment ?? "Approved");
|
||||
|
||||
return new ReviewDecision
|
||||
{
|
||||
ReviewId = reviewId,
|
||||
NewStatus = ReviewStatus.Approved,
|
||||
DecidedAt = now,
|
||||
DecidedBy = request.ApprovedBy,
|
||||
Comment = request.Comment
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ReviewDecision> RejectAsync(
|
||||
Guid tenantId,
|
||||
string reviewId,
|
||||
RejectReviewRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_reviews.TryGetValue((tenantId, reviewId), out var review))
|
||||
{
|
||||
throw new InvalidOperationException($"Review {reviewId} not found");
|
||||
}
|
||||
|
||||
if (review.Status is not (ReviewStatus.Pending or ReviewStatus.InReview or ReviewStatus.ChangesRequested))
|
||||
{
|
||||
throw new InvalidOperationException($"Review cannot be rejected in status: {review.Status}");
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var previousStatus = review.Status;
|
||||
|
||||
var updatedReview = review with
|
||||
{
|
||||
Status = ReviewStatus.Rejected,
|
||||
ResolvedAt = now,
|
||||
ResolvedBy = request.RejectedBy
|
||||
};
|
||||
|
||||
_reviews[(tenantId, reviewId)] = updatedReview;
|
||||
|
||||
// Revert pack to draft
|
||||
await _packStore.UpdateStatusAsync(tenantId, review.PackId, PolicyPackStatus.Draft, request.RejectedBy, cancellationToken);
|
||||
|
||||
// Add audit entry
|
||||
AddAuditEntry(tenantId, reviewId, review.PackId, ReviewAuditAction.Rejected, now, request.RejectedBy,
|
||||
previousStatus, ReviewStatus.Rejected, request.Reason);
|
||||
|
||||
return new ReviewDecision
|
||||
{
|
||||
ReviewId = reviewId,
|
||||
NewStatus = ReviewStatus.Rejected,
|
||||
DecidedAt = now,
|
||||
DecidedBy = request.RejectedBy,
|
||||
Comment = request.Reason
|
||||
};
|
||||
}
|
||||
|
||||
public Task<ReviewDecision> RequestChangesAsync(
|
||||
Guid tenantId,
|
||||
string reviewId,
|
||||
RequestChangesRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_reviews.TryGetValue((tenantId, reviewId), out var review))
|
||||
{
|
||||
throw new InvalidOperationException($"Review {reviewId} not found");
|
||||
}
|
||||
|
||||
if (review.Status is not (ReviewStatus.Pending or ReviewStatus.InReview))
|
||||
{
|
||||
throw new InvalidOperationException($"Changes cannot be requested in status: {review.Status}");
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var previousStatus = review.Status;
|
||||
|
||||
var updatedReview = review with
|
||||
{
|
||||
Status = ReviewStatus.ChangesRequested,
|
||||
PendingComments = request.Comments
|
||||
};
|
||||
|
||||
_reviews[(tenantId, reviewId)] = updatedReview;
|
||||
|
||||
// Add audit entry for status change
|
||||
AddAuditEntry(tenantId, reviewId, review.PackId, ReviewAuditAction.ChangesRequested, now, request.RequestedBy,
|
||||
previousStatus, ReviewStatus.ChangesRequested, $"Requested {request.Comments.Count} change(s)");
|
||||
|
||||
// Add audit entries for each comment
|
||||
foreach (var comment in request.Comments)
|
||||
{
|
||||
AddAuditEntry(tenantId, reviewId, review.PackId, ReviewAuditAction.CommentAdded, now, request.RequestedBy,
|
||||
null, null, comment.Comment,
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
["rule_id"] = comment.RuleId ?? "general",
|
||||
["severity"] = comment.Severity.ToString()
|
||||
});
|
||||
}
|
||||
|
||||
return Task.FromResult(new ReviewDecision
|
||||
{
|
||||
ReviewId = reviewId,
|
||||
NewStatus = ReviewStatus.ChangesRequested,
|
||||
DecidedAt = now,
|
||||
DecidedBy = request.RequestedBy,
|
||||
Comments = request.Comments
|
||||
});
|
||||
}
|
||||
|
||||
public Task<ReviewRequest?> GetReviewAsync(
|
||||
Guid tenantId,
|
||||
string reviewId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_reviews.TryGetValue((tenantId, reviewId), out var review);
|
||||
return Task.FromResult(review);
|
||||
}
|
||||
|
||||
public Task<ReviewRequestList> ListReviewsAsync(
|
||||
Guid tenantId,
|
||||
ReviewStatus? status = null,
|
||||
Guid? packId = null,
|
||||
int pageSize = 20,
|
||||
string? pageToken = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _reviews.Values.Where(r => r.TenantId == tenantId);
|
||||
|
||||
if (status.HasValue)
|
||||
{
|
||||
query = query.Where(r => r.Status == status.Value);
|
||||
}
|
||||
|
||||
if (packId.HasValue)
|
||||
{
|
||||
query = query.Where(r => r.PackId == packId.Value);
|
||||
}
|
||||
|
||||
var items = query.OrderByDescending(r => r.SubmittedAt).ToList();
|
||||
|
||||
int skip = 0;
|
||||
if (!string.IsNullOrEmpty(pageToken) && int.TryParse(pageToken, out var offset))
|
||||
{
|
||||
skip = offset;
|
||||
}
|
||||
|
||||
var pagedItems = items.Skip(skip).Take(pageSize).ToList();
|
||||
string? nextToken = skip + pagedItems.Count < items.Count
|
||||
? (skip + pagedItems.Count).ToString()
|
||||
: null;
|
||||
|
||||
return Task.FromResult(new ReviewRequestList
|
||||
{
|
||||
Items = pagedItems,
|
||||
NextPageToken = nextToken,
|
||||
TotalCount = items.Count
|
||||
});
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ReviewAuditEntry>> GetAuditTrailAsync(
|
||||
Guid tenantId,
|
||||
string reviewId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_auditTrails.TryGetValue((tenantId, reviewId), out var trail))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<ReviewAuditEntry>>(Array.Empty<ReviewAuditEntry>());
|
||||
}
|
||||
|
||||
var entries = trail.OrderByDescending(e => e.Timestamp).ToList();
|
||||
return Task.FromResult<IReadOnlyList<ReviewAuditEntry>>(entries);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ReviewAuditEntry>> GetPackAuditTrailAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entries = _auditTrails
|
||||
.Where(kv => kv.Key.TenantId == tenantId)
|
||||
.SelectMany(kv => kv.Value)
|
||||
.Where(e => e.PackId == packId)
|
||||
.OrderByDescending(e => e.Timestamp)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<ReviewAuditEntry>>(entries);
|
||||
}
|
||||
|
||||
private void AddAuditEntry(
|
||||
Guid tenantId,
|
||||
string reviewId,
|
||||
Guid packId,
|
||||
ReviewAuditAction action,
|
||||
DateTimeOffset timestamp,
|
||||
string? performedBy,
|
||||
ReviewStatus? previousStatus,
|
||||
ReviewStatus? newStatus,
|
||||
string? comment,
|
||||
IReadOnlyDictionary<string, object>? details = null)
|
||||
{
|
||||
var auditId = GenerateAuditId(tenantId, reviewId, timestamp);
|
||||
var entry = new ReviewAuditEntry
|
||||
{
|
||||
AuditId = auditId,
|
||||
ReviewId = reviewId,
|
||||
PackId = packId,
|
||||
Action = action,
|
||||
Timestamp = timestamp,
|
||||
PerformedBy = performedBy,
|
||||
PreviousStatus = previousStatus,
|
||||
NewStatus = newStatus,
|
||||
Comment = comment,
|
||||
Details = details
|
||||
};
|
||||
|
||||
_auditTrails.AddOrUpdate(
|
||||
(tenantId, reviewId),
|
||||
_ => [entry],
|
||||
(_, list) =>
|
||||
{
|
||||
list.Add(entry);
|
||||
return list;
|
||||
});
|
||||
}
|
||||
|
||||
private static string GenerateReviewId(Guid tenantId, Guid packId, DateTimeOffset timestamp)
|
||||
{
|
||||
var content = $"{tenantId}:{packId}:{timestamp.ToUnixTimeMilliseconds()}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
|
||||
return $"rev_{Convert.ToHexString(hash)[..16].ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static string GenerateAuditId(Guid tenantId, string reviewId, DateTimeOffset timestamp)
|
||||
{
|
||||
var content = $"{tenantId}:{reviewId}:{timestamp.ToUnixTimeMilliseconds()}:{Guid.NewGuid()}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
|
||||
return $"aud_{Convert.ToHexString(hash)[..12].ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user