Add signal contracts for reachability, exploitability, trust, and unknown symbols
- Introduced `ReachabilityState`, `RuntimeHit`, `ExploitabilitySignal`, `ReachabilitySignal`, `SignalEnvelope`, `SignalType`, `TrustSignal`, and `UnknownSymbolSignal` records to define various signal types and their properties. - Implemented JSON serialization attributes for proper data interchange. - Created project files for the new signal contracts library and corresponding test projects. - Added deterministic test fixtures for micro-interaction testing. - Included cryptographic keys for secure operations with cosign.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy.Engine.ConsoleSurface;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
@@ -8,6 +9,7 @@ internal static class ConsoleSimulationEndpoint
|
||||
public static IEndpointRouteBuilder MapConsoleSimulationDiff(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
routes.MapPost("/policy/console/simulations/diff", HandleAsync)
|
||||
.RequireRateLimiting(PolicyEngineRateLimitOptions.PolicyName)
|
||||
.WithName("PolicyEngine.ConsoleSimulationDiff")
|
||||
.Produces<ConsoleSimulationDiffResponse>(StatusCodes.Status200OK)
|
||||
.ProducesValidationProblem();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
using StellaOps.Policy.Engine.Overlay;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
@@ -8,6 +9,7 @@ public static class OverlaySimulationEndpoint
|
||||
public static IEndpointRouteBuilder MapOverlaySimulation(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
routes.MapPost("/simulation/overlay", HandleAsync)
|
||||
.RequireRateLimiting(PolicyEngineRateLimitOptions.PolicyName)
|
||||
.WithName("PolicyEngine.OverlaySimulation");
|
||||
|
||||
return routes;
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
using StellaOps.Policy.Engine.Streaming;
|
||||
using StellaOps.Policy.Engine.Overlay;
|
||||
|
||||
@@ -12,6 +13,7 @@ public static class PathScopeSimulationEndpoint
|
||||
public static IEndpointRouteBuilder MapPathScopeSimulation(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
routes.MapPost("/simulation/path-scope", HandleAsync)
|
||||
.RequireRateLimiting(PolicyEngineRateLimitOptions.PolicyName)
|
||||
.WithName("PolicyEngine.PathScopeSimulation");
|
||||
|
||||
return routes;
|
||||
|
||||
@@ -82,6 +82,12 @@ internal static class RiskProfileEndpoints
|
||||
.Produces<RiskProfileHashResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/{profileId}/metadata", GetProfileMetadata)
|
||||
.WithName("GetRiskProfileMetadata")
|
||||
.WithSummary("Export risk profile metadata for notification enrichment (POLICY-RISK-40-002).")
|
||||
.Produces<RiskProfileMetadataExportResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
@@ -461,6 +467,53 @@ internal static class RiskProfileEndpoints
|
||||
return Results.Ok(new RiskProfileHashResponse(profile.Id, profile.Version, hash, contentOnly));
|
||||
}
|
||||
|
||||
private static IResult GetProfileMetadata(
|
||||
HttpContext context,
|
||||
[FromRoute] string profileId,
|
||||
RiskProfileConfigurationService profileService,
|
||||
RiskProfileLifecycleService lifecycleService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var profile = profileService.GetProfile(profileId);
|
||||
if (profile == null)
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Profile not found",
|
||||
Detail = $"Risk profile '{profileId}' was not found.",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
var versions = lifecycleService.GetAllVersions(profileId);
|
||||
var activeVersion = versions.FirstOrDefault(v => v.Status == RiskProfileLifecycleStatus.Active);
|
||||
var hash = profileService.ComputeHash(profile);
|
||||
|
||||
// Extract signal names and severity thresholds for notification context
|
||||
var signalNames = profile.Signals.Select(s => s.Name).ToList();
|
||||
var severityThresholds = profile.Overrides.Severity
|
||||
.Select(s => new SeverityThresholdInfo(s.Set.ToString(), s.When))
|
||||
.ToList();
|
||||
|
||||
return Results.Ok(new RiskProfileMetadataExportResponse(
|
||||
ProfileId: profile.Id,
|
||||
Version: profile.Version,
|
||||
Description: profile.Description,
|
||||
Hash: hash,
|
||||
Status: activeVersion?.Status.ToString() ?? "unknown",
|
||||
SignalNames: signalNames,
|
||||
SeverityThresholds: severityThresholds,
|
||||
CustomMetadata: profile.Metadata,
|
||||
ExtendsProfile: profile.Extends,
|
||||
ExportedAt: DateTime.UtcNow
|
||||
));
|
||||
}
|
||||
|
||||
private static string? ResolveActorId(HttpContext context)
|
||||
{
|
||||
var user = context.User;
|
||||
@@ -521,4 +574,26 @@ internal sealed record CompareRiskProfilesRequest(
|
||||
string ToProfileId,
|
||||
string ToVersion);
|
||||
|
||||
/// <summary>
|
||||
/// Metadata export response for notification enrichment (POLICY-RISK-40-002).
|
||||
/// </summary>
|
||||
internal sealed record RiskProfileMetadataExportResponse(
|
||||
string ProfileId,
|
||||
string Version,
|
||||
string? Description,
|
||||
string Hash,
|
||||
string Status,
|
||||
IReadOnlyList<string> SignalNames,
|
||||
IReadOnlyList<SeverityThresholdInfo> SeverityThresholds,
|
||||
Dictionary<string, object?>? CustomMetadata,
|
||||
string? ExtendsProfile,
|
||||
DateTime ExportedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Severity threshold information for notification context.
|
||||
/// </summary>
|
||||
internal sealed record SeverityThresholdInfo(
|
||||
string TargetSeverity,
|
||||
Dictionary<string, object> WhenConditions);
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.Engine.Simulation;
|
||||
|
||||
@@ -12,6 +13,7 @@ internal static class RiskSimulationEndpoints
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/risk/simulation")
|
||||
.RequireAuthorization()
|
||||
.RequireRateLimiting(PolicyEngineRateLimitOptions.PolicyName)
|
||||
.WithTags("Risk Simulation");
|
||||
|
||||
group.MapPost("/", RunSimulation)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
namespace StellaOps.Policy.Engine.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Rate limiting configuration for Policy Engine simulation endpoints.
|
||||
/// </summary>
|
||||
public sealed class PolicyEngineRateLimitOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name for binding.
|
||||
/// </summary>
|
||||
public const string SectionName = "RateLimiting";
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of permits per window for simulation endpoints.
|
||||
/// Default: 100 requests per window.
|
||||
/// </summary>
|
||||
public int SimulationPermitLimit { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Window duration in seconds for rate limiting.
|
||||
/// Default: 60 seconds.
|
||||
/// </summary>
|
||||
public int WindowSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of requests that can be queued when the limit is reached.
|
||||
/// Default: 10 requests.
|
||||
/// </summary>
|
||||
public int QueueLimit { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to partition rate limits by tenant ID.
|
||||
/// When enabled, each tenant gets their own rate limit bucket.
|
||||
/// Default: true.
|
||||
/// </summary>
|
||||
public bool TenantPartitioning { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether rate limiting is enabled.
|
||||
/// Default: true.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Custom policy name for the simulation rate limiter.
|
||||
/// </summary>
|
||||
public const string PolicyName = "policy-simulation";
|
||||
}
|
||||
@@ -1,27 +1,29 @@
|
||||
using System.IO;
|
||||
using System.Threading.RateLimiting;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NetEscapades.Configuration.Yaml;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Policy.Engine.Hosting;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
using StellaOps.Policy.Engine.Compilation;
|
||||
using StellaOps.Policy.Engine.Endpoints;
|
||||
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.Hosting;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
using StellaOps.Policy.Engine.Compilation;
|
||||
using StellaOps.Policy.Engine.Endpoints;
|
||||
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;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -113,10 +115,10 @@ builder.Services.AddOptions<PolicyEngineOptions>()
|
||||
})
|
||||
.ValidateOnStart();
|
||||
|
||||
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<PolicyEngineOptions>>().Value);
|
||||
builder.Services.AddSingleton(sp => sp.GetRequiredService<PolicyEngineOptions>().ExceptionLifecycle);
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddSingleton<PolicyEngineStartupDiagnostics>();
|
||||
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<PolicyEngineOptions>>().Value);
|
||||
builder.Services.AddSingleton(sp => sp.GetRequiredService<PolicyEngineOptions>().ExceptionLifecycle);
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddSingleton<PolicyEngineStartupDiagnostics>();
|
||||
builder.Services.AddSingleton<PolicyTimelineEvents>();
|
||||
builder.Services.AddSingleton<EvidenceBundleService>();
|
||||
builder.Services.AddSingleton<PolicyEvaluationAttestationService>();
|
||||
@@ -125,63 +127,102 @@ builder.Services.AddSingleton<RiskProfileConfigurationService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.RiskProfile.Lifecycle.RiskProfileLifecycleService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.RiskProfile.Scope.ScopeAttachmentService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.RiskProfile.Overrides.OverrideService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Scoring.IRiskScoringJobStore, StellaOps.Policy.Engine.Scoring.InMemoryRiskScoringJobStore>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Scoring.RiskScoringTriggerService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Simulation.RiskSimulationService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Signals.Entropy.EntropyPenaltyCalculator>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.RiskProfile.Export.ProfileExportService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Events.ProfileEventPublisher>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Events.IExceptionEventPublisher>(sp =>
|
||||
new StellaOps.Policy.Engine.Events.LoggingExceptionEventPublisher(
|
||||
sp.GetService<StellaOps.Policy.Engine.ExceptionCache.IExceptionEffectiveCache>(),
|
||||
sp.GetRequiredService<ILogger<StellaOps.Policy.Engine.Events.LoggingExceptionEventPublisher>>()));
|
||||
builder.Services.AddSingleton<ExceptionLifecycleService>();
|
||||
builder.Services.AddHostedService<ExceptionLifecycleWorker>();
|
||||
builder.Services.AddHostedService<IncidentModeExpirationWorker>();
|
||||
builder.Services.AddHostedService<PolicyEngineBootstrapWorker>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Simulation.SimulationAnalyticsService>();
|
||||
builder.Services.AddSingleton<ConsoleSimulationDiffService>();
|
||||
builder.Services.AddSingleton<StellaOps.PolicyDsl.PolicyCompiler>();
|
||||
builder.Services.AddSingleton<PolicyCompilationService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.PathScopeMetrics>();
|
||||
builder.Services.AddSingleton<PolicyEvaluationService>();
|
||||
builder.Services.AddPolicyEngineCore();
|
||||
builder.Services.AddSingleton<PathScopeSimulationService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.OverlayProjectionService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.IOverlayEventSink, StellaOps.Policy.Engine.Overlay.LoggingOverlayEventSink>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.OverlayChangeEventPublisher>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.PathScopeSimulationBridgeService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Scoring.IRiskScoringJobStore, StellaOps.Policy.Engine.Scoring.InMemoryRiskScoringJobStore>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Scoring.RiskScoringTriggerService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Simulation.RiskSimulationService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Signals.Entropy.EntropyPenaltyCalculator>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.RiskProfile.Export.ProfileExportService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Events.ProfileEventPublisher>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Events.IExceptionEventPublisher>(sp =>
|
||||
new StellaOps.Policy.Engine.Events.LoggingExceptionEventPublisher(
|
||||
sp.GetService<StellaOps.Policy.Engine.ExceptionCache.IExceptionEffectiveCache>(),
|
||||
sp.GetRequiredService<ILogger<StellaOps.Policy.Engine.Events.LoggingExceptionEventPublisher>>()));
|
||||
builder.Services.AddSingleton<ExceptionLifecycleService>();
|
||||
builder.Services.AddHostedService<ExceptionLifecycleWorker>();
|
||||
builder.Services.AddHostedService<IncidentModeExpirationWorker>();
|
||||
builder.Services.AddHostedService<PolicyEngineBootstrapWorker>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Simulation.SimulationAnalyticsService>();
|
||||
builder.Services.AddSingleton<ConsoleSimulationDiffService>();
|
||||
builder.Services.AddSingleton<StellaOps.PolicyDsl.PolicyCompiler>();
|
||||
builder.Services.AddSingleton<PolicyCompilationService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.PathScopeMetrics>();
|
||||
builder.Services.AddSingleton<PolicyEvaluationService>();
|
||||
builder.Services.AddPolicyEngineCore();
|
||||
builder.Services.AddSingleton<PathScopeSimulationService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.OverlayProjectionService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.IOverlayEventSink, StellaOps.Policy.Engine.Overlay.LoggingOverlayEventSink>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.OverlayChangeEventPublisher>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.PathScopeSimulationBridgeService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.TrustWeighting.TrustWeightingService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.AdvisoryAI.AdvisoryAiKnobsService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.BatchContext.BatchContextService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.EvidenceSummaryService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.PolicyBundleService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.PolicyRuntimeEvaluator>();
|
||||
builder.Services.AddSingleton<IPolicyPackRepository, InMemoryPolicyPackRepository>();
|
||||
builder.Services.AddSingleton<IOrchestratorJobStore, InMemoryOrchestratorJobStore>();
|
||||
builder.Services.AddSingleton<OrchestratorJobService>();
|
||||
builder.Services.AddSingleton<IWorkerResultStore, InMemoryWorkerResultStore>();
|
||||
builder.Services.AddSingleton<PolicyWorkerService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Ledger.ILedgerExportStore, StellaOps.Policy.Engine.Ledger.InMemoryLedgerExportStore>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Ledger.LedgerExportService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Snapshots.ISnapshotStore, StellaOps.Policy.Engine.Snapshots.InMemorySnapshotStore>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Snapshots.SnapshotService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Violations.IViolationEventStore, StellaOps.Policy.Engine.Violations.InMemoryViolationEventStore>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Violations.ViolationEventService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Violations.SeverityFusionService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Violations.ConflictHandlingService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.PolicyDecisionService>();
|
||||
builder.Services.AddSingleton<IExceptionRepository, InMemoryExceptionRepository>();
|
||||
builder.Services.AddSingleton<IReachabilityFactsStore, InMemoryReachabilityFactsStore>();
|
||||
builder.Services.AddSingleton<IReachabilityFactsOverlayCache, InMemoryReachabilityFactsOverlayCache>();
|
||||
builder.Services.AddSingleton<ReachabilityFactsJoiningService>();
|
||||
builder.Services.AddSingleton<IRuntimeEvaluationExecutor, RuntimeEvaluationExecutor>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.PolicyRuntimeEvaluator>();
|
||||
builder.Services.AddSingleton<IPolicyPackRepository, InMemoryPolicyPackRepository>();
|
||||
builder.Services.AddSingleton<IOrchestratorJobStore, InMemoryOrchestratorJobStore>();
|
||||
builder.Services.AddSingleton<OrchestratorJobService>();
|
||||
builder.Services.AddSingleton<IWorkerResultStore, InMemoryWorkerResultStore>();
|
||||
builder.Services.AddSingleton<PolicyWorkerService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Ledger.ILedgerExportStore, StellaOps.Policy.Engine.Ledger.InMemoryLedgerExportStore>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Ledger.LedgerExportService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Snapshots.ISnapshotStore, StellaOps.Policy.Engine.Snapshots.InMemorySnapshotStore>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Snapshots.SnapshotService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Violations.IViolationEventStore, StellaOps.Policy.Engine.Violations.InMemoryViolationEventStore>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Violations.ViolationEventService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Violations.SeverityFusionService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Violations.ConflictHandlingService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.PolicyDecisionService>();
|
||||
builder.Services.AddSingleton<IExceptionRepository, InMemoryExceptionRepository>();
|
||||
builder.Services.AddSingleton<IReachabilityFactsStore, InMemoryReachabilityFactsStore>();
|
||||
builder.Services.AddSingleton<IReachabilityFactsOverlayCache, InMemoryReachabilityFactsOverlayCache>();
|
||||
builder.Services.AddSingleton<ReachabilityFactsJoiningService>();
|
||||
builder.Services.AddSingleton<IRuntimeEvaluationExecutor, RuntimeEvaluationExecutor>();
|
||||
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddRouting(options => options.LowercaseUrls = true);
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddHealthChecks();
|
||||
|
||||
// Rate limiting configuration for simulation endpoints
|
||||
var rateLimitOptions = builder.Configuration
|
||||
.GetSection(PolicyEngineRateLimitOptions.SectionName)
|
||||
.Get<PolicyEngineRateLimitOptions>() ?? new PolicyEngineRateLimitOptions();
|
||||
|
||||
if (rateLimitOptions.Enabled)
|
||||
{
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
|
||||
options.AddTokenBucketLimiter(PolicyEngineRateLimitOptions.PolicyName, limiterOptions =>
|
||||
{
|
||||
limiterOptions.TokenLimit = rateLimitOptions.SimulationPermitLimit;
|
||||
limiterOptions.ReplenishmentPeriod = TimeSpan.FromSeconds(rateLimitOptions.WindowSeconds);
|
||||
limiterOptions.TokensPerPeriod = rateLimitOptions.SimulationPermitLimit;
|
||||
limiterOptions.QueueLimit = rateLimitOptions.QueueLimit;
|
||||
limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
|
||||
});
|
||||
|
||||
options.OnRejected = async (context, cancellationToken) =>
|
||||
{
|
||||
var tenant = context.HttpContext.User.FindFirst("tenant_id")?.Value;
|
||||
var endpoint = context.HttpContext.Request.Path.Value;
|
||||
PolicyEngineTelemetry.RecordRateLimitExceeded(tenant, endpoint);
|
||||
|
||||
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||
context.HttpContext.Response.Headers.RetryAfter = rateLimitOptions.WindowSeconds.ToString();
|
||||
|
||||
await context.HttpContext.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
error = "ERR_POL_007",
|
||||
message = "Rate limit exceeded. Please retry after the reset window.",
|
||||
retryAfterSeconds = rateLimitOptions.WindowSeconds
|
||||
}, cancellationToken);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
builder.Services.AddAuthentication();
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddStellaOpsScopeHandler();
|
||||
@@ -211,6 +252,11 @@ var app = builder.Build();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
if (rateLimitOptions.Enabled)
|
||||
{
|
||||
app.UseRateLimiter();
|
||||
}
|
||||
|
||||
app.MapHealthChecks("/healthz");
|
||||
app.MapGet("/readyz", (PolicyEngineStartupDiagnostics diagnostics) =>
|
||||
diagnostics.IsReady
|
||||
@@ -220,16 +266,16 @@ app.MapGet("/readyz", (PolicyEngineStartupDiagnostics diagnostics) =>
|
||||
|
||||
app.MapGet("/", () => Results.Redirect("/healthz"));
|
||||
|
||||
app.MapPolicyCompilation();
|
||||
app.MapPolicyPacks();
|
||||
app.MapPathScopeSimulation();
|
||||
app.MapOverlaySimulation();
|
||||
app.MapEvidenceSummaries();
|
||||
app.MapBatchEvaluation();
|
||||
app.MapConsoleSimulationDiff();
|
||||
app.MapTrustWeighting();
|
||||
app.MapAdvisoryAiKnobs();
|
||||
app.MapBatchContext();
|
||||
app.MapPolicyCompilation();
|
||||
app.MapPolicyPacks();
|
||||
app.MapPathScopeSimulation();
|
||||
app.MapOverlaySimulation();
|
||||
app.MapEvidenceSummaries();
|
||||
app.MapBatchEvaluation();
|
||||
app.MapConsoleSimulationDiff();
|
||||
app.MapTrustWeighting();
|
||||
app.MapAdvisoryAiKnobs();
|
||||
app.MapBatchContext();
|
||||
app.MapOrchestratorJobs();
|
||||
app.MapPolicyWorker();
|
||||
app.MapLedgerExport();
|
||||
|
||||
@@ -55,12 +55,12 @@ public static class PolicyEngineTelemetry
|
||||
unit: "overrides",
|
||||
description: "Total number of VEX overrides applied during policy evaluation.");
|
||||
|
||||
// Counter: policy_compilation_total{outcome}
|
||||
private static readonly Counter<long> PolicyCompilationCounter =
|
||||
Meter.CreateCounter<long>(
|
||||
"policy_compilation_total",
|
||||
unit: "compilations",
|
||||
description: "Total number of policy compilations attempted.");
|
||||
// Counter: policy_compilation_total{outcome}
|
||||
private static readonly Counter<long> PolicyCompilationCounter =
|
||||
Meter.CreateCounter<long>(
|
||||
"policy_compilation_total",
|
||||
unit: "compilations",
|
||||
description: "Total number of policy compilations attempted.");
|
||||
|
||||
// Histogram: policy_compilation_seconds
|
||||
private static readonly Histogram<double> PolicyCompilationSecondsHistogram =
|
||||
@@ -70,73 +70,95 @@ public static class PolicyEngineTelemetry
|
||||
description: "Duration of policy compilation.");
|
||||
|
||||
// Counter: policy_simulation_total{tenant,outcome}
|
||||
private static readonly Counter<long> PolicySimulationCounter =
|
||||
Meter.CreateCounter<long>(
|
||||
"policy_simulation_total",
|
||||
unit: "simulations",
|
||||
description: "Total number of policy simulations executed.");
|
||||
|
||||
#region Entropy Metrics
|
||||
|
||||
// Counter: policy_entropy_penalty_total{outcome}
|
||||
private static readonly Counter<long> EntropyPenaltyCounter =
|
||||
Meter.CreateCounter<long>(
|
||||
"policy_entropy_penalty_total",
|
||||
unit: "penalties",
|
||||
description: "Total entropy penalties computed from scanner evidence.");
|
||||
|
||||
// Histogram: policy_entropy_penalty_value{outcome}
|
||||
private static readonly Histogram<double> EntropyPenaltyHistogram =
|
||||
Meter.CreateHistogram<double>(
|
||||
"policy_entropy_penalty_value",
|
||||
unit: "ratio",
|
||||
description: "Entropy penalty values (after cap).");
|
||||
|
||||
// Histogram: policy_entropy_image_opaque_ratio{outcome}
|
||||
private static readonly Histogram<double> EntropyImageOpaqueRatioHistogram =
|
||||
Meter.CreateHistogram<double>(
|
||||
"policy_entropy_image_opaque_ratio",
|
||||
unit: "ratio",
|
||||
description: "Image opaque ratios observed in layer summaries.");
|
||||
|
||||
// Histogram: policy_entropy_top_file_ratio{outcome}
|
||||
private static readonly Histogram<double> EntropyTopFileRatioHistogram =
|
||||
Meter.CreateHistogram<double>(
|
||||
"policy_entropy_top_file_ratio",
|
||||
unit: "ratio",
|
||||
description: "Opaque ratio of the top offending file when present.");
|
||||
|
||||
/// <summary>
|
||||
/// Records an entropy penalty computation.
|
||||
/// </summary>
|
||||
public static void RecordEntropyPenalty(
|
||||
double penalty,
|
||||
string outcome,
|
||||
double imageOpaqueRatio,
|
||||
double? topFileOpaqueRatio = null)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "outcome", NormalizeTag(outcome) },
|
||||
};
|
||||
|
||||
EntropyPenaltyCounter.Add(1, tags);
|
||||
EntropyPenaltyHistogram.Record(penalty, tags);
|
||||
EntropyImageOpaqueRatioHistogram.Record(imageOpaqueRatio, tags);
|
||||
|
||||
if (topFileOpaqueRatio.HasValue)
|
||||
{
|
||||
EntropyTopFileRatioHistogram.Record(topFileOpaqueRatio.Value, tags);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Golden Signals - Latency
|
||||
|
||||
// Histogram: policy_api_latency_seconds{endpoint,method,status}
|
||||
private static readonly Histogram<double> ApiLatencyHistogram =
|
||||
Meter.CreateHistogram<double>(
|
||||
private static readonly Counter<long> PolicySimulationCounter =
|
||||
Meter.CreateCounter<long>(
|
||||
"policy_simulation_total",
|
||||
unit: "simulations",
|
||||
description: "Total number of policy simulations executed.");
|
||||
|
||||
// Counter: policy_rate_limit_exceeded_total{tenant,endpoint}
|
||||
private static readonly Counter<long> RateLimitExceededCounter =
|
||||
Meter.CreateCounter<long>(
|
||||
"policy_rate_limit_exceeded_total",
|
||||
unit: "requests",
|
||||
description: "Total requests rejected due to rate limiting.");
|
||||
|
||||
/// <summary>
|
||||
/// Records a rate limit exceeded event.
|
||||
/// </summary>
|
||||
/// <param name="tenant">The tenant ID (or "anonymous" if not available).</param>
|
||||
/// <param name="endpoint">The endpoint that was rate limited.</param>
|
||||
public static void RecordRateLimitExceeded(string? tenant = null, string? endpoint = null)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "tenant", NormalizeTag(tenant ?? "anonymous") },
|
||||
{ "endpoint", NormalizeTag(endpoint ?? "simulation") },
|
||||
};
|
||||
RateLimitExceededCounter.Add(1, tags);
|
||||
}
|
||||
|
||||
#region Entropy Metrics
|
||||
|
||||
// Counter: policy_entropy_penalty_total{outcome}
|
||||
private static readonly Counter<long> EntropyPenaltyCounter =
|
||||
Meter.CreateCounter<long>(
|
||||
"policy_entropy_penalty_total",
|
||||
unit: "penalties",
|
||||
description: "Total entropy penalties computed from scanner evidence.");
|
||||
|
||||
// Histogram: policy_entropy_penalty_value{outcome}
|
||||
private static readonly Histogram<double> EntropyPenaltyHistogram =
|
||||
Meter.CreateHistogram<double>(
|
||||
"policy_entropy_penalty_value",
|
||||
unit: "ratio",
|
||||
description: "Entropy penalty values (after cap).");
|
||||
|
||||
// Histogram: policy_entropy_image_opaque_ratio{outcome}
|
||||
private static readonly Histogram<double> EntropyImageOpaqueRatioHistogram =
|
||||
Meter.CreateHistogram<double>(
|
||||
"policy_entropy_image_opaque_ratio",
|
||||
unit: "ratio",
|
||||
description: "Image opaque ratios observed in layer summaries.");
|
||||
|
||||
// Histogram: policy_entropy_top_file_ratio{outcome}
|
||||
private static readonly Histogram<double> EntropyTopFileRatioHistogram =
|
||||
Meter.CreateHistogram<double>(
|
||||
"policy_entropy_top_file_ratio",
|
||||
unit: "ratio",
|
||||
description: "Opaque ratio of the top offending file when present.");
|
||||
|
||||
/// <summary>
|
||||
/// Records an entropy penalty computation.
|
||||
/// </summary>
|
||||
public static void RecordEntropyPenalty(
|
||||
double penalty,
|
||||
string outcome,
|
||||
double imageOpaqueRatio,
|
||||
double? topFileOpaqueRatio = null)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "outcome", NormalizeTag(outcome) },
|
||||
};
|
||||
|
||||
EntropyPenaltyCounter.Add(1, tags);
|
||||
EntropyPenaltyHistogram.Record(penalty, tags);
|
||||
EntropyImageOpaqueRatioHistogram.Record(imageOpaqueRatio, tags);
|
||||
|
||||
if (topFileOpaqueRatio.HasValue)
|
||||
{
|
||||
EntropyTopFileRatioHistogram.Record(topFileOpaqueRatio.Value, tags);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Golden Signals - Latency
|
||||
|
||||
// Histogram: policy_api_latency_seconds{endpoint,method,status}
|
||||
private static readonly Histogram<double> ApiLatencyHistogram =
|
||||
Meter.CreateHistogram<double>(
|
||||
"policy_api_latency_seconds",
|
||||
unit: "s",
|
||||
description: "API request latency by endpoint.");
|
||||
@@ -419,33 +441,33 @@ public static class PolicyEngineTelemetry
|
||||
/// </summary>
|
||||
public static Counter<long> ExceptionOperations => ExceptionOperationsCounter;
|
||||
|
||||
// Counter: policy_exception_cache_operations_total{tenant,operation}
|
||||
private static readonly Counter<long> ExceptionCacheOperationsCounter =
|
||||
Meter.CreateCounter<long>(
|
||||
"policy_exception_cache_operations_total",
|
||||
unit: "operations",
|
||||
description: "Total exception cache operations (hit, miss, set, warm, invalidate).");
|
||||
|
||||
// Counter: policy_exception_applications_total{tenant,effect}
|
||||
private static readonly Counter<long> ExceptionApplicationsCounter =
|
||||
Meter.CreateCounter<long>(
|
||||
"policy_exception_applications_total",
|
||||
unit: "applications",
|
||||
description: "Total applied exceptions during evaluation by effect type.");
|
||||
|
||||
// Histogram: policy_exception_application_latency_seconds{tenant,effect}
|
||||
private static readonly Histogram<double> ExceptionApplicationLatencyHistogram =
|
||||
Meter.CreateHistogram<double>(
|
||||
"policy_exception_application_latency_seconds",
|
||||
unit: "s",
|
||||
description: "Latency impact of exception application during evaluation.");
|
||||
|
||||
// Counter: policy_exception_lifecycle_total{tenant,event}
|
||||
private static readonly Counter<long> ExceptionLifecycleCounter =
|
||||
Meter.CreateCounter<long>(
|
||||
"policy_exception_lifecycle_total",
|
||||
unit: "events",
|
||||
description: "Lifecycle events for exceptions (activated, expired, revoked).");
|
||||
// Counter: policy_exception_cache_operations_total{tenant,operation}
|
||||
private static readonly Counter<long> ExceptionCacheOperationsCounter =
|
||||
Meter.CreateCounter<long>(
|
||||
"policy_exception_cache_operations_total",
|
||||
unit: "operations",
|
||||
description: "Total exception cache operations (hit, miss, set, warm, invalidate).");
|
||||
|
||||
// Counter: policy_exception_applications_total{tenant,effect}
|
||||
private static readonly Counter<long> ExceptionApplicationsCounter =
|
||||
Meter.CreateCounter<long>(
|
||||
"policy_exception_applications_total",
|
||||
unit: "applications",
|
||||
description: "Total applied exceptions during evaluation by effect type.");
|
||||
|
||||
// Histogram: policy_exception_application_latency_seconds{tenant,effect}
|
||||
private static readonly Histogram<double> ExceptionApplicationLatencyHistogram =
|
||||
Meter.CreateHistogram<double>(
|
||||
"policy_exception_application_latency_seconds",
|
||||
unit: "s",
|
||||
description: "Latency impact of exception application during evaluation.");
|
||||
|
||||
// Counter: policy_exception_lifecycle_total{tenant,event}
|
||||
private static readonly Counter<long> ExceptionLifecycleCounter =
|
||||
Meter.CreateCounter<long>(
|
||||
"policy_exception_lifecycle_total",
|
||||
unit: "events",
|
||||
description: "Lifecycle events for exceptions (activated, expired, revoked).");
|
||||
|
||||
/// <summary>
|
||||
/// Counter for exception cache operations.
|
||||
@@ -688,58 +710,58 @@ public static class PolicyEngineTelemetry
|
||||
/// </summary>
|
||||
/// <param name="tenant">Tenant identifier.</param>
|
||||
/// <param name="operation">Operation type (hit, miss, set, warm, invalidate_*, event_*).</param>
|
||||
public static void RecordExceptionCacheOperation(string tenant, string operation)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "tenant", NormalizeTenant(tenant) },
|
||||
{ "operation", NormalizeTag(operation) },
|
||||
};
|
||||
|
||||
ExceptionCacheOperationsCounter.Add(1, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records that an exception was applied during evaluation.
|
||||
/// </summary>
|
||||
public static void RecordExceptionApplication(string tenant, string effectType)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "tenant", NormalizeTenant(tenant) },
|
||||
{ "effect", NormalizeTag(effectType) },
|
||||
};
|
||||
|
||||
ExceptionApplicationsCounter.Add(1, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records latency attributed to exception application during evaluation.
|
||||
/// </summary>
|
||||
public static void RecordExceptionApplicationLatency(double seconds, string tenant, string effectType)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "tenant", NormalizeTenant(tenant) },
|
||||
{ "effect", NormalizeTag(effectType) },
|
||||
};
|
||||
|
||||
ExceptionApplicationLatencyHistogram.Record(seconds, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records an exception lifecycle event (activated, expired, revoked).
|
||||
/// </summary>
|
||||
public static void RecordExceptionLifecycle(string tenant, string eventType)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "tenant", NormalizeTenant(tenant) },
|
||||
{ "event", NormalizeTag(eventType) },
|
||||
};
|
||||
|
||||
ExceptionLifecycleCounter.Add(1, tags);
|
||||
}
|
||||
public static void RecordExceptionCacheOperation(string tenant, string operation)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "tenant", NormalizeTenant(tenant) },
|
||||
{ "operation", NormalizeTag(operation) },
|
||||
};
|
||||
|
||||
ExceptionCacheOperationsCounter.Add(1, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records that an exception was applied during evaluation.
|
||||
/// </summary>
|
||||
public static void RecordExceptionApplication(string tenant, string effectType)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "tenant", NormalizeTenant(tenant) },
|
||||
{ "effect", NormalizeTag(effectType) },
|
||||
};
|
||||
|
||||
ExceptionApplicationsCounter.Add(1, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records latency attributed to exception application during evaluation.
|
||||
/// </summary>
|
||||
public static void RecordExceptionApplicationLatency(double seconds, string tenant, string effectType)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "tenant", NormalizeTenant(tenant) },
|
||||
{ "effect", NormalizeTag(effectType) },
|
||||
};
|
||||
|
||||
ExceptionApplicationLatencyHistogram.Record(seconds, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records an exception lifecycle event (activated, expired, revoked).
|
||||
/// </summary>
|
||||
public static void RecordExceptionLifecycle(string tenant, string eventType)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "tenant", NormalizeTenant(tenant) },
|
||||
{ "event", NormalizeTag(eventType) },
|
||||
};
|
||||
|
||||
ExceptionLifecycleCounter.Add(1, tags);
|
||||
}
|
||||
|
||||
#region Golden Signals - Recording Methods
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
-- Policy Schema Migration 003: Snapshots, Violations, Conflicts, Ledger Exports
|
||||
-- Adds tables for policy snapshots, violation events, conflict handling, and ledger exports
|
||||
|
||||
-- Snapshots table (immutable policy configuration snapshots)
|
||||
CREATE TABLE IF NOT EXISTS policy.snapshots (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id TEXT NOT NULL,
|
||||
policy_id UUID NOT NULL,
|
||||
version INT NOT NULL,
|
||||
content_digest TEXT NOT NULL,
|
||||
content JSONB NOT NULL,
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(tenant_id, policy_id, version)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_snapshots_tenant ON policy.snapshots(tenant_id);
|
||||
CREATE INDEX idx_snapshots_policy ON policy.snapshots(tenant_id, policy_id);
|
||||
CREATE INDEX idx_snapshots_digest ON policy.snapshots(content_digest);
|
||||
|
||||
-- Violation events table (append-only)
|
||||
CREATE TABLE IF NOT EXISTS policy.violation_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id TEXT NOT NULL,
|
||||
policy_id UUID NOT NULL,
|
||||
rule_id TEXT NOT NULL,
|
||||
severity TEXT NOT NULL CHECK (severity IN ('critical', 'high', 'medium', 'low', 'info')),
|
||||
subject_purl TEXT,
|
||||
subject_cve TEXT,
|
||||
details JSONB NOT NULL DEFAULT '{}',
|
||||
remediation TEXT,
|
||||
correlation_id TEXT,
|
||||
occurred_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Append-only: no UPDATE trigger, only INSERTs allowed
|
||||
CREATE INDEX idx_violation_events_tenant ON policy.violation_events(tenant_id);
|
||||
CREATE INDEX idx_violation_events_policy ON policy.violation_events(tenant_id, policy_id);
|
||||
CREATE INDEX idx_violation_events_rule ON policy.violation_events(rule_id);
|
||||
CREATE INDEX idx_violation_events_severity ON policy.violation_events(severity);
|
||||
CREATE INDEX idx_violation_events_purl ON policy.violation_events(subject_purl) WHERE subject_purl IS NOT NULL;
|
||||
CREATE INDEX idx_violation_events_occurred ON policy.violation_events(tenant_id, occurred_at);
|
||||
|
||||
-- Conflicts table (for conflict detection and resolution)
|
||||
CREATE TABLE IF NOT EXISTS policy.conflicts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id TEXT NOT NULL,
|
||||
conflict_type TEXT NOT NULL CHECK (conflict_type IN ('rule_overlap', 'scope_collision', 'version_mismatch', 'precedence', 'other')),
|
||||
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'resolved', 'dismissed')),
|
||||
severity TEXT NOT NULL DEFAULT 'medium' CHECK (severity IN ('critical', 'high', 'medium', 'low')),
|
||||
left_rule_id TEXT,
|
||||
right_rule_id TEXT,
|
||||
affected_scope TEXT,
|
||||
description TEXT NOT NULL,
|
||||
resolution TEXT,
|
||||
resolved_by TEXT,
|
||||
resolved_at TIMESTAMPTZ,
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX idx_conflicts_tenant ON policy.conflicts(tenant_id);
|
||||
CREATE INDEX idx_conflicts_status ON policy.conflicts(tenant_id, status);
|
||||
CREATE INDEX idx_conflicts_type ON policy.conflicts(conflict_type);
|
||||
|
||||
-- Ledger exports table (for tracking ledger exports)
|
||||
CREATE TABLE IF NOT EXISTS policy.ledger_exports (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id TEXT NOT NULL,
|
||||
export_type TEXT NOT NULL CHECK (export_type IN ('full', 'incremental', 'snapshot')),
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'running', 'completed', 'failed')),
|
||||
format TEXT NOT NULL DEFAULT 'ndjson' CHECK (format IN ('ndjson', 'json', 'parquet', 'csv')),
|
||||
content_digest TEXT,
|
||||
record_count INT,
|
||||
byte_size BIGINT,
|
||||
storage_path TEXT,
|
||||
start_time TIMESTAMPTZ,
|
||||
end_time TIMESTAMPTZ,
|
||||
error_message TEXT,
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX idx_ledger_exports_tenant ON policy.ledger_exports(tenant_id);
|
||||
CREATE INDEX idx_ledger_exports_status ON policy.ledger_exports(status);
|
||||
CREATE INDEX idx_ledger_exports_digest ON policy.ledger_exports(content_digest) WHERE content_digest IS NOT NULL;
|
||||
CREATE INDEX idx_ledger_exports_created ON policy.ledger_exports(tenant_id, created_at);
|
||||
|
||||
-- Worker results table (for background job tracking)
|
||||
CREATE TABLE IF NOT EXISTS policy.worker_results (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id TEXT NOT NULL,
|
||||
job_type TEXT NOT NULL,
|
||||
job_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'running', 'completed', 'failed', 'cancelled')),
|
||||
input_hash TEXT,
|
||||
output_hash TEXT,
|
||||
progress INT DEFAULT 0 CHECK (progress >= 0 AND progress <= 100),
|
||||
result JSONB,
|
||||
error_message TEXT,
|
||||
retry_count INT NOT NULL DEFAULT 0,
|
||||
max_retries INT NOT NULL DEFAULT 3,
|
||||
scheduled_at TIMESTAMPTZ,
|
||||
started_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by TEXT,
|
||||
UNIQUE(tenant_id, job_type, job_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_worker_results_tenant ON policy.worker_results(tenant_id);
|
||||
CREATE INDEX idx_worker_results_status ON policy.worker_results(status);
|
||||
CREATE INDEX idx_worker_results_job_type ON policy.worker_results(job_type);
|
||||
CREATE INDEX idx_worker_results_scheduled ON policy.worker_results(scheduled_at) WHERE status = 'pending';
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace StellaOps.Policy.Storage.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Entity representing a policy conflict for resolution.
|
||||
/// </summary>
|
||||
public sealed record ConflictEntity
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string ConflictType { get; init; }
|
||||
public string Status { get; init; } = "open";
|
||||
public string Severity { get; init; } = "medium";
|
||||
public string? LeftRuleId { get; init; }
|
||||
public string? RightRuleId { get; init; }
|
||||
public string? AffectedScope { get; init; }
|
||||
public required string Description { get; init; }
|
||||
public string? Resolution { get; init; }
|
||||
public string? ResolvedBy { get; init; }
|
||||
public DateTimeOffset? ResolvedAt { get; init; }
|
||||
public string Metadata { get; init; } = "{}";
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public string? CreatedBy { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace StellaOps.Policy.Storage.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Entity representing a ledger export operation.
|
||||
/// </summary>
|
||||
public sealed record LedgerExportEntity
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string ExportType { get; init; }
|
||||
public string Status { get; init; } = "pending";
|
||||
public string Format { get; init; } = "ndjson";
|
||||
public string? ContentDigest { get; init; }
|
||||
public int? RecordCount { get; init; }
|
||||
public long? ByteSize { get; init; }
|
||||
public string? StoragePath { get; init; }
|
||||
public DateTimeOffset? StartTime { get; init; }
|
||||
public DateTimeOffset? EndTime { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
public string Metadata { get; init; } = "{}";
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public string? CreatedBy { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace StellaOps.Policy.Storage.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Entity representing an immutable policy configuration snapshot.
|
||||
/// </summary>
|
||||
public sealed record SnapshotEntity
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public Guid PolicyId { get; init; }
|
||||
public int Version { get; init; }
|
||||
public required string ContentDigest { get; init; }
|
||||
public required string Content { get; init; }
|
||||
public string Metadata { get; init; } = "{}";
|
||||
public required string CreatedBy { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace StellaOps.Policy.Storage.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Entity representing an append-only violation event.
|
||||
/// </summary>
|
||||
public sealed record ViolationEventEntity
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public Guid PolicyId { get; init; }
|
||||
public required string RuleId { get; init; }
|
||||
public required string Severity { get; init; }
|
||||
public string? SubjectPurl { get; init; }
|
||||
public string? SubjectCve { get; init; }
|
||||
public string Details { get; init; } = "{}";
|
||||
public string? Remediation { get; init; }
|
||||
public string? CorrelationId { get; init; }
|
||||
public DateTimeOffset OccurredAt { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace StellaOps.Policy.Storage.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Entity representing a background worker job result.
|
||||
/// </summary>
|
||||
public sealed record WorkerResultEntity
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string JobType { get; init; }
|
||||
public required string JobId { get; init; }
|
||||
public string Status { get; init; } = "pending";
|
||||
public string? InputHash { get; init; }
|
||||
public string? OutputHash { get; init; }
|
||||
public int Progress { get; init; }
|
||||
public string? Result { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
public int RetryCount { get; init; }
|
||||
public int MaxRetries { get; init; } = 3;
|
||||
public DateTimeOffset? ScheduledAt { get; init; }
|
||||
public DateTimeOffset? StartedAt { get; init; }
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
public string Metadata { get; init; } = "{}";
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public string? CreatedBy { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.Policy.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for conflict detection and resolution operations.
|
||||
/// </summary>
|
||||
public sealed class ConflictRepository : RepositoryBase<PolicyDataSource>, IConflictRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new conflict repository.
|
||||
/// </summary>
|
||||
public ConflictRepository(PolicyDataSource dataSource, ILogger<ConflictRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ConflictEntity> CreateAsync(ConflictEntity conflict, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO policy.conflicts (
|
||||
id, tenant_id, conflict_type, severity, status, left_rule_id,
|
||||
right_rule_id, affected_scope, description, metadata, created_by
|
||||
)
|
||||
VALUES (
|
||||
@id, @tenant_id, @conflict_type, @severity, @status, @left_rule_id,
|
||||
@right_rule_id, @affected_scope, @description, @metadata::jsonb, @created_by
|
||||
)
|
||||
RETURNING *
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(conflict.TenantId, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddConflictParameters(command, conflict);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return MapConflict(reader);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ConflictEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "SELECT * FROM policy.conflicts WHERE tenant_id = @tenant_id AND id = @id";
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
},
|
||||
MapConflict,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ConflictEntity>> GetOpenAsync(
|
||||
string tenantId,
|
||||
int limit = 100,
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM policy.conflicts
|
||||
WHERE tenant_id = @tenant_id AND status = 'open'
|
||||
ORDER BY
|
||||
CASE severity
|
||||
WHEN 'critical' THEN 1
|
||||
WHEN 'high' THEN 2
|
||||
WHEN 'medium' THEN 3
|
||||
WHEN 'low' THEN 4
|
||||
END,
|
||||
created_at DESC
|
||||
LIMIT @limit OFFSET @offset
|
||||
""";
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
AddParameter(cmd, "offset", offset);
|
||||
},
|
||||
MapConflict,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ConflictEntity>> GetByTypeAsync(
|
||||
string tenantId,
|
||||
string conflictType,
|
||||
string? status = null,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = """
|
||||
SELECT * FROM policy.conflicts
|
||||
WHERE tenant_id = @tenant_id AND conflict_type = @conflict_type
|
||||
""";
|
||||
|
||||
if (!string.IsNullOrEmpty(status))
|
||||
{
|
||||
sql += " AND status = @status";
|
||||
}
|
||||
|
||||
sql += " ORDER BY created_at DESC LIMIT @limit";
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "conflict_type", conflictType);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
if (!string.IsNullOrEmpty(status))
|
||||
{
|
||||
AddParameter(cmd, "status", status);
|
||||
}
|
||||
},
|
||||
MapConflict,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> ResolveAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
string resolution,
|
||||
string resolvedBy,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE policy.conflicts
|
||||
SET status = 'resolved', resolution = @resolution, resolved_by = @resolved_by, resolved_at = NOW()
|
||||
WHERE tenant_id = @tenant_id AND id = @id AND status = 'open'
|
||||
""";
|
||||
|
||||
var rows = await ExecuteAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
AddParameter(cmd, "resolution", resolution);
|
||||
AddParameter(cmd, "resolved_by", resolvedBy);
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> DismissAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
string dismissedBy,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE policy.conflicts
|
||||
SET status = 'dismissed', resolved_by = @dismissed_by, resolved_at = NOW()
|
||||
WHERE tenant_id = @tenant_id AND id = @id AND status = 'open'
|
||||
""";
|
||||
|
||||
var rows = await ExecuteAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
AddParameter(cmd, "dismissed_by", dismissedBy);
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Dictionary<string, int>> CountOpenBySeverityAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT severity, COUNT(*)::int as count
|
||||
FROM policy.conflicts
|
||||
WHERE tenant_id = @tenant_id AND status = 'open'
|
||||
GROUP BY severity
|
||||
""";
|
||||
|
||||
var results = new Dictionary<string, int>();
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddParameter(command, "tenant_id", tenantId);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var severity = reader.GetString(reader.GetOrdinal("severity"));
|
||||
var count = reader.GetInt32(reader.GetOrdinal("count"));
|
||||
results[severity] = count;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static void AddConflictParameters(NpgsqlCommand command, ConflictEntity conflict)
|
||||
{
|
||||
AddParameter(command, "id", conflict.Id);
|
||||
AddParameter(command, "tenant_id", conflict.TenantId);
|
||||
AddParameter(command, "conflict_type", conflict.ConflictType);
|
||||
AddParameter(command, "severity", conflict.Severity);
|
||||
AddParameter(command, "status", conflict.Status);
|
||||
AddParameter(command, "left_rule_id", conflict.LeftRuleId as object ?? DBNull.Value);
|
||||
AddParameter(command, "right_rule_id", conflict.RightRuleId as object ?? DBNull.Value);
|
||||
AddParameter(command, "affected_scope", conflict.AffectedScope as object ?? DBNull.Value);
|
||||
AddParameter(command, "description", conflict.Description);
|
||||
AddJsonbParameter(command, "metadata", conflict.Metadata);
|
||||
AddParameter(command, "created_by", conflict.CreatedBy as object ?? DBNull.Value);
|
||||
}
|
||||
|
||||
private static ConflictEntity MapConflict(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetGuid(reader.GetOrdinal("id")),
|
||||
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
|
||||
ConflictType = reader.GetString(reader.GetOrdinal("conflict_type")),
|
||||
Severity = reader.GetString(reader.GetOrdinal("severity")),
|
||||
Status = reader.GetString(reader.GetOrdinal("status")),
|
||||
LeftRuleId = GetNullableString(reader, reader.GetOrdinal("left_rule_id")),
|
||||
RightRuleId = GetNullableString(reader, reader.GetOrdinal("right_rule_id")),
|
||||
AffectedScope = GetNullableString(reader, reader.GetOrdinal("affected_scope")),
|
||||
Description = reader.GetString(reader.GetOrdinal("description")),
|
||||
Resolution = GetNullableString(reader, reader.GetOrdinal("resolution")),
|
||||
ResolvedBy = GetNullableString(reader, reader.GetOrdinal("resolved_by")),
|
||||
ResolvedAt = reader.IsDBNull(reader.GetOrdinal("resolved_at"))
|
||||
? null
|
||||
: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("resolved_at")),
|
||||
Metadata = reader.GetString(reader.GetOrdinal("metadata")),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
|
||||
CreatedBy = GetNullableString(reader, reader.GetOrdinal("created_by"))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using StellaOps.Policy.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for conflict detection and resolution operations.
|
||||
/// </summary>
|
||||
public interface IConflictRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new conflict.
|
||||
/// </summary>
|
||||
Task<ConflictEntity> CreateAsync(ConflictEntity conflict, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a conflict by ID.
|
||||
/// </summary>
|
||||
Task<ConflictEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all open conflicts for a tenant.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ConflictEntity>> GetOpenAsync(
|
||||
string tenantId,
|
||||
int limit = 100,
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets conflicts by type.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ConflictEntity>> GetByTypeAsync(
|
||||
string tenantId,
|
||||
string conflictType,
|
||||
string? status = null,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a conflict.
|
||||
/// </summary>
|
||||
Task<bool> ResolveAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
string resolution,
|
||||
string resolvedBy,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Dismisses a conflict.
|
||||
/// </summary>
|
||||
Task<bool> DismissAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
string dismissedBy,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Counts open conflicts by severity.
|
||||
/// </summary>
|
||||
Task<Dictionary<string, int>> CountOpenBySeverityAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using StellaOps.Policy.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for ledger export operations.
|
||||
/// </summary>
|
||||
public interface ILedgerExportRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new ledger export.
|
||||
/// </summary>
|
||||
Task<LedgerExportEntity> CreateAsync(LedgerExportEntity export, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a ledger export by ID.
|
||||
/// </summary>
|
||||
Task<LedgerExportEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a ledger export by content digest.
|
||||
/// </summary>
|
||||
Task<LedgerExportEntity?> GetByDigestAsync(string contentDigest, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all ledger exports for a tenant.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<LedgerExportEntity>> GetAllAsync(
|
||||
string tenantId,
|
||||
string? status = null,
|
||||
int limit = 100,
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the status of a ledger export.
|
||||
/// </summary>
|
||||
Task<bool> UpdateStatusAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
string status,
|
||||
string? errorMessage = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Completes a ledger export with results.
|
||||
/// </summary>
|
||||
Task<bool> CompleteAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
string contentDigest,
|
||||
int recordCount,
|
||||
long byteSize,
|
||||
string? storagePath,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the latest completed export for a tenant.
|
||||
/// </summary>
|
||||
Task<LedgerExportEntity?> GetLatestCompletedAsync(
|
||||
string tenantId,
|
||||
string? exportType = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using StellaOps.Policy.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for policy snapshot operations.
|
||||
/// </summary>
|
||||
public interface ISnapshotRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new snapshot.
|
||||
/// </summary>
|
||||
Task<SnapshotEntity> CreateAsync(SnapshotEntity snapshot, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a snapshot by ID.
|
||||
/// </summary>
|
||||
Task<SnapshotEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the latest snapshot for a policy.
|
||||
/// </summary>
|
||||
Task<SnapshotEntity?> GetLatestByPolicyAsync(string tenantId, Guid policyId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a snapshot by content digest.
|
||||
/// </summary>
|
||||
Task<SnapshotEntity?> GetByDigestAsync(string contentDigest, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all snapshots for a policy.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<SnapshotEntity>> GetByPolicyAsync(
|
||||
string tenantId,
|
||||
Guid policyId,
|
||||
int limit = 100,
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a snapshot.
|
||||
/// </summary>
|
||||
Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using StellaOps.Policy.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for append-only violation event operations.
|
||||
/// </summary>
|
||||
public interface IViolationEventRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Appends a new violation event (immutable).
|
||||
/// </summary>
|
||||
Task<ViolationEventEntity> AppendAsync(ViolationEventEntity violationEvent, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Appends multiple violation events (immutable).
|
||||
/// </summary>
|
||||
Task<int> AppendBatchAsync(IEnumerable<ViolationEventEntity> events, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a violation event by ID.
|
||||
/// </summary>
|
||||
Task<ViolationEventEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets violation events for a policy.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ViolationEventEntity>> GetByPolicyAsync(
|
||||
string tenantId,
|
||||
Guid policyId,
|
||||
DateTimeOffset? since = null,
|
||||
int limit = 100,
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets violation events by severity.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ViolationEventEntity>> GetBySeverityAsync(
|
||||
string tenantId,
|
||||
string severity,
|
||||
DateTimeOffset? since = null,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets violation events for a PURL.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ViolationEventEntity>> GetByPurlAsync(
|
||||
string tenantId,
|
||||
string purl,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Counts violations by severity for a time range.
|
||||
/// </summary>
|
||||
Task<Dictionary<string, int>> CountBySeverityAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset since,
|
||||
DateTimeOffset until,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using StellaOps.Policy.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for worker result operations.
|
||||
/// </summary>
|
||||
public interface IWorkerResultRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new worker result.
|
||||
/// </summary>
|
||||
Task<WorkerResultEntity> CreateAsync(WorkerResultEntity result, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a worker result by ID.
|
||||
/// </summary>
|
||||
Task<WorkerResultEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a worker result by job type and job ID.
|
||||
/// </summary>
|
||||
Task<WorkerResultEntity?> GetByJobAsync(
|
||||
string tenantId,
|
||||
string jobType,
|
||||
string jobId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets worker results by status.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<WorkerResultEntity>> GetByStatusAsync(
|
||||
string tenantId,
|
||||
string status,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets pending worker results ready for execution.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<WorkerResultEntity>> GetPendingAsync(
|
||||
string? jobType = null,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the status and progress of a worker result.
|
||||
/// </summary>
|
||||
Task<bool> UpdateProgressAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
string status,
|
||||
int progress,
|
||||
string? errorMessage = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Completes a worker result with the final result.
|
||||
/// </summary>
|
||||
Task<bool> CompleteAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
string result,
|
||||
string? outputHash = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Marks a worker result as failed.
|
||||
/// </summary>
|
||||
Task<bool> FailAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
string errorMessage,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Increments the retry count for a worker result.
|
||||
/// </summary>
|
||||
Task<bool> IncrementRetryAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.Policy.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for ledger export operations.
|
||||
/// </summary>
|
||||
public sealed class LedgerExportRepository : RepositoryBase<PolicyDataSource>, ILedgerExportRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new ledger export repository.
|
||||
/// </summary>
|
||||
public LedgerExportRepository(PolicyDataSource dataSource, ILogger<LedgerExportRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<LedgerExportEntity> CreateAsync(LedgerExportEntity export, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO policy.ledger_exports (
|
||||
id, tenant_id, export_type, status, format, metadata, created_by
|
||||
)
|
||||
VALUES (
|
||||
@id, @tenant_id, @export_type, @status, @format, @metadata::jsonb, @created_by
|
||||
)
|
||||
RETURNING *
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(export.TenantId, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddExportParameters(command, export);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return MapExport(reader);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<LedgerExportEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "SELECT * FROM policy.ledger_exports WHERE tenant_id = @tenant_id AND id = @id";
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
},
|
||||
MapExport,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<LedgerExportEntity?> GetByDigestAsync(string contentDigest, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "SELECT * FROM policy.ledger_exports WHERE content_digest = @content_digest LIMIT 1";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "content_digest", contentDigest);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return MapExport(reader);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<LedgerExportEntity>> GetAllAsync(
|
||||
string tenantId,
|
||||
string? status = null,
|
||||
int limit = 100,
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = "SELECT * FROM policy.ledger_exports WHERE tenant_id = @tenant_id";
|
||||
|
||||
if (!string.IsNullOrEmpty(status))
|
||||
{
|
||||
sql += " AND status = @status";
|
||||
}
|
||||
|
||||
sql += " ORDER BY created_at DESC LIMIT @limit OFFSET @offset";
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
AddParameter(cmd, "offset", offset);
|
||||
if (!string.IsNullOrEmpty(status))
|
||||
{
|
||||
AddParameter(cmd, "status", status);
|
||||
}
|
||||
},
|
||||
MapExport,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> UpdateStatusAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
string status,
|
||||
string? errorMessage = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE policy.ledger_exports
|
||||
SET status = @status, error_message = @error_message,
|
||||
start_time = CASE WHEN @status = 'running' AND start_time IS NULL THEN NOW() ELSE start_time END
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
""";
|
||||
|
||||
var rows = await ExecuteAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
AddParameter(cmd, "status", status);
|
||||
AddParameter(cmd, "error_message", errorMessage as object ?? DBNull.Value);
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> CompleteAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
string contentDigest,
|
||||
int recordCount,
|
||||
long byteSize,
|
||||
string? storagePath,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE policy.ledger_exports
|
||||
SET status = 'completed',
|
||||
content_digest = @content_digest,
|
||||
record_count = @record_count,
|
||||
byte_size = @byte_size,
|
||||
storage_path = @storage_path,
|
||||
end_time = NOW()
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
""";
|
||||
|
||||
var rows = await ExecuteAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
AddParameter(cmd, "content_digest", contentDigest);
|
||||
AddParameter(cmd, "record_count", recordCount);
|
||||
AddParameter(cmd, "byte_size", byteSize);
|
||||
AddParameter(cmd, "storage_path", storagePath as object ?? DBNull.Value);
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<LedgerExportEntity?> GetLatestCompletedAsync(
|
||||
string tenantId,
|
||||
string? exportType = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = """
|
||||
SELECT * FROM policy.ledger_exports
|
||||
WHERE tenant_id = @tenant_id AND status = 'completed'
|
||||
""";
|
||||
|
||||
if (!string.IsNullOrEmpty(exportType))
|
||||
{
|
||||
sql += " AND export_type = @export_type";
|
||||
}
|
||||
|
||||
sql += " ORDER BY end_time DESC LIMIT 1";
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
if (!string.IsNullOrEmpty(exportType))
|
||||
{
|
||||
AddParameter(cmd, "export_type", exportType);
|
||||
}
|
||||
},
|
||||
MapExport,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static void AddExportParameters(NpgsqlCommand command, LedgerExportEntity export)
|
||||
{
|
||||
AddParameter(command, "id", export.Id);
|
||||
AddParameter(command, "tenant_id", export.TenantId);
|
||||
AddParameter(command, "export_type", export.ExportType);
|
||||
AddParameter(command, "status", export.Status);
|
||||
AddParameter(command, "format", export.Format);
|
||||
AddJsonbParameter(command, "metadata", export.Metadata);
|
||||
AddParameter(command, "created_by", export.CreatedBy as object ?? DBNull.Value);
|
||||
}
|
||||
|
||||
private static LedgerExportEntity MapExport(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetGuid(reader.GetOrdinal("id")),
|
||||
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
|
||||
ExportType = reader.GetString(reader.GetOrdinal("export_type")),
|
||||
Status = reader.GetString(reader.GetOrdinal("status")),
|
||||
Format = reader.GetString(reader.GetOrdinal("format")),
|
||||
ContentDigest = GetNullableString(reader, reader.GetOrdinal("content_digest")),
|
||||
RecordCount = reader.IsDBNull(reader.GetOrdinal("record_count"))
|
||||
? null
|
||||
: reader.GetInt32(reader.GetOrdinal("record_count")),
|
||||
ByteSize = reader.IsDBNull(reader.GetOrdinal("byte_size"))
|
||||
? null
|
||||
: reader.GetInt64(reader.GetOrdinal("byte_size")),
|
||||
StoragePath = GetNullableString(reader, reader.GetOrdinal("storage_path")),
|
||||
StartTime = reader.IsDBNull(reader.GetOrdinal("start_time"))
|
||||
? null
|
||||
: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("start_time")),
|
||||
EndTime = reader.IsDBNull(reader.GetOrdinal("end_time"))
|
||||
? null
|
||||
: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("end_time")),
|
||||
ErrorMessage = GetNullableString(reader, reader.GetOrdinal("error_message")),
|
||||
Metadata = reader.GetString(reader.GetOrdinal("metadata")),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
|
||||
CreatedBy = GetNullableString(reader, reader.GetOrdinal("created_by"))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.Policy.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for policy snapshot operations.
|
||||
/// </summary>
|
||||
public sealed class SnapshotRepository : RepositoryBase<PolicyDataSource>, ISnapshotRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new snapshot repository.
|
||||
/// </summary>
|
||||
public SnapshotRepository(PolicyDataSource dataSource, ILogger<SnapshotRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SnapshotEntity> CreateAsync(SnapshotEntity snapshot, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO policy.snapshots (
|
||||
id, tenant_id, policy_id, version, content_digest, content,
|
||||
created_by, metadata
|
||||
)
|
||||
VALUES (
|
||||
@id, @tenant_id, @policy_id, @version, @content_digest, @content::jsonb,
|
||||
@created_by, @metadata::jsonb
|
||||
)
|
||||
RETURNING *
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(snapshot.TenantId, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddSnapshotParameters(command, snapshot);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return MapSnapshot(reader);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SnapshotEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "SELECT * FROM policy.snapshots WHERE tenant_id = @tenant_id AND id = @id";
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
},
|
||||
MapSnapshot,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SnapshotEntity?> GetLatestByPolicyAsync(
|
||||
string tenantId,
|
||||
Guid policyId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM policy.snapshots
|
||||
WHERE tenant_id = @tenant_id AND policy_id = @policy_id
|
||||
ORDER BY version DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "policy_id", policyId);
|
||||
},
|
||||
MapSnapshot,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SnapshotEntity?> GetByDigestAsync(string contentDigest, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "SELECT * FROM policy.snapshots WHERE content_digest = @content_digest LIMIT 1";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "content_digest", contentDigest);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return MapSnapshot(reader);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<SnapshotEntity>> GetByPolicyAsync(
|
||||
string tenantId,
|
||||
Guid policyId,
|
||||
int limit = 100,
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM policy.snapshots
|
||||
WHERE tenant_id = @tenant_id AND policy_id = @policy_id
|
||||
ORDER BY version DESC
|
||||
LIMIT @limit OFFSET @offset
|
||||
""";
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "policy_id", policyId);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
AddParameter(cmd, "offset", offset);
|
||||
},
|
||||
MapSnapshot,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "DELETE FROM policy.snapshots WHERE tenant_id = @tenant_id AND id = @id";
|
||||
|
||||
var rows = await ExecuteAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
private static void AddSnapshotParameters(NpgsqlCommand command, SnapshotEntity snapshot)
|
||||
{
|
||||
AddParameter(command, "id", snapshot.Id);
|
||||
AddParameter(command, "tenant_id", snapshot.TenantId);
|
||||
AddParameter(command, "policy_id", snapshot.PolicyId);
|
||||
AddParameter(command, "version", snapshot.Version);
|
||||
AddParameter(command, "content_digest", snapshot.ContentDigest);
|
||||
AddParameter(command, "content", snapshot.Content);
|
||||
AddParameter(command, "created_by", snapshot.CreatedBy);
|
||||
AddJsonbParameter(command, "metadata", snapshot.Metadata);
|
||||
}
|
||||
|
||||
private static SnapshotEntity MapSnapshot(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetGuid(reader.GetOrdinal("id")),
|
||||
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
|
||||
PolicyId = reader.GetGuid(reader.GetOrdinal("policy_id")),
|
||||
Version = reader.GetInt32(reader.GetOrdinal("version")),
|
||||
ContentDigest = reader.GetString(reader.GetOrdinal("content_digest")),
|
||||
Content = reader.GetString(reader.GetOrdinal("content")),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
|
||||
CreatedBy = reader.GetString(reader.GetOrdinal("created_by")),
|
||||
Metadata = reader.GetString(reader.GetOrdinal("metadata"))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.Policy.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for append-only violation event operations.
|
||||
/// </summary>
|
||||
public sealed class ViolationEventRepository : RepositoryBase<PolicyDataSource>, IViolationEventRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new violation event repository.
|
||||
/// </summary>
|
||||
public ViolationEventRepository(PolicyDataSource dataSource, ILogger<ViolationEventRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ViolationEventEntity> AppendAsync(ViolationEventEntity violationEvent, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO policy.violation_events (
|
||||
id, tenant_id, policy_id, rule_id, severity, subject_purl,
|
||||
subject_cve, details, remediation, correlation_id, occurred_at
|
||||
)
|
||||
VALUES (
|
||||
@id, @tenant_id, @policy_id, @rule_id, @severity, @subject_purl,
|
||||
@subject_cve, @details::jsonb, @remediation, @correlation_id, @occurred_at
|
||||
)
|
||||
RETURNING *
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(violationEvent.TenantId, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddViolationParameters(command, violationEvent);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return MapViolation(reader);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> AppendBatchAsync(IEnumerable<ViolationEventEntity> events, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var eventList = events.ToList();
|
||||
if (eventList.Count == 0) return 0;
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO policy.violation_events (
|
||||
id, tenant_id, policy_id, rule_id, severity, subject_purl,
|
||||
subject_cve, details, remediation, correlation_id, occurred_at
|
||||
)
|
||||
VALUES (
|
||||
@id, @tenant_id, @policy_id, @rule_id, @severity, @subject_purl,
|
||||
@subject_cve, @details::jsonb, @remediation, @correlation_id, @occurred_at
|
||||
)
|
||||
""";
|
||||
|
||||
var tenantId = eventList[0].TenantId;
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var count = 0;
|
||||
foreach (var evt in eventList)
|
||||
{
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddViolationParameters(command, evt);
|
||||
count += await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ViolationEventEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "SELECT * FROM policy.violation_events WHERE tenant_id = @tenant_id AND id = @id";
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
},
|
||||
MapViolation,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ViolationEventEntity>> GetByPolicyAsync(
|
||||
string tenantId,
|
||||
Guid policyId,
|
||||
DateTimeOffset? since = null,
|
||||
int limit = 100,
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = """
|
||||
SELECT * FROM policy.violation_events
|
||||
WHERE tenant_id = @tenant_id AND policy_id = @policy_id
|
||||
""";
|
||||
|
||||
if (since.HasValue)
|
||||
{
|
||||
sql += " AND occurred_at >= @since";
|
||||
}
|
||||
|
||||
sql += " ORDER BY occurred_at DESC LIMIT @limit OFFSET @offset";
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "policy_id", policyId);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
AddParameter(cmd, "offset", offset);
|
||||
if (since.HasValue)
|
||||
{
|
||||
AddParameter(cmd, "since", since.Value);
|
||||
}
|
||||
},
|
||||
MapViolation,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ViolationEventEntity>> GetBySeverityAsync(
|
||||
string tenantId,
|
||||
string severity,
|
||||
DateTimeOffset? since = null,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = """
|
||||
SELECT * FROM policy.violation_events
|
||||
WHERE tenant_id = @tenant_id AND severity = @severity
|
||||
""";
|
||||
|
||||
if (since.HasValue)
|
||||
{
|
||||
sql += " AND occurred_at >= @since";
|
||||
}
|
||||
|
||||
sql += " ORDER BY occurred_at DESC LIMIT @limit";
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "severity", severity);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
if (since.HasValue)
|
||||
{
|
||||
AddParameter(cmd, "since", since.Value);
|
||||
}
|
||||
},
|
||||
MapViolation,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ViolationEventEntity>> GetByPurlAsync(
|
||||
string tenantId,
|
||||
string purl,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM policy.violation_events
|
||||
WHERE tenant_id = @tenant_id AND subject_purl = @purl
|
||||
ORDER BY occurred_at DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "purl", purl);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
},
|
||||
MapViolation,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Dictionary<string, int>> CountBySeverityAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset since,
|
||||
DateTimeOffset until,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT severity, COUNT(*)::int as count
|
||||
FROM policy.violation_events
|
||||
WHERE tenant_id = @tenant_id AND occurred_at >= @since AND occurred_at < @until
|
||||
GROUP BY severity
|
||||
""";
|
||||
|
||||
var results = new Dictionary<string, int>();
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddParameter(command, "tenant_id", tenantId);
|
||||
AddParameter(command, "since", since);
|
||||
AddParameter(command, "until", until);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var severity = reader.GetString(reader.GetOrdinal("severity"));
|
||||
var count = reader.GetInt32(reader.GetOrdinal("count"));
|
||||
results[severity] = count;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static void AddViolationParameters(NpgsqlCommand command, ViolationEventEntity violation)
|
||||
{
|
||||
AddParameter(command, "id", violation.Id);
|
||||
AddParameter(command, "tenant_id", violation.TenantId);
|
||||
AddParameter(command, "policy_id", violation.PolicyId);
|
||||
AddParameter(command, "rule_id", violation.RuleId);
|
||||
AddParameter(command, "severity", violation.Severity);
|
||||
AddParameter(command, "subject_purl", violation.SubjectPurl as object ?? DBNull.Value);
|
||||
AddParameter(command, "subject_cve", violation.SubjectCve as object ?? DBNull.Value);
|
||||
AddJsonbParameter(command, "details", violation.Details);
|
||||
AddParameter(command, "remediation", violation.Remediation as object ?? DBNull.Value);
|
||||
AddParameter(command, "correlation_id", violation.CorrelationId as object ?? DBNull.Value);
|
||||
AddParameter(command, "occurred_at", violation.OccurredAt);
|
||||
}
|
||||
|
||||
private static ViolationEventEntity MapViolation(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetGuid(reader.GetOrdinal("id")),
|
||||
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
|
||||
PolicyId = reader.GetGuid(reader.GetOrdinal("policy_id")),
|
||||
RuleId = reader.GetString(reader.GetOrdinal("rule_id")),
|
||||
Severity = reader.GetString(reader.GetOrdinal("severity")),
|
||||
SubjectPurl = GetNullableString(reader, reader.GetOrdinal("subject_purl")),
|
||||
SubjectCve = GetNullableString(reader, reader.GetOrdinal("subject_cve")),
|
||||
Details = reader.GetString(reader.GetOrdinal("details")),
|
||||
Remediation = GetNullableString(reader, reader.GetOrdinal("remediation")),
|
||||
CorrelationId = GetNullableString(reader, reader.GetOrdinal("correlation_id")),
|
||||
OccurredAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("occurred_at")),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at"))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.Policy.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for worker result operations.
|
||||
/// </summary>
|
||||
public sealed class WorkerResultRepository : RepositoryBase<PolicyDataSource>, IWorkerResultRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new worker result repository.
|
||||
/// </summary>
|
||||
public WorkerResultRepository(PolicyDataSource dataSource, ILogger<WorkerResultRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<WorkerResultEntity> CreateAsync(WorkerResultEntity result, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO policy.worker_results (
|
||||
id, tenant_id, job_type, job_id, status, progress,
|
||||
input_hash, max_retries, scheduled_at, metadata, created_by
|
||||
)
|
||||
VALUES (
|
||||
@id, @tenant_id, @job_type, @job_id, @status, @progress,
|
||||
@input_hash, @max_retries, @scheduled_at, @metadata::jsonb, @created_by
|
||||
)
|
||||
RETURNING *
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(result.TenantId, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddResultParameters(command, result);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return MapResult(reader);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<WorkerResultEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "SELECT * FROM policy.worker_results WHERE tenant_id = @tenant_id AND id = @id";
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
},
|
||||
MapResult,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<WorkerResultEntity?> GetByJobAsync(
|
||||
string tenantId,
|
||||
string jobType,
|
||||
string jobId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM policy.worker_results
|
||||
WHERE tenant_id = @tenant_id AND job_type = @job_type AND job_id = @job_id
|
||||
""";
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "job_type", jobType);
|
||||
AddParameter(cmd, "job_id", jobId);
|
||||
},
|
||||
MapResult,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<WorkerResultEntity>> GetByStatusAsync(
|
||||
string tenantId,
|
||||
string status,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM policy.worker_results
|
||||
WHERE tenant_id = @tenant_id AND status = @status
|
||||
ORDER BY created_at DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "status", status);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
},
|
||||
MapResult,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<WorkerResultEntity>> GetPendingAsync(
|
||||
string? jobType = null,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = """
|
||||
SELECT * FROM policy.worker_results
|
||||
WHERE status = 'pending'
|
||||
""";
|
||||
|
||||
if (!string.IsNullOrEmpty(jobType))
|
||||
{
|
||||
sql += " AND job_type = @job_type";
|
||||
}
|
||||
|
||||
sql += " ORDER BY scheduled_at ASC NULLS LAST, created_at ASC LIMIT @limit";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddParameter(command, "limit", limit);
|
||||
if (!string.IsNullOrEmpty(jobType))
|
||||
{
|
||||
AddParameter(command, "job_type", jobType);
|
||||
}
|
||||
|
||||
var results = new List<WorkerResultEntity>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(MapResult(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> UpdateProgressAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
string status,
|
||||
int progress,
|
||||
string? errorMessage = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE policy.worker_results
|
||||
SET status = @status, progress = @progress, error_message = @error_message,
|
||||
started_at = CASE WHEN @status = 'running' AND started_at IS NULL THEN NOW() ELSE started_at END
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
""";
|
||||
|
||||
var rows = await ExecuteAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
AddParameter(cmd, "status", status);
|
||||
AddParameter(cmd, "progress", progress);
|
||||
AddParameter(cmd, "error_message", errorMessage as object ?? DBNull.Value);
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> CompleteAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
string result,
|
||||
string? outputHash = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE policy.worker_results
|
||||
SET status = 'completed', progress = 100, result = @result::jsonb,
|
||||
output_hash = @output_hash, completed_at = NOW()
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
""";
|
||||
|
||||
var rows = await ExecuteAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
AddParameter(cmd, "result", result);
|
||||
AddParameter(cmd, "output_hash", outputHash as object ?? DBNull.Value);
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> FailAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
string errorMessage,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE policy.worker_results
|
||||
SET status = 'failed', error_message = @error_message, completed_at = NOW()
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
""";
|
||||
|
||||
var rows = await ExecuteAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
AddParameter(cmd, "error_message", errorMessage);
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> IncrementRetryAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE policy.worker_results
|
||||
SET retry_count = retry_count + 1, status = 'pending', started_at = NULL
|
||||
WHERE tenant_id = @tenant_id AND id = @id AND retry_count < max_retries
|
||||
""";
|
||||
|
||||
var rows = await ExecuteAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
private static void AddResultParameters(NpgsqlCommand command, WorkerResultEntity result)
|
||||
{
|
||||
AddParameter(command, "id", result.Id);
|
||||
AddParameter(command, "tenant_id", result.TenantId);
|
||||
AddParameter(command, "job_type", result.JobType);
|
||||
AddParameter(command, "job_id", result.JobId);
|
||||
AddParameter(command, "status", result.Status);
|
||||
AddParameter(command, "progress", result.Progress);
|
||||
AddParameter(command, "input_hash", result.InputHash as object ?? DBNull.Value);
|
||||
AddParameter(command, "max_retries", result.MaxRetries);
|
||||
AddParameter(command, "scheduled_at", result.ScheduledAt as object ?? DBNull.Value);
|
||||
AddJsonbParameter(command, "metadata", result.Metadata);
|
||||
AddParameter(command, "created_by", result.CreatedBy as object ?? DBNull.Value);
|
||||
}
|
||||
|
||||
private static WorkerResultEntity MapResult(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetGuid(reader.GetOrdinal("id")),
|
||||
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
|
||||
JobType = reader.GetString(reader.GetOrdinal("job_type")),
|
||||
JobId = reader.GetString(reader.GetOrdinal("job_id")),
|
||||
Status = reader.GetString(reader.GetOrdinal("status")),
|
||||
Progress = reader.GetInt32(reader.GetOrdinal("progress")),
|
||||
Result = GetNullableString(reader, reader.GetOrdinal("result")),
|
||||
InputHash = GetNullableString(reader, reader.GetOrdinal("input_hash")),
|
||||
OutputHash = GetNullableString(reader, reader.GetOrdinal("output_hash")),
|
||||
ErrorMessage = GetNullableString(reader, reader.GetOrdinal("error_message")),
|
||||
RetryCount = reader.GetInt32(reader.GetOrdinal("retry_count")),
|
||||
MaxRetries = reader.GetInt32(reader.GetOrdinal("max_retries")),
|
||||
ScheduledAt = reader.IsDBNull(reader.GetOrdinal("scheduled_at"))
|
||||
? null
|
||||
: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("scheduled_at")),
|
||||
StartedAt = reader.IsDBNull(reader.GetOrdinal("started_at"))
|
||||
? null
|
||||
: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("started_at")),
|
||||
CompletedAt = reader.IsDBNull(reader.GetOrdinal("completed_at"))
|
||||
? null
|
||||
: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("completed_at")),
|
||||
Metadata = reader.GetString(reader.GetOrdinal("metadata")),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
|
||||
CreatedBy = GetNullableString(reader, reader.GetOrdinal("created_by"))
|
||||
};
|
||||
}
|
||||
@@ -37,6 +37,11 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<IReceiptRepository, PostgresReceiptRepository>();
|
||||
services.AddScoped<IExplanationRepository, ExplanationRepository>();
|
||||
services.AddScoped<IPolicyAuditRepository, PolicyAuditRepository>();
|
||||
services.AddScoped<ISnapshotRepository, SnapshotRepository>();
|
||||
services.AddScoped<IViolationEventRepository, ViolationEventRepository>();
|
||||
services.AddScoped<IConflictRepository, ConflictRepository>();
|
||||
services.AddScoped<ILedgerExportRepository, LedgerExportRepository>();
|
||||
services.AddScoped<IWorkerResultRepository, WorkerResultRepository>();
|
||||
|
||||
return services;
|
||||
}
|
||||
@@ -64,6 +69,11 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<IReceiptRepository, PostgresReceiptRepository>();
|
||||
services.AddScoped<IExplanationRepository, ExplanationRepository>();
|
||||
services.AddScoped<IPolicyAuditRepository, PolicyAuditRepository>();
|
||||
services.AddScoped<ISnapshotRepository, SnapshotRepository>();
|
||||
services.AddScoped<IViolationEventRepository, ViolationEventRepository>();
|
||||
services.AddScoped<IConflictRepository, ConflictRepository>();
|
||||
services.AddScoped<ILedgerExportRepository, LedgerExportRepository>();
|
||||
services.AddScoped<IWorkerResultRepository, WorkerResultRepository>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user