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

@@ -15,15 +15,18 @@ using StellaOps.Policy.Engine.BatchEvaluation;
using StellaOps.Policy.Engine.DependencyInjection;
using StellaOps.PolicyDsl;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Engine.Workers;
using StellaOps.Policy.Engine.Streaming;
using StellaOps.Policy.Engine.Telemetry;
using StellaOps.Policy.Engine.ConsoleSurface;
using StellaOps.AirGap.Policy;
using StellaOps.Policy.Engine.Orchestration;
using StellaOps.Policy.Engine.ReachabilityFacts;
using StellaOps.Policy.Engine.Storage.InMemory;
using StellaOps.Policy.Engine.Storage.Mongo.Repositories;
using StellaOps.Policy.Engine.Workers;
using StellaOps.Policy.Engine.Streaming;
using StellaOps.Policy.Engine.Telemetry;
using StellaOps.Policy.Engine.ConsoleSurface;
using StellaOps.AirGap.Policy;
using StellaOps.Policy.Engine.Orchestration;
using StellaOps.Policy.Engine.ReachabilityFacts;
using StellaOps.Policy.Engine.Storage.InMemory;
using StellaOps.Policy.Engine.Storage.Mongo.Repositories;
using StellaOps.Policy.Scoring.Engine;
using StellaOps.Policy.Scoring.Receipts;
using StellaOps.Policy.Storage.Postgres;
var builder = WebApplication.CreateBuilder(args);
@@ -92,9 +95,16 @@ var bootstrap = StellaOpsConfigurationBootstrapper.Build<PolicyEngineOptions>(op
builder.Configuration.AddConfiguration(bootstrap.Configuration);
builder.ConfigurePolicyEngineTelemetry(bootstrap.Options);
builder.Services.AddAirGapEgressPolicy(builder.Configuration, sectionName: "AirGap");
builder.ConfigurePolicyEngineTelemetry(bootstrap.Options);
builder.Services.AddAirGapEgressPolicy(builder.Configuration, sectionName: "AirGap");
// CVSS receipts rely on PostgreSQL storage for deterministic persistence.
builder.Services.AddPolicyPostgresStorage(builder.Configuration, sectionName: "Postgres:Policy");
builder.Services.AddSingleton<ICvssV4Engine, CvssV4Engine>();
builder.Services.AddScoped<IReceiptBuilder, ReceiptBuilder>();
builder.Services.AddScoped<IReceiptHistoryService, ReceiptHistoryService>();
builder.Services.AddOptions<PolicyEngineOptions>()
.Bind(builder.Configuration.GetSection(PolicyEngineOptions.SectionName))
@@ -314,29 +324,30 @@ app.MapAdvisoryAiKnobs();
app.MapBatchContext();
app.MapOrchestratorJobs();
app.MapPolicyWorker();
app.MapLedgerExport();
app.MapConsoleExportJobs(); // CONTRACT-EXPORT-BUNDLE-009
app.MapPolicyPackBundles(); // CONTRACT-MIRROR-BUNDLE-003
app.MapSealedMode(); // CONTRACT-SEALED-MODE-004
app.MapStalenessSignaling(); // CONTRACT-SEALED-MODE-004 staleness
app.MapAirGapNotifications(); // Air-gap notifications
app.MapPolicyLint(); // POLICY-AOC-19-001 determinism linting
app.MapVerificationPolicies(); // CONTRACT-VERIFICATION-POLICY-006 attestation policies
app.MapVerificationPolicyEditor(); // CONTRACT-VERIFICATION-POLICY-006 editor DTOs/validation
app.MapAttestationReports(); // CONTRACT-VERIFICATION-POLICY-006 attestation reports
app.MapConsoleAttestationReports(); // CONTRACT-VERIFICATION-POLICY-006 Console integration
app.MapSnapshots();
app.MapViolations();
app.MapPolicyDecisions();
app.MapRiskProfiles();
app.MapRiskProfileSchema();
app.MapScopeAttachments();
app.MapEffectivePolicies(); // CONTRACT-AUTHORITY-EFFECTIVE-WRITE-008
app.MapRiskSimulation();
app.MapOverrides();
app.MapProfileExport();
app.MapRiskProfileAirGap(); // CONTRACT-MIRROR-BUNDLE-003 risk profile air-gap
app.MapProfileEvents();
app.MapLedgerExport();
app.MapConsoleExportJobs(); // CONTRACT-EXPORT-BUNDLE-009
app.MapPolicyPackBundles(); // CONTRACT-MIRROR-BUNDLE-003
app.MapSealedMode(); // CONTRACT-SEALED-MODE-004
app.MapStalenessSignaling(); // CONTRACT-SEALED-MODE-004 staleness
app.MapAirGapNotifications(); // Air-gap notifications
app.MapPolicyLint(); // POLICY-AOC-19-001 determinism linting
app.MapVerificationPolicies(); // CONTRACT-VERIFICATION-POLICY-006 attestation policies
app.MapVerificationPolicyEditor(); // CONTRACT-VERIFICATION-POLICY-006 editor DTOs/validation
app.MapAttestationReports(); // CONTRACT-VERIFICATION-POLICY-006 attestation reports
app.MapConsoleAttestationReports(); // CONTRACT-VERIFICATION-POLICY-006 Console integration
app.MapSnapshots();
app.MapViolations();
app.MapPolicyDecisions();
app.MapRiskProfiles();
app.MapRiskProfileSchema();
app.MapScopeAttachments();
app.MapEffectivePolicies(); // CONTRACT-AUTHORITY-EFFECTIVE-WRITE-008
app.MapRiskSimulation();
app.MapOverrides();
app.MapProfileExport();
app.MapRiskProfileAirGap(); // CONTRACT-MIRROR-BUNDLE-003 risk profile air-gap
app.MapProfileEvents();
app.MapCvssReceipts(); // CVSS v4 receipt CRUD & history
// Phase 5: Multi-tenant PostgreSQL-backed API endpoints
app.MapPolicySnapshotsApi();

View File

@@ -0,0 +1,27 @@
# StellaOps.Policy.Gateway — AGENTS Charter
## Working Directory & Mission
- Working directory: `src/Policy/StellaOps.Policy.Gateway/**`.
- Mission: expose policy APIs (incl. CVSS v4.0 receipt endpoints) with tenant-safe, deterministic responses, DSSE-backed receipts, and offline-friendly defaults.
## Roles
- **Backend engineer (.NET 10 / ASP.NET Core minimal API):** endpoints, auth scopes, persistence wiring.
- **QA engineer:** WebApplicationFactory integration slices; deterministic contract tests (status codes, schema, ordering, hashes).
## Required Reading (treat as read before DOING)
- `docs/modules/policy/architecture.md`
- `docs/modules/platform/architecture-overview.md`
- `docs/modules/policy/cvss-v4.md`
- `docs/product-advisories/25-Nov-2025 - Add CVSS v4.0 Score Receipts for Transparency.md`
- Sprint tracker: `docs/implplan/SPRINT_0190_0001_0001_cvss_v4_receipts.md`
## Working Agreements
- Enforce tenant isolation and `policy:*`/`cvss:*`/`effective:write` scopes on all endpoints.
- Determinism: stable ordering, UTC ISO-8601 timestamps, canonical JSON for receipts and exports; include scorer version/hash in responses.
- Offline-first: no outbound calls beyond configured internal services; feature flags default to offline-safe.
- DSSE: receipt create/amend routes must emit DSSE (`stella.ops/cvssReceipt@v1`) and persist references.
- Schema governance: keep OpenAPI/JSON schemas in sync with models; update docs and sprint Decisions & Risks when contracts change.
## Testing
- Prefer integration tests via WebApplicationFactory (in a `StellaOps.Policy.Gateway.Tests` project) covering auth, tenancy, determinism, DSSE presence, and schema validation.
- No network; seed deterministic fixtures; assert consistent hashes across runs.

View File

@@ -1,4 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Policy.Registry.Services;
using StellaOps.Policy.Registry.Storage;
namespace StellaOps.Policy.Registry;
@@ -43,4 +45,140 @@ public static class PolicyRegistryServiceCollectionExtensions
return services;
}
/// <summary>
/// Adds the in-memory storage implementations for testing and development.
/// </summary>
public static IServiceCollection AddPolicyRegistryInMemoryStorage(this IServiceCollection services)
{
services.AddSingleton<IPolicyPackStore, InMemoryPolicyPackStore>();
services.AddSingleton<IVerificationPolicyStore, InMemoryVerificationPolicyStore>();
services.AddSingleton<ISnapshotStore, InMemorySnapshotStore>();
services.AddSingleton<IViolationStore, InMemoryViolationStore>();
services.AddSingleton<IOverrideStore, InMemoryOverrideStore>();
// Add compiler service
services.AddSingleton<IPolicyPackCompiler, PolicyPackCompiler>();
// Add simulation service
services.AddSingleton<IPolicySimulationService, PolicySimulationService>();
// Add batch simulation orchestrator
services.AddSingleton<IBatchSimulationOrchestrator, BatchSimulationOrchestrator>();
// Add review workflow service
services.AddSingleton<IReviewWorkflowService, ReviewWorkflowService>();
// Add publish pipeline service
services.AddSingleton<IPublishPipelineService, PublishPipelineService>();
// Add promotion service
services.AddSingleton<IPromotionService, PromotionService>();
return services;
}
/// <summary>
/// Adds the policy pack compiler service.
/// </summary>
public static IServiceCollection AddPolicyPackCompiler(this IServiceCollection services)
{
services.AddSingleton<IPolicyPackCompiler, PolicyPackCompiler>();
return services;
}
/// <summary>
/// Adds the policy simulation service.
/// </summary>
public static IServiceCollection AddPolicySimulationService(this IServiceCollection services)
{
services.AddSingleton<IPolicySimulationService, PolicySimulationService>();
return services;
}
/// <summary>
/// Adds the batch simulation orchestrator service.
/// </summary>
public static IServiceCollection AddBatchSimulationOrchestrator(this IServiceCollection services)
{
services.AddSingleton<IBatchSimulationOrchestrator, BatchSimulationOrchestrator>();
return services;
}
/// <summary>
/// Adds the review workflow service.
/// </summary>
public static IServiceCollection AddReviewWorkflowService(this IServiceCollection services)
{
services.AddSingleton<IReviewWorkflowService, ReviewWorkflowService>();
return services;
}
/// <summary>
/// Adds the publish pipeline service.
/// </summary>
public static IServiceCollection AddPublishPipelineService(this IServiceCollection services)
{
services.AddSingleton<IPublishPipelineService, PublishPipelineService>();
return services;
}
/// <summary>
/// Adds the promotion service.
/// </summary>
public static IServiceCollection AddPromotionService(this IServiceCollection services)
{
services.AddSingleton<IPromotionService, PromotionService>();
return services;
}
/// <summary>
/// Adds a custom policy pack store implementation.
/// </summary>
public static IServiceCollection AddPolicyPackStore<TStore>(this IServiceCollection services)
where TStore : class, IPolicyPackStore
{
services.AddSingleton<IPolicyPackStore, TStore>();
return services;
}
/// <summary>
/// Adds a custom verification policy store implementation.
/// </summary>
public static IServiceCollection AddVerificationPolicyStore<TStore>(this IServiceCollection services)
where TStore : class, IVerificationPolicyStore
{
services.AddSingleton<IVerificationPolicyStore, TStore>();
return services;
}
/// <summary>
/// Adds a custom snapshot store implementation.
/// </summary>
public static IServiceCollection AddSnapshotStore<TStore>(this IServiceCollection services)
where TStore : class, ISnapshotStore
{
services.AddSingleton<ISnapshotStore, TStore>();
return services;
}
/// <summary>
/// Adds a custom violation store implementation.
/// </summary>
public static IServiceCollection AddViolationStore<TStore>(this IServiceCollection services)
where TStore : class, IViolationStore
{
services.AddSingleton<IViolationStore, TStore>();
return services;
}
/// <summary>
/// Adds a custom override store implementation.
/// </summary>
public static IServiceCollection AddOverrideStore<TStore>(this IServiceCollection services)
where TStore : class, IOverrideStore
{
services.AddSingleton<IOverrideStore, TStore>();
return services;
}
}

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

View File

@@ -0,0 +1,180 @@
using System.Diagnostics;
namespace StellaOps.Policy.Registry.Telemetry;
/// <summary>
/// Activity source for Policy Registry tracing.
/// Provides distributed tracing capabilities for all registry operations.
/// </summary>
public static class PolicyRegistryActivitySource
{
public const string SourceName = "StellaOps.Policy.Registry";
public static readonly ActivitySource ActivitySource = new(SourceName, "1.0.0");
// Pack operations
public static Activity? StartCreatePack(string tenantId, string packName)
{
var activity = ActivitySource.StartActivity("policy_registry.pack.create", ActivityKind.Internal);
activity?.SetTag("tenant_id", tenantId);
activity?.SetTag("pack_name", packName);
return activity;
}
public static Activity? StartGetPack(string tenantId, Guid packId)
{
var activity = ActivitySource.StartActivity("policy_registry.pack.get", ActivityKind.Internal);
activity?.SetTag("tenant_id", tenantId);
activity?.SetTag("pack_id", packId.ToString());
return activity;
}
public static Activity? StartUpdatePack(string tenantId, Guid packId)
{
var activity = ActivitySource.StartActivity("policy_registry.pack.update", ActivityKind.Internal);
activity?.SetTag("tenant_id", tenantId);
activity?.SetTag("pack_id", packId.ToString());
return activity;
}
public static Activity? StartDeletePack(string tenantId, Guid packId)
{
var activity = ActivitySource.StartActivity("policy_registry.pack.delete", ActivityKind.Internal);
activity?.SetTag("tenant_id", tenantId);
activity?.SetTag("pack_id", packId.ToString());
return activity;
}
// Compilation operations
public static Activity? StartCompile(string tenantId, Guid packId)
{
var activity = ActivitySource.StartActivity("policy_registry.compile", ActivityKind.Internal);
activity?.SetTag("tenant_id", tenantId);
activity?.SetTag("pack_id", packId.ToString());
return activity;
}
public static Activity? StartValidateRule(string tenantId, string ruleId)
{
var activity = ActivitySource.StartActivity("policy_registry.rule.validate", ActivityKind.Internal);
activity?.SetTag("tenant_id", tenantId);
activity?.SetTag("rule_id", ruleId);
return activity;
}
// Simulation operations
public static Activity? StartSimulation(string tenantId, Guid packId)
{
var activity = ActivitySource.StartActivity("policy_registry.simulate", ActivityKind.Internal);
activity?.SetTag("tenant_id", tenantId);
activity?.SetTag("pack_id", packId.ToString());
return activity;
}
public static Activity? StartBatchSimulation(string tenantId, Guid packId, int inputCount)
{
var activity = ActivitySource.StartActivity("policy_registry.batch_simulate", ActivityKind.Internal);
activity?.SetTag("tenant_id", tenantId);
activity?.SetTag("pack_id", packId.ToString());
activity?.SetTag("input_count", inputCount);
return activity;
}
// Review operations
public static Activity? StartSubmitReview(string tenantId, Guid packId)
{
var activity = ActivitySource.StartActivity("policy_registry.review.submit", ActivityKind.Internal);
activity?.SetTag("tenant_id", tenantId);
activity?.SetTag("pack_id", packId.ToString());
return activity;
}
public static Activity? StartApproveReview(string tenantId, string reviewId)
{
var activity = ActivitySource.StartActivity("policy_registry.review.approve", ActivityKind.Internal);
activity?.SetTag("tenant_id", tenantId);
activity?.SetTag("review_id", reviewId);
return activity;
}
public static Activity? StartRejectReview(string tenantId, string reviewId)
{
var activity = ActivitySource.StartActivity("policy_registry.review.reject", ActivityKind.Internal);
activity?.SetTag("tenant_id", tenantId);
activity?.SetTag("review_id", reviewId);
return activity;
}
// Publish operations
public static Activity? StartPublish(string tenantId, Guid packId)
{
var activity = ActivitySource.StartActivity("policy_registry.publish", ActivityKind.Internal);
activity?.SetTag("tenant_id", tenantId);
activity?.SetTag("pack_id", packId.ToString());
return activity;
}
public static Activity? StartRevoke(string tenantId, Guid packId)
{
var activity = ActivitySource.StartActivity("policy_registry.revoke", ActivityKind.Internal);
activity?.SetTag("tenant_id", tenantId);
activity?.SetTag("pack_id", packId.ToString());
return activity;
}
public static Activity? StartVerifyAttestation(string tenantId, Guid packId)
{
var activity = ActivitySource.StartActivity("policy_registry.attestation.verify", ActivityKind.Internal);
activity?.SetTag("tenant_id", tenantId);
activity?.SetTag("pack_id", packId.ToString());
return activity;
}
// Promotion operations
public static Activity? StartPromotion(string tenantId, Guid packId, string targetEnvironment)
{
var activity = ActivitySource.StartActivity("policy_registry.promote", ActivityKind.Internal);
activity?.SetTag("tenant_id", tenantId);
activity?.SetTag("pack_id", packId.ToString());
activity?.SetTag("target_environment", targetEnvironment);
return activity;
}
public static Activity? StartRollback(string tenantId, string environment)
{
var activity = ActivitySource.StartActivity("policy_registry.rollback", ActivityKind.Internal);
activity?.SetTag("tenant_id", tenantId);
activity?.SetTag("environment", environment);
return activity;
}
public static Activity? StartValidatePromotion(string tenantId, Guid packId, string targetEnvironment)
{
var activity = ActivitySource.StartActivity("policy_registry.promotion.validate", ActivityKind.Internal);
activity?.SetTag("tenant_id", tenantId);
activity?.SetTag("pack_id", packId.ToString());
activity?.SetTag("target_environment", targetEnvironment);
return activity;
}
// Helper methods
public static void SetError(this Activity? activity, Exception ex)
{
if (activity is null) return;
activity.SetStatus(ActivityStatusCode.Error, ex.Message);
activity.SetTag("error.type", ex.GetType().FullName);
activity.SetTag("error.message", ex.Message);
}
public static void SetSuccess(this Activity? activity)
{
activity?.SetStatus(ActivityStatusCode.Ok);
}
public static void SetResult(this Activity? activity, string key, object? value)
{
if (activity is null || value is null) return;
activity.SetTag($"result.{key}", value.ToString());
}
}

View File

@@ -0,0 +1,143 @@
using Microsoft.Extensions.Logging;
namespace StellaOps.Policy.Registry.Telemetry;
/// <summary>
/// Structured logging event IDs for Policy Registry operations.
/// Provides consistent event identification for log analysis and alerting.
/// </summary>
public static class PolicyRegistryLogEvents
{
// Pack operations (1000-1099)
public static readonly EventId PackCreated = new(1000, "PackCreated");
public static readonly EventId PackUpdated = new(1001, "PackUpdated");
public static readonly EventId PackDeleted = new(1002, "PackDeleted");
public static readonly EventId PackStatusChanged = new(1003, "PackStatusChanged");
public static readonly EventId PackNotFound = new(1004, "PackNotFound");
public static readonly EventId PackValidationFailed = new(1005, "PackValidationFailed");
// Compilation operations (1100-1199)
public static readonly EventId CompilationStarted = new(1100, "CompilationStarted");
public static readonly EventId CompilationSucceeded = new(1101, "CompilationSucceeded");
public static readonly EventId CompilationFailed = new(1102, "CompilationFailed");
public static readonly EventId RuleValidationStarted = new(1110, "RuleValidationStarted");
public static readonly EventId RuleValidationSucceeded = new(1111, "RuleValidationSucceeded");
public static readonly EventId RuleValidationFailed = new(1112, "RuleValidationFailed");
public static readonly EventId DigestComputed = new(1120, "DigestComputed");
// Simulation operations (1200-1299)
public static readonly EventId SimulationStarted = new(1200, "SimulationStarted");
public static readonly EventId SimulationCompleted = new(1201, "SimulationCompleted");
public static readonly EventId SimulationFailed = new(1202, "SimulationFailed");
public static readonly EventId ViolationDetected = new(1210, "ViolationDetected");
public static readonly EventId BatchSimulationSubmitted = new(1220, "BatchSimulationSubmitted");
public static readonly EventId BatchSimulationStarted = new(1221, "BatchSimulationStarted");
public static readonly EventId BatchSimulationCompleted = new(1222, "BatchSimulationCompleted");
public static readonly EventId BatchSimulationFailed = new(1223, "BatchSimulationFailed");
public static readonly EventId BatchSimulationCancelled = new(1224, "BatchSimulationCancelled");
public static readonly EventId BatchSimulationProgress = new(1225, "BatchSimulationProgress");
// Review operations (1300-1399)
public static readonly EventId ReviewSubmitted = new(1300, "ReviewSubmitted");
public static readonly EventId ReviewApproved = new(1301, "ReviewApproved");
public static readonly EventId ReviewRejected = new(1302, "ReviewRejected");
public static readonly EventId ReviewChangesRequested = new(1303, "ReviewChangesRequested");
public static readonly EventId ReviewCancelled = new(1304, "ReviewCancelled");
public static readonly EventId ReviewerAssigned = new(1310, "ReviewerAssigned");
public static readonly EventId ReviewerRemoved = new(1311, "ReviewerRemoved");
public static readonly EventId ReviewCommentAdded = new(1320, "ReviewCommentAdded");
// Publish operations (1400-1499)
public static readonly EventId PublishStarted = new(1400, "PublishStarted");
public static readonly EventId PublishSucceeded = new(1401, "PublishSucceeded");
public static readonly EventId PublishFailed = new(1402, "PublishFailed");
public static readonly EventId AttestationGenerated = new(1410, "AttestationGenerated");
public static readonly EventId AttestationVerified = new(1411, "AttestationVerified");
public static readonly EventId AttestationVerificationFailed = new(1412, "AttestationVerificationFailed");
public static readonly EventId SignatureGenerated = new(1420, "SignatureGenerated");
public static readonly EventId PackRevoked = new(1430, "PackRevoked");
// Promotion operations (1500-1599)
public static readonly EventId PromotionStarted = new(1500, "PromotionStarted");
public static readonly EventId PromotionSucceeded = new(1501, "PromotionSucceeded");
public static readonly EventId PromotionFailed = new(1502, "PromotionFailed");
public static readonly EventId PromotionValidationStarted = new(1510, "PromotionValidationStarted");
public static readonly EventId PromotionValidationPassed = new(1511, "PromotionValidationPassed");
public static readonly EventId PromotionValidationFailed = new(1512, "PromotionValidationFailed");
public static readonly EventId BindingCreated = new(1520, "BindingCreated");
public static readonly EventId BindingActivated = new(1521, "BindingActivated");
public static readonly EventId BindingSuperseded = new(1522, "BindingSuperseded");
public static readonly EventId RollbackStarted = new(1530, "RollbackStarted");
public static readonly EventId RollbackSucceeded = new(1531, "RollbackSucceeded");
public static readonly EventId RollbackFailed = new(1532, "RollbackFailed");
// Store operations (1600-1699)
public static readonly EventId StoreReadStarted = new(1600, "StoreReadStarted");
public static readonly EventId StoreReadCompleted = new(1601, "StoreReadCompleted");
public static readonly EventId StoreWriteStarted = new(1610, "StoreWriteStarted");
public static readonly EventId StoreWriteCompleted = new(1611, "StoreWriteCompleted");
public static readonly EventId StoreDeleteStarted = new(1620, "StoreDeleteStarted");
public static readonly EventId StoreDeleteCompleted = new(1621, "StoreDeleteCompleted");
// Verification policy operations (1700-1799)
public static readonly EventId VerificationPolicyCreated = new(1700, "VerificationPolicyCreated");
public static readonly EventId VerificationPolicyUpdated = new(1701, "VerificationPolicyUpdated");
public static readonly EventId VerificationPolicyDeleted = new(1702, "VerificationPolicyDeleted");
// Snapshot operations (1800-1899)
public static readonly EventId SnapshotCreated = new(1800, "SnapshotCreated");
public static readonly EventId SnapshotDeleted = new(1801, "SnapshotDeleted");
public static readonly EventId SnapshotVerified = new(1802, "SnapshotVerified");
// Override operations (1900-1999)
public static readonly EventId OverrideCreated = new(1900, "OverrideCreated");
public static readonly EventId OverrideApproved = new(1901, "OverrideApproved");
public static readonly EventId OverrideDisabled = new(1902, "OverrideDisabled");
public static readonly EventId OverrideExpired = new(1903, "OverrideExpired");
}
/// <summary>
/// Log message templates for Policy Registry operations.
/// </summary>
public static class PolicyRegistryLogMessages
{
// Pack messages
public const string PackCreated = "Created policy pack {PackId} '{PackName}' v{Version} for tenant {TenantId}";
public const string PackUpdated = "Updated policy pack {PackId} for tenant {TenantId}";
public const string PackDeleted = "Deleted policy pack {PackId} for tenant {TenantId}";
public const string PackStatusChanged = "Policy pack {PackId} status changed from {OldStatus} to {NewStatus}";
public const string PackNotFound = "Policy pack {PackId} not found for tenant {TenantId}";
// Compilation messages
public const string CompilationStarted = "Starting compilation for pack {PackId}";
public const string CompilationSucceeded = "Compilation succeeded for pack {PackId}: {RuleCount} rules, digest {Digest}";
public const string CompilationFailed = "Compilation failed for pack {PackId}: {ErrorCount} errors";
public const string DigestComputed = "Computed digest {Digest} for pack {PackId}";
// Simulation messages
public const string SimulationStarted = "Starting simulation for pack {PackId}";
public const string SimulationCompleted = "Simulation completed for pack {PackId}: {ViolationCount} violations in {DurationMs}ms";
public const string ViolationDetected = "Violation detected: rule {RuleId}, severity {Severity}";
public const string BatchSimulationSubmitted = "Batch simulation {JobId} submitted with {InputCount} inputs";
public const string BatchSimulationCompleted = "Batch simulation {JobId} completed: {Succeeded} succeeded, {Failed} failed";
// Review messages
public const string ReviewSubmitted = "Review {ReviewId} submitted for pack {PackId}";
public const string ReviewApproved = "Review {ReviewId} approved by {ApprovedBy}";
public const string ReviewRejected = "Review {ReviewId} rejected: {Reason}";
public const string ReviewChangesRequested = "Review {ReviewId}: {CommentCount} changes requested";
// Publish messages
public const string PublishStarted = "Starting publish for pack {PackId}";
public const string PublishSucceeded = "Pack {PackId} published with digest {Digest}";
public const string PublishFailed = "Failed to publish pack {PackId}: {Error}";
public const string AttestationGenerated = "Generated attestation for pack {PackId} with {SignatureCount} signatures";
public const string PackRevoked = "Pack {PackId} revoked: {Reason}";
// Promotion messages
public const string PromotionStarted = "Starting promotion of pack {PackId} to {Environment}";
public const string PromotionSucceeded = "Pack {PackId} promoted to {Environment}";
public const string PromotionFailed = "Failed to promote pack {PackId} to {Environment}: {Error}";
public const string RollbackStarted = "Starting rollback in {Environment}";
public const string RollbackSucceeded = "Rollback succeeded in {Environment}, restored binding {BindingId}";
}

View File

@@ -0,0 +1,261 @@
using System.Diagnostics.Metrics;
namespace StellaOps.Policy.Registry.Telemetry;
/// <summary>
/// Metrics instrumentation for Policy Registry.
/// Implements REGISTRY-API-27-009: Metrics/logs/traces + dashboards.
/// </summary>
public sealed class PolicyRegistryMetrics : IDisposable
{
public const string MeterName = "StellaOps.Policy.Registry";
private readonly Meter _meter;
// Counters
private readonly Counter<long> _packsCreated;
private readonly Counter<long> _packsPublished;
private readonly Counter<long> _packsRevoked;
private readonly Counter<long> _compilations;
private readonly Counter<long> _compilationErrors;
private readonly Counter<long> _simulations;
private readonly Counter<long> _batchSimulations;
private readonly Counter<long> _reviewsSubmitted;
private readonly Counter<long> _reviewsApproved;
private readonly Counter<long> _reviewsRejected;
private readonly Counter<long> _promotions;
private readonly Counter<long> _rollbacks;
private readonly Counter<long> _violations;
// Histograms
private readonly Histogram<double> _compilationDuration;
private readonly Histogram<double> _simulationDuration;
private readonly Histogram<double> _batchSimulationDuration;
private readonly Histogram<long> _rulesPerPack;
private readonly Histogram<long> _violationsPerSimulation;
private readonly Histogram<long> _inputsPerBatch;
// Gauges (via ObservableGauge)
private long _activePacks;
private long _pendingReviews;
private long _runningBatchJobs;
public PolicyRegistryMetrics(IMeterFactory? meterFactory = null)
{
_meter = meterFactory?.Create(MeterName) ?? new Meter(MeterName, "1.0.0");
// Counters
_packsCreated = _meter.CreateCounter<long>(
"policy_registry.packs.created",
unit: "{pack}",
description: "Total number of policy packs created");
_packsPublished = _meter.CreateCounter<long>(
"policy_registry.packs.published",
unit: "{pack}",
description: "Total number of policy packs published");
_packsRevoked = _meter.CreateCounter<long>(
"policy_registry.packs.revoked",
unit: "{pack}",
description: "Total number of policy packs revoked");
_compilations = _meter.CreateCounter<long>(
"policy_registry.compilations.total",
unit: "{compilation}",
description: "Total number of policy pack compilations");
_compilationErrors = _meter.CreateCounter<long>(
"policy_registry.compilations.errors",
unit: "{error}",
description: "Total number of compilation errors");
_simulations = _meter.CreateCounter<long>(
"policy_registry.simulations.total",
unit: "{simulation}",
description: "Total number of policy simulations");
_batchSimulations = _meter.CreateCounter<long>(
"policy_registry.batch_simulations.total",
unit: "{batch}",
description: "Total number of batch simulations");
_reviewsSubmitted = _meter.CreateCounter<long>(
"policy_registry.reviews.submitted",
unit: "{review}",
description: "Total number of reviews submitted");
_reviewsApproved = _meter.CreateCounter<long>(
"policy_registry.reviews.approved",
unit: "{review}",
description: "Total number of reviews approved");
_reviewsRejected = _meter.CreateCounter<long>(
"policy_registry.reviews.rejected",
unit: "{review}",
description: "Total number of reviews rejected");
_promotions = _meter.CreateCounter<long>(
"policy_registry.promotions.total",
unit: "{promotion}",
description: "Total number of environment promotions");
_rollbacks = _meter.CreateCounter<long>(
"policy_registry.rollbacks.total",
unit: "{rollback}",
description: "Total number of environment rollbacks");
_violations = _meter.CreateCounter<long>(
"policy_registry.violations.total",
unit: "{violation}",
description: "Total number of policy violations detected");
// Histograms
_compilationDuration = _meter.CreateHistogram<double>(
"policy_registry.compilation.duration",
unit: "ms",
description: "Duration of policy pack compilations");
_simulationDuration = _meter.CreateHistogram<double>(
"policy_registry.simulation.duration",
unit: "ms",
description: "Duration of policy simulations");
_batchSimulationDuration = _meter.CreateHistogram<double>(
"policy_registry.batch_simulation.duration",
unit: "ms",
description: "Duration of batch simulations");
_rulesPerPack = _meter.CreateHistogram<long>(
"policy_registry.pack.rules",
unit: "{rule}",
description: "Number of rules per policy pack");
_violationsPerSimulation = _meter.CreateHistogram<long>(
"policy_registry.simulation.violations",
unit: "{violation}",
description: "Number of violations per simulation");
_inputsPerBatch = _meter.CreateHistogram<long>(
"policy_registry.batch_simulation.inputs",
unit: "{input}",
description: "Number of inputs per batch simulation");
// Observable gauges
_meter.CreateObservableGauge(
"policy_registry.packs.active",
() => _activePacks,
unit: "{pack}",
description: "Number of currently active policy packs");
_meter.CreateObservableGauge(
"policy_registry.reviews.pending",
() => _pendingReviews,
unit: "{review}",
description: "Number of pending reviews");
_meter.CreateObservableGauge(
"policy_registry.batch_jobs.running",
() => _runningBatchJobs,
unit: "{job}",
description: "Number of running batch simulation jobs");
}
// Record methods
public void RecordPackCreated(string tenantId, string packName)
{
_packsCreated.Add(1, new KeyValuePair<string, object?>("tenant_id", tenantId),
new KeyValuePair<string, object?>("pack_name", packName));
Interlocked.Increment(ref _activePacks);
}
public void RecordPackPublished(string tenantId, string environment)
{
_packsPublished.Add(1, new KeyValuePair<string, object?>("tenant_id", tenantId),
new KeyValuePair<string, object?>("environment", environment));
}
public void RecordPackRevoked(string tenantId, string reason)
{
_packsRevoked.Add(1, new KeyValuePair<string, object?>("tenant_id", tenantId),
new KeyValuePair<string, object?>("reason", reason));
Interlocked.Decrement(ref _activePacks);
}
public void RecordCompilation(string tenantId, bool success, long durationMs, int ruleCount)
{
var status = success ? "success" : "failure";
_compilations.Add(1, new KeyValuePair<string, object?>("tenant_id", tenantId),
new KeyValuePair<string, object?>("status", status));
if (!success)
{
_compilationErrors.Add(1, new KeyValuePair<string, object?>("tenant_id", tenantId));
}
_compilationDuration.Record(durationMs, new KeyValuePair<string, object?>("tenant_id", tenantId),
new KeyValuePair<string, object?>("status", status));
_rulesPerPack.Record(ruleCount, new KeyValuePair<string, object?>("tenant_id", tenantId));
}
public void RecordSimulation(string tenantId, bool success, long durationMs, int violationCount)
{
var status = success ? "success" : "failure";
_simulations.Add(1, new KeyValuePair<string, object?>("tenant_id", tenantId),
new KeyValuePair<string, object?>("status", status));
_simulationDuration.Record(durationMs, new KeyValuePair<string, object?>("tenant_id", tenantId),
new KeyValuePair<string, object?>("status", status));
_violationsPerSimulation.Record(violationCount, new KeyValuePair<string, object?>("tenant_id", tenantId));
if (violationCount > 0)
{
_violations.Add(violationCount, new KeyValuePair<string, object?>("tenant_id", tenantId));
}
}
public void RecordBatchSimulation(string tenantId, int inputCount, int succeeded, int failed, long durationMs)
{
_batchSimulations.Add(1, new KeyValuePair<string, object?>("tenant_id", tenantId));
_batchSimulationDuration.Record(durationMs, new KeyValuePair<string, object?>("tenant_id", tenantId));
_inputsPerBatch.Record(inputCount, new KeyValuePair<string, object?>("tenant_id", tenantId));
}
public void RecordReviewSubmitted(string tenantId, string urgency)
{
_reviewsSubmitted.Add(1, new KeyValuePair<string, object?>("tenant_id", tenantId),
new KeyValuePair<string, object?>("urgency", urgency));
Interlocked.Increment(ref _pendingReviews);
}
public void RecordReviewApproved(string tenantId)
{
_reviewsApproved.Add(1, new KeyValuePair<string, object?>("tenant_id", tenantId));
Interlocked.Decrement(ref _pendingReviews);
}
public void RecordReviewRejected(string tenantId)
{
_reviewsRejected.Add(1, new KeyValuePair<string, object?>("tenant_id", tenantId));
Interlocked.Decrement(ref _pendingReviews);
}
public void RecordPromotion(string tenantId, string environment)
{
_promotions.Add(1, new KeyValuePair<string, object?>("tenant_id", tenantId),
new KeyValuePair<string, object?>("environment", environment));
}
public void RecordRollback(string tenantId, string environment)
{
_rollbacks.Add(1, new KeyValuePair<string, object?>("tenant_id", tenantId),
new KeyValuePair<string, object?>("environment", environment));
}
public void IncrementRunningBatchJobs() => Interlocked.Increment(ref _runningBatchJobs);
public void DecrementRunningBatchJobs() => Interlocked.Decrement(ref _runningBatchJobs);
public void Dispose() => _meter.Dispose();
}

View File

@@ -0,0 +1,277 @@
using StellaOps.Policy.Registry.Contracts;
using StellaOps.Policy.Registry.Services;
namespace StellaOps.Policy.Registry.Testing;
/// <summary>
/// Test fixtures and data generators for Policy Registry testing.
/// </summary>
public static class PolicyRegistryTestFixtures
{
/// <summary>
/// Creates basic policy rules for testing.
/// </summary>
public static IReadOnlyList<PolicyRule> CreateBasicRules()
{
return
[
new PolicyRule
{
RuleId = "test-rule-001",
Name = "Deny Critical CVEs",
Description = "Blocks any image with critical CVEs",
Severity = Severity.Critical,
Rego = @"
package stellaops.policy.test
default deny = false
deny {
input.vulnerabilities[_].severity == ""critical""
}
",
Enabled = true
},
new PolicyRule
{
RuleId = "test-rule-002",
Name = "Require SBOM",
Description = "Requires valid SBOM for all images",
Severity = Severity.High,
Rego = @"
package stellaops.policy.test
default require_sbom = false
require_sbom {
input.sbom != null
count(input.sbom.packages) > 0
}
",
Enabled = true
},
new PolicyRule
{
RuleId = "test-rule-003",
Name = "Warn on Medium CVEs",
Description = "Warns when medium severity CVEs are present",
Severity = Severity.Medium,
Rego = @"
package stellaops.policy.test
warn[msg] {
vuln := input.vulnerabilities[_]
vuln.severity == ""medium""
msg := sprintf(""Medium CVE found: %s"", [vuln.id])
}
",
Enabled = true
}
];
}
/// <summary>
/// Creates rules with Rego syntax errors for testing compilation failures.
/// </summary>
public static IReadOnlyList<PolicyRule> CreateInvalidRegoRules()
{
return
[
new PolicyRule
{
RuleId = "invalid-rule-001",
Name = "Invalid Syntax",
Description = "Rule with syntax errors",
Severity = Severity.High,
Rego = @"
package stellaops.policy.test
deny {
input.something == ""value
} // missing closing quote
",
Enabled = true
}
];
}
/// <summary>
/// Creates rules without Rego code for testing name-based matching.
/// </summary>
public static IReadOnlyList<PolicyRule> CreateRulesWithoutRego()
{
return
[
new PolicyRule
{
RuleId = "no-rego-001",
Name = "Vulnerability Check",
Description = "Checks for vulnerabilities",
Severity = Severity.High,
Enabled = true
},
new PolicyRule
{
RuleId = "no-rego-002",
Name = "License Compliance",
Description = "Verifies license compliance",
Severity = Severity.Medium,
Enabled = true
}
];
}
/// <summary>
/// Creates test simulation input.
/// </summary>
public static IReadOnlyDictionary<string, object> CreateTestSimulationInput()
{
return new Dictionary<string, object>
{
["subject"] = new Dictionary<string, object>
{
["type"] = "container_image",
["name"] = "myregistry.io/myapp",
["digest"] = "sha256:abc123"
},
["vulnerabilities"] = new[]
{
new Dictionary<string, object>
{
["id"] = "CVE-2024-1234",
["severity"] = "critical",
["package"] = "openssl",
["version"] = "1.1.1"
},
new Dictionary<string, object>
{
["id"] = "CVE-2024-5678",
["severity"] = "medium",
["package"] = "curl",
["version"] = "7.88.0"
}
},
["sbom"] = new Dictionary<string, object>
{
["format"] = "spdx",
["packages"] = new[]
{
new Dictionary<string, object> { ["name"] = "openssl", ["version"] = "1.1.1" },
new Dictionary<string, object> { ["name"] = "curl", ["version"] = "7.88.0" }
}
},
["context"] = new Dictionary<string, object>
{
["environment"] = "production",
["namespace"] = "default"
}
};
}
/// <summary>
/// Creates batch simulation inputs.
/// </summary>
public static IReadOnlyList<BatchSimulationInput> CreateBatchSimulationInputs(int count = 5)
{
var inputs = new List<BatchSimulationInput>();
for (int i = 0; i < count; i++)
{
inputs.Add(new BatchSimulationInput
{
InputId = $"input-{i:D3}",
Input = CreateTestSimulationInput(),
Tags = new Dictionary<string, string>
{
["test_batch"] = "true",
["index"] = i.ToString()
}
});
}
return inputs;
}
/// <summary>
/// Creates a verification policy request.
/// </summary>
public static CreateVerificationPolicyRequest CreateVerificationPolicyRequest(
string? policyId = null)
{
return new CreateVerificationPolicyRequest
{
PolicyId = policyId ?? $"test-policy-{Guid.NewGuid():N}",
Version = "1.0.0",
Description = "Test verification policy",
TenantScope = "*",
PredicateTypes = ["https://slsa.dev/provenance/v1", "https://spdx.dev/Document"],
SignerRequirements = new SignerRequirements
{
MinimumSignatures = 1,
TrustedKeyFingerprints = ["SHA256:test-fingerprint-1", "SHA256:test-fingerprint-2"],
RequireRekor = false
},
ValidityWindow = new ValidityWindow
{
MaxAttestationAge = 86400 // 24 hours
}
};
}
/// <summary>
/// Creates a snapshot request.
/// </summary>
public static CreateSnapshotRequest CreateSnapshotRequest(params Guid[] packIds)
{
return new CreateSnapshotRequest
{
Description = "Test snapshot",
PackIds = packIds.Length > 0 ? packIds.ToList() : [Guid.NewGuid()],
Metadata = new Dictionary<string, object>
{
["created_for_test"] = true
}
};
}
/// <summary>
/// Creates a violation request.
/// </summary>
public static CreateViolationRequest CreateViolationRequest(
string? ruleId = null,
Severity severity = Severity.High)
{
return new CreateViolationRequest
{
RuleId = ruleId ?? "test-rule-001",
Severity = severity,
Message = $"Test violation for rule {ruleId ?? "test-rule-001"}",
Purl = "pkg:npm/lodash@4.17.20",
CveId = "CVE-2024-1234",
Context = new Dictionary<string, object>
{
["environment"] = "test",
["detected_at"] = DateTimeOffset.UtcNow.ToString("O")
}
};
}
/// <summary>
/// Creates an override request.
/// </summary>
public static CreateOverrideRequest CreateOverrideRequest(
string? ruleId = null)
{
return new CreateOverrideRequest
{
RuleId = ruleId ?? "test-rule-001",
Reason = "Test override for false positive",
Scope = new OverrideScope
{
Purl = "pkg:npm/lodash@4.17.20",
Environment = "development"
},
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30)
};
}
}

View File

@@ -0,0 +1,148 @@
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Policy.Registry.Contracts;
using StellaOps.Policy.Registry.Services;
using StellaOps.Policy.Registry.Storage;
namespace StellaOps.Policy.Registry.Testing;
/// <summary>
/// Test harness for Policy Registry integration testing.
/// Implements REGISTRY-API-27-010: Test suites + fixtures.
/// </summary>
public sealed class PolicyRegistryTestHarness : IDisposable
{
private readonly ServiceProvider _serviceProvider;
private readonly TimeProvider _timeProvider;
public IPolicyPackStore PackStore { get; }
public IVerificationPolicyStore VerificationPolicyStore { get; }
public ISnapshotStore SnapshotStore { get; }
public IViolationStore ViolationStore { get; }
public IOverrideStore OverrideStore { get; }
public IPolicyPackCompiler Compiler { get; }
public IPolicySimulationService SimulationService { get; }
public IBatchSimulationOrchestrator BatchOrchestrator { get; }
public IReviewWorkflowService ReviewService { get; }
public IPublishPipelineService PublishService { get; }
public IPromotionService PromotionService { get; }
public PolicyRegistryTestHarness(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
var services = new ServiceCollection();
services.AddSingleton(_timeProvider);
services.AddPolicyRegistryInMemoryStorage();
_serviceProvider = services.BuildServiceProvider();
PackStore = _serviceProvider.GetRequiredService<IPolicyPackStore>();
VerificationPolicyStore = _serviceProvider.GetRequiredService<IVerificationPolicyStore>();
SnapshotStore = _serviceProvider.GetRequiredService<ISnapshotStore>();
ViolationStore = _serviceProvider.GetRequiredService<IViolationStore>();
OverrideStore = _serviceProvider.GetRequiredService<IOverrideStore>();
Compiler = _serviceProvider.GetRequiredService<IPolicyPackCompiler>();
SimulationService = _serviceProvider.GetRequiredService<IPolicySimulationService>();
BatchOrchestrator = _serviceProvider.GetRequiredService<IBatchSimulationOrchestrator>();
ReviewService = _serviceProvider.GetRequiredService<IReviewWorkflowService>();
PublishService = _serviceProvider.GetRequiredService<IPublishPipelineService>();
PromotionService = _serviceProvider.GetRequiredService<IPromotionService>();
}
/// <summary>
/// Creates a test tenant ID.
/// </summary>
public static Guid CreateTestTenantId() => Guid.NewGuid();
/// <summary>
/// Creates a policy pack with test data.
/// </summary>
public async Task<PolicyPackEntity> CreateTestPackAsync(
Guid tenantId,
string? name = null,
string? version = null,
IReadOnlyList<PolicyRule>? rules = null,
CancellationToken cancellationToken = default)
{
var request = new CreatePolicyPackRequest
{
Name = name ?? $"test-pack-{Guid.NewGuid():N}",
Version = version ?? "1.0.0",
Description = "Test policy pack",
Rules = rules ?? PolicyRegistryTestFixtures.CreateBasicRules()
};
return await PackStore.CreateAsync(tenantId, request, "test-user", cancellationToken);
}
/// <summary>
/// Creates and publishes a policy pack through the full workflow.
/// </summary>
public async Task<PublishResult> CreateAndPublishPackAsync(
Guid tenantId,
string? name = null,
CancellationToken cancellationToken = default)
{
// Create pack
var pack = await CreateTestPackAsync(tenantId, name, cancellationToken: cancellationToken);
// Submit for review
var review = await ReviewService.SubmitForReviewAsync(tenantId, pack.PackId,
new SubmitReviewRequest { Description = "Test review" }, cancellationToken);
// Approve review
await ReviewService.ApproveAsync(tenantId, review.ReviewId,
new ApproveReviewRequest { ApprovedBy = "test-approver" }, cancellationToken);
// Publish
return await PublishService.PublishAsync(tenantId, pack.PackId,
new PublishPackRequest { PublishedBy = "test-publisher" }, cancellationToken);
}
/// <summary>
/// Runs a determinism test to verify consistent outputs.
/// </summary>
public async Task<DeterminismTestResult> RunDeterminismTestAsync(
Guid tenantId,
int iterations = 3,
CancellationToken cancellationToken = default)
{
var results = new List<string>();
var pack = await CreateTestPackAsync(tenantId, cancellationToken: cancellationToken);
for (int i = 0; i < iterations; i++)
{
var compilationResult = await Compiler.CompileAsync(tenantId, pack.PackId, cancellationToken);
if (compilationResult.Success && compilationResult.Digest is not null)
{
results.Add(compilationResult.Digest);
}
}
var allSame = results.Distinct().Count() == 1;
return new DeterminismTestResult
{
Passed = allSame && results.Count == iterations,
Iterations = iterations,
UniqueResults = results.Distinct().Count(),
Digests = results
};
}
public void Dispose()
{
(_serviceProvider as IDisposable)?.Dispose();
}
}
/// <summary>
/// Result of a determinism test.
/// </summary>
public sealed record DeterminismTestResult
{
public required bool Passed { get; init; }
public required int Iterations { get; init; }
public required int UniqueResults { get; init; }
public required IReadOnlyList<string> Digests { get; init; }
}