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