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

- 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:
StellaOps Bot
2025-12-07 00:27:33 +02:00
parent 9bd6a73926
commit 0de92144d2
229 changed files with 32351 additions and 1481 deletions

View File

@@ -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();
}
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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()}";
}
}

View File

@@ -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);
}
}

View File

@@ -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()}";
}
}