Merge branch 'main' of https://git.stella-ops.org/stella-ops.org/git.stella-ops.org
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

This commit is contained in:
master
2025-12-09 13:08:17 +02:00
10050 changed files with 313174 additions and 7518670 deletions

View File

@@ -0,0 +1,89 @@
# Policy Engine EditorConfig
# Enforces determinism, nullability, and async consistency rules
# See: docs/modules/policy/design/policy-aoc-linting-rules.md
# Applies only to StellaOps.Policy.Engine project
root = false
[*.cs]
# C# 12+ Style Preferences
csharp_style_namespace_declarations = file_scoped:error
csharp_style_prefer_primary_constructors = true:suggestion
csharp_style_prefer_collection_expression = when_types_loosely_match:suggestion
# Expression-bodied members
csharp_style_expression_bodied_methods = when_on_single_line:suggestion
csharp_style_expression_bodied_properties = true:suggestion
csharp_style_expression_bodied_accessors = true:suggestion
# Pattern matching preferences
csharp_style_prefer_pattern_matching = true:suggestion
csharp_style_prefer_switch_expression = true:suggestion
# Null checking preferences
csharp_style_prefer_null_check_over_type_check = true:suggestion
csharp_style_throw_expression = true:suggestion
csharp_style_conditional_delegate_call = true:suggestion
# Code block preferences
csharp_prefer_braces = when_multiline:suggestion
csharp_prefer_simple_using_statement = true:suggestion
# Using directive preferences
csharp_using_directive_placement = outside_namespace:error
# var preferences
csharp_style_var_for_built_in_types = true:suggestion
csharp_style_var_when_type_is_apparent = true:suggestion
csharp_style_var_elsewhere = true:suggestion
# Naming conventions
dotnet_naming_rule.interface_should_be_begins_with_i.severity = error
dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface
dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i
dotnet_naming_symbols.interface.applicable_kinds = interface
dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected
dotnet_naming_style.begins_with_i.required_prefix = I
dotnet_naming_style.begins_with_i.capitalization = pascal_case
# Private field naming
dotnet_naming_rule.private_fields_should_be_camel_case.severity = suggestion
dotnet_naming_rule.private_fields_should_be_camel_case.symbols = private_fields
dotnet_naming_rule.private_fields_should_be_camel_case.style = camel_case_underscore
dotnet_naming_symbols.private_fields.applicable_kinds = field
dotnet_naming_symbols.private_fields.applicable_accessibilities = private
dotnet_naming_style.camel_case_underscore.required_prefix = _
dotnet_naming_style.camel_case_underscore.capitalization = camel_case
# ===== Code Analysis Rules for Policy Engine =====
# These rules are specific to the determinism requirements of the Policy Engine
# Note: Rules marked as "baseline" have existing violations that need gradual remediation
# Async rules - important for deterministic evaluation
dotnet_diagnostic.CA2012.severity = error # Do not pass async lambdas to void-returning methods
dotnet_diagnostic.CA2007.severity = suggestion # ConfigureAwait - suggestion only
dotnet_diagnostic.CA1849.severity = suggestion # Call async methods when in async method (baseline: Redis sync calls)
# Performance rules - baseline violations exist
dotnet_diagnostic.CA1829.severity = suggestion # Use Length/Count instead of Count()
dotnet_diagnostic.CA1826.severity = suggestion # Use property instead of Linq (baseline: ~10 violations)
dotnet_diagnostic.CA1827.severity = suggestion # Do not use Count when Any can be used
dotnet_diagnostic.CA1836.severity = suggestion # Prefer IsEmpty over Count
# Design rules - relaxed for flexibility
dotnet_diagnostic.CA1002.severity = suggestion # Generic list in public API
dotnet_diagnostic.CA1031.severity = suggestion # Catch general exception
dotnet_diagnostic.CA1062.severity = none # Using ThrowIfNull instead
# Reliability rules
dotnet_diagnostic.CA2011.severity = error # Do not assign property within its setter
dotnet_diagnostic.CA2013.severity = error # Do not use ReferenceEquals with value types
dotnet_diagnostic.CA2016.severity = suggestion # Forward the CancellationToken parameter
# Security rules - critical, must remain errors
dotnet_diagnostic.CA2100.severity = error # Review SQL queries for security vulnerabilities
dotnet_diagnostic.CA5350.severity = error # Do not use weak cryptographic algorithms
dotnet_diagnostic.CA5351.severity = error # Do not use broken cryptographic algorithms

View File

@@ -0,0 +1,421 @@
using Microsoft.Extensions.Logging;
namespace StellaOps.Policy.Engine.AirGap;
/// <summary>
/// Notification types for air-gap events.
/// </summary>
public enum AirGapNotificationType
{
/// <summary>Staleness warning threshold crossed.</summary>
StalenessWarning,
/// <summary>Staleness breach occurred.</summary>
StalenessBreach,
/// <summary>Staleness recovered.</summary>
StalenessRecovered,
/// <summary>Bundle import started.</summary>
BundleImportStarted,
/// <summary>Bundle import completed.</summary>
BundleImportCompleted,
/// <summary>Bundle import failed.</summary>
BundleImportFailed,
/// <summary>Environment sealed.</summary>
EnvironmentSealed,
/// <summary>Environment unsealed.</summary>
EnvironmentUnsealed,
/// <summary>Time anchor missing.</summary>
TimeAnchorMissing,
/// <summary>Policy pack updated.</summary>
PolicyPackUpdated
}
/// <summary>
/// Notification severity levels.
/// </summary>
public enum NotificationSeverity
{
Info,
Warning,
Error,
Critical
}
/// <summary>
/// Represents a notification to be delivered.
/// </summary>
public sealed record AirGapNotification(
string NotificationId,
string TenantId,
AirGapNotificationType Type,
NotificationSeverity Severity,
string Title,
string Message,
DateTimeOffset OccurredAt,
IDictionary<string, object?>? Metadata = null);
/// <summary>
/// Interface for notification delivery channels.
/// </summary>
public interface IAirGapNotificationChannel
{
/// <summary>
/// Gets the name of this notification channel.
/// </summary>
string ChannelName { get; }
/// <summary>
/// Delivers a notification through this channel.
/// </summary>
Task<bool> DeliverAsync(AirGapNotification notification, CancellationToken cancellationToken = default);
}
/// <summary>
/// Service for managing air-gap notifications.
/// </summary>
public interface IAirGapNotificationService
{
/// <summary>
/// Sends a notification through all configured channels.
/// </summary>
Task SendAsync(AirGapNotification notification, CancellationToken cancellationToken = default);
/// <summary>
/// Sends a staleness-related notification.
/// </summary>
Task NotifyStalenessEventAsync(
string tenantId,
StalenessEventType eventType,
int ageSeconds,
int thresholdSeconds,
CancellationToken cancellationToken = default);
/// <summary>
/// Sends a bundle import notification.
/// </summary>
Task NotifyBundleImportAsync(
string tenantId,
string bundleId,
bool success,
string? error = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Sends a sealed-mode state change notification.
/// </summary>
Task NotifySealedStateChangeAsync(
string tenantId,
bool isSealed,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Default implementation of air-gap notification service.
/// </summary>
internal sealed class AirGapNotificationService : IAirGapNotificationService, IStalenessEventSink
{
private readonly IEnumerable<IAirGapNotificationChannel> _channels;
private readonly TimeProvider _timeProvider;
private readonly ILogger<AirGapNotificationService> _logger;
public AirGapNotificationService(
IEnumerable<IAirGapNotificationChannel> channels,
TimeProvider timeProvider,
ILogger<AirGapNotificationService> logger)
{
_channels = channels ?? [];
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task SendAsync(AirGapNotification notification, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(notification);
_logger.LogInformation(
"Sending air-gap notification {NotificationId}: {Type} for tenant {TenantId}",
notification.NotificationId, notification.Type, notification.TenantId);
var deliveryTasks = _channels.Select(channel =>
DeliverToChannelAsync(channel, notification, cancellationToken));
await Task.WhenAll(deliveryTasks).ConfigureAwait(false);
}
private async Task DeliverToChannelAsync(
IAirGapNotificationChannel channel,
AirGapNotification notification,
CancellationToken cancellationToken)
{
try
{
var delivered = await channel.DeliverAsync(notification, cancellationToken).ConfigureAwait(false);
if (delivered)
{
_logger.LogDebug(
"Notification {NotificationId} delivered via {Channel}",
notification.NotificationId, channel.ChannelName);
}
else
{
_logger.LogWarning(
"Notification {NotificationId} delivery to {Channel} returned false",
notification.NotificationId, channel.ChannelName);
}
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to deliver notification {NotificationId} via {Channel}",
notification.NotificationId, channel.ChannelName);
}
}
public async Task NotifyStalenessEventAsync(
string tenantId,
StalenessEventType eventType,
int ageSeconds,
int thresholdSeconds,
CancellationToken cancellationToken = default)
{
var (notificationType, severity, title, message) = eventType switch
{
StalenessEventType.Warning => (
AirGapNotificationType.StalenessWarning,
NotificationSeverity.Warning,
"Staleness Warning",
$"Time anchor age ({ageSeconds}s) approaching breach threshold ({thresholdSeconds}s)"),
StalenessEventType.Breach => (
AirGapNotificationType.StalenessBreach,
NotificationSeverity.Critical,
"Staleness Breach",
$"Time anchor staleness breached: age {ageSeconds}s exceeds threshold {thresholdSeconds}s"),
StalenessEventType.Recovered => (
AirGapNotificationType.StalenessRecovered,
NotificationSeverity.Info,
"Staleness Recovered",
"Time anchor has been refreshed, staleness recovered"),
StalenessEventType.AnchorMissing => (
AirGapNotificationType.TimeAnchorMissing,
NotificationSeverity.Error,
"Time Anchor Missing",
"Time anchor not configured in sealed mode"),
_ => (
AirGapNotificationType.StalenessWarning,
NotificationSeverity.Info,
"Staleness Event",
$"Staleness event: {eventType}")
};
var notification = new AirGapNotification(
NotificationId: GenerateNotificationId(),
TenantId: tenantId,
Type: notificationType,
Severity: severity,
Title: title,
Message: message,
OccurredAt: _timeProvider.GetUtcNow(),
Metadata: new Dictionary<string, object?>
{
["age_seconds"] = ageSeconds,
["threshold_seconds"] = thresholdSeconds,
["event_type"] = eventType.ToString()
});
await SendAsync(notification, cancellationToken).ConfigureAwait(false);
}
public async Task NotifyBundleImportAsync(
string tenantId,
string bundleId,
bool success,
string? error = null,
CancellationToken cancellationToken = default)
{
var (notificationType, severity, title, message) = success
? (
AirGapNotificationType.BundleImportCompleted,
NotificationSeverity.Info,
"Bundle Import Completed",
$"Policy pack bundle '{bundleId}' imported successfully")
: (
AirGapNotificationType.BundleImportFailed,
NotificationSeverity.Error,
"Bundle Import Failed",
$"Policy pack bundle '{bundleId}' import failed: {error ?? "unknown error"}");
var notification = new AirGapNotification(
NotificationId: GenerateNotificationId(),
TenantId: tenantId,
Type: notificationType,
Severity: severity,
Title: title,
Message: message,
OccurredAt: _timeProvider.GetUtcNow(),
Metadata: new Dictionary<string, object?>
{
["bundle_id"] = bundleId,
["success"] = success,
["error"] = error
});
await SendAsync(notification, cancellationToken).ConfigureAwait(false);
}
public async Task NotifySealedStateChangeAsync(
string tenantId,
bool isSealed,
CancellationToken cancellationToken = default)
{
var (notificationType, title, message) = isSealed
? (
AirGapNotificationType.EnvironmentSealed,
"Environment Sealed",
"Policy engine environment has been sealed for air-gap operation")
: (
AirGapNotificationType.EnvironmentUnsealed,
"Environment Unsealed",
"Policy engine environment has been unsealed");
var notification = new AirGapNotification(
NotificationId: GenerateNotificationId(),
TenantId: tenantId,
Type: notificationType,
Severity: NotificationSeverity.Info,
Title: title,
Message: message,
OccurredAt: _timeProvider.GetUtcNow(),
Metadata: new Dictionary<string, object?>
{
["sealed"] = isSealed
});
await SendAsync(notification, cancellationToken).ConfigureAwait(false);
}
// Implement IStalenessEventSink to auto-notify on staleness events
public Task OnStalenessEventAsync(StalenessEvent evt, CancellationToken cancellationToken = default)
{
return NotifyStalenessEventAsync(
evt.TenantId,
evt.Type,
evt.AgeSeconds,
evt.ThresholdSeconds,
cancellationToken);
}
private static string GenerateNotificationId()
{
return $"notify-{Guid.NewGuid():N}"[..24];
}
}
/// <summary>
/// Logging-based notification channel for observability.
/// </summary>
internal sealed class LoggingNotificationChannel : IAirGapNotificationChannel
{
private readonly ILogger<LoggingNotificationChannel> _logger;
public LoggingNotificationChannel(ILogger<LoggingNotificationChannel> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public string ChannelName => "Logging";
public Task<bool> DeliverAsync(AirGapNotification notification, CancellationToken cancellationToken = default)
{
var logLevel = notification.Severity switch
{
NotificationSeverity.Critical => LogLevel.Critical,
NotificationSeverity.Error => LogLevel.Error,
NotificationSeverity.Warning => LogLevel.Warning,
_ => LogLevel.Information
};
_logger.Log(
logLevel,
"[{NotificationType}] {Title}: {Message} (tenant={TenantId}, id={NotificationId})",
notification.Type,
notification.Title,
notification.Message,
notification.TenantId,
notification.NotificationId);
return Task.FromResult(true);
}
}
/// <summary>
/// Webhook-based notification channel for external integrations.
/// </summary>
internal sealed class WebhookNotificationChannel : IAirGapNotificationChannel
{
private readonly HttpClient _httpClient;
private readonly string _webhookUrl;
private readonly ILogger<WebhookNotificationChannel> _logger;
public WebhookNotificationChannel(
HttpClient httpClient,
string webhookUrl,
ILogger<WebhookNotificationChannel> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_webhookUrl = webhookUrl ?? throw new ArgumentNullException(nameof(webhookUrl));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public string ChannelName => $"Webhook({_webhookUrl})";
public async Task<bool> DeliverAsync(AirGapNotification notification, CancellationToken cancellationToken = default)
{
try
{
var payload = new
{
notification_id = notification.NotificationId,
tenant_id = notification.TenantId,
type = notification.Type.ToString(),
severity = notification.Severity.ToString(),
title = notification.Title,
message = notification.Message,
occurred_at = notification.OccurredAt.ToString("O"),
metadata = notification.Metadata
};
var response = await _httpClient.PostAsJsonAsync(_webhookUrl, payload, cancellationToken).ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
return true;
}
_logger.LogWarning(
"Webhook delivery returned {StatusCode} for notification {NotificationId}",
response.StatusCode, notification.NotificationId);
return false;
}
catch (Exception ex)
{
_logger.LogError(ex,
"Webhook delivery failed for notification {NotificationId} to {WebhookUrl}",
notification.NotificationId, _webhookUrl);
return false;
}
}
}

View File

@@ -0,0 +1,12 @@
namespace StellaOps.Policy.Engine.AirGap;
/// <summary>
/// Store for imported policy pack bundles.
/// </summary>
public interface IPolicyPackBundleStore
{
Task<ImportedPolicyPackBundle?> GetAsync(string bundleId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<ImportedPolicyPackBundle>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default);
Task SaveAsync(ImportedPolicyPackBundle bundle, CancellationToken cancellationToken = default);
Task DeleteAsync(string bundleId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,52 @@
namespace StellaOps.Policy.Engine.AirGap;
/// <summary>
/// Service for managing sealed-mode operations for policy packs per CONTRACT-SEALED-MODE-004.
/// </summary>
public interface ISealedModeService
{
/// <summary>
/// Gets whether the environment is currently sealed.
/// </summary>
bool IsSealed { get; }
/// <summary>
/// Gets the current sealed state for a tenant.
/// </summary>
Task<PolicyPackSealedState> GetStateAsync(string tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets the sealed status with staleness evaluation.
/// </summary>
Task<SealedStatusResponse> GetStatusAsync(string tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// Seals the environment for a tenant.
/// </summary>
Task<SealResponse> SealAsync(string tenantId, SealRequest request, CancellationToken cancellationToken = default);
/// <summary>
/// Unseals the environment for a tenant.
/// </summary>
Task<SealResponse> UnsealAsync(string tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// Evaluates staleness for the current time anchor.
/// </summary>
Task<StalenessEvaluation?> EvaluateStalenessAsync(string tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// Enforces sealed-mode constraints for bundle import operations.
/// </summary>
Task<SealedModeEnforcementResult> EnforceBundleImportAsync(
string tenantId,
string bundlePath,
CancellationToken cancellationToken = default);
/// <summary>
/// Verifies a bundle against trust roots.
/// </summary>
Task<BundleVerifyResponse> VerifyBundleAsync(
BundleVerifyRequest request,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,10 @@
namespace StellaOps.Policy.Engine.AirGap;
/// <summary>
/// Store for sealed-mode state persistence.
/// </summary>
public interface ISealedModeStateStore
{
Task<PolicyPackSealedState?> GetAsync(string tenantId, CancellationToken cancellationToken = default);
Task SaveAsync(PolicyPackSealedState state, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,47 @@
using System.Collections.Concurrent;
namespace StellaOps.Policy.Engine.AirGap;
/// <summary>
/// In-memory implementation of policy pack bundle store.
/// </summary>
internal sealed class InMemoryPolicyPackBundleStore : IPolicyPackBundleStore
{
private readonly ConcurrentDictionary<string, ImportedPolicyPackBundle> _bundles = new(StringComparer.Ordinal);
public Task<ImportedPolicyPackBundle?> GetAsync(string bundleId, CancellationToken cancellationToken = default)
{
_bundles.TryGetValue(bundleId, out var bundle);
return Task.FromResult(bundle);
}
public Task<IReadOnlyList<ImportedPolicyPackBundle>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
IEnumerable<ImportedPolicyPackBundle> bundles = _bundles.Values;
if (!string.IsNullOrWhiteSpace(tenantId))
{
bundles = bundles.Where(b => string.Equals(b.TenantId, tenantId, StringComparison.Ordinal));
}
var ordered = bundles
.OrderByDescending(b => b.ImportedAt)
.ThenBy(b => b.BundleId, StringComparer.Ordinal)
.ToList();
return Task.FromResult<IReadOnlyList<ImportedPolicyPackBundle>>(ordered);
}
public Task SaveAsync(ImportedPolicyPackBundle bundle, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(bundle);
_bundles[bundle.BundleId] = bundle;
return Task.CompletedTask;
}
public Task DeleteAsync(string bundleId, CancellationToken cancellationToken = default)
{
_bundles.TryRemove(bundleId, out _);
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,24 @@
using System.Collections.Concurrent;
namespace StellaOps.Policy.Engine.AirGap;
/// <summary>
/// In-memory implementation of sealed-mode state store.
/// </summary>
internal sealed class InMemorySealedModeStateStore : ISealedModeStateStore
{
private readonly ConcurrentDictionary<string, PolicyPackSealedState> _states = new(StringComparer.Ordinal);
public Task<PolicyPackSealedState?> GetAsync(string tenantId, CancellationToken cancellationToken = default)
{
_states.TryGetValue(tenantId, out var state);
return Task.FromResult(state);
}
public Task SaveAsync(PolicyPackSealedState state, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(state);
_states[state.TenantId] = state;
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,265 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace StellaOps.Policy.Engine.AirGap;
/// <summary>
/// Service for importing policy pack bundles per CONTRACT-MIRROR-BUNDLE-003.
/// </summary>
internal sealed class PolicyPackBundleImportService
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly IPolicyPackBundleStore _store;
private readonly ISealedModeService? _sealedModeService;
private readonly TimeProvider _timeProvider;
private readonly ILogger<PolicyPackBundleImportService> _logger;
public PolicyPackBundleImportService(
IPolicyPackBundleStore store,
TimeProvider timeProvider,
ILogger<PolicyPackBundleImportService> logger,
ISealedModeService? sealedModeService = null)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_sealedModeService = sealedModeService;
}
/// <summary>
/// Registers a bundle for import and begins validation.
/// </summary>
public async Task<RegisterBundleResponse> RegisterBundleAsync(
string tenantId,
RegisterBundleRequest request,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(request.BundlePath);
// Enforce sealed-mode constraints
if (_sealedModeService is not null)
{
var enforcement = await _sealedModeService.EnforceBundleImportAsync(
tenantId, request.BundlePath, cancellationToken).ConfigureAwait(false);
if (!enforcement.Allowed)
{
_logger.LogWarning("Bundle import blocked by sealed-mode: {Reason}", enforcement.Reason);
throw new InvalidOperationException(
$"Bundle import blocked: {enforcement.Reason}. {enforcement.Remediation}");
}
}
var now = _timeProvider.GetUtcNow();
var importId = GenerateImportId();
_logger.LogInformation("Registering bundle import {ImportId} from {BundlePath} for tenant {TenantId}",
importId, request.BundlePath, tenantId);
// Create initial entry in validating state
var entry = new ImportedPolicyPackBundle(
BundleId: importId,
DomainId: BundleDomainIds.PolicyPacks,
TenantId: tenantId,
Status: BundleImportStatus.Validating,
ExportCount: 0,
ImportedAt: now.ToString("O"),
Error: null,
Bundle: null);
await _store.SaveAsync(entry, cancellationToken).ConfigureAwait(false);
// Start async import process
_ = ImportBundleAsync(tenantId, importId, request, cancellationToken);
return new RegisterBundleResponse(importId, BundleImportStatus.Validating);
}
/// <summary>
/// Gets the status of a bundle import.
/// </summary>
public async Task<BundleStatusResponse?> GetBundleStatusAsync(
string bundleId,
CancellationToken cancellationToken = default)
{
var bundle = await _store.GetAsync(bundleId, cancellationToken).ConfigureAwait(false);
if (bundle is null)
{
return null;
}
return new BundleStatusResponse(
BundleId: bundle.BundleId,
DomainId: bundle.DomainId,
Status: bundle.Status,
ExportCount: bundle.ExportCount,
ImportedAt: bundle.ImportedAt,
Error: bundle.Error);
}
/// <summary>
/// Lists imported bundles for a tenant.
/// </summary>
public async Task<IReadOnlyList<BundleStatusResponse>> ListBundlesAsync(
string? tenantId = null,
CancellationToken cancellationToken = default)
{
var bundles = await _store.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
return bundles.Select(b => new BundleStatusResponse(
BundleId: b.BundleId,
DomainId: b.DomainId,
Status: b.Status,
ExportCount: b.ExportCount,
ImportedAt: b.ImportedAt,
Error: b.Error)).ToList();
}
private async Task ImportBundleAsync(
string tenantId,
string importId,
RegisterBundleRequest request,
CancellationToken cancellationToken)
{
try
{
_logger.LogInformation("Starting bundle import {ImportId}", importId);
// Update status to importing
var current = await _store.GetAsync(importId, cancellationToken).ConfigureAwait(false);
if (current is null)
{
return;
}
await _store.SaveAsync(current with { Status = BundleImportStatus.Importing }, cancellationToken).ConfigureAwait(false);
// Load and parse bundle
var bundle = await LoadBundleAsync(request.BundlePath, cancellationToken).ConfigureAwait(false);
// Validate bundle
ValidateBundle(bundle);
// Verify signatures if present
if (bundle.Signature is not null)
{
await VerifySignatureAsync(bundle, request.TrustRootsPath, cancellationToken).ConfigureAwait(false);
}
// Verify export digests
VerifyExportDigests(bundle);
// Mark as imported
var now = _timeProvider.GetUtcNow();
var imported = new ImportedPolicyPackBundle(
BundleId: importId,
DomainId: bundle.DomainId,
TenantId: tenantId,
Status: BundleImportStatus.Imported,
ExportCount: bundle.Exports.Count,
ImportedAt: now.ToString("O"),
Error: null,
Bundle: bundle);
await _store.SaveAsync(imported, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Bundle import {ImportId} completed successfully with {ExportCount} exports",
importId, bundle.Exports.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "Bundle import {ImportId} failed: {Error}", importId, ex.Message);
var failed = await _store.GetAsync(importId, CancellationToken.None).ConfigureAwait(false);
if (failed is not null)
{
await _store.SaveAsync(failed with
{
Status = BundleImportStatus.Failed,
Error = ex.Message
}, CancellationToken.None).ConfigureAwait(false);
}
}
}
private static async Task<PolicyPackBundle> LoadBundleAsync(string bundlePath, CancellationToken cancellationToken)
{
if (!File.Exists(bundlePath))
{
throw new FileNotFoundException($"Bundle file not found: {bundlePath}");
}
var json = await File.ReadAllTextAsync(bundlePath, cancellationToken).ConfigureAwait(false);
var bundle = JsonSerializer.Deserialize<PolicyPackBundle>(json, JsonOptions)
?? throw new InvalidDataException("Failed to parse bundle JSON");
return bundle;
}
private static void ValidateBundle(PolicyPackBundle bundle)
{
if (bundle.SchemaVersion < 1)
{
throw new InvalidDataException("Invalid schema version");
}
if (string.IsNullOrWhiteSpace(bundle.DomainId))
{
throw new InvalidDataException("Domain ID is required");
}
if (bundle.Exports.Count == 0)
{
throw new InvalidDataException("Bundle must contain at least one export");
}
foreach (var export in bundle.Exports)
{
if (string.IsNullOrWhiteSpace(export.Key))
{
throw new InvalidDataException("Export key is required");
}
if (string.IsNullOrWhiteSpace(export.ArtifactDigest))
{
throw new InvalidDataException($"Artifact digest is required for export '{export.Key}'");
}
}
}
private Task VerifySignatureAsync(PolicyPackBundle bundle, string? trustRootsPath, CancellationToken cancellationToken)
{
// Signature verification would integrate with the AirGap.Importer DsseVerifier
// For now, log that signature is present
_logger.LogInformation("Bundle signature present: algorithm={Algorithm}, keyId={KeyId}",
bundle.Signature!.Algorithm, bundle.Signature.KeyId);
return Task.CompletedTask;
}
private void VerifyExportDigests(PolicyPackBundle bundle)
{
foreach (var export in bundle.Exports)
{
// Verify digest format
if (!export.ArtifactDigest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidDataException($"Invalid digest format for export '{export.Key}': expected sha256: prefix");
}
_logger.LogDebug("Verified export '{Key}' with digest {Digest}",
export.Key, export.ArtifactDigest);
}
}
private static string GenerateImportId()
{
return $"import-{Guid.NewGuid():N}"[..20];
}
}

View File

@@ -0,0 +1,113 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Engine.AirGap;
/// <summary>
/// Mirror bundle for policy packs per CONTRACT-MIRROR-BUNDLE-003.
/// </summary>
public sealed record PolicyPackBundle(
[property: JsonPropertyName("schemaVersion")] int SchemaVersion,
[property: JsonPropertyName("generatedAt")] string GeneratedAt,
[property: JsonPropertyName("targetRepository")] string? TargetRepository,
[property: JsonPropertyName("domainId")] string DomainId,
[property: JsonPropertyName("displayName")] string? DisplayName,
[property: JsonPropertyName("exports")] IReadOnlyList<PolicyPackExport> Exports,
[property: JsonPropertyName("signature")] BundleSignature? Signature);
/// <summary>
/// Export entry within a policy pack bundle.
/// </summary>
public sealed record PolicyPackExport(
[property: JsonPropertyName("key")] string Key,
[property: JsonPropertyName("format")] string Format,
[property: JsonPropertyName("exportId")] string ExportId,
[property: JsonPropertyName("querySignature")] string? QuerySignature,
[property: JsonPropertyName("createdAt")] string CreatedAt,
[property: JsonPropertyName("artifactSizeBytes")] long ArtifactSizeBytes,
[property: JsonPropertyName("artifactDigest")] string ArtifactDigest,
[property: JsonPropertyName("sourceProviders")] IReadOnlyList<string>? SourceProviders,
[property: JsonPropertyName("consensusRevision")] string? ConsensusRevision,
[property: JsonPropertyName("policyRevisionId")] string? PolicyRevisionId,
[property: JsonPropertyName("policyDigest")] string? PolicyDigest,
[property: JsonPropertyName("consensusDigest")] string? ConsensusDigest,
[property: JsonPropertyName("scoreDigest")] string? ScoreDigest,
[property: JsonPropertyName("attestation")] AttestationDescriptor? Attestation);
/// <summary>
/// Attestation metadata for signed exports.
/// </summary>
public sealed record AttestationDescriptor(
[property: JsonPropertyName("predicateType")] string PredicateType,
[property: JsonPropertyName("rekorLocation")] string? RekorLocation,
[property: JsonPropertyName("envelopeDigest")] string? EnvelopeDigest,
[property: JsonPropertyName("signedAt")] string SignedAt);
/// <summary>
/// Bundle signature metadata.
/// </summary>
public sealed record BundleSignature(
[property: JsonPropertyName("path")] string Path,
[property: JsonPropertyName("algorithm")] string Algorithm,
[property: JsonPropertyName("keyId")] string KeyId,
[property: JsonPropertyName("provider")] string? Provider,
[property: JsonPropertyName("signedAt")] string SignedAt);
/// <summary>
/// Request to register a bundle for import.
/// </summary>
public sealed record RegisterBundleRequest(
[property: JsonPropertyName("bundlePath")] string BundlePath,
[property: JsonPropertyName("trustRootsPath")] string? TrustRootsPath);
/// <summary>
/// Response for bundle registration.
/// </summary>
public sealed record RegisterBundleResponse(
[property: JsonPropertyName("importId")] string ImportId,
[property: JsonPropertyName("status")] string Status);
/// <summary>
/// Bundle import status response.
/// </summary>
public sealed record BundleStatusResponse(
[property: JsonPropertyName("bundleId")] string BundleId,
[property: JsonPropertyName("domainId")] string DomainId,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("exportCount")] int ExportCount,
[property: JsonPropertyName("importedAt")] string? ImportedAt,
[property: JsonPropertyName("error")] string? Error);
/// <summary>
/// Imported bundle catalog entry.
/// </summary>
public sealed record ImportedPolicyPackBundle(
string BundleId,
string DomainId,
string TenantId,
string Status,
int ExportCount,
string ImportedAt,
string? Error,
PolicyPackBundle? Bundle);
/// <summary>
/// Bundle import status values.
/// </summary>
public static class BundleImportStatus
{
public const string Validating = "validating";
public const string Importing = "importing";
public const string Imported = "imported";
public const string Failed = "failed";
}
/// <summary>
/// Domain IDs per CONTRACT-MIRROR-BUNDLE-003.
/// </summary>
public static class BundleDomainIds
{
public const string VexAdvisories = "vex-advisories";
public const string VulnerabilityFeeds = "vulnerability-feeds";
public const string PolicyPacks = "policy-packs";
public const string SbomCatalog = "sbom-catalog";
}

View File

@@ -0,0 +1,544 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using StellaOps.Cryptography;
using StellaOps.Policy.RiskProfile.Export;
using StellaOps.Policy.RiskProfile.Hashing;
using StellaOps.Policy.RiskProfile.Models;
namespace StellaOps.Policy.Engine.AirGap;
/// <summary>
/// Air-gap export/import for risk profiles per CONTRACT-MIRROR-BUNDLE-003.
/// </summary>
public sealed class RiskProfileAirGapExportService
{
private const string FormatVersion = "1.0";
private const string DomainId = "risk-profiles";
private const string PredicateType = "https://stella.ops/attestation/risk-profile/v1";
private readonly ICryptoHash _cryptoHash;
private readonly TimeProvider _timeProvider;
private readonly ISealedModeService? _sealedModeService;
private readonly RiskProfileHasher _hasher;
private readonly ILogger<RiskProfileAirGapExportService> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public RiskProfileAirGapExportService(
ICryptoHash cryptoHash,
TimeProvider timeProvider,
ILogger<RiskProfileAirGapExportService> logger,
ISealedModeService? sealedModeService = null)
{
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_sealedModeService = sealedModeService;
_hasher = new RiskProfileHasher(cryptoHash);
}
/// <summary>
/// Creates an air-gap compatible bundle from risk profiles.
/// </summary>
public async Task<RiskProfileAirGapBundle> ExportAsync(
IReadOnlyList<RiskProfileModel> profiles,
AirGapExportRequest request,
string? tenantId = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(profiles);
ArgumentNullException.ThrowIfNull(request);
var now = _timeProvider.GetUtcNow();
var bundleId = GenerateBundleId(now);
_logger.LogInformation("Creating air-gap bundle {BundleId} with {Count} profiles",
bundleId, profiles.Count);
// Create exports for each profile
var exports = new List<RiskProfileAirGapExport>();
foreach (var profile in profiles)
{
var contentHash = _hasher.ComputeContentHash(profile);
var profileJson = JsonSerializer.Serialize(profile, JsonOptions);
var artifactDigest = ComputeArtifactDigest(profileJson);
var export = new RiskProfileAirGapExport(
Key: $"profile-{profile.Id}-{profile.Version}",
Format: "json",
ExportId: Guid.NewGuid().ToString("N")[..16],
ProfileId: profile.Id,
ProfileVersion: profile.Version,
CreatedAt: now.ToString("O"),
ArtifactSizeBytes: Encoding.UTF8.GetByteCount(profileJson),
ArtifactDigest: artifactDigest,
ContentHash: contentHash,
ProfileDigest: ComputeProfileDigest(profile),
Attestation: request.SignBundle ? CreateAttestation(now) : null);
exports.Add(export);
}
// Compute bundle-level Merkle root
var merkleRoot = ComputeMerkleRoot(exports);
// Create signature if requested
BundleSignature? signature = null;
if (request.SignBundle)
{
signature = await CreateSignatureAsync(
exports, merkleRoot, request.KeyId, now, cancellationToken).ConfigureAwait(false);
}
return new RiskProfileAirGapBundle(
SchemaVersion: 1,
GeneratedAt: now.ToString("O"),
TargetRepository: request.TargetRepository,
DomainId: DomainId,
DisplayName: request.DisplayName ?? "Risk Profiles Export",
TenantId: tenantId,
Exports: exports.AsReadOnly(),
MerkleRoot: merkleRoot,
Signature: signature,
Profiles: profiles);
}
/// <summary>
/// Imports profiles from an air-gap bundle with sealed-mode enforcement.
/// </summary>
public async Task<RiskProfileAirGapImportResult> ImportAsync(
RiskProfileAirGapBundle bundle,
AirGapImportRequest request,
string tenantId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(bundle);
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var details = new List<RiskProfileAirGapImportDetail>();
var errors = new List<string>();
// Enforce sealed-mode constraints
if (_sealedModeService is not null && request.EnforceSealedMode)
{
// Pass bundle domain ID as path identifier for sealed-mode enforcement
var enforcement = await _sealedModeService.EnforceBundleImportAsync(
tenantId, $"risk-profile-bundle:{bundle.DomainId}", cancellationToken).ConfigureAwait(false);
if (!enforcement.Allowed)
{
_logger.LogWarning("Air-gap profile import blocked by sealed-mode: {Reason}",
enforcement.Reason);
return new RiskProfileAirGapImportResult(
BundleId: bundle.GeneratedAt,
Success: false,
TotalCount: bundle.Exports.Count,
ImportedCount: 0,
SkippedCount: 0,
ErrorCount: bundle.Exports.Count,
Details: details.AsReadOnly(),
Errors: new[] { $"Sealed-mode blocked: {enforcement.Reason}. {enforcement.Remediation}" },
SignatureVerified: false,
MerkleVerified: false);
}
}
// Verify signature if present and requested
bool? signatureVerified = null;
if (request.VerifySignature && bundle.Signature is not null)
{
signatureVerified = VerifySignature(bundle);
if (!signatureVerified.Value)
{
errors.Add("Bundle signature verification failed");
if (request.RejectOnSignatureFailure)
{
return new RiskProfileAirGapImportResult(
BundleId: bundle.GeneratedAt,
Success: false,
TotalCount: bundle.Exports.Count,
ImportedCount: 0,
SkippedCount: 0,
ErrorCount: bundle.Exports.Count,
Details: details.AsReadOnly(),
Errors: errors.AsReadOnly(),
SignatureVerified: false,
MerkleVerified: null);
}
}
}
// Verify Merkle root
bool? merkleVerified = null;
if (request.VerifyMerkle && !string.IsNullOrEmpty(bundle.MerkleRoot))
{
var computedMerkle = ComputeMerkleRoot(bundle.Exports.ToList());
merkleVerified = string.Equals(computedMerkle, bundle.MerkleRoot, StringComparison.OrdinalIgnoreCase);
if (!merkleVerified.Value)
{
errors.Add("Merkle root verification failed - bundle may have been tampered with");
if (request.RejectOnMerkleFailure)
{
return new RiskProfileAirGapImportResult(
BundleId: bundle.GeneratedAt,
Success: false,
TotalCount: bundle.Exports.Count,
ImportedCount: 0,
SkippedCount: 0,
ErrorCount: bundle.Exports.Count,
Details: details.AsReadOnly(),
Errors: errors.AsReadOnly(),
SignatureVerified: signatureVerified,
MerkleVerified: false);
}
}
}
// Verify individual exports
var importedCount = 0;
var skippedCount = 0;
var errorCount = 0;
if (bundle.Profiles is not null)
{
for (var i = 0; i < bundle.Exports.Count; i++)
{
var export = bundle.Exports[i];
var profile = bundle.Profiles.FirstOrDefault(p =>
p.Id == export.ProfileId && p.Version == export.ProfileVersion);
if (profile is null)
{
details.Add(new RiskProfileAirGapImportDetail(
ProfileId: export.ProfileId,
Version: export.ProfileVersion,
Status: AirGapImportStatus.Error,
Message: "Profile data missing from bundle"));
errorCount++;
continue;
}
// Verify content hash
var computedHash = _hasher.ComputeContentHash(profile);
if (!string.Equals(computedHash, export.ContentHash, StringComparison.OrdinalIgnoreCase))
{
details.Add(new RiskProfileAirGapImportDetail(
ProfileId: export.ProfileId,
Version: export.ProfileVersion,
Status: AirGapImportStatus.Error,
Message: "Content hash mismatch - profile may have been modified"));
errorCount++;
continue;
}
// Import successful
details.Add(new RiskProfileAirGapImportDetail(
ProfileId: export.ProfileId,
Version: export.ProfileVersion,
Status: AirGapImportStatus.Imported,
Message: null));
importedCount++;
}
}
var success = errorCount == 0 && errors.Count == 0;
_logger.LogInformation(
"Air-gap import completed: success={Success}, imported={Imported}, skipped={Skipped}, errors={Errors}",
success, importedCount, skippedCount, errorCount);
return new RiskProfileAirGapImportResult(
BundleId: bundle.GeneratedAt,
Success: success,
TotalCount: bundle.Exports.Count,
ImportedCount: importedCount,
SkippedCount: skippedCount,
ErrorCount: errorCount,
Details: details.AsReadOnly(),
Errors: errors.AsReadOnly(),
SignatureVerified: signatureVerified,
MerkleVerified: merkleVerified);
}
/// <summary>
/// Verifies bundle integrity without importing.
/// </summary>
public AirGapBundleVerification Verify(RiskProfileAirGapBundle bundle)
{
ArgumentNullException.ThrowIfNull(bundle);
var signatureValid = bundle.Signature is not null && VerifySignature(bundle);
var merkleValid = !string.IsNullOrEmpty(bundle.MerkleRoot) &&
string.Equals(ComputeMerkleRoot(bundle.Exports.ToList()), bundle.MerkleRoot, StringComparison.OrdinalIgnoreCase);
var exportDigestResults = new List<ExportDigestVerification>();
if (bundle.Profiles is not null)
{
foreach (var export in bundle.Exports)
{
var profile = bundle.Profiles.FirstOrDefault(p =>
p.Id == export.ProfileId && p.Version == export.ProfileVersion);
var valid = profile is not null &&
string.Equals(_hasher.ComputeContentHash(profile), export.ContentHash, StringComparison.OrdinalIgnoreCase);
exportDigestResults.Add(new ExportDigestVerification(
ExportKey: export.Key,
ProfileId: export.ProfileId,
Valid: valid));
}
}
return new AirGapBundleVerification(
SignatureValid: signatureValid,
MerkleValid: merkleValid,
ExportDigests: exportDigestResults.AsReadOnly(),
AllValid: signatureValid && merkleValid && exportDigestResults.All(e => e.Valid));
}
private bool VerifySignature(RiskProfileAirGapBundle bundle)
{
if (bundle.Signature is null)
{
return false;
}
// Compute expected signature from exports and Merkle root
var data = ComputeSignatureData(bundle.Exports.ToList(), bundle.MerkleRoot ?? "");
var expectedSignature = ComputeHmacSignature(data, GetSigningKey(bundle.Signature.KeyId));
return string.Equals(expectedSignature, bundle.Signature.Path, StringComparison.OrdinalIgnoreCase);
}
private async Task<BundleSignature> CreateSignatureAsync(
IReadOnlyList<RiskProfileAirGapExport> exports,
string merkleRoot,
string? keyId,
DateTimeOffset signedAt,
CancellationToken cancellationToken)
{
var data = ComputeSignatureData(exports.ToList(), merkleRoot);
var signatureValue = ComputeHmacSignature(data, GetSigningKey(keyId));
return new BundleSignature(
Path: signatureValue,
Algorithm: "HMAC-SHA256",
KeyId: keyId ?? "default",
Provider: "stellaops",
SignedAt: signedAt.ToString("O"));
}
private static string ComputeSignatureData(List<RiskProfileAirGapExport> exports, string merkleRoot)
{
var sb = new StringBuilder();
foreach (var export in exports.OrderBy(e => e.Key))
{
sb.Append(export.ContentHash);
sb.Append('|');
}
sb.Append(merkleRoot);
return sb.ToString();
}
private static string ComputeHmacSignature(string data, string key)
{
var keyBytes = Encoding.UTF8.GetBytes(key);
var dataBytes = Encoding.UTF8.GetBytes(data);
using var hmac = new HMACSHA256(keyBytes);
var hashBytes = hmac.ComputeHash(dataBytes);
return Convert.ToHexStringLower(hashBytes);
}
private string ComputeMerkleRoot(List<RiskProfileAirGapExport> exports)
{
if (exports.Count == 0)
{
return string.Empty;
}
// Leaf hashes from artifact digests
var leaves = exports
.OrderBy(e => e.Key)
.Select(e => e.ArtifactDigest.Replace("sha256:", "", StringComparison.OrdinalIgnoreCase))
.ToList();
// Build Merkle tree
while (leaves.Count > 1)
{
var nextLevel = new List<string>();
for (var i = 0; i < leaves.Count; i += 2)
{
if (i + 1 < leaves.Count)
{
var combined = leaves[i] + leaves[i + 1];
nextLevel.Add(ComputeSha256(combined));
}
else
{
nextLevel.Add(leaves[i]);
}
}
leaves = nextLevel;
}
return $"sha256:{leaves[0]}";
}
private string ComputeArtifactDigest(string content)
{
return $"sha256:{_cryptoHash.ComputeHashHexForPurpose(
Encoding.UTF8.GetBytes(content), HashPurpose.Content)}";
}
private string ComputeProfileDigest(RiskProfileModel profile)
{
var json = JsonSerializer.Serialize(profile, JsonOptions);
return ComputeArtifactDigest(json);
}
private static string ComputeSha256(string input)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return Convert.ToHexStringLower(bytes);
}
private AttestationDescriptor CreateAttestation(DateTimeOffset signedAt)
{
return new AttestationDescriptor(
PredicateType: PredicateType,
RekorLocation: null,
EnvelopeDigest: null,
SignedAt: signedAt.ToString("O"));
}
private static string GenerateBundleId(DateTimeOffset timestamp)
{
return $"rpab-{timestamp:yyyyMMddHHmmss}-{Guid.NewGuid():N}"[..24];
}
private static string GetSigningKey(string? keyId)
{
// In production, this would look up the key from secure storage
return "stellaops-airgap-signing-key-change-in-production";
}
}
#region Models
/// <summary>
/// Air-gap bundle for risk profiles per CONTRACT-MIRROR-BUNDLE-003.
/// </summary>
public sealed record RiskProfileAirGapBundle(
[property: JsonPropertyName("schemaVersion")] int SchemaVersion,
[property: JsonPropertyName("generatedAt")] string GeneratedAt,
[property: JsonPropertyName("targetRepository")] string? TargetRepository,
[property: JsonPropertyName("domainId")] string DomainId,
[property: JsonPropertyName("displayName")] string? DisplayName,
[property: JsonPropertyName("tenantId")] string? TenantId,
[property: JsonPropertyName("exports")] IReadOnlyList<RiskProfileAirGapExport> Exports,
[property: JsonPropertyName("merkleRoot")] string? MerkleRoot,
[property: JsonPropertyName("signature")] BundleSignature? Signature,
[property: JsonPropertyName("profiles")] IReadOnlyList<RiskProfileModel>? Profiles);
/// <summary>
/// Export entry for a risk profile.
/// </summary>
public sealed record RiskProfileAirGapExport(
[property: JsonPropertyName("key")] string Key,
[property: JsonPropertyName("format")] string Format,
[property: JsonPropertyName("exportId")] string ExportId,
[property: JsonPropertyName("profileId")] string ProfileId,
[property: JsonPropertyName("profileVersion")] string ProfileVersion,
[property: JsonPropertyName("createdAt")] string CreatedAt,
[property: JsonPropertyName("artifactSizeBytes")] long ArtifactSizeBytes,
[property: JsonPropertyName("artifactDigest")] string ArtifactDigest,
[property: JsonPropertyName("contentHash")] string ContentHash,
[property: JsonPropertyName("profileDigest")] string? ProfileDigest,
[property: JsonPropertyName("attestation")] AttestationDescriptor? Attestation);
/// <summary>
/// Request to create an air-gap export.
/// </summary>
public sealed record AirGapExportRequest(
bool SignBundle = true,
string? KeyId = null,
string? TargetRepository = null,
string? DisplayName = null);
/// <summary>
/// Request to import from an air-gap bundle.
/// </summary>
public sealed record AirGapImportRequest(
bool VerifySignature = true,
bool VerifyMerkle = true,
bool EnforceSealedMode = true,
bool RejectOnSignatureFailure = true,
bool RejectOnMerkleFailure = true);
/// <summary>
/// Result of air-gap import.
/// </summary>
public sealed record RiskProfileAirGapImportResult(
string BundleId,
bool Success,
int TotalCount,
int ImportedCount,
int SkippedCount,
int ErrorCount,
IReadOnlyList<RiskProfileAirGapImportDetail> Details,
IReadOnlyList<string> Errors,
bool? SignatureVerified,
bool? MerkleVerified);
/// <summary>
/// Import detail for a single profile.
/// </summary>
public sealed record RiskProfileAirGapImportDetail(
string ProfileId,
string Version,
AirGapImportStatus Status,
string? Message);
/// <summary>
/// Import status values.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<AirGapImportStatus>))]
public enum AirGapImportStatus
{
Imported,
Skipped,
Error
}
/// <summary>
/// Bundle verification result.
/// </summary>
public sealed record AirGapBundleVerification(
bool SignatureValid,
bool MerkleValid,
IReadOnlyList<ExportDigestVerification> ExportDigests,
bool AllValid);
/// <summary>
/// Export digest verification result.
/// </summary>
public sealed record ExportDigestVerification(
string ExportKey,
string ProfileId,
bool Valid);
#endregion

View File

@@ -0,0 +1,255 @@
namespace StellaOps.Policy.Engine.AirGap;
/// <summary>
/// Error codes for sealed-mode operations per CONTRACT-SEALED-MODE-004.
/// </summary>
public static class SealedModeErrorCodes
{
/// <summary>Time anchor missing when required.</summary>
public const string AnchorMissing = "ERR_AIRGAP_001";
/// <summary>Time anchor staleness breached.</summary>
public const string StalenessBreach = "ERR_AIRGAP_002";
/// <summary>Time anchor staleness warning threshold exceeded.</summary>
public const string StalenessWarning = "ERR_AIRGAP_003";
/// <summary>Bundle signature verification failed.</summary>
public const string SignatureInvalid = "ERR_AIRGAP_004";
/// <summary>Bundle format or structure invalid.</summary>
public const string BundleInvalid = "ERR_AIRGAP_005";
/// <summary>Egress blocked in sealed mode.</summary>
public const string EgressBlocked = "ERR_AIRGAP_006";
/// <summary>Seal operation failed.</summary>
public const string SealFailed = "ERR_AIRGAP_007";
/// <summary>Unseal operation failed.</summary>
public const string UnsealFailed = "ERR_AIRGAP_008";
/// <summary>Trust roots not found or invalid.</summary>
public const string TrustRootsInvalid = "ERR_AIRGAP_009";
/// <summary>Bundle import blocked by policy.</summary>
public const string ImportBlocked = "ERR_AIRGAP_010";
/// <summary>Policy hash mismatch.</summary>
public const string PolicyHashMismatch = "ERR_AIRGAP_011";
/// <summary>Startup blocked due to sealed-mode requirements.</summary>
public const string StartupBlocked = "ERR_AIRGAP_012";
}
/// <summary>
/// Problem types for sealed-mode errors (RFC 7807 compatible).
/// </summary>
public static class SealedModeProblemTypes
{
private const string BaseUri = "https://stellaops.org/problems/airgap";
public static readonly string AnchorMissing = $"{BaseUri}/anchor-missing";
public static readonly string StalenessBreach = $"{BaseUri}/staleness-breach";
public static readonly string StalenessWarning = $"{BaseUri}/staleness-warning";
public static readonly string SignatureInvalid = $"{BaseUri}/signature-invalid";
public static readonly string BundleInvalid = $"{BaseUri}/bundle-invalid";
public static readonly string EgressBlocked = $"{BaseUri}/egress-blocked";
public static readonly string SealFailed = $"{BaseUri}/seal-failed";
public static readonly string UnsealFailed = $"{BaseUri}/unseal-failed";
public static readonly string TrustRootsInvalid = $"{BaseUri}/trust-roots-invalid";
public static readonly string ImportBlocked = $"{BaseUri}/import-blocked";
public static readonly string PolicyHashMismatch = $"{BaseUri}/policy-hash-mismatch";
public static readonly string StartupBlocked = $"{BaseUri}/startup-blocked";
}
/// <summary>
/// Structured error details for sealed-mode problems.
/// </summary>
public sealed record SealedModeErrorDetails(
string Code,
string Message,
string? Remediation = null,
string? DocumentationUrl = null,
IDictionary<string, object?>? Extensions = null);
/// <summary>
/// Represents a sealed-mode violation that occurred during an operation.
/// </summary>
public class SealedModeException : Exception
{
public SealedModeException(
string code,
string message,
string? remediation = null)
: base(message)
{
Code = code;
Remediation = remediation;
}
public SealedModeException(
string code,
string message,
Exception innerException,
string? remediation = null)
: base(message, innerException)
{
Code = code;
Remediation = remediation;
}
/// <summary>
/// Gets the error code for this exception.
/// </summary>
public string Code { get; }
/// <summary>
/// Gets optional remediation guidance.
/// </summary>
public string? Remediation { get; }
/// <summary>
/// Creates an exception for time anchor missing.
/// </summary>
public static SealedModeException AnchorMissing(string tenantId) =>
new(SealedModeErrorCodes.AnchorMissing,
$"Time anchor required for tenant '{tenantId}' in sealed mode",
"Provide a verified time anchor using POST /system/airgap/seal");
/// <summary>
/// Creates an exception for staleness breach.
/// </summary>
public static SealedModeException StalenessBreach(string tenantId, int ageSeconds, int thresholdSeconds) =>
new(SealedModeErrorCodes.StalenessBreach,
$"Time anchor staleness breached for tenant '{tenantId}': age {ageSeconds}s exceeds threshold {thresholdSeconds}s",
"Refresh time anchor before continuing operations");
/// <summary>
/// Creates an exception for egress blocked.
/// </summary>
public static SealedModeException EgressBlocked(string destination, string? reason = null) =>
new(SealedModeErrorCodes.EgressBlocked,
$"Egress to '{destination}' blocked in sealed mode" + (reason is not null ? $": {reason}" : ""),
"Add destination to egress allowlist or unseal environment");
/// <summary>
/// Creates an exception for bundle import blocked.
/// </summary>
public static SealedModeException ImportBlocked(string bundlePath, string reason) =>
new(SealedModeErrorCodes.ImportBlocked,
$"Bundle import blocked: {reason}",
"Ensure time anchor is fresh and bundle is properly signed");
/// <summary>
/// Creates an exception for invalid bundle.
/// </summary>
public static SealedModeException BundleInvalid(string bundlePath, string reason) =>
new(SealedModeErrorCodes.BundleInvalid,
$"Bundle '{bundlePath}' is invalid: {reason}",
"Verify bundle format and content integrity");
/// <summary>
/// Creates an exception for signature verification failure.
/// </summary>
public static SealedModeException SignatureInvalid(string bundlePath, string reason) =>
new(SealedModeErrorCodes.SignatureInvalid,
$"Bundle signature verification failed for '{bundlePath}': {reason}",
"Ensure bundle is signed by trusted key and trust roots are properly configured");
/// <summary>
/// Creates an exception for startup blocked.
/// </summary>
public static SealedModeException StartupBlocked(string reason) =>
new(SealedModeErrorCodes.StartupBlocked,
$"Startup blocked in sealed mode: {reason}",
"Resolve sealed-mode requirements before starting the service");
}
/// <summary>
/// Result helper for converting sealed-mode errors to HTTP problem details.
/// </summary>
public static class SealedModeResultHelper
{
/// <summary>
/// Creates a problem result for a sealed-mode exception.
/// </summary>
public static IResult ToProblem(SealedModeException ex)
{
var (problemType, statusCode) = GetProblemTypeAndStatus(ex.Code);
return Results.Problem(
title: GetTitle(ex.Code),
detail: ex.Message,
type: problemType,
statusCode: statusCode,
extensions: new Dictionary<string, object?>
{
["code"] = ex.Code,
["remediation"] = ex.Remediation
});
}
/// <summary>
/// Creates a problem result for a generic sealed-mode error.
/// </summary>
public static IResult ToProblem(
string code,
string message,
string? remediation = null,
int? statusCode = null)
{
var (problemType, defaultStatusCode) = GetProblemTypeAndStatus(code);
return Results.Problem(
title: GetTitle(code),
detail: message,
type: problemType,
statusCode: statusCode ?? defaultStatusCode,
extensions: new Dictionary<string, object?>
{
["code"] = code,
["remediation"] = remediation
});
}
private static (string ProblemType, int StatusCode) GetProblemTypeAndStatus(string code)
{
return code switch
{
SealedModeErrorCodes.AnchorMissing => (SealedModeProblemTypes.AnchorMissing, 412),
SealedModeErrorCodes.StalenessBreach => (SealedModeProblemTypes.StalenessBreach, 412),
SealedModeErrorCodes.StalenessWarning => (SealedModeProblemTypes.StalenessWarning, 200), // Warning only
SealedModeErrorCodes.SignatureInvalid => (SealedModeProblemTypes.SignatureInvalid, 422),
SealedModeErrorCodes.BundleInvalid => (SealedModeProblemTypes.BundleInvalid, 422),
SealedModeErrorCodes.EgressBlocked => (SealedModeProblemTypes.EgressBlocked, 403),
SealedModeErrorCodes.SealFailed => (SealedModeProblemTypes.SealFailed, 500),
SealedModeErrorCodes.UnsealFailed => (SealedModeProblemTypes.UnsealFailed, 500),
SealedModeErrorCodes.TrustRootsInvalid => (SealedModeProblemTypes.TrustRootsInvalid, 422),
SealedModeErrorCodes.ImportBlocked => (SealedModeProblemTypes.ImportBlocked, 403),
SealedModeErrorCodes.PolicyHashMismatch => (SealedModeProblemTypes.PolicyHashMismatch, 409),
SealedModeErrorCodes.StartupBlocked => (SealedModeProblemTypes.StartupBlocked, 503),
_ => ("about:blank", 500)
};
}
private static string GetTitle(string code)
{
return code switch
{
SealedModeErrorCodes.AnchorMissing => "Time anchor required",
SealedModeErrorCodes.StalenessBreach => "Staleness threshold breached",
SealedModeErrorCodes.StalenessWarning => "Staleness warning",
SealedModeErrorCodes.SignatureInvalid => "Signature verification failed",
SealedModeErrorCodes.BundleInvalid => "Invalid bundle",
SealedModeErrorCodes.EgressBlocked => "Egress blocked",
SealedModeErrorCodes.SealFailed => "Seal operation failed",
SealedModeErrorCodes.UnsealFailed => "Unseal operation failed",
SealedModeErrorCodes.TrustRootsInvalid => "Trust roots invalid",
SealedModeErrorCodes.ImportBlocked => "Import blocked",
SealedModeErrorCodes.PolicyHashMismatch => "Policy hash mismatch",
SealedModeErrorCodes.StartupBlocked => "Startup blocked",
_ => "Sealed mode error"
};
}
}

View File

@@ -0,0 +1,114 @@
namespace StellaOps.Policy.Engine.AirGap;
/// <summary>
/// Sealed-mode state for policy packs per CONTRACT-SEALED-MODE-004.
/// </summary>
public sealed record PolicyPackSealedState(
string TenantId,
bool IsSealed,
string? PolicyHash,
TimeAnchorInfo? TimeAnchor,
StalenessBudget StalenessBudget,
DateTimeOffset LastTransitionAt);
/// <summary>
/// Time anchor information for sealed-mode operations.
/// </summary>
public sealed record TimeAnchorInfo(
DateTimeOffset AnchorTime,
string Source,
string Format,
string? SignatureFingerprint,
string? TokenDigest);
/// <summary>
/// Staleness budget configuration.
/// </summary>
public sealed record StalenessBudget(
int WarningSeconds,
int BreachSeconds)
{
public static StalenessBudget Default => new(3600, 7200);
}
/// <summary>
/// Result of staleness evaluation.
/// </summary>
public sealed record StalenessEvaluation(
int AgeSeconds,
int WarningSeconds,
int BreachSeconds,
bool IsBreached,
int RemainingSeconds)
{
public bool IsWarning => AgeSeconds >= WarningSeconds && !IsBreached;
}
/// <summary>
/// Request to seal the environment.
/// </summary>
public sealed record SealRequest(
string? PolicyHash,
TimeAnchorInfo? TimeAnchor,
StalenessBudget? StalenessBudget);
/// <summary>
/// Response from seal/unseal operations.
/// </summary>
public sealed record SealResponse(
bool Sealed,
DateTimeOffset LastTransitionAt);
/// <summary>
/// Sealed status response.
/// </summary>
public sealed record SealedStatusResponse(
bool Sealed,
string TenantId,
StalenessEvaluation? Staleness,
TimeAnchorInfo? TimeAnchor,
string? PolicyHash);
/// <summary>
/// Bundle verification request.
/// </summary>
public sealed record BundleVerifyRequest(
string BundlePath,
string? TrustRootsPath);
/// <summary>
/// Bundle verification response.
/// </summary>
public sealed record BundleVerifyResponse(
bool Valid,
BundleVerificationResult VerificationResult);
/// <summary>
/// Detailed verification result.
/// </summary>
public sealed record BundleVerificationResult(
bool DsseValid,
bool TufValid,
bool MerkleValid,
string? Error);
/// <summary>
/// Sealed-mode enforcement result for bundle operations.
/// </summary>
public sealed record SealedModeEnforcementResult(
bool Allowed,
string? Reason,
string? Remediation);
/// <summary>
/// Sealed-mode telemetry constants.
/// </summary>
public static class SealedModeTelemetry
{
public const string MetricSealedGauge = "policy_airgap_sealed";
public const string MetricAnchorDriftSeconds = "policy_airgap_anchor_drift_seconds";
public const string MetricAnchorExpirySeconds = "policy_airgap_anchor_expiry_seconds";
public const string MetricSealTotal = "policy_airgap_seal_total";
public const string MetricUnsealTotal = "policy_airgap_unseal_total";
public const string MetricBundleImportBlocked = "policy_airgap_bundle_import_blocked_total";
}

View File

@@ -0,0 +1,216 @@
using Microsoft.Extensions.Logging;
using StellaOps.AirGap.Policy;
namespace StellaOps.Policy.Engine.AirGap;
/// <summary>
/// Service for managing sealed-mode operations for policy packs per CONTRACT-SEALED-MODE-004.
/// </summary>
internal sealed class SealedModeService : ISealedModeService
{
private readonly ISealedModeStateStore _store;
private readonly IEgressPolicy _egressPolicy;
private readonly TimeProvider _timeProvider;
private readonly ILogger<SealedModeService> _logger;
public SealedModeService(
ISealedModeStateStore store,
IEgressPolicy egressPolicy,
TimeProvider timeProvider,
ILogger<SealedModeService> logger)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_egressPolicy = egressPolicy ?? throw new ArgumentNullException(nameof(egressPolicy));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public bool IsSealed => _egressPolicy.IsSealed;
public async Task<PolicyPackSealedState> GetStateAsync(string tenantId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var state = await _store.GetAsync(tenantId, cancellationToken).ConfigureAwait(false);
if (state is null)
{
// Return default unsealed state
return new PolicyPackSealedState(
TenantId: tenantId,
IsSealed: _egressPolicy.IsSealed,
PolicyHash: null,
TimeAnchor: null,
StalenessBudget: StalenessBudget.Default,
LastTransitionAt: DateTimeOffset.MinValue);
}
return state;
}
public async Task<SealedStatusResponse> GetStatusAsync(string tenantId, CancellationToken cancellationToken = default)
{
var state = await GetStateAsync(tenantId, cancellationToken).ConfigureAwait(false);
var staleness = await EvaluateStalenessAsync(tenantId, cancellationToken).ConfigureAwait(false);
return new SealedStatusResponse(
Sealed: state.IsSealed,
TenantId: state.TenantId,
Staleness: staleness,
TimeAnchor: state.TimeAnchor,
PolicyHash: state.PolicyHash);
}
public async Task<SealResponse> SealAsync(string tenantId, SealRequest request, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentNullException.ThrowIfNull(request);
var now = _timeProvider.GetUtcNow();
_logger.LogInformation("Sealing environment for tenant {TenantId} with policy hash {PolicyHash}",
tenantId, request.PolicyHash ?? "(none)");
var state = new PolicyPackSealedState(
TenantId: tenantId,
IsSealed: true,
PolicyHash: request.PolicyHash,
TimeAnchor: request.TimeAnchor,
StalenessBudget: request.StalenessBudget ?? StalenessBudget.Default,
LastTransitionAt: now);
await _store.SaveAsync(state, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Environment sealed for tenant {TenantId} at {TransitionAt}",
tenantId, now);
return new SealResponse(Sealed: true, LastTransitionAt: now);
}
public async Task<SealResponse> UnsealAsync(string tenantId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var now = _timeProvider.GetUtcNow();
var existing = await _store.GetAsync(tenantId, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Unsealing environment for tenant {TenantId}", tenantId);
var state = new PolicyPackSealedState(
TenantId: tenantId,
IsSealed: false,
PolicyHash: existing?.PolicyHash,
TimeAnchor: existing?.TimeAnchor,
StalenessBudget: existing?.StalenessBudget ?? StalenessBudget.Default,
LastTransitionAt: now);
await _store.SaveAsync(state, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Environment unsealed for tenant {TenantId} at {TransitionAt}",
tenantId, now);
return new SealResponse(Sealed: false, LastTransitionAt: now);
}
public async Task<StalenessEvaluation?> EvaluateStalenessAsync(string tenantId, CancellationToken cancellationToken = default)
{
var state = await _store.GetAsync(tenantId, cancellationToken).ConfigureAwait(false);
if (state?.TimeAnchor is null)
{
return null;
}
var now = _timeProvider.GetUtcNow();
var age = now - state.TimeAnchor.AnchorTime;
var ageSeconds = (int)age.TotalSeconds;
var breachSeconds = state.StalenessBudget.BreachSeconds;
var remainingSeconds = Math.Max(0, breachSeconds - ageSeconds);
return new StalenessEvaluation(
AgeSeconds: ageSeconds,
WarningSeconds: state.StalenessBudget.WarningSeconds,
BreachSeconds: breachSeconds,
IsBreached: ageSeconds >= breachSeconds,
RemainingSeconds: remainingSeconds);
}
public async Task<SealedModeEnforcementResult> EnforceBundleImportAsync(
string tenantId,
string bundlePath,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(bundlePath);
// If not in sealed mode at the infrastructure level, allow bundle import
if (!_egressPolicy.IsSealed)
{
_logger.LogDebug("Bundle import allowed: environment not sealed");
return new SealedModeEnforcementResult(Allowed: true, Reason: null, Remediation: null);
}
// In sealed mode, verify the tenant state
var state = await GetStateAsync(tenantId, cancellationToken).ConfigureAwait(false);
// Check staleness
var staleness = await EvaluateStalenessAsync(tenantId, cancellationToken).ConfigureAwait(false);
if (staleness?.IsBreached == true)
{
_logger.LogWarning(
"Bundle import blocked: staleness breached for tenant {TenantId} (age={AgeSeconds}s, breach={BreachSeconds}s) [{ErrorCode}]",
tenantId, staleness.AgeSeconds, staleness.BreachSeconds, SealedModeErrorCodes.StalenessBreach);
return new SealedModeEnforcementResult(
Allowed: false,
Reason: $"[{SealedModeErrorCodes.StalenessBreach}] Time anchor staleness breached ({staleness.AgeSeconds}s > {staleness.BreachSeconds}s threshold)",
Remediation: "Refresh time anchor before importing bundles in sealed mode");
}
// Warn if approaching staleness threshold
if (staleness?.IsWarning == true)
{
_logger.LogWarning(
"Staleness warning for tenant {TenantId}: age={AgeSeconds}s approaching breach at {BreachSeconds}s [{ErrorCode}]",
tenantId, staleness.AgeSeconds, staleness.BreachSeconds, SealedModeErrorCodes.StalenessWarning);
}
// Bundle imports are allowed in sealed mode (they're the approved ingestion path)
_logger.LogDebug("Bundle import allowed in sealed mode for tenant {TenantId}", tenantId);
return new SealedModeEnforcementResult(Allowed: true, Reason: null, Remediation: null);
}
public Task<BundleVerifyResponse> VerifyBundleAsync(
BundleVerifyRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(request.BundlePath);
// This would integrate with StellaOps.AirGap.Importer DsseVerifier
// For now, perform basic verification
_logger.LogInformation("Verifying bundle at {BundlePath} with trust roots {TrustRootsPath}",
request.BundlePath, request.TrustRootsPath ?? "(none)");
if (!File.Exists(request.BundlePath))
{
return Task.FromResult(new BundleVerifyResponse(
Valid: false,
VerificationResult: new BundleVerificationResult(
DsseValid: false,
TufValid: false,
MerkleValid: false,
Error: $"Bundle file not found: {request.BundlePath}")));
}
// Placeholder: Full verification would check DSSE signatures, TUF metadata, and Merkle proofs
return Task.FromResult(new BundleVerifyResponse(
Valid: true,
VerificationResult: new BundleVerificationResult(
DsseValid: true,
TufValid: true,
MerkleValid: true,
Error: null)));
}
}

View File

@@ -0,0 +1,327 @@
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Engine.Telemetry;
namespace StellaOps.Policy.Engine.AirGap;
/// <summary>
/// Staleness signaling status for health endpoints.
/// </summary>
public sealed record StalenessSignalStatus(
bool IsHealthy,
bool HasWarning,
bool IsBreach,
int? AgeSeconds,
int? RemainingSeconds,
string? Message);
/// <summary>
/// Fallback mode configuration for when primary data is stale.
/// </summary>
public sealed record FallbackConfiguration(
bool Enabled,
FallbackStrategy Strategy,
int? CacheTimeoutSeconds,
bool AllowDegradedOperation);
/// <summary>
/// Available fallback strategies when data becomes stale.
/// </summary>
public enum FallbackStrategy
{
/// <summary>No fallback - fail hard on staleness.</summary>
None,
/// <summary>Use cached data with warning.</summary>
Cache,
/// <summary>Use last-known-good state.</summary>
LastKnownGood,
/// <summary>Degrade to read-only mode.</summary>
ReadOnly,
/// <summary>Require manual intervention.</summary>
ManualIntervention
}
/// <summary>
/// Staleness event for signaling.
/// </summary>
public sealed record StalenessEvent(
string TenantId,
StalenessEventType Type,
int AgeSeconds,
int ThresholdSeconds,
DateTimeOffset OccurredAt,
string? Message);
/// <summary>
/// Types of staleness events.
/// </summary>
public enum StalenessEventType
{
/// <summary>Staleness warning threshold crossed.</summary>
Warning,
/// <summary>Staleness breach threshold crossed.</summary>
Breach,
/// <summary>Staleness recovered (time anchor refreshed).</summary>
Recovered,
/// <summary>Time anchor missing.</summary>
AnchorMissing
}
/// <summary>
/// Interface for staleness event subscribers.
/// </summary>
public interface IStalenessEventSink
{
Task OnStalenessEventAsync(StalenessEvent evt, CancellationToken cancellationToken = default);
}
/// <summary>
/// Service for managing staleness signaling and fallback behavior.
/// </summary>
public interface IStalenessSignalingService
{
/// <summary>
/// Gets the current staleness signal status for a tenant.
/// </summary>
Task<StalenessSignalStatus> GetSignalStatusAsync(string tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets the fallback configuration for a tenant.
/// </summary>
Task<FallbackConfiguration> GetFallbackConfigurationAsync(string tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// Checks if fallback mode is active for a tenant.
/// </summary>
Task<bool> IsFallbackActiveAsync(string tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// Evaluates staleness and raises events if thresholds are crossed.
/// </summary>
Task EvaluateAndSignalAsync(string tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// Signals that the time anchor has been refreshed.
/// </summary>
Task SignalRecoveryAsync(string tenantId, CancellationToken cancellationToken = default);
}
/// <summary>
/// Default implementation of staleness signaling service.
/// </summary>
internal sealed class StalenessSignalingService : IStalenessSignalingService
{
private readonly ISealedModeService _sealedModeService;
private readonly IEnumerable<IStalenessEventSink> _eventSinks;
private readonly TimeProvider _timeProvider;
private readonly ILogger<StalenessSignalingService> _logger;
// Track last signaled state per tenant to avoid duplicate events
private readonly Dictionary<string, StalenessEventType?> _lastSignaledState = new();
private readonly object _stateLock = new();
public StalenessSignalingService(
ISealedModeService sealedModeService,
IEnumerable<IStalenessEventSink> eventSinks,
TimeProvider timeProvider,
ILogger<StalenessSignalingService> logger)
{
_sealedModeService = sealedModeService ?? throw new ArgumentNullException(nameof(sealedModeService));
_eventSinks = eventSinks ?? [];
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<StalenessSignalStatus> GetSignalStatusAsync(string tenantId, CancellationToken cancellationToken = default)
{
var staleness = await _sealedModeService.EvaluateStalenessAsync(tenantId, cancellationToken).ConfigureAwait(false);
if (staleness is null)
{
// No time anchor - cannot evaluate staleness
return new StalenessSignalStatus(
IsHealthy: !_sealedModeService.IsSealed, // Healthy if not sealed (anchor not required)
HasWarning: _sealedModeService.IsSealed,
IsBreach: false,
AgeSeconds: null,
RemainingSeconds: null,
Message: _sealedModeService.IsSealed ? "Time anchor not configured" : null);
}
var message = staleness.IsBreached
? $"Staleness breach: data is {staleness.AgeSeconds}s old (threshold: {staleness.BreachSeconds}s)"
: staleness.IsWarning
? $"Staleness warning: data is {staleness.AgeSeconds}s old (breach at: {staleness.BreachSeconds}s)"
: null;
return new StalenessSignalStatus(
IsHealthy: !staleness.IsBreached,
HasWarning: staleness.IsWarning,
IsBreach: staleness.IsBreached,
AgeSeconds: staleness.AgeSeconds,
RemainingSeconds: staleness.RemainingSeconds,
Message: message);
}
public Task<FallbackConfiguration> GetFallbackConfigurationAsync(string tenantId, CancellationToken cancellationToken = default)
{
// Default fallback configuration - could be extended to read from configuration
return Task.FromResult(new FallbackConfiguration(
Enabled: true,
Strategy: FallbackStrategy.LastKnownGood,
CacheTimeoutSeconds: 3600,
AllowDegradedOperation: true));
}
public async Task<bool> IsFallbackActiveAsync(string tenantId, CancellationToken cancellationToken = default)
{
var status = await GetSignalStatusAsync(tenantId, cancellationToken).ConfigureAwait(false);
var config = await GetFallbackConfigurationAsync(tenantId, cancellationToken).ConfigureAwait(false);
return config.Enabled && (status.IsBreach || status.HasWarning);
}
public async Task EvaluateAndSignalAsync(string tenantId, CancellationToken cancellationToken = default)
{
var staleness = await _sealedModeService.EvaluateStalenessAsync(tenantId, cancellationToken).ConfigureAwait(false);
var now = _timeProvider.GetUtcNow();
StalenessEventType? currentState = null;
string? message = null;
if (staleness is null && _sealedModeService.IsSealed)
{
currentState = StalenessEventType.AnchorMissing;
message = "Time anchor not configured in sealed mode";
}
else if (staleness?.IsBreached == true)
{
currentState = StalenessEventType.Breach;
message = $"Staleness breach: {staleness.AgeSeconds}s > {staleness.BreachSeconds}s";
}
else if (staleness?.IsWarning == true)
{
currentState = StalenessEventType.Warning;
message = $"Staleness warning: {staleness.AgeSeconds}s approaching {staleness.BreachSeconds}s";
}
// Only signal if state changed
lock (_stateLock)
{
_lastSignaledState.TryGetValue(tenantId, out var lastState);
if (currentState == lastState)
{
return; // No change
}
_lastSignaledState[tenantId] = currentState;
}
if (currentState.HasValue)
{
var evt = new StalenessEvent(
TenantId: tenantId,
Type: currentState.Value,
AgeSeconds: staleness?.AgeSeconds ?? 0,
ThresholdSeconds: staleness?.BreachSeconds ?? 0,
OccurredAt: now,
Message: message);
await RaiseEventAsync(evt, cancellationToken).ConfigureAwait(false);
// Record telemetry
PolicyEngineTelemetry.RecordStalenessEvent(tenantId, currentState.Value.ToString());
}
}
public async Task SignalRecoveryAsync(string tenantId, CancellationToken cancellationToken = default)
{
var now = _timeProvider.GetUtcNow();
lock (_stateLock)
{
_lastSignaledState.TryGetValue(tenantId, out var lastState);
if (lastState is null)
{
return; // Nothing to recover from
}
_lastSignaledState[tenantId] = null;
}
var evt = new StalenessEvent(
TenantId: tenantId,
Type: StalenessEventType.Recovered,
AgeSeconds: 0,
ThresholdSeconds: 0,
OccurredAt: now,
Message: "Time anchor refreshed, staleness recovered");
await RaiseEventAsync(evt, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Staleness recovered for tenant {TenantId}", tenantId);
}
private async Task RaiseEventAsync(StalenessEvent evt, CancellationToken cancellationToken)
{
_logger.LogInformation(
"Staleness event {EventType} for tenant {TenantId}: {Message}",
evt.Type, evt.TenantId, evt.Message);
foreach (var sink in _eventSinks)
{
try
{
await sink.OnStalenessEventAsync(evt, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to deliver staleness event to sink {SinkType}", sink.GetType().Name);
}
}
}
}
/// <summary>
/// Logging-based staleness event sink for observability.
/// </summary>
internal sealed class LoggingStalenessEventSink : IStalenessEventSink
{
private readonly ILogger<LoggingStalenessEventSink> _logger;
public LoggingStalenessEventSink(ILogger<LoggingStalenessEventSink> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task OnStalenessEventAsync(StalenessEvent evt, CancellationToken cancellationToken = default)
{
var logLevel = evt.Type switch
{
StalenessEventType.Breach => LogLevel.Error,
StalenessEventType.Warning => LogLevel.Warning,
StalenessEventType.AnchorMissing => LogLevel.Warning,
StalenessEventType.Recovered => LogLevel.Information,
_ => LogLevel.Information
};
_logger.Log(
logLevel,
"Staleness {EventType} for tenant {TenantId}: age={AgeSeconds}s, threshold={ThresholdSeconds}s - {Message}",
evt.Type,
evt.TenantId,
evt.AgeSeconds,
evt.ThresholdSeconds,
evt.Message);
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,178 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Engine.Attestation;
/// <summary>
/// Status of an attestation report section.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<AttestationReportStatus>))]
public enum AttestationReportStatus
{
Pass,
Fail,
Warn,
Skipped,
Pending
}
/// <summary>
/// Aggregated attestation report for an artifact per CONTRACT-VERIFICATION-POLICY-006.
/// </summary>
public sealed record ArtifactAttestationReport(
[property: JsonPropertyName("artifact_digest")] string ArtifactDigest,
[property: JsonPropertyName("artifact_uri")] string? ArtifactUri,
[property: JsonPropertyName("overall_status")] AttestationReportStatus OverallStatus,
[property: JsonPropertyName("attestation_count")] int AttestationCount,
[property: JsonPropertyName("verification_results")] IReadOnlyList<AttestationVerificationSummary> VerificationResults,
[property: JsonPropertyName("policy_compliance")] PolicyComplianceSummary PolicyCompliance,
[property: JsonPropertyName("coverage")] AttestationCoverageSummary Coverage,
[property: JsonPropertyName("evaluated_at")] DateTimeOffset EvaluatedAt);
/// <summary>
/// Summary of a single attestation verification.
/// </summary>
public sealed record AttestationVerificationSummary(
[property: JsonPropertyName("attestation_id")] string AttestationId,
[property: JsonPropertyName("predicate_type")] string PredicateType,
[property: JsonPropertyName("status")] AttestationReportStatus Status,
[property: JsonPropertyName("policy_id")] string? PolicyId,
[property: JsonPropertyName("policy_version")] string? PolicyVersion,
[property: JsonPropertyName("signature_status")] SignatureVerificationStatus SignatureStatus,
[property: JsonPropertyName("freshness_status")] FreshnessVerificationStatus FreshnessStatus,
[property: JsonPropertyName("transparency_status")] TransparencyVerificationStatus TransparencyStatus,
[property: JsonPropertyName("issues")] IReadOnlyList<string> Issues,
[property: JsonPropertyName("created_at")] DateTimeOffset CreatedAt);
/// <summary>
/// Signature verification status.
/// </summary>
public sealed record SignatureVerificationStatus(
[property: JsonPropertyName("status")] AttestationReportStatus Status,
[property: JsonPropertyName("total_signatures")] int TotalSignatures,
[property: JsonPropertyName("verified_signatures")] int VerifiedSignatures,
[property: JsonPropertyName("required_signatures")] int RequiredSignatures,
[property: JsonPropertyName("signers")] IReadOnlyList<SignerVerificationInfo> Signers);
/// <summary>
/// Signer verification information.
/// </summary>
public sealed record SignerVerificationInfo(
[property: JsonPropertyName("key_fingerprint")] string KeyFingerprint,
[property: JsonPropertyName("issuer")] string? Issuer,
[property: JsonPropertyName("subject")] string? Subject,
[property: JsonPropertyName("algorithm")] string Algorithm,
[property: JsonPropertyName("verified")] bool Verified,
[property: JsonPropertyName("trusted")] bool Trusted);
/// <summary>
/// Freshness verification status.
/// </summary>
public sealed record FreshnessVerificationStatus(
[property: JsonPropertyName("status")] AttestationReportStatus Status,
[property: JsonPropertyName("created_at")] DateTimeOffset CreatedAt,
[property: JsonPropertyName("age_seconds")] int AgeSeconds,
[property: JsonPropertyName("max_age_seconds")] int? MaxAgeSeconds,
[property: JsonPropertyName("is_fresh")] bool IsFresh);
/// <summary>
/// Transparency log verification status.
/// </summary>
public sealed record TransparencyVerificationStatus(
[property: JsonPropertyName("status")] AttestationReportStatus Status,
[property: JsonPropertyName("rekor_entry")] RekorEntryInfo? RekorEntry,
[property: JsonPropertyName("inclusion_verified")] bool InclusionVerified);
/// <summary>
/// Rekor transparency log entry information.
/// </summary>
public sealed record RekorEntryInfo(
[property: JsonPropertyName("uuid")] string Uuid,
[property: JsonPropertyName("log_index")] long LogIndex,
[property: JsonPropertyName("log_url")] string? LogUrl,
[property: JsonPropertyName("integrated_time")] DateTimeOffset IntegratedTime);
/// <summary>
/// Summary of policy compliance for an artifact.
/// </summary>
public sealed record PolicyComplianceSummary(
[property: JsonPropertyName("status")] AttestationReportStatus Status,
[property: JsonPropertyName("policies_evaluated")] int PoliciesEvaluated,
[property: JsonPropertyName("policies_passed")] int PoliciesPassed,
[property: JsonPropertyName("policies_failed")] int PoliciesFailed,
[property: JsonPropertyName("policies_warned")] int PoliciesWarned,
[property: JsonPropertyName("policy_results")] IReadOnlyList<PolicyEvaluationSummary> PolicyResults);
/// <summary>
/// Summary of a policy evaluation.
/// </summary>
public sealed record PolicyEvaluationSummary(
[property: JsonPropertyName("policy_id")] string PolicyId,
[property: JsonPropertyName("policy_version")] string PolicyVersion,
[property: JsonPropertyName("status")] AttestationReportStatus Status,
[property: JsonPropertyName("verdict")] string Verdict,
[property: JsonPropertyName("issues")] IReadOnlyList<string> Issues);
/// <summary>
/// Summary of attestation coverage for an artifact.
/// </summary>
public sealed record AttestationCoverageSummary(
[property: JsonPropertyName("predicate_types_required")] IReadOnlyList<string> PredicateTypesRequired,
[property: JsonPropertyName("predicate_types_present")] IReadOnlyList<string> PredicateTypesPresent,
[property: JsonPropertyName("predicate_types_missing")] IReadOnlyList<string> PredicateTypesMissing,
[property: JsonPropertyName("coverage_percentage")] double CoveragePercentage,
[property: JsonPropertyName("is_complete")] bool IsComplete);
/// <summary>
/// Query options for attestation reports.
/// </summary>
public sealed record AttestationReportQuery(
[property: JsonPropertyName("artifact_digests")] IReadOnlyList<string>? ArtifactDigests,
[property: JsonPropertyName("artifact_uri_pattern")] string? ArtifactUriPattern,
[property: JsonPropertyName("policy_ids")] IReadOnlyList<string>? PolicyIds,
[property: JsonPropertyName("predicate_types")] IReadOnlyList<string>? PredicateTypes,
[property: JsonPropertyName("status_filter")] IReadOnlyList<AttestationReportStatus>? StatusFilter,
[property: JsonPropertyName("from_time")] DateTimeOffset? FromTime,
[property: JsonPropertyName("to_time")] DateTimeOffset? ToTime,
[property: JsonPropertyName("include_details")] bool IncludeDetails,
[property: JsonPropertyName("limit")] int Limit = 100,
[property: JsonPropertyName("offset")] int Offset = 0);
/// <summary>
/// Response containing attestation reports.
/// </summary>
public sealed record AttestationReportListResponse(
[property: JsonPropertyName("reports")] IReadOnlyList<ArtifactAttestationReport> Reports,
[property: JsonPropertyName("total")] int Total,
[property: JsonPropertyName("limit")] int Limit,
[property: JsonPropertyName("offset")] int Offset);
/// <summary>
/// Aggregated attestation statistics.
/// </summary>
public sealed record AttestationStatistics(
[property: JsonPropertyName("total_artifacts")] int TotalArtifacts,
[property: JsonPropertyName("total_attestations")] int TotalAttestations,
[property: JsonPropertyName("status_distribution")] IReadOnlyDictionary<AttestationReportStatus, int> StatusDistribution,
[property: JsonPropertyName("predicate_type_distribution")] IReadOnlyDictionary<string, int> PredicateTypeDistribution,
[property: JsonPropertyName("policy_distribution")] IReadOnlyDictionary<string, int> PolicyDistribution,
[property: JsonPropertyName("average_age_seconds")] double AverageAgeSeconds,
[property: JsonPropertyName("coverage_rate")] double CoverageRate,
[property: JsonPropertyName("evaluated_at")] DateTimeOffset EvaluatedAt);
/// <summary>
/// Request to verify attestations for an artifact.
/// </summary>
public sealed record VerifyArtifactRequest(
[property: JsonPropertyName("artifact_digest")] string ArtifactDigest,
[property: JsonPropertyName("artifact_uri")] string? ArtifactUri,
[property: JsonPropertyName("policy_ids")] IReadOnlyList<string>? PolicyIds,
[property: JsonPropertyName("include_transparency")] bool IncludeTransparency = true);
/// <summary>
/// Stored attestation report entry.
/// </summary>
public sealed record StoredAttestationReport(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("report")] ArtifactAttestationReport Report,
[property: JsonPropertyName("stored_at")] DateTimeOffset StoredAt,
[property: JsonPropertyName("expires_at")] DateTimeOffset? ExpiresAt);

View File

@@ -0,0 +1,394 @@
using Microsoft.Extensions.Logging;
namespace StellaOps.Policy.Engine.Attestation;
/// <summary>
/// Service for managing attestation reports per CONTRACT-VERIFICATION-POLICY-006.
/// </summary>
internal sealed class AttestationReportService : IAttestationReportService
{
private readonly IAttestationReportStore _store;
private readonly IVerificationPolicyStore _policyStore;
private readonly TimeProvider _timeProvider;
private readonly ILogger<AttestationReportService> _logger;
private static readonly TimeSpan DefaultTtl = TimeSpan.FromDays(7);
public AttestationReportService(
IAttestationReportStore store,
IVerificationPolicyStore policyStore,
TimeProvider timeProvider,
ILogger<AttestationReportService> logger)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_policyStore = policyStore ?? throw new ArgumentNullException(nameof(policyStore));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<ArtifactAttestationReport?> GetReportAsync(string artifactDigest, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
var stored = await _store.GetAsync(artifactDigest, cancellationToken).ConfigureAwait(false);
if (stored == null)
{
return null;
}
// Check if expired
if (stored.ExpiresAt.HasValue && stored.ExpiresAt.Value <= _timeProvider.GetUtcNow())
{
_logger.LogDebug("Report for artifact {ArtifactDigest} has expired", artifactDigest);
return null;
}
return stored.Report;
}
public async Task<AttestationReportListResponse> ListReportsAsync(AttestationReportQuery query, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
var reports = await _store.ListAsync(query, cancellationToken).ConfigureAwait(false);
var total = await _store.CountAsync(query, cancellationToken).ConfigureAwait(false);
var artifactReports = reports
.Where(r => !r.ExpiresAt.HasValue || r.ExpiresAt.Value > _timeProvider.GetUtcNow())
.Select(r => r.Report)
.ToList();
return new AttestationReportListResponse(
Reports: artifactReports,
Total: total,
Limit: query.Limit,
Offset: query.Offset);
}
public async Task<ArtifactAttestationReport> GenerateReportAsync(VerifyArtifactRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(request.ArtifactDigest);
var now = _timeProvider.GetUtcNow();
// Get applicable policies
var policies = await GetApplicablePoliciesAsync(request.PolicyIds, cancellationToken).ConfigureAwait(false);
// Generate verification results (simulated - would connect to actual Attestor service)
var verificationResults = await GenerateVerificationResultsAsync(request, policies, now, cancellationToken).ConfigureAwait(false);
// Calculate policy compliance
var policyCompliance = CalculatePolicyCompliance(policies, verificationResults);
// Calculate coverage
var coverage = CalculateCoverage(policies, verificationResults);
// Determine overall status
var overallStatus = DetermineOverallStatus(verificationResults, policyCompliance);
var report = new ArtifactAttestationReport(
ArtifactDigest: request.ArtifactDigest,
ArtifactUri: request.ArtifactUri,
OverallStatus: overallStatus,
AttestationCount: verificationResults.Count,
VerificationResults: verificationResults,
PolicyCompliance: policyCompliance,
Coverage: coverage,
EvaluatedAt: now);
_logger.LogInformation(
"Generated attestation report for artifact {ArtifactDigest} with status {Status}",
request.ArtifactDigest,
overallStatus);
return report;
}
public async Task<StoredAttestationReport> StoreReportAsync(ArtifactAttestationReport report, TimeSpan? ttl = null, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(report);
var now = _timeProvider.GetUtcNow();
var expiresAt = now.Add(ttl ?? DefaultTtl);
var storedReport = new StoredAttestationReport(
Id: $"report-{report.ArtifactDigest}-{now.Ticks}",
Report: report,
StoredAt: now,
ExpiresAt: expiresAt);
await _store.CreateAsync(storedReport, cancellationToken).ConfigureAwait(false);
_logger.LogDebug(
"Stored attestation report for artifact {ArtifactDigest}, expires at {ExpiresAt}",
report.ArtifactDigest,
expiresAt);
return storedReport;
}
public async Task<AttestationStatistics> GetStatisticsAsync(AttestationReportQuery? filter = null, CancellationToken cancellationToken = default)
{
var query = filter ?? new AttestationReportQuery(
ArtifactDigests: null,
ArtifactUriPattern: null,
PolicyIds: null,
PredicateTypes: null,
StatusFilter: null,
FromTime: null,
ToTime: null,
IncludeDetails: false,
Limit: int.MaxValue,
Offset: 0);
var reports = await _store.ListAsync(query, cancellationToken).ConfigureAwait(false);
var now = _timeProvider.GetUtcNow();
// Filter expired
var validReports = reports
.Where(r => !r.ExpiresAt.HasValue || r.ExpiresAt.Value > now)
.ToList();
var statusDistribution = validReports
.GroupBy(r => r.Report.OverallStatus)
.ToDictionary(g => g.Key, g => g.Count());
var predicateTypeDistribution = validReports
.SelectMany(r => r.Report.VerificationResults)
.GroupBy(v => v.PredicateType)
.ToDictionary(g => g.Key, g => g.Count());
var policyDistribution = validReports
.SelectMany(r => r.Report.VerificationResults)
.Where(v => v.PolicyId != null)
.GroupBy(v => v.PolicyId!)
.ToDictionary(g => g.Key, g => g.Count());
var totalAttestations = validReports.Sum(r => r.Report.AttestationCount);
var averageAgeSeconds = validReports.Count > 0
? validReports.Average(r => (now - r.Report.EvaluatedAt).TotalSeconds)
: 0;
var coverageRate = validReports.Count > 0
? validReports.Average(r => r.Report.Coverage.CoveragePercentage)
: 0;
return new AttestationStatistics(
TotalArtifacts: validReports.Count,
TotalAttestations: totalAttestations,
StatusDistribution: statusDistribution,
PredicateTypeDistribution: predicateTypeDistribution,
PolicyDistribution: policyDistribution,
AverageAgeSeconds: averageAgeSeconds,
CoverageRate: coverageRate,
EvaluatedAt: now);
}
public async Task<int> PurgeExpiredReportsAsync(CancellationToken cancellationToken = default)
{
var now = _timeProvider.GetUtcNow();
var count = await _store.DeleteExpiredAsync(now, cancellationToken).ConfigureAwait(false);
if (count > 0)
{
_logger.LogInformation("Purged {Count} expired attestation reports", count);
}
return count;
}
private async Task<IReadOnlyList<VerificationPolicy>> GetApplicablePoliciesAsync(
IReadOnlyList<string>? policyIds,
CancellationToken cancellationToken)
{
if (policyIds is { Count: > 0 })
{
var policies = new List<VerificationPolicy>();
foreach (var policyId in policyIds)
{
var policy = await _policyStore.GetAsync(policyId, cancellationToken).ConfigureAwait(false);
if (policy != null)
{
policies.Add(policy);
}
}
return policies;
}
// Get all policies if none specified
return await _policyStore.ListAsync(null, cancellationToken).ConfigureAwait(false);
}
private Task<IReadOnlyList<AttestationVerificationSummary>> GenerateVerificationResultsAsync(
VerifyArtifactRequest request,
IReadOnlyList<VerificationPolicy> policies,
DateTimeOffset now,
CancellationToken cancellationToken)
{
// This would normally connect to the Attestor service to verify actual attestations
// For now, generate placeholder results based on policies
var results = new List<AttestationVerificationSummary>();
foreach (var policy in policies)
{
foreach (var predicateType in policy.PredicateTypes)
{
// Simulated verification result
results.Add(new AttestationVerificationSummary(
AttestationId: $"attest-{Guid.NewGuid():N}",
PredicateType: predicateType,
Status: AttestationReportStatus.Pending,
PolicyId: policy.PolicyId,
PolicyVersion: policy.Version,
SignatureStatus: new SignatureVerificationStatus(
Status: AttestationReportStatus.Pending,
TotalSignatures: 0,
VerifiedSignatures: 0,
RequiredSignatures: policy.SignerRequirements.MinimumSignatures,
Signers: []),
FreshnessStatus: new FreshnessVerificationStatus(
Status: AttestationReportStatus.Pending,
CreatedAt: now,
AgeSeconds: 0,
MaxAgeSeconds: policy.ValidityWindow?.MaxAttestationAge,
IsFresh: true),
TransparencyStatus: new TransparencyVerificationStatus(
Status: policy.SignerRequirements.RequireRekor
? AttestationReportStatus.Pending
: AttestationReportStatus.Skipped,
RekorEntry: null,
InclusionVerified: false),
Issues: [],
CreatedAt: now));
}
}
return Task.FromResult<IReadOnlyList<AttestationVerificationSummary>>(results);
}
private static PolicyComplianceSummary CalculatePolicyCompliance(
IReadOnlyList<VerificationPolicy> policies,
IReadOnlyList<AttestationVerificationSummary> results)
{
var policyResults = new List<PolicyEvaluationSummary>();
var passed = 0;
var failed = 0;
var warned = 0;
foreach (var policy in policies)
{
var policyVerifications = results.Where(r => r.PolicyId == policy.PolicyId).ToList();
var status = AttestationReportStatus.Pending;
var verdict = "pending";
var issues = new List<string>();
if (policyVerifications.All(v => v.Status == AttestationReportStatus.Pass))
{
status = AttestationReportStatus.Pass;
verdict = "compliant";
passed++;
}
else if (policyVerifications.Any(v => v.Status == AttestationReportStatus.Fail))
{
status = AttestationReportStatus.Fail;
verdict = "non-compliant";
failed++;
issues.AddRange(policyVerifications.SelectMany(v => v.Issues));
}
else if (policyVerifications.Any(v => v.Status == AttestationReportStatus.Warn))
{
status = AttestationReportStatus.Warn;
verdict = "warning";
warned++;
}
policyResults.Add(new PolicyEvaluationSummary(
PolicyId: policy.PolicyId,
PolicyVersion: policy.Version,
Status: status,
Verdict: verdict,
Issues: issues));
}
var overallStatus = failed > 0
? AttestationReportStatus.Fail
: warned > 0
? AttestationReportStatus.Warn
: passed > 0
? AttestationReportStatus.Pass
: AttestationReportStatus.Pending;
return new PolicyComplianceSummary(
Status: overallStatus,
PoliciesEvaluated: policies.Count,
PoliciesPassed: passed,
PoliciesFailed: failed,
PoliciesWarned: warned,
PolicyResults: policyResults);
}
private static AttestationCoverageSummary CalculateCoverage(
IReadOnlyList<VerificationPolicy> policies,
IReadOnlyList<AttestationVerificationSummary> results)
{
var requiredTypes = policies
.SelectMany(p => p.PredicateTypes)
.Distinct()
.ToList();
var presentTypes = results
.Select(r => r.PredicateType)
.Distinct()
.ToList();
var missingTypes = requiredTypes.Except(presentTypes).ToList();
var coveragePercentage = requiredTypes.Count > 0
? (double)(requiredTypes.Count - missingTypes.Count) / requiredTypes.Count * 100
: 100;
return new AttestationCoverageSummary(
PredicateTypesRequired: requiredTypes,
PredicateTypesPresent: presentTypes,
PredicateTypesMissing: missingTypes,
CoveragePercentage: Math.Round(coveragePercentage, 2),
IsComplete: missingTypes.Count == 0);
}
private static AttestationReportStatus DetermineOverallStatus(
IReadOnlyList<AttestationVerificationSummary> results,
PolicyComplianceSummary compliance)
{
if (compliance.Status == AttestationReportStatus.Fail)
{
return AttestationReportStatus.Fail;
}
if (results.Any(r => r.Status == AttestationReportStatus.Fail))
{
return AttestationReportStatus.Fail;
}
if (compliance.Status == AttestationReportStatus.Warn ||
results.Any(r => r.Status == AttestationReportStatus.Warn))
{
return AttestationReportStatus.Warn;
}
if (results.All(r => r.Status == AttestationReportStatus.Pass))
{
return AttestationReportStatus.Pass;
}
if (results.All(r => r.Status == AttestationReportStatus.Pending))
{
return AttestationReportStatus.Pending;
}
return AttestationReportStatus.Skipped;
}
}

View File

@@ -0,0 +1,97 @@
namespace StellaOps.Policy.Engine.Attestation;
/// <summary>
/// Service for managing and querying attestation reports per CONTRACT-VERIFICATION-POLICY-006.
/// </summary>
public interface IAttestationReportService
{
/// <summary>
/// Gets an attestation report for a specific artifact.
/// </summary>
Task<ArtifactAttestationReport?> GetReportAsync(
string artifactDigest,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists attestation reports matching the query.
/// </summary>
Task<AttestationReportListResponse> ListReportsAsync(
AttestationReportQuery query,
CancellationToken cancellationToken = default);
/// <summary>
/// Generates an attestation report for an artifact by verifying its attestations.
/// </summary>
Task<ArtifactAttestationReport> GenerateReportAsync(
VerifyArtifactRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Stores an attestation report.
/// </summary>
Task<StoredAttestationReport> StoreReportAsync(
ArtifactAttestationReport report,
TimeSpan? ttl = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets aggregated attestation statistics.
/// </summary>
Task<AttestationStatistics> GetStatisticsAsync(
AttestationReportQuery? filter = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes expired attestation reports.
/// </summary>
Task<int> PurgeExpiredReportsAsync(CancellationToken cancellationToken = default);
}
/// <summary>
/// Store for persisting attestation reports.
/// </summary>
public interface IAttestationReportStore
{
/// <summary>
/// Gets a stored report by artifact digest.
/// </summary>
Task<StoredAttestationReport?> GetAsync(
string artifactDigest,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists stored reports matching the query.
/// </summary>
Task<IReadOnlyList<StoredAttestationReport>> ListAsync(
AttestationReportQuery query,
CancellationToken cancellationToken = default);
/// <summary>
/// Counts stored reports matching the query.
/// </summary>
Task<int> CountAsync(
AttestationReportQuery? query = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Stores a report.
/// </summary>
Task<StoredAttestationReport> CreateAsync(
StoredAttestationReport report,
CancellationToken cancellationToken = default);
/// <summary>
/// Updates a stored report.
/// </summary>
Task<StoredAttestationReport?> UpdateAsync(
string artifactDigest,
Func<StoredAttestationReport, StoredAttestationReport> update,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes expired reports.
/// </summary>
Task<int> DeleteExpiredAsync(
DateTimeOffset now,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,44 @@
namespace StellaOps.Policy.Engine.Attestation;
/// <summary>
/// Interface for persisting verification policies per CONTRACT-VERIFICATION-POLICY-006.
/// </summary>
public interface IVerificationPolicyStore
{
/// <summary>
/// Gets a policy by ID.
/// </summary>
Task<VerificationPolicy?> GetAsync(string policyId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all policies for a tenant scope.
/// </summary>
Task<IReadOnlyList<VerificationPolicy>> ListAsync(
string? tenantScope = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Creates a new policy.
/// </summary>
Task<VerificationPolicy> CreateAsync(
VerificationPolicy policy,
CancellationToken cancellationToken = default);
/// <summary>
/// Updates an existing policy.
/// </summary>
Task<VerificationPolicy?> UpdateAsync(
string policyId,
Func<VerificationPolicy, VerificationPolicy> update,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a policy.
/// </summary>
Task<bool> DeleteAsync(string policyId, CancellationToken cancellationToken = default);
/// <summary>
/// Checks if a policy exists.
/// </summary>
Task<bool> ExistsAsync(string policyId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,188 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
namespace StellaOps.Policy.Engine.Attestation;
/// <summary>
/// In-memory implementation of attestation report store per CONTRACT-VERIFICATION-POLICY-006.
/// </summary>
internal sealed class InMemoryAttestationReportStore : IAttestationReportStore
{
private readonly ConcurrentDictionary<string, StoredAttestationReport> _reports = new(StringComparer.OrdinalIgnoreCase);
public Task<StoredAttestationReport?> GetAsync(string artifactDigest, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
_reports.TryGetValue(artifactDigest, out var report);
return Task.FromResult(report);
}
public Task<IReadOnlyList<StoredAttestationReport>> ListAsync(AttestationReportQuery query, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
IEnumerable<StoredAttestationReport> reports = _reports.Values;
// Filter by artifact digests
if (query.ArtifactDigests is { Count: > 0 })
{
var digestSet = query.ArtifactDigests.ToHashSet(StringComparer.OrdinalIgnoreCase);
reports = reports.Where(r => digestSet.Contains(r.Report.ArtifactDigest));
}
// Filter by artifact URI pattern
if (!string.IsNullOrWhiteSpace(query.ArtifactUriPattern))
{
var pattern = new Regex(query.ArtifactUriPattern, RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1));
reports = reports.Where(r => r.Report.ArtifactUri != null && pattern.IsMatch(r.Report.ArtifactUri));
}
// Filter by policy IDs
if (query.PolicyIds is { Count: > 0 })
{
var policySet = query.PolicyIds.ToHashSet(StringComparer.OrdinalIgnoreCase);
reports = reports.Where(r =>
r.Report.VerificationResults.Any(v =>
v.PolicyId != null && policySet.Contains(v.PolicyId)));
}
// Filter by predicate types
if (query.PredicateTypes is { Count: > 0 })
{
var predicateSet = query.PredicateTypes.ToHashSet(StringComparer.Ordinal);
reports = reports.Where(r =>
r.Report.VerificationResults.Any(v => predicateSet.Contains(v.PredicateType)));
}
// Filter by status
if (query.StatusFilter is { Count: > 0 })
{
var statusSet = query.StatusFilter.ToHashSet();
reports = reports.Where(r => statusSet.Contains(r.Report.OverallStatus));
}
// Filter by time range
if (query.FromTime.HasValue)
{
reports = reports.Where(r => r.Report.EvaluatedAt >= query.FromTime.Value);
}
if (query.ToTime.HasValue)
{
reports = reports.Where(r => r.Report.EvaluatedAt <= query.ToTime.Value);
}
// Order by evaluated time descending
var result = reports
.OrderByDescending(r => r.Report.EvaluatedAt)
.Skip(query.Offset)
.Take(query.Limit)
.ToList() as IReadOnlyList<StoredAttestationReport>;
return Task.FromResult(result);
}
public Task<int> CountAsync(AttestationReportQuery? query = null, CancellationToken cancellationToken = default)
{
if (query == null)
{
return Task.FromResult(_reports.Count);
}
IEnumerable<StoredAttestationReport> reports = _reports.Values;
// Apply same filters as ListAsync but only count
if (query.ArtifactDigests is { Count: > 0 })
{
var digestSet = query.ArtifactDigests.ToHashSet(StringComparer.OrdinalIgnoreCase);
reports = reports.Where(r => digestSet.Contains(r.Report.ArtifactDigest));
}
if (!string.IsNullOrWhiteSpace(query.ArtifactUriPattern))
{
var pattern = new Regex(query.ArtifactUriPattern, RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1));
reports = reports.Where(r => r.Report.ArtifactUri != null && pattern.IsMatch(r.Report.ArtifactUri));
}
if (query.PolicyIds is { Count: > 0 })
{
var policySet = query.PolicyIds.ToHashSet(StringComparer.OrdinalIgnoreCase);
reports = reports.Where(r =>
r.Report.VerificationResults.Any(v =>
v.PolicyId != null && policySet.Contains(v.PolicyId)));
}
if (query.PredicateTypes is { Count: > 0 })
{
var predicateSet = query.PredicateTypes.ToHashSet(StringComparer.Ordinal);
reports = reports.Where(r =>
r.Report.VerificationResults.Any(v => predicateSet.Contains(v.PredicateType)));
}
if (query.StatusFilter is { Count: > 0 })
{
var statusSet = query.StatusFilter.ToHashSet();
reports = reports.Where(r => statusSet.Contains(r.Report.OverallStatus));
}
if (query.FromTime.HasValue)
{
reports = reports.Where(r => r.Report.EvaluatedAt >= query.FromTime.Value);
}
if (query.ToTime.HasValue)
{
reports = reports.Where(r => r.Report.EvaluatedAt <= query.ToTime.Value);
}
return Task.FromResult(reports.Count());
}
public Task<StoredAttestationReport> CreateAsync(StoredAttestationReport report, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(report);
// Upsert behavior - replace if exists
_reports[report.Report.ArtifactDigest] = report;
return Task.FromResult(report);
}
public Task<StoredAttestationReport?> UpdateAsync(
string artifactDigest,
Func<StoredAttestationReport, StoredAttestationReport> update,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
ArgumentNullException.ThrowIfNull(update);
if (!_reports.TryGetValue(artifactDigest, out var existing))
{
return Task.FromResult<StoredAttestationReport?>(null);
}
var updated = update(existing);
_reports[artifactDigest] = updated;
return Task.FromResult<StoredAttestationReport?>(updated);
}
public Task<int> DeleteExpiredAsync(DateTimeOffset now, CancellationToken cancellationToken = default)
{
var expired = _reports.Values
.Where(r => r.ExpiresAt.HasValue && r.ExpiresAt.Value <= now)
.Select(r => r.Report.ArtifactDigest)
.ToList();
var count = 0;
foreach (var digest in expired)
{
if (_reports.TryRemove(digest, out _))
{
count++;
}
}
return Task.FromResult(count);
}
}

View File

@@ -0,0 +1,86 @@
using System.Collections.Concurrent;
namespace StellaOps.Policy.Engine.Attestation;
/// <summary>
/// In-memory implementation of verification policy store per CONTRACT-VERIFICATION-POLICY-006.
/// </summary>
internal sealed class InMemoryVerificationPolicyStore : IVerificationPolicyStore
{
private readonly ConcurrentDictionary<string, VerificationPolicy> _policies = new(StringComparer.OrdinalIgnoreCase);
public Task<VerificationPolicy?> GetAsync(string policyId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(policyId);
_policies.TryGetValue(policyId, out var policy);
return Task.FromResult(policy);
}
public Task<IReadOnlyList<VerificationPolicy>> ListAsync(
string? tenantScope = null,
CancellationToken cancellationToken = default)
{
IEnumerable<VerificationPolicy> policies = _policies.Values;
if (!string.IsNullOrWhiteSpace(tenantScope))
{
policies = policies.Where(p =>
p.TenantScope == "*" ||
p.TenantScope.Equals(tenantScope, StringComparison.OrdinalIgnoreCase));
}
var result = policies
.OrderBy(p => p.PolicyId)
.ToList() as IReadOnlyList<VerificationPolicy>;
return Task.FromResult(result);
}
public Task<VerificationPolicy> CreateAsync(
VerificationPolicy policy,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(policy);
if (!_policies.TryAdd(policy.PolicyId, policy))
{
throw new InvalidOperationException($"Policy '{policy.PolicyId}' already exists.");
}
return Task.FromResult(policy);
}
public Task<VerificationPolicy?> UpdateAsync(
string policyId,
Func<VerificationPolicy, VerificationPolicy> update,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(policyId);
ArgumentNullException.ThrowIfNull(update);
if (!_policies.TryGetValue(policyId, out var existing))
{
return Task.FromResult<VerificationPolicy?>(null);
}
var updated = update(existing);
_policies[policyId] = updated;
return Task.FromResult<VerificationPolicy?>(updated);
}
public Task<bool> DeleteAsync(string policyId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(policyId);
return Task.FromResult(_policies.TryRemove(policyId, out _));
}
public Task<bool> ExistsAsync(string policyId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(policyId);
return Task.FromResult(_policies.ContainsKey(policyId));
}
}

View File

@@ -0,0 +1,264 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Engine.Attestation;
/// <summary>
/// Editor metadata for verification policy forms per CONTRACT-VERIFICATION-POLICY-006.
/// </summary>
public sealed record VerificationPolicyEditorMetadata(
[property: JsonPropertyName("available_predicate_types")] IReadOnlyList<PredicateTypeInfo> AvailablePredicateTypes,
[property: JsonPropertyName("available_algorithms")] IReadOnlyList<AlgorithmInfo> AvailableAlgorithms,
[property: JsonPropertyName("default_signer_requirements")] SignerRequirements DefaultSignerRequirements,
[property: JsonPropertyName("validation_constraints")] ValidationConstraintsInfo ValidationConstraints);
/// <summary>
/// Information about a predicate type for editor dropdowns.
/// </summary>
public sealed record PredicateTypeInfo(
[property: JsonPropertyName("type")] string Type,
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("description")] string Description,
[property: JsonPropertyName("category")] PredicateCategory Category,
[property: JsonPropertyName("is_default")] bool IsDefault);
/// <summary>
/// Category of predicate type.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<PredicateCategory>))]
public enum PredicateCategory
{
StellaOps,
Slsa,
Sbom,
Vex
}
/// <summary>
/// Information about a signing algorithm for editor dropdowns.
/// </summary>
public sealed record AlgorithmInfo(
[property: JsonPropertyName("algorithm")] string Algorithm,
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("description")] string Description,
[property: JsonPropertyName("key_type")] string KeyType,
[property: JsonPropertyName("is_recommended")] bool IsRecommended);
/// <summary>
/// Validation constraints exposed to the editor.
/// </summary>
public sealed record ValidationConstraintsInfo(
[property: JsonPropertyName("max_policy_id_length")] int MaxPolicyIdLength,
[property: JsonPropertyName("max_version_length")] int MaxVersionLength,
[property: JsonPropertyName("max_description_length")] int MaxDescriptionLength,
[property: JsonPropertyName("max_predicate_types")] int MaxPredicateTypes,
[property: JsonPropertyName("max_trusted_key_fingerprints")] int MaxTrustedKeyFingerprints,
[property: JsonPropertyName("max_trusted_issuers")] int MaxTrustedIssuers,
[property: JsonPropertyName("max_algorithms")] int MaxAlgorithms,
[property: JsonPropertyName("max_metadata_entries")] int MaxMetadataEntries,
[property: JsonPropertyName("max_attestation_age_seconds")] int MaxAttestationAgeSeconds);
/// <summary>
/// Editor view of a verification policy with validation state.
/// </summary>
public sealed record VerificationPolicyEditorView(
[property: JsonPropertyName("policy")] VerificationPolicy Policy,
[property: JsonPropertyName("validation")] VerificationPolicyValidationResult Validation,
[property: JsonPropertyName("suggestions")] IReadOnlyList<PolicySuggestion>? Suggestions,
[property: JsonPropertyName("can_delete")] bool CanDelete,
[property: JsonPropertyName("is_referenced")] bool IsReferenced);
/// <summary>
/// Suggestion for policy improvement.
/// </summary>
public sealed record PolicySuggestion(
[property: JsonPropertyName("code")] string Code,
[property: JsonPropertyName("field")] string Field,
[property: JsonPropertyName("message")] string Message,
[property: JsonPropertyName("suggested_value")] object? SuggestedValue);
/// <summary>
/// Request to validate a verification policy without persisting.
/// </summary>
public sealed record ValidatePolicyRequest(
[property: JsonPropertyName("policy_id")] string? PolicyId,
[property: JsonPropertyName("version")] string? Version,
[property: JsonPropertyName("description")] string? Description,
[property: JsonPropertyName("tenant_scope")] string? TenantScope,
[property: JsonPropertyName("predicate_types")] IReadOnlyList<string>? PredicateTypes,
[property: JsonPropertyName("signer_requirements")] SignerRequirements? SignerRequirements,
[property: JsonPropertyName("validity_window")] ValidityWindow? ValidityWindow,
[property: JsonPropertyName("metadata")] IReadOnlyDictionary<string, object?>? Metadata);
/// <summary>
/// Response from policy validation.
/// </summary>
public sealed record ValidatePolicyResponse(
[property: JsonPropertyName("valid")] bool Valid,
[property: JsonPropertyName("errors")] IReadOnlyList<VerificationPolicyValidationError> Errors,
[property: JsonPropertyName("warnings")] IReadOnlyList<VerificationPolicyValidationError> Warnings,
[property: JsonPropertyName("suggestions")] IReadOnlyList<PolicySuggestion> Suggestions);
/// <summary>
/// Request to clone a verification policy.
/// </summary>
public sealed record ClonePolicyRequest(
[property: JsonPropertyName("source_policy_id")] string SourcePolicyId,
[property: JsonPropertyName("new_policy_id")] string NewPolicyId,
[property: JsonPropertyName("new_version")] string? NewVersion);
/// <summary>
/// Request to compare two verification policies.
/// </summary>
public sealed record ComparePoliciesRequest(
[property: JsonPropertyName("policy_id_a")] string PolicyIdA,
[property: JsonPropertyName("policy_id_b")] string PolicyIdB);
/// <summary>
/// Result of comparing two verification policies.
/// </summary>
public sealed record ComparePoliciesResponse(
[property: JsonPropertyName("policy_a")] VerificationPolicy PolicyA,
[property: JsonPropertyName("policy_b")] VerificationPolicy PolicyB,
[property: JsonPropertyName("differences")] IReadOnlyList<PolicyDifference> Differences);
/// <summary>
/// A difference between two policies.
/// </summary>
public sealed record PolicyDifference(
[property: JsonPropertyName("field")] string Field,
[property: JsonPropertyName("value_a")] object? ValueA,
[property: JsonPropertyName("value_b")] object? ValueB,
[property: JsonPropertyName("change_type")] DifferenceType ChangeType);
/// <summary>
/// Type of difference between policies.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<DifferenceType>))]
public enum DifferenceType
{
Added,
Removed,
Modified
}
/// <summary>
/// Provider of editor metadata for verification policies.
/// </summary>
public static class VerificationPolicyEditorMetadataProvider
{
private static readonly IReadOnlyList<PredicateTypeInfo> AvailablePredicateTypes =
[
// StellaOps types
new(PredicateTypes.SbomV1, "StellaOps SBOM", "Software Bill of Materials attestation", PredicateCategory.StellaOps, true),
new(PredicateTypes.VexV1, "StellaOps VEX", "Vulnerability Exploitability Exchange attestation", PredicateCategory.StellaOps, true),
new(PredicateTypes.VexDecisionV1, "StellaOps VEX Decision", "VEX decision record attestation", PredicateCategory.StellaOps, false),
new(PredicateTypes.PolicyV1, "StellaOps Policy", "Policy decision attestation", PredicateCategory.StellaOps, false),
new(PredicateTypes.PromotionV1, "StellaOps Promotion", "Artifact promotion attestation", PredicateCategory.StellaOps, false),
new(PredicateTypes.EvidenceV1, "StellaOps Evidence", "Evidence collection attestation", PredicateCategory.StellaOps, false),
new(PredicateTypes.GraphV1, "StellaOps Graph", "Dependency graph attestation", PredicateCategory.StellaOps, false),
new(PredicateTypes.ReplayV1, "StellaOps Replay", "Replay verification attestation", PredicateCategory.StellaOps, false),
// SLSA types
new(PredicateTypes.SlsaProvenanceV1, "SLSA Provenance v1", "SLSA v1.0 provenance attestation", PredicateCategory.Slsa, true),
new(PredicateTypes.SlsaProvenanceV02, "SLSA Provenance v0.2", "SLSA v0.2 provenance attestation (legacy)", PredicateCategory.Slsa, false),
// SBOM types
new(PredicateTypes.CycloneDxBom, "CycloneDX BOM", "CycloneDX Bill of Materials", PredicateCategory.Sbom, true),
new(PredicateTypes.SpdxDocument, "SPDX Document", "SPDX SBOM document", PredicateCategory.Sbom, true),
// VEX types
new(PredicateTypes.OpenVex, "OpenVEX", "OpenVEX vulnerability exchange", PredicateCategory.Vex, true)
];
private static readonly IReadOnlyList<AlgorithmInfo> AvailableAlgorithms =
[
new("ES256", "ECDSA P-256", "ECDSA with SHA-256 and P-256 curve", "EC", true),
new("ES384", "ECDSA P-384", "ECDSA with SHA-384 and P-384 curve", "EC", false),
new("ES512", "ECDSA P-521", "ECDSA with SHA-512 and P-521 curve", "EC", false),
new("RS256", "RSA-SHA256", "RSA with SHA-256", "RSA", true),
new("RS384", "RSA-SHA384", "RSA with SHA-384", "RSA", false),
new("RS512", "RSA-SHA512", "RSA with SHA-512", "RSA", false),
new("PS256", "RSA-PSS-SHA256", "RSA-PSS with SHA-256", "RSA", false),
new("PS384", "RSA-PSS-SHA384", "RSA-PSS with SHA-384", "RSA", false),
new("PS512", "RSA-PSS-SHA512", "RSA-PSS with SHA-512", "RSA", false),
new("EdDSA", "EdDSA", "Edwards-curve Digital Signature Algorithm (Ed25519)", "OKP", true)
];
/// <summary>
/// Gets the editor metadata for verification policy forms.
/// </summary>
public static VerificationPolicyEditorMetadata GetMetadata(
VerificationPolicyValidationConstraints? constraints = null)
{
var c = constraints ?? VerificationPolicyValidationConstraints.Default;
return new VerificationPolicyEditorMetadata(
AvailablePredicateTypes: AvailablePredicateTypes,
AvailableAlgorithms: AvailableAlgorithms,
DefaultSignerRequirements: SignerRequirements.Default,
ValidationConstraints: new ValidationConstraintsInfo(
MaxPolicyIdLength: c.MaxPolicyIdLength,
MaxVersionLength: c.MaxVersionLength,
MaxDescriptionLength: c.MaxDescriptionLength,
MaxPredicateTypes: c.MaxPredicateTypes,
MaxTrustedKeyFingerprints: c.MaxTrustedKeyFingerprints,
MaxTrustedIssuers: c.MaxTrustedIssuers,
MaxAlgorithms: c.MaxAlgorithms,
MaxMetadataEntries: c.MaxMetadataEntries,
MaxAttestationAgeSeconds: c.MaxAttestationAgeSeconds));
}
/// <summary>
/// Generates suggestions for a policy based on validation results.
/// </summary>
public static IReadOnlyList<PolicySuggestion> GenerateSuggestions(
CreateVerificationPolicyRequest request,
VerificationPolicyValidationResult validation)
{
var suggestions = new List<PolicySuggestion>();
// Suggest adding Rekor if not enabled
if (request.SignerRequirements is { RequireRekor: false })
{
suggestions.Add(new PolicySuggestion(
"SUG_VP_001",
"signer_requirements.require_rekor",
"Consider enabling Rekor for transparency log verification.",
true));
}
// Suggest adding trusted key fingerprints if empty
if (request.SignerRequirements is { TrustedKeyFingerprints.Count: 0 })
{
suggestions.Add(new PolicySuggestion(
"SUG_VP_002",
"signer_requirements.trusted_key_fingerprints",
"Consider adding trusted key fingerprints to restrict accepted signers.",
null));
}
// Suggest adding validity window if not set
if (request.ValidityWindow == null)
{
suggestions.Add(new PolicySuggestion(
"SUG_VP_003",
"validity_window",
"Consider setting a validity window to limit attestation age.",
new ValidityWindow(null, null, 2592000))); // 30 days default
}
// Suggest EdDSA if only RSA algorithms are selected
if (request.SignerRequirements?.Algorithms != null &&
request.SignerRequirements.Algorithms.All(a => a.StartsWith("RS", StringComparison.OrdinalIgnoreCase) ||
a.StartsWith("PS", StringComparison.OrdinalIgnoreCase)))
{
suggestions.Add(new PolicySuggestion(
"SUG_VP_004",
"signer_requirements.algorithms",
"Consider adding ES256 or EdDSA for better performance and smaller signatures.",
null));
}
return suggestions;
}
}

View File

@@ -0,0 +1,136 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Engine.Attestation;
/// <summary>
/// Verification policy for attestation validation per CONTRACT-VERIFICATION-POLICY-006.
/// </summary>
public sealed record VerificationPolicy(
[property: JsonPropertyName("policy_id")] string PolicyId,
[property: JsonPropertyName("version")] string Version,
[property: JsonPropertyName("description")] string? Description,
[property: JsonPropertyName("tenant_scope")] string TenantScope,
[property: JsonPropertyName("predicate_types")] IReadOnlyList<string> PredicateTypes,
[property: JsonPropertyName("signer_requirements")] SignerRequirements SignerRequirements,
[property: JsonPropertyName("validity_window")] ValidityWindow? ValidityWindow,
[property: JsonPropertyName("metadata")] IReadOnlyDictionary<string, object?>? Metadata,
[property: JsonPropertyName("created_at")] DateTimeOffset CreatedAt,
[property: JsonPropertyName("updated_at")] DateTimeOffset UpdatedAt);
/// <summary>
/// Signer requirements for attestation verification.
/// </summary>
public sealed record SignerRequirements(
[property: JsonPropertyName("minimum_signatures")] int MinimumSignatures,
[property: JsonPropertyName("trusted_key_fingerprints")] IReadOnlyList<string> TrustedKeyFingerprints,
[property: JsonPropertyName("trusted_issuers")] IReadOnlyList<string>? TrustedIssuers,
[property: JsonPropertyName("require_rekor")] bool RequireRekor,
[property: JsonPropertyName("algorithms")] IReadOnlyList<string>? Algorithms)
{
public static SignerRequirements Default => new(
MinimumSignatures: 1,
TrustedKeyFingerprints: [],
TrustedIssuers: null,
RequireRekor: false,
Algorithms: ["ES256", "RS256", "EdDSA"]);
}
/// <summary>
/// Validity window for attestations.
/// </summary>
public sealed record ValidityWindow(
[property: JsonPropertyName("not_before")] DateTimeOffset? NotBefore,
[property: JsonPropertyName("not_after")] DateTimeOffset? NotAfter,
[property: JsonPropertyName("max_attestation_age")] int? MaxAttestationAge);
/// <summary>
/// Request to create a verification policy.
/// </summary>
public sealed record CreateVerificationPolicyRequest(
[property: JsonPropertyName("policy_id")] string PolicyId,
[property: JsonPropertyName("version")] string Version,
[property: JsonPropertyName("description")] string? Description,
[property: JsonPropertyName("tenant_scope")] string? TenantScope,
[property: JsonPropertyName("predicate_types")] IReadOnlyList<string> PredicateTypes,
[property: JsonPropertyName("signer_requirements")] SignerRequirements? SignerRequirements,
[property: JsonPropertyName("validity_window")] ValidityWindow? ValidityWindow,
[property: JsonPropertyName("metadata")] IReadOnlyDictionary<string, object?>? Metadata);
/// <summary>
/// Request to update a verification policy.
/// </summary>
public sealed record UpdateVerificationPolicyRequest(
[property: JsonPropertyName("version")] string? Version,
[property: JsonPropertyName("description")] string? Description,
[property: JsonPropertyName("predicate_types")] IReadOnlyList<string>? PredicateTypes,
[property: JsonPropertyName("signer_requirements")] SignerRequirements? SignerRequirements,
[property: JsonPropertyName("validity_window")] ValidityWindow? ValidityWindow,
[property: JsonPropertyName("metadata")] IReadOnlyDictionary<string, object?>? Metadata);
/// <summary>
/// Result of verifying an attestation.
/// </summary>
public sealed record VerificationResult(
[property: JsonPropertyName("valid")] bool Valid,
[property: JsonPropertyName("predicate_type")] string? PredicateType,
[property: JsonPropertyName("signature_count")] int SignatureCount,
[property: JsonPropertyName("signers")] IReadOnlyList<SignerInfo> Signers,
[property: JsonPropertyName("rekor_entry")] RekorEntry? RekorEntry,
[property: JsonPropertyName("attestation_timestamp")] DateTimeOffset? AttestationTimestamp,
[property: JsonPropertyName("policy_id")] string PolicyId,
[property: JsonPropertyName("policy_version")] string PolicyVersion,
[property: JsonPropertyName("errors")] IReadOnlyList<string>? Errors);
/// <summary>
/// Information about a signer.
/// </summary>
public sealed record SignerInfo(
[property: JsonPropertyName("key_fingerprint")] string KeyFingerprint,
[property: JsonPropertyName("issuer")] string? Issuer,
[property: JsonPropertyName("algorithm")] string Algorithm,
[property: JsonPropertyName("verified")] bool Verified);
/// <summary>
/// Rekor transparency log entry.
/// </summary>
public sealed record RekorEntry(
[property: JsonPropertyName("uuid")] string Uuid,
[property: JsonPropertyName("log_index")] long LogIndex,
[property: JsonPropertyName("integrated_time")] DateTimeOffset IntegratedTime);
/// <summary>
/// Request to verify an attestation.
/// </summary>
public sealed record VerifyAttestationRequest(
[property: JsonPropertyName("envelope")] string Envelope,
[property: JsonPropertyName("policy_id")] string PolicyId);
/// <summary>
/// Standard predicate types supported by StellaOps.
/// </summary>
public static class PredicateTypes
{
// StellaOps types
public const string SbomV1 = "stella.ops/sbom@v1";
public const string VexV1 = "stella.ops/vex@v1";
public const string VexDecisionV1 = "stella.ops/vexDecision@v1";
public const string PolicyV1 = "stella.ops/policy@v1";
public const string PromotionV1 = "stella.ops/promotion@v1";
public const string EvidenceV1 = "stella.ops/evidence@v1";
public const string GraphV1 = "stella.ops/graph@v1";
public const string ReplayV1 = "stella.ops/replay@v1";
// Third-party types
public const string SlsaProvenanceV02 = "https://slsa.dev/provenance/v0.2";
public const string SlsaProvenanceV1 = "https://slsa.dev/provenance/v1";
public const string CycloneDxBom = "https://cyclonedx.org/bom";
public const string SpdxDocument = "https://spdx.dev/Document";
public const string OpenVex = "https://openvex.dev/ns";
public static readonly IReadOnlyList<string> DefaultAllowed = new[]
{
SbomV1, VexV1, VexDecisionV1, PolicyV1, PromotionV1,
EvidenceV1, GraphV1, ReplayV1,
SlsaProvenanceV1, CycloneDxBom, SpdxDocument, OpenVex
};
}

View File

@@ -0,0 +1,516 @@
using System.Text.RegularExpressions;
namespace StellaOps.Policy.Engine.Attestation;
/// <summary>
/// Validation result for verification policy per CONTRACT-VERIFICATION-POLICY-006.
/// </summary>
public sealed record VerificationPolicyValidationResult(
bool IsValid,
IReadOnlyList<VerificationPolicyValidationError> Errors)
{
public static VerificationPolicyValidationResult Success() =>
new(IsValid: true, Errors: Array.Empty<VerificationPolicyValidationError>());
public static VerificationPolicyValidationResult Failure(params VerificationPolicyValidationError[] errors) =>
new(IsValid: false, Errors: errors);
public static VerificationPolicyValidationResult Failure(IEnumerable<VerificationPolicyValidationError> errors) =>
new(IsValid: false, Errors: errors.ToList());
}
/// <summary>
/// Validation error for verification policy.
/// </summary>
public sealed record VerificationPolicyValidationError(
string Code,
string Field,
string Message,
ValidationSeverity Severity = ValidationSeverity.Error);
/// <summary>
/// Severity of validation error.
/// </summary>
public enum ValidationSeverity
{
Warning,
Error
}
/// <summary>
/// Constraints for verification policy validation.
/// </summary>
public sealed record VerificationPolicyValidationConstraints
{
public static VerificationPolicyValidationConstraints Default { get; } = new();
public int MaxPolicyIdLength { get; init; } = 256;
public int MaxVersionLength { get; init; } = 64;
public int MaxDescriptionLength { get; init; } = 2048;
public int MaxPredicateTypes { get; init; } = 50;
public int MaxTrustedKeyFingerprints { get; init; } = 100;
public int MaxTrustedIssuers { get; init; } = 50;
public int MaxAlgorithms { get; init; } = 20;
public int MaxMetadataEntries { get; init; } = 50;
public int MaxAttestationAgeSeconds { get; init; } = 31536000; // 1 year
}
/// <summary>
/// Validator for verification policies per CONTRACT-VERIFICATION-POLICY-006.
/// </summary>
public sealed class VerificationPolicyValidator
{
private static readonly Regex PolicyIdPattern = new(
@"^[a-zA-Z0-9][a-zA-Z0-9\-_.]*$",
RegexOptions.Compiled,
TimeSpan.FromSeconds(1));
private static readonly Regex VersionPattern = new(
@"^\d+\.\d+\.\d+(-[a-zA-Z0-9\-.]+)?(\+[a-zA-Z0-9\-.]+)?$",
RegexOptions.Compiled,
TimeSpan.FromSeconds(1));
private static readonly Regex FingerprintPattern = new(
@"^[0-9a-fA-F]{40,128}$",
RegexOptions.Compiled,
TimeSpan.FromSeconds(1));
private static readonly Regex TenantScopePattern = new(
@"^(\*|[a-zA-Z0-9][a-zA-Z0-9\-_.]*(\*[a-zA-Z0-9\-_.]*)?|[a-zA-Z0-9\-_.]*\*)$",
RegexOptions.Compiled,
TimeSpan.FromSeconds(1));
private static readonly HashSet<string> AllowedAlgorithms = new(StringComparer.OrdinalIgnoreCase)
{
"ES256", "ES384", "ES512",
"RS256", "RS384", "RS512",
"PS256", "PS384", "PS512",
"EdDSA"
};
private readonly VerificationPolicyValidationConstraints _constraints;
public VerificationPolicyValidator(VerificationPolicyValidationConstraints? constraints = null)
{
_constraints = constraints ?? VerificationPolicyValidationConstraints.Default;
}
/// <summary>
/// Validates a create request for verification policy.
/// </summary>
public VerificationPolicyValidationResult ValidateCreate(CreateVerificationPolicyRequest request)
{
ArgumentNullException.ThrowIfNull(request);
var errors = new List<VerificationPolicyValidationError>();
// Validate PolicyId
ValidatePolicyId(request.PolicyId, errors);
// Validate Version
ValidateVersion(request.Version, errors);
// Validate Description
ValidateDescription(request.Description, errors);
// Validate TenantScope
ValidateTenantScope(request.TenantScope, errors);
// Validate PredicateTypes
ValidatePredicateTypes(request.PredicateTypes, errors);
// Validate SignerRequirements
ValidateSignerRequirements(request.SignerRequirements, errors);
// Validate ValidityWindow
ValidateValidityWindow(request.ValidityWindow, errors);
// Validate Metadata
ValidateMetadata(request.Metadata, errors);
return errors.Count == 0
? VerificationPolicyValidationResult.Success()
: VerificationPolicyValidationResult.Failure(errors);
}
/// <summary>
/// Validates an update request for verification policy.
/// </summary>
public VerificationPolicyValidationResult ValidateUpdate(UpdateVerificationPolicyRequest request)
{
ArgumentNullException.ThrowIfNull(request);
var errors = new List<VerificationPolicyValidationError>();
// Version is optional in updates but must be valid if provided
if (request.Version != null)
{
ValidateVersion(request.Version, errors);
}
// Description is optional in updates
if (request.Description != null)
{
ValidateDescription(request.Description, errors);
}
// PredicateTypes is optional in updates
if (request.PredicateTypes != null)
{
ValidatePredicateTypes(request.PredicateTypes, errors);
}
// SignerRequirements is optional in updates
if (request.SignerRequirements != null)
{
ValidateSignerRequirements(request.SignerRequirements, errors);
}
// ValidityWindow is optional in updates
if (request.ValidityWindow != null)
{
ValidateValidityWindow(request.ValidityWindow, errors);
}
// Metadata is optional in updates
if (request.Metadata != null)
{
ValidateMetadata(request.Metadata, errors);
}
return errors.Count == 0
? VerificationPolicyValidationResult.Success()
: VerificationPolicyValidationResult.Failure(errors);
}
private void ValidatePolicyId(string? policyId, List<VerificationPolicyValidationError> errors)
{
if (string.IsNullOrWhiteSpace(policyId))
{
errors.Add(new VerificationPolicyValidationError(
"ERR_VP_001",
"policy_id",
"Policy ID is required."));
return;
}
if (policyId.Length > _constraints.MaxPolicyIdLength)
{
errors.Add(new VerificationPolicyValidationError(
"ERR_VP_002",
"policy_id",
$"Policy ID exceeds maximum length of {_constraints.MaxPolicyIdLength} characters."));
return;
}
if (!PolicyIdPattern.IsMatch(policyId))
{
errors.Add(new VerificationPolicyValidationError(
"ERR_VP_003",
"policy_id",
"Policy ID must start with alphanumeric and contain only alphanumeric, hyphens, underscores, or dots."));
}
}
private void ValidateVersion(string? version, List<VerificationPolicyValidationError> errors)
{
if (string.IsNullOrWhiteSpace(version))
{
// Version defaults to "1.0.0" if not provided, so this is a warning
errors.Add(new VerificationPolicyValidationError(
"WARN_VP_001",
"version",
"Version not provided; defaulting to 1.0.0.",
ValidationSeverity.Warning));
return;
}
if (version.Length > _constraints.MaxVersionLength)
{
errors.Add(new VerificationPolicyValidationError(
"ERR_VP_004",
"version",
$"Version exceeds maximum length of {_constraints.MaxVersionLength} characters."));
return;
}
if (!VersionPattern.IsMatch(version))
{
errors.Add(new VerificationPolicyValidationError(
"ERR_VP_005",
"version",
"Version must follow semver format (e.g., 1.0.0, 2.1.0-alpha.1)."));
}
}
private void ValidateDescription(string? description, List<VerificationPolicyValidationError> errors)
{
if (description != null && description.Length > _constraints.MaxDescriptionLength)
{
errors.Add(new VerificationPolicyValidationError(
"ERR_VP_006",
"description",
$"Description exceeds maximum length of {_constraints.MaxDescriptionLength} characters."));
}
}
private void ValidateTenantScope(string? tenantScope, List<VerificationPolicyValidationError> errors)
{
if (string.IsNullOrWhiteSpace(tenantScope))
{
// Defaults to "*" if not provided
return;
}
if (!TenantScopePattern.IsMatch(tenantScope))
{
errors.Add(new VerificationPolicyValidationError(
"ERR_VP_007",
"tenant_scope",
"Tenant scope must be '*' or a valid identifier with optional wildcard suffix."));
}
}
private void ValidatePredicateTypes(IReadOnlyList<string>? predicateTypes, List<VerificationPolicyValidationError> errors)
{
if (predicateTypes == null || predicateTypes.Count == 0)
{
errors.Add(new VerificationPolicyValidationError(
"ERR_VP_008",
"predicate_types",
"At least one predicate type is required."));
return;
}
if (predicateTypes.Count > _constraints.MaxPredicateTypes)
{
errors.Add(new VerificationPolicyValidationError(
"ERR_VP_009",
"predicate_types",
$"Predicate types exceeds maximum count of {_constraints.MaxPredicateTypes}."));
return;
}
var seen = new HashSet<string>(StringComparer.Ordinal);
for (var i = 0; i < predicateTypes.Count; i++)
{
var predicateType = predicateTypes[i];
if (string.IsNullOrWhiteSpace(predicateType))
{
errors.Add(new VerificationPolicyValidationError(
"ERR_VP_010",
$"predicate_types[{i}]",
"Predicate type cannot be empty."));
continue;
}
if (!seen.Add(predicateType))
{
errors.Add(new VerificationPolicyValidationError(
"WARN_VP_002",
$"predicate_types[{i}]",
$"Duplicate predicate type '{predicateType}'.",
ValidationSeverity.Warning));
}
// Check if it's a known predicate type or valid URI format
if (!IsKnownPredicateType(predicateType) && !IsValidPredicateTypeUri(predicateType))
{
errors.Add(new VerificationPolicyValidationError(
"WARN_VP_003",
$"predicate_types[{i}]",
$"Predicate type '{predicateType}' is not a known StellaOps or standard type.",
ValidationSeverity.Warning));
}
}
}
private void ValidateSignerRequirements(SignerRequirements? requirements, List<VerificationPolicyValidationError> errors)
{
if (requirements == null)
{
// Defaults to SignerRequirements.Default if not provided
return;
}
if (requirements.MinimumSignatures < 1)
{
errors.Add(new VerificationPolicyValidationError(
"ERR_VP_011",
"signer_requirements.minimum_signatures",
"Minimum signatures must be at least 1."));
}
if (requirements.TrustedKeyFingerprints.Count > _constraints.MaxTrustedKeyFingerprints)
{
errors.Add(new VerificationPolicyValidationError(
"ERR_VP_012",
"signer_requirements.trusted_key_fingerprints",
$"Trusted key fingerprints exceeds maximum count of {_constraints.MaxTrustedKeyFingerprints}."));
}
var seenFingerprints = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < requirements.TrustedKeyFingerprints.Count; i++)
{
var fingerprint = requirements.TrustedKeyFingerprints[i];
if (string.IsNullOrWhiteSpace(fingerprint))
{
errors.Add(new VerificationPolicyValidationError(
"ERR_VP_013",
$"signer_requirements.trusted_key_fingerprints[{i}]",
"Key fingerprint cannot be empty."));
continue;
}
if (!FingerprintPattern.IsMatch(fingerprint))
{
errors.Add(new VerificationPolicyValidationError(
"ERR_VP_014",
$"signer_requirements.trusted_key_fingerprints[{i}]",
"Key fingerprint must be a 40-128 character hex string."));
}
if (!seenFingerprints.Add(fingerprint))
{
errors.Add(new VerificationPolicyValidationError(
"WARN_VP_004",
$"signer_requirements.trusted_key_fingerprints[{i}]",
$"Duplicate key fingerprint.",
ValidationSeverity.Warning));
}
}
if (requirements.TrustedIssuers != null)
{
if (requirements.TrustedIssuers.Count > _constraints.MaxTrustedIssuers)
{
errors.Add(new VerificationPolicyValidationError(
"ERR_VP_015",
"signer_requirements.trusted_issuers",
$"Trusted issuers exceeds maximum count of {_constraints.MaxTrustedIssuers}."));
}
for (var i = 0; i < requirements.TrustedIssuers.Count; i++)
{
var issuer = requirements.TrustedIssuers[i];
if (string.IsNullOrWhiteSpace(issuer))
{
errors.Add(new VerificationPolicyValidationError(
"ERR_VP_016",
$"signer_requirements.trusted_issuers[{i}]",
"Issuer cannot be empty."));
}
}
}
if (requirements.Algorithms != null)
{
if (requirements.Algorithms.Count > _constraints.MaxAlgorithms)
{
errors.Add(new VerificationPolicyValidationError(
"ERR_VP_017",
"signer_requirements.algorithms",
$"Algorithms exceeds maximum count of {_constraints.MaxAlgorithms}."));
}
for (var i = 0; i < requirements.Algorithms.Count; i++)
{
var algorithm = requirements.Algorithms[i];
if (string.IsNullOrWhiteSpace(algorithm))
{
errors.Add(new VerificationPolicyValidationError(
"ERR_VP_018",
$"signer_requirements.algorithms[{i}]",
"Algorithm cannot be empty."));
continue;
}
if (!AllowedAlgorithms.Contains(algorithm))
{
errors.Add(new VerificationPolicyValidationError(
"ERR_VP_019",
$"signer_requirements.algorithms[{i}]",
$"Algorithm '{algorithm}' is not supported. Allowed: {string.Join(", ", AllowedAlgorithms)}."));
}
}
}
}
private void ValidateValidityWindow(ValidityWindow? window, List<VerificationPolicyValidationError> errors)
{
if (window == null)
{
return;
}
if (window.NotBefore.HasValue && window.NotAfter.HasValue)
{
if (window.NotBefore.Value >= window.NotAfter.Value)
{
errors.Add(new VerificationPolicyValidationError(
"ERR_VP_020",
"validity_window",
"not_before must be earlier than not_after."));
}
}
if (window.MaxAttestationAge.HasValue)
{
if (window.MaxAttestationAge.Value <= 0)
{
errors.Add(new VerificationPolicyValidationError(
"ERR_VP_021",
"validity_window.max_attestation_age",
"Maximum attestation age must be a positive integer (seconds)."));
}
else if (window.MaxAttestationAge.Value > _constraints.MaxAttestationAgeSeconds)
{
errors.Add(new VerificationPolicyValidationError(
"ERR_VP_022",
"validity_window.max_attestation_age",
$"Maximum attestation age exceeds limit of {_constraints.MaxAttestationAgeSeconds} seconds."));
}
}
}
private void ValidateMetadata(IReadOnlyDictionary<string, object?>? metadata, List<VerificationPolicyValidationError> errors)
{
if (metadata == null)
{
return;
}
if (metadata.Count > _constraints.MaxMetadataEntries)
{
errors.Add(new VerificationPolicyValidationError(
"ERR_VP_023",
"metadata",
$"Metadata exceeds maximum of {_constraints.MaxMetadataEntries} entries."));
}
}
private static bool IsKnownPredicateType(string predicateType)
{
return predicateType == PredicateTypes.SbomV1
|| predicateType == PredicateTypes.VexV1
|| predicateType == PredicateTypes.VexDecisionV1
|| predicateType == PredicateTypes.PolicyV1
|| predicateType == PredicateTypes.PromotionV1
|| predicateType == PredicateTypes.EvidenceV1
|| predicateType == PredicateTypes.GraphV1
|| predicateType == PredicateTypes.ReplayV1
|| predicateType == PredicateTypes.SlsaProvenanceV02
|| predicateType == PredicateTypes.SlsaProvenanceV1
|| predicateType == PredicateTypes.CycloneDxBom
|| predicateType == PredicateTypes.SpdxDocument
|| predicateType == PredicateTypes.OpenVex;
}
private static bool IsValidPredicateTypeUri(string predicateType)
{
// Predicate types are typically URIs or namespaced identifiers
return predicateType.Contains('/') || predicateType.Contains(':');
}
}

View File

@@ -0,0 +1,228 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
using StellaOps.Policy.Engine.Attestation;
namespace StellaOps.Policy.Engine.ConsoleSurface;
/// <summary>
/// Console request for attestation report query per CONTRACT-VERIFICATION-POLICY-006.
/// </summary>
internal sealed record ConsoleAttestationReportRequest(
[property: JsonPropertyName("artifact_digests")] IReadOnlyList<string>? ArtifactDigests,
[property: JsonPropertyName("artifact_uri_pattern")] string? ArtifactUriPattern,
[property: JsonPropertyName("policy_ids")] IReadOnlyList<string>? PolicyIds,
[property: JsonPropertyName("predicate_types")] IReadOnlyList<string>? PredicateTypes,
[property: JsonPropertyName("status_filter")] IReadOnlyList<string>? StatusFilter,
[property: JsonPropertyName("from_time")] DateTimeOffset? FromTime,
[property: JsonPropertyName("to_time")] DateTimeOffset? ToTime,
[property: JsonPropertyName("group_by")] ConsoleReportGroupBy? GroupBy,
[property: JsonPropertyName("sort_by")] ConsoleReportSortBy? SortBy,
[property: JsonPropertyName("page")] int Page = 1,
[property: JsonPropertyName("page_size")] int PageSize = 25);
/// <summary>
/// Grouping options for Console attestation reports.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<ConsoleReportGroupBy>))]
internal enum ConsoleReportGroupBy
{
None,
Policy,
PredicateType,
Status,
ArtifactUri
}
/// <summary>
/// Sorting options for Console attestation reports.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<ConsoleReportSortBy>))]
internal enum ConsoleReportSortBy
{
EvaluatedAtDesc,
EvaluatedAtAsc,
StatusAsc,
StatusDesc,
CoverageDesc,
CoverageAsc
}
/// <summary>
/// Console response for attestation reports.
/// </summary>
internal sealed record ConsoleAttestationReportResponse(
[property: JsonPropertyName("schema_version")] string SchemaVersion,
[property: JsonPropertyName("summary")] ConsoleReportSummary Summary,
[property: JsonPropertyName("reports")] IReadOnlyList<ConsoleArtifactReport> Reports,
[property: JsonPropertyName("groups")] IReadOnlyList<ConsoleReportGroup>? Groups,
[property: JsonPropertyName("pagination")] ConsolePagination Pagination,
[property: JsonPropertyName("filters_applied")] ConsoleFiltersApplied FiltersApplied);
/// <summary>
/// Summary of attestation reports for Console.
/// </summary>
internal sealed record ConsoleReportSummary(
[property: JsonPropertyName("total_artifacts")] int TotalArtifacts,
[property: JsonPropertyName("total_attestations")] int TotalAttestations,
[property: JsonPropertyName("status_breakdown")] ImmutableDictionary<string, int> StatusBreakdown,
[property: JsonPropertyName("coverage_rate")] double CoverageRate,
[property: JsonPropertyName("compliance_rate")] double ComplianceRate,
[property: JsonPropertyName("average_age_hours")] double AverageAgeHours);
/// <summary>
/// Console-friendly artifact attestation report.
/// </summary>
internal sealed record ConsoleArtifactReport(
[property: JsonPropertyName("artifact_digest")] string ArtifactDigest,
[property: JsonPropertyName("artifact_uri")] string? ArtifactUri,
[property: JsonPropertyName("artifact_short_digest")] string ArtifactShortDigest,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("status_label")] string StatusLabel,
[property: JsonPropertyName("status_icon")] string StatusIcon,
[property: JsonPropertyName("attestation_count")] int AttestationCount,
[property: JsonPropertyName("coverage_percentage")] double CoveragePercentage,
[property: JsonPropertyName("policies_passed")] int PoliciesPassed,
[property: JsonPropertyName("policies_failed")] int PoliciesFailed,
[property: JsonPropertyName("evaluated_at")] DateTimeOffset EvaluatedAt,
[property: JsonPropertyName("evaluated_at_relative")] string EvaluatedAtRelative,
[property: JsonPropertyName("details")] ConsoleReportDetails? Details);
/// <summary>
/// Detailed report information for Console.
/// </summary>
internal sealed record ConsoleReportDetails(
[property: JsonPropertyName("predicate_types")] IReadOnlyList<ConsolePredicateTypeStatus> PredicateTypes,
[property: JsonPropertyName("policies")] IReadOnlyList<ConsolePolicyStatus> Policies,
[property: JsonPropertyName("signers")] IReadOnlyList<ConsoleSignerInfo> Signers,
[property: JsonPropertyName("issues")] IReadOnlyList<ConsoleIssue> Issues);
/// <summary>
/// Predicate type status for Console.
/// </summary>
internal sealed record ConsolePredicateTypeStatus(
[property: JsonPropertyName("type")] string Type,
[property: JsonPropertyName("type_label")] string TypeLabel,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("status_label")] string StatusLabel,
[property: JsonPropertyName("freshness")] string Freshness);
/// <summary>
/// Policy status for Console.
/// </summary>
internal sealed record ConsolePolicyStatus(
[property: JsonPropertyName("policy_id")] string PolicyId,
[property: JsonPropertyName("policy_version")] string PolicyVersion,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("status_label")] string StatusLabel,
[property: JsonPropertyName("verdict")] string Verdict);
/// <summary>
/// Signer information for Console.
/// </summary>
internal sealed record ConsoleSignerInfo(
[property: JsonPropertyName("key_fingerprint_short")] string KeyFingerprintShort,
[property: JsonPropertyName("issuer")] string? Issuer,
[property: JsonPropertyName("subject")] string? Subject,
[property: JsonPropertyName("algorithm")] string Algorithm,
[property: JsonPropertyName("verified")] bool Verified,
[property: JsonPropertyName("trusted")] bool Trusted);
/// <summary>
/// Issue for Console display.
/// </summary>
internal sealed record ConsoleIssue(
[property: JsonPropertyName("severity")] string Severity,
[property: JsonPropertyName("message")] string Message,
[property: JsonPropertyName("field")] string? Field);
/// <summary>
/// Report group for Console.
/// </summary>
internal sealed record ConsoleReportGroup(
[property: JsonPropertyName("key")] string Key,
[property: JsonPropertyName("label")] string Label,
[property: JsonPropertyName("count")] int Count,
[property: JsonPropertyName("status_breakdown")] ImmutableDictionary<string, int> StatusBreakdown);
/// <summary>
/// Pagination information for Console.
/// </summary>
internal sealed record ConsolePagination(
[property: JsonPropertyName("page")] int Page,
[property: JsonPropertyName("page_size")] int PageSize,
[property: JsonPropertyName("total_pages")] int TotalPages,
[property: JsonPropertyName("total_items")] int TotalItems,
[property: JsonPropertyName("has_next")] bool HasNext,
[property: JsonPropertyName("has_previous")] bool HasPrevious);
/// <summary>
/// Applied filters information for Console.
/// </summary>
internal sealed record ConsoleFiltersApplied(
[property: JsonPropertyName("artifact_count")] int ArtifactCount,
[property: JsonPropertyName("policy_ids")] IReadOnlyList<string>? PolicyIds,
[property: JsonPropertyName("predicate_types")] IReadOnlyList<string>? PredicateTypes,
[property: JsonPropertyName("status_filter")] IReadOnlyList<string>? StatusFilter,
[property: JsonPropertyName("time_range")] ConsoleTimeRange? TimeRange);
/// <summary>
/// Time range for Console filters.
/// </summary>
internal sealed record ConsoleTimeRange(
[property: JsonPropertyName("from")] DateTimeOffset? From,
[property: JsonPropertyName("to")] DateTimeOffset? To);
/// <summary>
/// Console request for attestation statistics dashboard.
/// </summary>
internal sealed record ConsoleAttestationDashboardRequest(
[property: JsonPropertyName("time_range")] string? TimeRange,
[property: JsonPropertyName("policy_ids")] IReadOnlyList<string>? PolicyIds,
[property: JsonPropertyName("artifact_uri_pattern")] string? ArtifactUriPattern);
/// <summary>
/// Console response for attestation statistics dashboard.
/// </summary>
internal sealed record ConsoleAttestationDashboardResponse(
[property: JsonPropertyName("schema_version")] string SchemaVersion,
[property: JsonPropertyName("overview")] ConsoleDashboardOverview Overview,
[property: JsonPropertyName("trends")] ConsoleDashboardTrends Trends,
[property: JsonPropertyName("top_issues")] IReadOnlyList<ConsoleDashboardIssue> TopIssues,
[property: JsonPropertyName("policy_compliance")] IReadOnlyList<ConsoleDashboardPolicyCompliance> PolicyCompliance,
[property: JsonPropertyName("evaluated_at")] DateTimeOffset EvaluatedAt);
/// <summary>
/// Dashboard overview for Console.
/// </summary>
internal sealed record ConsoleDashboardOverview(
[property: JsonPropertyName("total_artifacts")] int TotalArtifacts,
[property: JsonPropertyName("total_attestations")] int TotalAttestations,
[property: JsonPropertyName("pass_rate")] double PassRate,
[property: JsonPropertyName("coverage_rate")] double CoverageRate,
[property: JsonPropertyName("average_freshness_hours")] double AverageFreshnessHours);
/// <summary>
/// Dashboard trends for Console.
/// </summary>
internal sealed record ConsoleDashboardTrends(
[property: JsonPropertyName("pass_rate_change")] double PassRateChange,
[property: JsonPropertyName("coverage_rate_change")] double CoverageRateChange,
[property: JsonPropertyName("attestation_count_change")] int AttestationCountChange,
[property: JsonPropertyName("trend_direction")] string TrendDirection);
/// <summary>
/// Dashboard issue for Console.
/// </summary>
internal sealed record ConsoleDashboardIssue(
[property: JsonPropertyName("issue")] string Issue,
[property: JsonPropertyName("count")] int Count,
[property: JsonPropertyName("severity")] string Severity);
/// <summary>
/// Dashboard policy compliance for Console.
/// </summary>
internal sealed record ConsoleDashboardPolicyCompliance(
[property: JsonPropertyName("policy_id")] string PolicyId,
[property: JsonPropertyName("policy_version")] string PolicyVersion,
[property: JsonPropertyName("compliance_rate")] double ComplianceRate,
[property: JsonPropertyName("artifacts_evaluated")] int ArtifactsEvaluated);

View File

@@ -0,0 +1,470 @@
using System.Collections.Immutable;
using StellaOps.Policy.Engine.Attestation;
namespace StellaOps.Policy.Engine.ConsoleSurface;
/// <summary>
/// Service for Console attestation report integration per CONTRACT-VERIFICATION-POLICY-006.
/// </summary>
internal sealed class ConsoleAttestationReportService
{
private const string SchemaVersion = "1.0.0";
private readonly IAttestationReportService _reportService;
private readonly IVerificationPolicyStore _policyStore;
private readonly TimeProvider _timeProvider;
public ConsoleAttestationReportService(
IAttestationReportService reportService,
IVerificationPolicyStore policyStore,
TimeProvider timeProvider)
{
_reportService = reportService ?? throw new ArgumentNullException(nameof(reportService));
_policyStore = policyStore ?? throw new ArgumentNullException(nameof(policyStore));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public async Task<ConsoleAttestationReportResponse> QueryReportsAsync(
ConsoleAttestationReportRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var now = _timeProvider.GetUtcNow();
// Convert Console request to internal query
var query = new AttestationReportQuery(
ArtifactDigests: request.ArtifactDigests,
ArtifactUriPattern: request.ArtifactUriPattern,
PolicyIds: request.PolicyIds,
PredicateTypes: request.PredicateTypes,
StatusFilter: ParseStatusFilter(request.StatusFilter),
FromTime: request.FromTime,
ToTime: request.ToTime,
IncludeDetails: true,
Limit: request.PageSize,
Offset: (request.Page - 1) * request.PageSize);
// Get reports
var response = await _reportService.ListReportsAsync(query, cancellationToken).ConfigureAwait(false);
// Get statistics for summary
var statistics = await _reportService.GetStatisticsAsync(query, cancellationToken).ConfigureAwait(false);
// Convert to Console format
var consoleReports = response.Reports.Select(r => ToConsoleReport(r, now)).ToList();
// Calculate groups if requested
IReadOnlyList<ConsoleReportGroup>? groups = null;
if (request.GroupBy.HasValue && request.GroupBy.Value != ConsoleReportGroupBy.None)
{
groups = CalculateGroups(response.Reports, request.GroupBy.Value);
}
// Calculate pagination
var totalPages = (int)Math.Ceiling((double)response.Total / request.PageSize);
var pagination = new ConsolePagination(
Page: request.Page,
PageSize: request.PageSize,
TotalPages: totalPages,
TotalItems: response.Total,
HasNext: request.Page < totalPages,
HasPrevious: request.Page > 1);
// Create summary
var summary = new ConsoleReportSummary(
TotalArtifacts: statistics.TotalArtifacts,
TotalAttestations: statistics.TotalAttestations,
StatusBreakdown: statistics.StatusDistribution
.ToImmutableDictionary(kvp => kvp.Key.ToString(), kvp => kvp.Value),
CoverageRate: Math.Round(statistics.CoverageRate, 2),
ComplianceRate: CalculateComplianceRate(response.Reports),
AverageAgeHours: Math.Round(statistics.AverageAgeSeconds / 3600, 2));
return new ConsoleAttestationReportResponse(
SchemaVersion: SchemaVersion,
Summary: summary,
Reports: consoleReports,
Groups: groups,
Pagination: pagination,
FiltersApplied: new ConsoleFiltersApplied(
ArtifactCount: request.ArtifactDigests?.Count ?? 0,
PolicyIds: request.PolicyIds,
PredicateTypes: request.PredicateTypes,
StatusFilter: request.StatusFilter,
TimeRange: request.FromTime.HasValue || request.ToTime.HasValue
? new ConsoleTimeRange(request.FromTime, request.ToTime)
: null));
}
public async Task<ConsoleAttestationDashboardResponse> GetDashboardAsync(
ConsoleAttestationDashboardRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var now = _timeProvider.GetUtcNow();
var (fromTime, toTime) = ParseTimeRange(request.TimeRange, now);
var query = new AttestationReportQuery(
ArtifactDigests: null,
ArtifactUriPattern: request.ArtifactUriPattern,
PolicyIds: request.PolicyIds,
PredicateTypes: null,
StatusFilter: null,
FromTime: fromTime,
ToTime: toTime,
IncludeDetails: false,
Limit: int.MaxValue,
Offset: 0);
var statistics = await _reportService.GetStatisticsAsync(query, cancellationToken).ConfigureAwait(false);
var reports = await _reportService.ListReportsAsync(query, cancellationToken).ConfigureAwait(false);
// Calculate pass rate
var passCount = statistics.StatusDistribution.GetValueOrDefault(AttestationReportStatus.Pass, 0);
var failCount = statistics.StatusDistribution.GetValueOrDefault(AttestationReportStatus.Fail, 0);
var warnCount = statistics.StatusDistribution.GetValueOrDefault(AttestationReportStatus.Warn, 0);
var total = passCount + failCount + warnCount;
var passRate = total > 0 ? (double)passCount / total * 100 : 0;
// Calculate overview
var overview = new ConsoleDashboardOverview(
TotalArtifacts: statistics.TotalArtifacts,
TotalAttestations: statistics.TotalAttestations,
PassRate: Math.Round(passRate, 2),
CoverageRate: Math.Round(statistics.CoverageRate, 2),
AverageFreshnessHours: Math.Round(statistics.AverageAgeSeconds / 3600, 2));
// Calculate trends (simplified - would normally compare to previous period)
var trends = new ConsoleDashboardTrends(
PassRateChange: 0,
CoverageRateChange: 0,
AttestationCountChange: 0,
TrendDirection: "stable");
// Get top issues
var topIssues = reports.Reports
.SelectMany(r => r.VerificationResults)
.SelectMany(v => v.Issues)
.GroupBy(i => i)
.OrderByDescending(g => g.Count())
.Take(5)
.Select(g => new ConsoleDashboardIssue(
Issue: g.Key,
Count: g.Count(),
Severity: "error"))
.ToList();
// Get policy compliance
var policyCompliance = await CalculatePolicyComplianceAsync(reports.Reports, cancellationToken).ConfigureAwait(false);
return new ConsoleAttestationDashboardResponse(
SchemaVersion: SchemaVersion,
Overview: overview,
Trends: trends,
TopIssues: topIssues,
PolicyCompliance: policyCompliance,
EvaluatedAt: now);
}
private ConsoleArtifactReport ToConsoleReport(ArtifactAttestationReport report, DateTimeOffset now)
{
var age = now - report.EvaluatedAt;
var ageRelative = FormatRelativeTime(age);
return new ConsoleArtifactReport(
ArtifactDigest: report.ArtifactDigest,
ArtifactUri: report.ArtifactUri,
ArtifactShortDigest: report.ArtifactDigest.Length > 12
? report.ArtifactDigest[..12]
: report.ArtifactDigest,
Status: report.OverallStatus.ToString().ToLowerInvariant(),
StatusLabel: GetStatusLabel(report.OverallStatus),
StatusIcon: GetStatusIcon(report.OverallStatus),
AttestationCount: report.AttestationCount,
CoveragePercentage: report.Coverage.CoveragePercentage,
PoliciesPassed: report.PolicyCompliance.PoliciesPassed,
PoliciesFailed: report.PolicyCompliance.PoliciesFailed,
EvaluatedAt: report.EvaluatedAt,
EvaluatedAtRelative: ageRelative,
Details: ToConsoleDetails(report));
}
private static ConsoleReportDetails ToConsoleDetails(ArtifactAttestationReport report)
{
var predicateTypes = report.VerificationResults
.GroupBy(v => v.PredicateType)
.Select(g => new ConsolePredicateTypeStatus(
Type: g.Key,
TypeLabel: GetPredicateTypeLabel(g.Key),
Status: g.First().Status.ToString().ToLowerInvariant(),
StatusLabel: GetStatusLabel(g.First().Status),
Freshness: FormatFreshness(g.First().FreshnessStatus)))
.ToList();
var policies = report.PolicyCompliance.PolicyResults
.Select(p => new ConsolePolicyStatus(
PolicyId: p.PolicyId,
PolicyVersion: p.PolicyVersion,
Status: p.Status.ToString().ToLowerInvariant(),
StatusLabel: GetStatusLabel(p.Status),
Verdict: p.Verdict))
.ToList();
var signers = report.VerificationResults
.SelectMany(v => v.SignatureStatus.Signers)
.DistinctBy(s => s.KeyFingerprint)
.Select(s => new ConsoleSignerInfo(
KeyFingerprintShort: s.KeyFingerprint.Length > 8
? s.KeyFingerprint[..8]
: s.KeyFingerprint,
Issuer: s.Issuer,
Subject: s.Subject,
Algorithm: s.Algorithm,
Verified: s.Verified,
Trusted: s.Trusted))
.ToList();
var issues = report.VerificationResults
.SelectMany(v => v.Issues)
.Distinct()
.Select(i => new ConsoleIssue(
Severity: "error",
Message: i,
Field: null))
.ToList();
return new ConsoleReportDetails(
PredicateTypes: predicateTypes,
Policies: policies,
Signers: signers,
Issues: issues);
}
private static IReadOnlyList<ConsoleReportGroup> CalculateGroups(
IReadOnlyList<ArtifactAttestationReport> reports,
ConsoleReportGroupBy groupBy)
{
return groupBy switch
{
ConsoleReportGroupBy.Policy => GroupByPolicy(reports),
ConsoleReportGroupBy.PredicateType => GroupByPredicateType(reports),
ConsoleReportGroupBy.Status => GroupByStatus(reports),
ConsoleReportGroupBy.ArtifactUri => GroupByArtifactUri(reports),
_ => []
};
}
private static IReadOnlyList<ConsoleReportGroup> GroupByPolicy(IReadOnlyList<ArtifactAttestationReport> reports)
{
return reports
.SelectMany(r => r.PolicyCompliance.PolicyResults)
.GroupBy(p => p.PolicyId)
.Select(g => new ConsoleReportGroup(
Key: g.Key,
Label: g.Key,
Count: g.Count(),
StatusBreakdown: g.GroupBy(p => p.Status.ToString())
.ToImmutableDictionary(s => s.Key, s => s.Count())))
.ToList();
}
private static IReadOnlyList<ConsoleReportGroup> GroupByPredicateType(IReadOnlyList<ArtifactAttestationReport> reports)
{
return reports
.SelectMany(r => r.VerificationResults)
.GroupBy(v => v.PredicateType)
.Select(g => new ConsoleReportGroup(
Key: g.Key,
Label: GetPredicateTypeLabel(g.Key),
Count: g.Count(),
StatusBreakdown: g.GroupBy(v => v.Status.ToString())
.ToImmutableDictionary(s => s.Key, s => s.Count())))
.ToList();
}
private static IReadOnlyList<ConsoleReportGroup> GroupByStatus(IReadOnlyList<ArtifactAttestationReport> reports)
{
return reports
.GroupBy(r => r.OverallStatus)
.Select(g => new ConsoleReportGroup(
Key: g.Key.ToString(),
Label: GetStatusLabel(g.Key),
Count: g.Count(),
StatusBreakdown: ImmutableDictionary<string, int>.Empty.Add(g.Key.ToString(), g.Count())))
.ToList();
}
private static IReadOnlyList<ConsoleReportGroup> GroupByArtifactUri(IReadOnlyList<ArtifactAttestationReport> reports)
{
return reports
.Where(r => !string.IsNullOrWhiteSpace(r.ArtifactUri))
.GroupBy(r => ExtractRepository(r.ArtifactUri!))
.Select(g => new ConsoleReportGroup(
Key: g.Key,
Label: g.Key,
Count: g.Count(),
StatusBreakdown: g.GroupBy(r => r.OverallStatus.ToString())
.ToImmutableDictionary(s => s.Key, s => s.Count())))
.ToList();
}
private async Task<IReadOnlyList<ConsoleDashboardPolicyCompliance>> CalculatePolicyComplianceAsync(
IReadOnlyList<ArtifactAttestationReport> reports,
CancellationToken cancellationToken)
{
var policyResults = reports
.SelectMany(r => r.PolicyCompliance.PolicyResults)
.GroupBy(p => p.PolicyId)
.Select(g =>
{
var total = g.Count();
var passed = g.Count(p => p.Status == AttestationReportStatus.Pass);
var complianceRate = total > 0 ? (double)passed / total * 100 : 0;
return new ConsoleDashboardPolicyCompliance(
PolicyId: g.Key,
PolicyVersion: g.First().PolicyVersion,
ComplianceRate: Math.Round(complianceRate, 2),
ArtifactsEvaluated: total);
})
.OrderByDescending(p => p.ArtifactsEvaluated)
.Take(10)
.ToList();
return policyResults;
}
private static IReadOnlyList<AttestationReportStatus>? ParseStatusFilter(IReadOnlyList<string>? statusFilter)
{
if (statusFilter == null || statusFilter.Count == 0)
{
return null;
}
return statusFilter
.Select(s => Enum.TryParse<AttestationReportStatus>(s, true, out var status) ? status : (AttestationReportStatus?)null)
.Where(s => s.HasValue)
.Select(s => s!.Value)
.ToList();
}
private static (DateTimeOffset? from, DateTimeOffset? to) ParseTimeRange(string? timeRange, DateTimeOffset now)
{
return timeRange?.ToLowerInvariant() switch
{
"1h" => (now.AddHours(-1), now),
"24h" => (now.AddDays(-1), now),
"7d" => (now.AddDays(-7), now),
"30d" => (now.AddDays(-30), now),
"90d" => (now.AddDays(-90), now),
_ => (null, null)
};
}
private static double CalculateComplianceRate(IReadOnlyList<ArtifactAttestationReport> reports)
{
if (reports.Count == 0)
{
return 0;
}
var compliant = reports.Count(r =>
r.OverallStatus == AttestationReportStatus.Pass ||
r.OverallStatus == AttestationReportStatus.Warn);
return Math.Round((double)compliant / reports.Count * 100, 2);
}
private static string GetStatusLabel(AttestationReportStatus status)
{
return status switch
{
AttestationReportStatus.Pass => "Passed",
AttestationReportStatus.Fail => "Failed",
AttestationReportStatus.Warn => "Warning",
AttestationReportStatus.Skipped => "Skipped",
AttestationReportStatus.Pending => "Pending",
_ => "Unknown"
};
}
private static string GetStatusIcon(AttestationReportStatus status)
{
return status switch
{
AttestationReportStatus.Pass => "check-circle",
AttestationReportStatus.Fail => "x-circle",
AttestationReportStatus.Warn => "alert-triangle",
AttestationReportStatus.Skipped => "minus-circle",
AttestationReportStatus.Pending => "clock",
_ => "help-circle"
};
}
private static string GetPredicateTypeLabel(string predicateType)
{
return predicateType switch
{
PredicateTypes.SbomV1 => "SBOM",
PredicateTypes.VexV1 => "VEX",
PredicateTypes.VexDecisionV1 => "VEX Decision",
PredicateTypes.PolicyV1 => "Policy",
PredicateTypes.PromotionV1 => "Promotion",
PredicateTypes.EvidenceV1 => "Evidence",
PredicateTypes.GraphV1 => "Graph",
PredicateTypes.ReplayV1 => "Replay",
PredicateTypes.SlsaProvenanceV1 => "SLSA v1",
PredicateTypes.SlsaProvenanceV02 => "SLSA v0.2",
PredicateTypes.CycloneDxBom => "CycloneDX",
PredicateTypes.SpdxDocument => "SPDX",
PredicateTypes.OpenVex => "OpenVEX",
_ => predicateType
};
}
private static string FormatFreshness(FreshnessVerificationStatus freshness)
{
return freshness.IsFresh ? "Fresh" : $"{freshness.AgeSeconds / 3600}h old";
}
private static string FormatRelativeTime(TimeSpan age)
{
if (age.TotalMinutes < 1)
{
return "just now";
}
if (age.TotalHours < 1)
{
return $"{(int)age.TotalMinutes}m ago";
}
if (age.TotalDays < 1)
{
return $"{(int)age.TotalHours}h ago";
}
if (age.TotalDays < 7)
{
return $"{(int)age.TotalDays}d ago";
}
return $"{(int)(age.TotalDays / 7)}w ago";
}
private static string ExtractRepository(string artifactUri)
{
try
{
var uri = new Uri(artifactUri);
var path = uri.AbsolutePath.Split('/');
return path.Length >= 2 ? path[1] : uri.Host;
}
catch
{
return artifactUri;
}
}
}

View File

@@ -0,0 +1,300 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using StellaOps.Policy.Engine.Ledger;
namespace StellaOps.Policy.Engine.ConsoleExport;
/// <summary>
/// Service for managing Console export jobs per CONTRACT-EXPORT-BUNDLE-009.
/// </summary>
internal sealed partial class ConsoleExportJobService
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private static readonly Regex CronRegex = CreateCronRegex();
private readonly IConsoleExportJobStore _jobStore;
private readonly IConsoleExportExecutionStore _executionStore;
private readonly IConsoleExportBundleStore _bundleStore;
private readonly LedgerExportService _ledgerExport;
private readonly TimeProvider _timeProvider;
public ConsoleExportJobService(
IConsoleExportJobStore jobStore,
IConsoleExportExecutionStore executionStore,
IConsoleExportBundleStore bundleStore,
LedgerExportService ledgerExport,
TimeProvider timeProvider)
{
_jobStore = jobStore ?? throw new ArgumentNullException(nameof(jobStore));
_executionStore = executionStore ?? throw new ArgumentNullException(nameof(executionStore));
_bundleStore = bundleStore ?? throw new ArgumentNullException(nameof(bundleStore));
_ledgerExport = ledgerExport ?? throw new ArgumentNullException(nameof(ledgerExport));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public async Task<ExportBundleJob> CreateJobAsync(
string tenantId,
CreateExportJobRequest request,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentNullException.ThrowIfNull(request);
ValidateRequest(request);
var now = _timeProvider.GetUtcNow();
var jobId = GenerateId("job");
var job = new ExportBundleJob(
JobId: jobId,
TenantId: tenantId,
Name: request.Name,
Description: request.Description,
Query: request.Query,
Format: request.Format,
Schedule: request.Schedule,
Destination: request.Destination,
Signing: request.Signing,
Enabled: true,
CreatedAt: now.ToString("O"),
LastRunAt: null,
NextRunAt: CalculateNextRun(request.Schedule, now));
await _jobStore.SaveAsync(job, cancellationToken).ConfigureAwait(false);
return job;
}
public async Task<ExportBundleJob?> GetJobAsync(string jobId, CancellationToken cancellationToken = default)
{
return await _jobStore.GetAsync(jobId, cancellationToken).ConfigureAwait(false);
}
public async Task<ListJobsResponse> ListJobsAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
var jobs = await _jobStore.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
return new ListJobsResponse(jobs, jobs.Count);
}
public async Task<ExportBundleJob> UpdateJobAsync(
string jobId,
UpdateExportJobRequest request,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
ArgumentNullException.ThrowIfNull(request);
var existing = await _jobStore.GetAsync(jobId, cancellationToken).ConfigureAwait(false)
?? throw new KeyNotFoundException($"Job '{jobId}' not found");
if (request.Schedule is not null && !IsValidCron(request.Schedule))
{
throw new ArgumentException("Invalid schedule expression", nameof(request));
}
var now = _timeProvider.GetUtcNow();
var newSchedule = request.Schedule ?? existing.Schedule;
var updated = existing with
{
Name = request.Name ?? existing.Name,
Description = request.Description ?? existing.Description,
Schedule = newSchedule,
Signing = request.Signing ?? existing.Signing,
Enabled = request.Enabled ?? existing.Enabled,
NextRunAt = CalculateNextRun(newSchedule, now)
};
await _jobStore.SaveAsync(updated, cancellationToken).ConfigureAwait(false);
return updated;
}
public async Task DeleteJobAsync(string jobId, CancellationToken cancellationToken = default)
{
await _jobStore.DeleteAsync(jobId, cancellationToken).ConfigureAwait(false);
}
public async Task<TriggerExecutionResponse> TriggerJobAsync(string jobId, CancellationToken cancellationToken = default)
{
var job = await _jobStore.GetAsync(jobId, cancellationToken).ConfigureAwait(false)
?? throw new KeyNotFoundException($"Job '{jobId}' not found");
var now = _timeProvider.GetUtcNow();
var executionId = GenerateId("exec");
var execution = new ExportExecution(
ExecutionId: executionId,
JobId: jobId,
Status: "running",
BundleId: null,
StartedAt: now.ToString("O"),
CompletedAt: null,
Error: null);
await _executionStore.SaveAsync(execution, cancellationToken).ConfigureAwait(false);
// Execute the export asynchronously
_ = ExecuteJobAsync(job, execution, cancellationToken);
return new TriggerExecutionResponse(executionId, "running");
}
public async Task<ExportExecution?> GetExecutionAsync(string executionId, CancellationToken cancellationToken = default)
{
return await _executionStore.GetAsync(executionId, cancellationToken).ConfigureAwait(false);
}
public async Task<ExportBundleManifest?> GetBundleAsync(string bundleId, CancellationToken cancellationToken = default)
{
return await _bundleStore.GetAsync(bundleId, cancellationToken).ConfigureAwait(false);
}
public async Task<byte[]?> GetBundleContentAsync(string bundleId, CancellationToken cancellationToken = default)
{
return await _bundleStore.GetContentAsync(bundleId, cancellationToken).ConfigureAwait(false);
}
private async Task ExecuteJobAsync(ExportBundleJob job, ExportExecution execution, CancellationToken cancellationToken)
{
try
{
// Build ledger export for this tenant
var request = new LedgerExportRequest(job.TenantId);
var ledgerExport = await _ledgerExport.BuildAsync(request, cancellationToken).ConfigureAwait(false);
// Build bundle content based on format
var content = BuildContent(job, ledgerExport);
var contentBytes = Encoding.UTF8.GetBytes(content);
// Create manifest
var now = _timeProvider.GetUtcNow();
var bundleId = GenerateId("bundle");
var artifactDigest = ComputeSha256(contentBytes);
var querySignature = ComputeSha256(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(job.Query, JsonOptions)));
var manifest = new ExportBundleManifest(
BundleId: bundleId,
JobId: job.JobId,
TenantId: job.TenantId,
CreatedAt: now.ToString("O"),
Format: job.Format,
ArtifactDigest: artifactDigest,
ArtifactSizeBytes: contentBytes.Length,
QuerySignature: querySignature,
ItemCount: ledgerExport.Records.Count,
PolicyDigest: ledgerExport.Manifest.Sha256,
ConsensusDigest: null,
ScoreDigest: null,
Attestation: null);
await _bundleStore.SaveAsync(manifest, contentBytes, cancellationToken).ConfigureAwait(false);
// Update execution as completed
var completedExecution = execution with
{
Status = "completed",
BundleId = bundleId,
CompletedAt = now.ToString("O")
};
await _executionStore.SaveAsync(completedExecution, cancellationToken).ConfigureAwait(false);
// Update job with last run
var updatedJob = job with
{
LastRunAt = now.ToString("O"),
NextRunAt = CalculateNextRun(job.Schedule, now)
};
await _jobStore.SaveAsync(updatedJob, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
var failedExecution = execution with
{
Status = "failed",
CompletedAt = _timeProvider.GetUtcNow().ToString("O"),
Error = ex.Message
};
await _executionStore.SaveAsync(failedExecution, CancellationToken.None).ConfigureAwait(false);
}
}
private static string BuildContent(ExportBundleJob job, LedgerExport ledgerExport)
{
return job.Format.ToLowerInvariant() switch
{
ExportFormats.Ndjson => string.Join('\n', ledgerExport.Lines),
ExportFormats.Json => JsonSerializer.Serialize(ledgerExport.Records, JsonOptions),
_ => JsonSerializer.Serialize(ledgerExport.Records, JsonOptions)
};
}
private void ValidateRequest(CreateExportJobRequest request)
{
if (string.IsNullOrWhiteSpace(request.Name))
{
throw new ArgumentException("Name is required", nameof(request));
}
if (!ExportFormats.IsValid(request.Format))
{
throw new ArgumentException($"Invalid format: {request.Format}", nameof(request));
}
if (!IsValidCron(request.Schedule))
{
throw new ArgumentException("Invalid schedule expression", nameof(request));
}
if (!DestinationTypes.IsValid(request.Destination.Type))
{
throw new ArgumentException($"Invalid destination type: {request.Destination.Type}", nameof(request));
}
}
private static bool IsValidCron(string schedule)
{
if (string.IsNullOrWhiteSpace(schedule))
{
return false;
}
// Basic 5-field cron validation
return CronRegex.IsMatch(schedule);
}
private static string? CalculateNextRun(string schedule, DateTimeOffset from)
{
// Simplified next run calculation - just add 24 hours for daily schedules
// In production, this would use a proper cron parser like Cronos
if (schedule.StartsWith("0 0 ", StringComparison.Ordinal))
{
return from.AddDays(1).ToString("O");
}
if (schedule.StartsWith("0 */", StringComparison.Ordinal))
{
var hourMatch = Regex.Match(schedule, @"\*/(\d+)");
if (hourMatch.Success && int.TryParse(hourMatch.Groups[1].Value, out var hours))
{
return from.AddHours(hours).ToString("O");
}
}
return from.AddDays(1).ToString("O");
}
private static string GenerateId(string prefix)
{
return $"{prefix}-{Guid.NewGuid():N}"[..16];
}
private static string ComputeSha256(byte[] data)
{
var hash = SHA256.HashData(data);
return $"sha256:{Convert.ToHexStringLower(hash)}";
}
[GeneratedRegex(@"^(\*|[0-9]|[1-5][0-9])\s+(\*|[0-9]|1[0-9]|2[0-3])\s+(\*|[1-9]|[12][0-9]|3[01])\s+(\*|[1-9]|1[0-2])\s+(\*|[0-6])$")]
private static partial Regex CreateCronRegex();
}

View File

@@ -0,0 +1,190 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Engine.ConsoleExport;
/// <summary>
/// Export bundle job definition per CONTRACT-EXPORT-BUNDLE-009.
/// </summary>
public sealed record ExportBundleJob(
[property: JsonPropertyName("job_id")] string JobId,
[property: JsonPropertyName("tenant_id")] string TenantId,
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("description")] string? Description,
[property: JsonPropertyName("query")] ExportQuery Query,
[property: JsonPropertyName("format")] string Format,
[property: JsonPropertyName("schedule")] string Schedule,
[property: JsonPropertyName("destination")] ExportDestination Destination,
[property: JsonPropertyName("signing")] ExportSigning? Signing,
[property: JsonPropertyName("enabled")] bool Enabled,
[property: JsonPropertyName("created_at")] string CreatedAt,
[property: JsonPropertyName("last_run_at")] string? LastRunAt,
[property: JsonPropertyName("next_run_at")] string? NextRunAt);
/// <summary>
/// Query definition for export jobs.
/// </summary>
public sealed record ExportQuery(
[property: JsonPropertyName("type")] string Type,
[property: JsonPropertyName("filters")] ExportFilters? Filters);
/// <summary>
/// Filters for export queries.
/// </summary>
public sealed record ExportFilters(
[property: JsonPropertyName("severity")] IReadOnlyList<string>? Severity,
[property: JsonPropertyName("providers")] IReadOnlyList<string>? Providers,
[property: JsonPropertyName("status")] IReadOnlyList<string>? Status,
[property: JsonPropertyName("advisory_ids")] IReadOnlyList<string>? AdvisoryIds,
[property: JsonPropertyName("component_purls")] IReadOnlyList<string>? ComponentPurls);
/// <summary>
/// Export destination configuration.
/// </summary>
public sealed record ExportDestination(
[property: JsonPropertyName("type")] string Type,
[property: JsonPropertyName("config")] IReadOnlyDictionary<string, string>? Config);
/// <summary>
/// Signing configuration for exports.
/// </summary>
public sealed record ExportSigning(
[property: JsonPropertyName("enabled")] bool Enabled,
[property: JsonPropertyName("predicate_type")] string? PredicateType,
[property: JsonPropertyName("key_id")] string? KeyId,
[property: JsonPropertyName("include_rekor")] bool IncludeRekor);
/// <summary>
/// Request to create a new export job.
/// </summary>
public sealed record CreateExportJobRequest(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("description")] string? Description,
[property: JsonPropertyName("query")] ExportQuery Query,
[property: JsonPropertyName("format")] string Format,
[property: JsonPropertyName("schedule")] string Schedule,
[property: JsonPropertyName("destination")] ExportDestination Destination,
[property: JsonPropertyName("signing")] ExportSigning? Signing);
/// <summary>
/// Request to update an existing export job.
/// </summary>
public sealed record UpdateExportJobRequest(
[property: JsonPropertyName("name")] string? Name,
[property: JsonPropertyName("description")] string? Description,
[property: JsonPropertyName("schedule")] string? Schedule,
[property: JsonPropertyName("enabled")] bool? Enabled,
[property: JsonPropertyName("signing")] ExportSigning? Signing);
/// <summary>
/// Response for job execution trigger.
/// </summary>
public sealed record TriggerExecutionResponse(
[property: JsonPropertyName("execution_id")] string ExecutionId,
[property: JsonPropertyName("status")] string Status);
/// <summary>
/// Export job execution status.
/// </summary>
public sealed record ExportExecution(
[property: JsonPropertyName("execution_id")] string ExecutionId,
[property: JsonPropertyName("job_id")] string JobId,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("bundle_id")] string? BundleId,
[property: JsonPropertyName("started_at")] string StartedAt,
[property: JsonPropertyName("completed_at")] string? CompletedAt,
[property: JsonPropertyName("error")] string? Error);
/// <summary>
/// Export bundle manifest per CONTRACT-EXPORT-BUNDLE-009.
/// </summary>
public sealed record ExportBundleManifest(
[property: JsonPropertyName("bundle_id")] string BundleId,
[property: JsonPropertyName("job_id")] string JobId,
[property: JsonPropertyName("tenant_id")] string TenantId,
[property: JsonPropertyName("created_at")] string CreatedAt,
[property: JsonPropertyName("format")] string Format,
[property: JsonPropertyName("artifact_digest")] string ArtifactDigest,
[property: JsonPropertyName("artifact_size_bytes")] long ArtifactSizeBytes,
[property: JsonPropertyName("query_signature")] string QuerySignature,
[property: JsonPropertyName("item_count")] int ItemCount,
[property: JsonPropertyName("policy_digest")] string? PolicyDigest,
[property: JsonPropertyName("consensus_digest")] string? ConsensusDigest,
[property: JsonPropertyName("score_digest")] string? ScoreDigest,
[property: JsonPropertyName("attestation")] ExportAttestation? Attestation);
/// <summary>
/// Attestation metadata for export bundles.
/// </summary>
public sealed record ExportAttestation(
[property: JsonPropertyName("predicate_type")] string PredicateType,
[property: JsonPropertyName("rekor_uuid")] string? RekorUuid,
[property: JsonPropertyName("rekor_index")] long? RekorIndex,
[property: JsonPropertyName("signed_at")] string SignedAt);
/// <summary>
/// List response for jobs.
/// </summary>
public sealed record ListJobsResponse(
[property: JsonPropertyName("items")] IReadOnlyList<ExportBundleJob> Items,
[property: JsonPropertyName("total")] int Total);
/// <summary>
/// Export formats per CONTRACT-EXPORT-BUNDLE-009.
/// </summary>
public static class ExportFormats
{
public const string OpenVex = "openvex";
public const string Csaf = "csaf";
public const string CycloneDx = "cyclonedx";
public const string Spdx = "spdx";
public const string Ndjson = "ndjson";
public const string Json = "json";
public static readonly IReadOnlySet<string> All = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
OpenVex, Csaf, CycloneDx, Spdx, Ndjson, Json
};
public static bool IsValid(string format) => All.Contains(format);
}
/// <summary>
/// Destination types per CONTRACT-EXPORT-BUNDLE-009.
/// </summary>
public static class DestinationTypes
{
public const string S3 = "s3";
public const string File = "file";
public const string Webhook = "webhook";
public static readonly IReadOnlySet<string> All = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
S3, File, Webhook
};
public static bool IsValid(string type) => All.Contains(type);
}
/// <summary>
/// Job status values per CONTRACT-EXPORT-BUNDLE-009.
/// </summary>
public static class JobStatus
{
public const string Idle = "idle";
public const string Running = "running";
public const string Completed = "completed";
public const string Failed = "failed";
public const string Disabled = "disabled";
}
/// <summary>
/// Export error codes per CONTRACT-EXPORT-BUNDLE-009.
/// </summary>
public static class ExportErrorCodes
{
public const string InvalidSchedule = "ERR_EXP_001";
public const string InvalidDestination = "ERR_EXP_002";
public const string ExportFailed = "ERR_EXP_003";
public const string SigningFailed = "ERR_EXP_004";
public const string JobNotFound = "ERR_EXP_005";
}

View File

@@ -0,0 +1,33 @@
namespace StellaOps.Policy.Engine.ConsoleExport;
/// <summary>
/// Store for Console export jobs.
/// </summary>
public interface IConsoleExportJobStore
{
Task<ExportBundleJob?> GetAsync(string jobId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<ExportBundleJob>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default);
Task SaveAsync(ExportBundleJob job, CancellationToken cancellationToken = default);
Task DeleteAsync(string jobId, CancellationToken cancellationToken = default);
}
/// <summary>
/// Store for export job executions.
/// </summary>
public interface IConsoleExportExecutionStore
{
Task<ExportExecution?> GetAsync(string executionId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<ExportExecution>> ListByJobAsync(string jobId, CancellationToken cancellationToken = default);
Task SaveAsync(ExportExecution execution, CancellationToken cancellationToken = default);
}
/// <summary>
/// Store for export bundle manifests.
/// </summary>
public interface IConsoleExportBundleStore
{
Task<ExportBundleManifest?> GetAsync(string bundleId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<ExportBundleManifest>> ListByJobAsync(string jobId, CancellationToken cancellationToken = default);
Task SaveAsync(ExportBundleManifest manifest, byte[] content, CancellationToken cancellationToken = default);
Task<byte[]?> GetContentAsync(string bundleId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,118 @@
using System.Collections.Concurrent;
namespace StellaOps.Policy.Engine.ConsoleExport;
/// <summary>
/// In-memory implementation of IConsoleExportJobStore.
/// </summary>
internal sealed class InMemoryConsoleExportJobStore : IConsoleExportJobStore
{
private readonly ConcurrentDictionary<string, ExportBundleJob> _jobs = new(StringComparer.Ordinal);
public Task<ExportBundleJob?> GetAsync(string jobId, CancellationToken cancellationToken = default)
{
_jobs.TryGetValue(jobId, out var job);
return Task.FromResult(job);
}
public Task<IReadOnlyList<ExportBundleJob>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
IEnumerable<ExportBundleJob> jobs = _jobs.Values;
if (!string.IsNullOrWhiteSpace(tenantId))
{
jobs = jobs.Where(j => string.Equals(j.TenantId, tenantId, StringComparison.Ordinal));
}
var ordered = jobs
.OrderBy(j => j.CreatedAt, StringComparer.Ordinal)
.ThenBy(j => j.JobId, StringComparer.Ordinal)
.ToList();
return Task.FromResult<IReadOnlyList<ExportBundleJob>>(ordered);
}
public Task SaveAsync(ExportBundleJob job, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(job);
_jobs[job.JobId] = job;
return Task.CompletedTask;
}
public Task DeleteAsync(string jobId, CancellationToken cancellationToken = default)
{
_jobs.TryRemove(jobId, out _);
return Task.CompletedTask;
}
}
/// <summary>
/// In-memory implementation of IConsoleExportExecutionStore.
/// </summary>
internal sealed class InMemoryConsoleExportExecutionStore : IConsoleExportExecutionStore
{
private readonly ConcurrentDictionary<string, ExportExecution> _executions = new(StringComparer.Ordinal);
public Task<ExportExecution?> GetAsync(string executionId, CancellationToken cancellationToken = default)
{
_executions.TryGetValue(executionId, out var execution);
return Task.FromResult(execution);
}
public Task<IReadOnlyList<ExportExecution>> ListByJobAsync(string jobId, CancellationToken cancellationToken = default)
{
var executions = _executions.Values
.Where(e => string.Equals(e.JobId, jobId, StringComparison.Ordinal))
.OrderByDescending(e => e.StartedAt)
.ToList();
return Task.FromResult<IReadOnlyList<ExportExecution>>(executions);
}
public Task SaveAsync(ExportExecution execution, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(execution);
_executions[execution.ExecutionId] = execution;
return Task.CompletedTask;
}
}
/// <summary>
/// In-memory implementation of IConsoleExportBundleStore.
/// </summary>
internal sealed class InMemoryConsoleExportBundleStore : IConsoleExportBundleStore
{
private readonly ConcurrentDictionary<string, ExportBundleManifest> _manifests = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, byte[]> _contents = new(StringComparer.Ordinal);
public Task<ExportBundleManifest?> GetAsync(string bundleId, CancellationToken cancellationToken = default)
{
_manifests.TryGetValue(bundleId, out var manifest);
return Task.FromResult(manifest);
}
public Task<IReadOnlyList<ExportBundleManifest>> ListByJobAsync(string jobId, CancellationToken cancellationToken = default)
{
var manifests = _manifests.Values
.Where(m => string.Equals(m.JobId, jobId, StringComparison.Ordinal))
.OrderByDescending(m => m.CreatedAt)
.ToList();
return Task.FromResult<IReadOnlyList<ExportBundleManifest>>(manifests);
}
public Task SaveAsync(ExportBundleManifest manifest, byte[] content, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(manifest);
ArgumentNullException.ThrowIfNull(content);
_manifests[manifest.BundleId] = manifest;
_contents[manifest.BundleId] = content;
return Task.CompletedTask;
}
public Task<byte[]?> GetContentAsync(string bundleId, CancellationToken cancellationToken = default)
{
_contents.TryGetValue(bundleId, out var content);
return Task.FromResult(content);
}
}

View File

@@ -65,6 +65,13 @@ public sealed record PolicyDecisionLocator(
/// <summary>
/// Summary statistics for the decision response.
/// </summary>
/// <param name="TotalDecisions">Total number of policy decisions made.</param>
/// <param name="TotalConflicts">Number of conflicting decisions.</param>
/// <param name="SeverityCounts">Count of findings by severity level.</param>
/// <param name="TopSeveritySources">
/// DEPRECATED: Source ranking. Use trust weighting service instead.
/// Scheduled for removal in v2.0. See DESIGN-POLICY-NORMALIZED-FIELD-REMOVAL-001.
/// </param>
public sealed record PolicyDecisionSummary(
[property: JsonPropertyName("total_decisions")] int TotalDecisions,
[property: JsonPropertyName("total_conflicts")] int TotalConflicts,
@@ -72,7 +79,9 @@ public sealed record PolicyDecisionSummary(
[property: JsonPropertyName("top_severity_sources")] IReadOnlyList<PolicyDecisionSourceRank> TopSeveritySources);
/// <summary>
/// Aggregated source rank across all decisions.
/// DEPRECATED: Aggregated source rank across all decisions.
/// Scheduled for removal in v2.0. See DESIGN-POLICY-NORMALIZED-FIELD-REMOVAL-001.
/// Use trust weighting service instead.
/// </summary>
public sealed record PolicyDecisionSourceRank(
[property: JsonPropertyName("source")] string Source,

View File

@@ -0,0 +1,88 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Policy.Engine.AirGap;
namespace StellaOps.Policy.Engine.Endpoints;
/// <summary>
/// Endpoints for air-gap notification testing and management.
/// </summary>
public static class AirGapNotificationEndpoints
{
public static IEndpointRouteBuilder MapAirGapNotifications(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/system/airgap/notifications");
group.MapPost("/test", SendTestNotificationAsync)
.WithName("AirGap.TestNotification")
.WithDescription("Send a test notification")
.RequireAuthorization(policy => policy.RequireClaim("scope", "airgap:seal"));
group.MapGet("/channels", GetChannelsAsync)
.WithName("AirGap.GetNotificationChannels")
.WithDescription("Get configured notification channels")
.RequireAuthorization(policy => policy.RequireClaim("scope", "airgap:status:read"));
return routes;
}
private static async Task<IResult> SendTestNotificationAsync(
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
[FromBody] TestNotificationRequest? request,
IAirGapNotificationService notificationService,
TimeProvider timeProvider,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
tenantId = "default";
}
var notification = new AirGapNotification(
NotificationId: $"test-{Guid.NewGuid():N}"[..20],
TenantId: tenantId,
Type: request?.Type ?? AirGapNotificationType.StalenessWarning,
Severity: request?.Severity ?? NotificationSeverity.Info,
Title: request?.Title ?? "Test Notification",
Message: request?.Message ?? "This is a test notification from the air-gap notification system.",
OccurredAt: timeProvider.GetUtcNow(),
Metadata: new Dictionary<string, object?>
{
["test"] = true
});
await notificationService.SendAsync(notification, cancellationToken).ConfigureAwait(false);
return Results.Ok(new
{
sent = true,
notification_id = notification.NotificationId,
type = notification.Type.ToString(),
severity = notification.Severity.ToString()
});
}
private static Task<IResult> GetChannelsAsync(
[FromServices] IEnumerable<IAirGapNotificationChannel> channels,
CancellationToken cancellationToken)
{
var channelList = channels.Select(c => new
{
name = c.ChannelName
}).ToList();
return Task.FromResult(Results.Ok(new
{
channels = channelList,
count = channelList.Count
}));
}
}
/// <summary>
/// Request for sending a test notification.
/// </summary>
public sealed record TestNotificationRequest(
AirGapNotificationType? Type = null,
NotificationSeverity? Severity = null,
string? Title = null,
string? Message = null);

View File

@@ -0,0 +1,233 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Policy.Engine.Attestation;
namespace StellaOps.Policy.Engine.Endpoints;
/// <summary>
/// Endpoints for attestation reports per CONTRACT-VERIFICATION-POLICY-006.
/// </summary>
public static class AttestationReportEndpoints
{
public static IEndpointRouteBuilder MapAttestationReports(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/v1/attestor/reports")
.WithTags("Attestation Reports");
group.MapGet("/{artifactDigest}", GetReportAsync)
.WithName("Attestor.GetReport")
.WithSummary("Get attestation report for an artifact")
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
.Produces<ArtifactAttestationReport>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapPost("/query", ListReportsAsync)
.WithName("Attestor.ListReports")
.WithSummary("Query attestation reports")
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
.Produces<AttestationReportListResponse>(StatusCodes.Status200OK);
group.MapPost("/verify", VerifyArtifactAsync)
.WithName("Attestor.VerifyArtifact")
.WithSummary("Generate attestation report for an artifact")
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
.Produces<ArtifactAttestationReport>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
group.MapGet("/statistics", GetStatisticsAsync)
.WithName("Attestor.GetStatistics")
.WithSummary("Get aggregated attestation statistics")
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
.Produces<AttestationStatistics>(StatusCodes.Status200OK);
group.MapPost("/store", StoreReportAsync)
.WithName("Attestor.StoreReport")
.WithSummary("Store an attestation report")
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyWrite))
.Produces<StoredAttestationReport>(StatusCodes.Status201Created)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
group.MapDelete("/expired", PurgeExpiredAsync)
.WithName("Attestor.PurgeExpired")
.WithSummary("Purge expired attestation reports")
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyWrite))
.Produces<PurgeExpiredResponse>(StatusCodes.Status200OK);
return routes;
}
private static async Task<IResult> GetReportAsync(
[FromRoute] string artifactDigest,
IAttestationReportService service,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(artifactDigest))
{
return Results.BadRequest(CreateProblem(
"Invalid request",
"Artifact digest is required.",
"ERR_ATTEST_010"));
}
var report = await service.GetReportAsync(artifactDigest, cancellationToken).ConfigureAwait(false);
if (report == null)
{
return Results.NotFound(CreateProblem(
"Report not found",
$"No attestation report found for artifact '{artifactDigest}'.",
"ERR_ATTEST_011"));
}
return Results.Ok(report);
}
private static async Task<IResult> ListReportsAsync(
[FromBody] AttestationReportQuery? query,
IAttestationReportService service,
CancellationToken cancellationToken)
{
var effectiveQuery = query ?? new AttestationReportQuery(
ArtifactDigests: null,
ArtifactUriPattern: null,
PolicyIds: null,
PredicateTypes: null,
StatusFilter: null,
FromTime: null,
ToTime: null,
IncludeDetails: true,
Limit: 100,
Offset: 0);
var response = await service.ListReportsAsync(effectiveQuery, cancellationToken).ConfigureAwait(false);
return Results.Ok(response);
}
private static async Task<IResult> VerifyArtifactAsync(
[FromBody] VerifyArtifactRequest? request,
IAttestationReportService service,
CancellationToken cancellationToken)
{
if (request == null)
{
return Results.BadRequest(CreateProblem(
"Invalid request",
"Request body is required.",
"ERR_ATTEST_001"));
}
if (string.IsNullOrWhiteSpace(request.ArtifactDigest))
{
return Results.BadRequest(CreateProblem(
"Invalid request",
"Artifact digest is required.",
"ERR_ATTEST_010"));
}
var report = await service.GenerateReportAsync(request, cancellationToken).ConfigureAwait(false);
return Results.Ok(report);
}
private static async Task<IResult> GetStatisticsAsync(
[FromQuery] string? policyIds,
[FromQuery] string? predicateTypes,
[FromQuery] string? status,
[FromQuery] DateTimeOffset? fromTime,
[FromQuery] DateTimeOffset? toTime,
IAttestationReportService service,
CancellationToken cancellationToken)
{
AttestationReportQuery? filter = null;
if (!string.IsNullOrWhiteSpace(policyIds) ||
!string.IsNullOrWhiteSpace(predicateTypes) ||
!string.IsNullOrWhiteSpace(status) ||
fromTime.HasValue ||
toTime.HasValue)
{
filter = new AttestationReportQuery(
ArtifactDigests: null,
ArtifactUriPattern: null,
PolicyIds: string.IsNullOrWhiteSpace(policyIds) ? null : policyIds.Split(',').ToList(),
PredicateTypes: string.IsNullOrWhiteSpace(predicateTypes) ? null : predicateTypes.Split(',').ToList(),
StatusFilter: string.IsNullOrWhiteSpace(status)
? null
: status.Split(',')
.Select(s => Enum.TryParse<AttestationReportStatus>(s, true, out var parsed) ? parsed : (AttestationReportStatus?)null)
.Where(s => s.HasValue)
.Select(s => s!.Value)
.ToList(),
FromTime: fromTime,
ToTime: toTime,
IncludeDetails: false,
Limit: int.MaxValue,
Offset: 0);
}
var statistics = await service.GetStatisticsAsync(filter, cancellationToken).ConfigureAwait(false);
return Results.Ok(statistics);
}
private static async Task<IResult> StoreReportAsync(
[FromBody] StoreReportRequest? request,
IAttestationReportService service,
CancellationToken cancellationToken)
{
if (request?.Report == null)
{
return Results.BadRequest(CreateProblem(
"Invalid request",
"Report is required.",
"ERR_ATTEST_012"));
}
TimeSpan? ttl = request.TtlSeconds.HasValue
? TimeSpan.FromSeconds(request.TtlSeconds.Value)
: null;
var stored = await service.StoreReportAsync(request.Report, ttl, cancellationToken).ConfigureAwait(false);
return Results.Created(
$"/api/v1/attestor/reports/{stored.Report.ArtifactDigest}",
stored);
}
private static async Task<IResult> PurgeExpiredAsync(
IAttestationReportService service,
CancellationToken cancellationToken)
{
var count = await service.PurgeExpiredReportsAsync(cancellationToken).ConfigureAwait(false);
return Results.Ok(new PurgeExpiredResponse(PurgedCount: count));
}
private static ProblemDetails CreateProblem(string title, string detail, string? errorCode = null)
{
var problem = new ProblemDetails
{
Title = title,
Detail = detail,
Status = StatusCodes.Status400BadRequest
};
if (!string.IsNullOrWhiteSpace(errorCode))
{
problem.Extensions["error_code"] = errorCode;
}
return problem;
}
}
/// <summary>
/// Request to store an attestation report.
/// </summary>
public sealed record StoreReportRequest(
[property: System.Text.Json.Serialization.JsonPropertyName("report")] ArtifactAttestationReport Report,
[property: System.Text.Json.Serialization.JsonPropertyName("ttl_seconds")] int? TtlSeconds);
/// <summary>
/// Response from purging expired reports.
/// </summary>
public sealed record PurgeExpiredResponse(
[property: System.Text.Json.Serialization.JsonPropertyName("purged_count")] int PurgedCount);

View File

@@ -0,0 +1,125 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Policy.Engine.ConsoleSurface;
using StellaOps.Policy.Engine.Options;
namespace StellaOps.Policy.Engine.Endpoints;
/// <summary>
/// Console endpoints for attestation reports per CONTRACT-VERIFICATION-POLICY-006.
/// </summary>
internal static class ConsoleAttestationReportEndpoints
{
public static IEndpointRouteBuilder MapConsoleAttestationReports(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/policy/console/attestation")
.WithTags("Console Attestation Reports")
.RequireRateLimiting(PolicyEngineRateLimitOptions.PolicyName);
group.MapPost("/reports", QueryReportsAsync)
.WithName("PolicyEngine.ConsoleAttestationReports")
.WithSummary("Query attestation reports for Console")
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
.Produces<ConsoleAttestationReportResponse>(StatusCodes.Status200OK)
.ProducesValidationProblem();
group.MapPost("/dashboard", GetDashboardAsync)
.WithName("PolicyEngine.ConsoleAttestationDashboard")
.WithSummary("Get attestation dashboard for Console")
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
.Produces<ConsoleAttestationDashboardResponse>(StatusCodes.Status200OK)
.ProducesValidationProblem();
group.MapGet("/report/{artifactDigest}", GetReportAsync)
.WithName("PolicyEngine.ConsoleGetAttestationReport")
.WithSummary("Get attestation report for a specific artifact")
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
.Produces<ConsoleArtifactReport>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
return routes;
}
private static async Task<IResult> QueryReportsAsync(
[FromBody] ConsoleAttestationReportRequest? request,
ConsoleAttestationReportService service,
CancellationToken cancellationToken)
{
if (request is null)
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["request"] = ["Request body is required."]
});
}
if (request.Page < 1)
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["page"] = ["Page must be at least 1."]
});
}
if (request.PageSize < 1 || request.PageSize > 100)
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["pageSize"] = ["Page size must be between 1 and 100."]
});
}
var response = await service.QueryReportsAsync(request, cancellationToken).ConfigureAwait(false);
return Results.Json(response);
}
private static async Task<IResult> GetDashboardAsync(
[FromBody] ConsoleAttestationDashboardRequest? request,
ConsoleAttestationReportService service,
CancellationToken cancellationToken)
{
var effectiveRequest = request ?? new ConsoleAttestationDashboardRequest(
TimeRange: "24h",
PolicyIds: null,
ArtifactUriPattern: null);
var response = await service.GetDashboardAsync(effectiveRequest, cancellationToken).ConfigureAwait(false);
return Results.Json(response);
}
private static async Task<IResult> GetReportAsync(
[FromRoute] string artifactDigest,
ConsoleAttestationReportService service,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(artifactDigest))
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["artifactDigest"] = ["Artifact digest is required."]
});
}
var request = new ConsoleAttestationReportRequest(
ArtifactDigests: [artifactDigest],
ArtifactUriPattern: null,
PolicyIds: null,
PredicateTypes: null,
StatusFilter: null,
FromTime: null,
ToTime: null,
GroupBy: null,
SortBy: null,
Page: 1,
PageSize: 1);
var response = await service.QueryReportsAsync(request, cancellationToken).ConfigureAwait(false);
if (response.Reports.Count == 0)
{
return Results.NotFound();
}
return Results.Json(response.Reports[0]);
}
}

View File

@@ -0,0 +1,238 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Policy.Engine.ConsoleExport;
namespace StellaOps.Policy.Engine.Endpoints;
/// <summary>
/// Endpoints for Console export jobs per CONTRACT-EXPORT-BUNDLE-009.
/// </summary>
public static class ConsoleExportEndpoints
{
public static IEndpointRouteBuilder MapConsoleExportJobs(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/v1/export");
// Job management
group.MapPost("/jobs", CreateJobAsync)
.WithName("Export.CreateJob")
.WithDescription("Create a new export job");
group.MapGet("/jobs", ListJobsAsync)
.WithName("Export.ListJobs")
.WithDescription("List export jobs");
group.MapGet("/jobs/{jobId}", GetJobAsync)
.WithName("Export.GetJob")
.WithDescription("Get an export job by ID");
group.MapPut("/jobs/{jobId}", UpdateJobAsync)
.WithName("Export.UpdateJob")
.WithDescription("Update an export job");
group.MapDelete("/jobs/{jobId}", DeleteJobAsync)
.WithName("Export.DeleteJob")
.WithDescription("Delete an export job");
// Job execution
group.MapPost("/jobs/{jobId}/run", TriggerJobAsync)
.WithName("Export.TriggerJob")
.WithDescription("Trigger a job execution");
group.MapGet("/jobs/{jobId}/executions/{executionId}", GetExecutionAsync)
.WithName("Export.GetExecution")
.WithDescription("Get execution status");
// Bundle retrieval
group.MapGet("/bundles/{bundleId}", GetBundleAsync)
.WithName("Export.GetBundle")
.WithDescription("Get bundle manifest");
group.MapGet("/bundles/{bundleId}/download", DownloadBundleAsync)
.WithName("Export.DownloadBundle")
.WithDescription("Download bundle content");
return routes;
}
private static async Task<IResult> CreateJobAsync(
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
[FromBody] CreateExportJobRequest request,
ConsoleExportJobService service,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.Problem(
title: "Tenant ID required",
detail: "X-Tenant-Id header is required",
statusCode: 400,
extensions: new Dictionary<string, object?> { ["code"] = "TENANT_REQUIRED" });
}
try
{
var job = await service.CreateJobAsync(tenantId, request, cancellationToken).ConfigureAwait(false);
return Results.Created($"/api/v1/export/jobs/{job.JobId}", job);
}
catch (ArgumentException ex)
{
var code = ex.Message.Contains("schedule", StringComparison.OrdinalIgnoreCase)
? ExportErrorCodes.InvalidSchedule
: ExportErrorCodes.InvalidDestination;
return Results.Problem(
title: "Validation failed",
detail: ex.Message,
statusCode: 400,
extensions: new Dictionary<string, object?> { ["code"] = code });
}
}
private static async Task<IResult> ListJobsAsync(
[FromQuery] string? tenant_id,
ConsoleExportJobService service,
CancellationToken cancellationToken)
{
var response = await service.ListJobsAsync(tenant_id, cancellationToken).ConfigureAwait(false);
return Results.Ok(response);
}
private static async Task<IResult> GetJobAsync(
[FromRoute] string jobId,
ConsoleExportJobService service,
CancellationToken cancellationToken)
{
var job = await service.GetJobAsync(jobId, cancellationToken).ConfigureAwait(false);
if (job is null)
{
return Results.Problem(
title: "Job not found",
detail: $"Job '{jobId}' not found",
statusCode: 404,
extensions: new Dictionary<string, object?> { ["code"] = ExportErrorCodes.JobNotFound });
}
return Results.Ok(job);
}
private static async Task<IResult> UpdateJobAsync(
[FromRoute] string jobId,
[FromBody] UpdateExportJobRequest request,
ConsoleExportJobService service,
CancellationToken cancellationToken)
{
try
{
var job = await service.UpdateJobAsync(jobId, request, cancellationToken).ConfigureAwait(false);
return Results.Ok(job);
}
catch (KeyNotFoundException)
{
return Results.Problem(
title: "Job not found",
detail: $"Job '{jobId}' not found",
statusCode: 404,
extensions: new Dictionary<string, object?> { ["code"] = ExportErrorCodes.JobNotFound });
}
catch (ArgumentException ex)
{
return Results.Problem(
title: "Validation failed",
detail: ex.Message,
statusCode: 400,
extensions: new Dictionary<string, object?> { ["code"] = ExportErrorCodes.InvalidSchedule });
}
}
private static async Task<IResult> DeleteJobAsync(
[FromRoute] string jobId,
ConsoleExportJobService service,
CancellationToken cancellationToken)
{
await service.DeleteJobAsync(jobId, cancellationToken).ConfigureAwait(false);
return Results.NoContent();
}
private static async Task<IResult> TriggerJobAsync(
[FromRoute] string jobId,
ConsoleExportJobService service,
CancellationToken cancellationToken)
{
try
{
var response = await service.TriggerJobAsync(jobId, cancellationToken).ConfigureAwait(false);
return Results.Accepted($"/api/v1/export/jobs/{jobId}/executions/{response.ExecutionId}", response);
}
catch (KeyNotFoundException)
{
return Results.Problem(
title: "Job not found",
detail: $"Job '{jobId}' not found",
statusCode: 404,
extensions: new Dictionary<string, object?> { ["code"] = ExportErrorCodes.JobNotFound });
}
}
private static async Task<IResult> GetExecutionAsync(
[FromRoute] string jobId,
[FromRoute] string executionId,
ConsoleExportJobService service,
CancellationToken cancellationToken)
{
var execution = await service.GetExecutionAsync(executionId, cancellationToken).ConfigureAwait(false);
if (execution is null || !string.Equals(execution.JobId, jobId, StringComparison.Ordinal))
{
return Results.NotFound();
}
return Results.Ok(execution);
}
private static async Task<IResult> GetBundleAsync(
[FromRoute] string bundleId,
ConsoleExportJobService service,
CancellationToken cancellationToken)
{
var bundle = await service.GetBundleAsync(bundleId, cancellationToken).ConfigureAwait(false);
if (bundle is null)
{
return Results.NotFound();
}
return Results.Ok(bundle);
}
private static async Task<IResult> DownloadBundleAsync(
[FromRoute] string bundleId,
ConsoleExportJobService service,
CancellationToken cancellationToken)
{
var bundle = await service.GetBundleAsync(bundleId, cancellationToken).ConfigureAwait(false);
if (bundle is null)
{
return Results.NotFound();
}
var content = await service.GetBundleContentAsync(bundleId, cancellationToken).ConfigureAwait(false);
if (content is null)
{
return Results.NotFound();
}
var contentType = bundle.Format switch
{
ExportFormats.Ndjson => "application/x-ndjson",
_ => "application/json"
};
var fileName = $"export-{bundle.BundleId}-{DateTime.UtcNow:yyyy-MM-dd}.json";
return Results.File(
content,
contentType,
fileName);
}
}

View File

@@ -0,0 +1,327 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Attestor.Envelope;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Scoring;
using StellaOps.Policy.Scoring.Engine;
using StellaOps.Policy.Scoring.Receipts;
namespace StellaOps.Policy.Engine.Endpoints;
/// <summary>
/// Minimal API surface for CVSS v4.0 score receipts (create, read, amend, history).
/// </summary>
internal static class CvssReceiptEndpoints
{
public static IEndpointRouteBuilder MapCvssReceipts(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/cvss")
.RequireAuthorization()
.WithTags("CVSS Receipts");
group.MapPost("/receipts", CreateReceipt)
.WithName("CreateCvssReceipt")
.WithSummary("Create a CVSS v4.0 receipt with deterministic hashing and optional DSSE attestation.")
.Produces<CvssScoreReceipt>(StatusCodes.Status201Created)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
.Produces<ProblemHttpResult>(StatusCodes.Status401Unauthorized);
group.MapGet("/receipts/{receiptId}", GetReceipt)
.WithName("GetCvssReceipt")
.WithSummary("Retrieve a CVSS v4.0 receipt by ID.")
.Produces<CvssScoreReceipt>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapPut("/receipts/{receiptId}/amend", AmendReceipt)
.WithName("AmendCvssReceipt")
.WithSummary("Append an amendment entry to a CVSS receipt history and optionally re-sign.")
.Produces<CvssScoreReceipt>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapGet("/receipts/{receiptId}/history", GetReceiptHistory)
.WithName("GetCvssReceiptHistory")
.WithSummary("Return the ordered amendment history for a CVSS receipt.")
.Produces<IReadOnlyList<ReceiptHistoryEntry>>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapGet("/policies", ListPolicies)
.WithName("ListCvssPolicies")
.WithSummary("List available CVSS policies configured on this host.")
.Produces<IReadOnlyList<CvssPolicy>>(StatusCodes.Status200OK);
return endpoints;
}
private static async Task<IResult> CreateReceipt(
HttpContext context,
[FromBody] CreateCvssReceiptRequest request,
IReceiptBuilder receiptBuilder,
CancellationToken cancellationToken)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRun);
if (scopeResult is not null)
{
return scopeResult;
}
if (request is null)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Request body required.",
Status = StatusCodes.Status400BadRequest
});
}
if (request.Policy is null || string.IsNullOrWhiteSpace(request.Policy.Hash))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Policy hash required",
Detail = "CvssPolicy with a deterministic hash must be supplied.",
Status = StatusCodes.Status400BadRequest
});
}
var tenantId = ResolveTenantId(context);
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Tenant required",
Detail = "Specify tenant via X-Tenant-Id header or tenant_id claim.",
Status = StatusCodes.Status400BadRequest
});
}
var actor = ResolveActorId(context) ?? request.CreatedBy ?? "system";
var createdAt = request.CreatedAt ?? DateTimeOffset.UtcNow;
var createRequest = new CreateReceiptRequest
{
TenantId = tenantId,
VulnerabilityId = request.VulnerabilityId,
CreatedBy = actor,
CreatedAt = createdAt,
Policy = request.Policy,
BaseMetrics = request.BaseMetrics,
ThreatMetrics = request.ThreatMetrics,
EnvironmentalMetrics = request.EnvironmentalMetrics ?? request.Policy.DefaultEnvironmentalMetrics,
SupplementalMetrics = request.SupplementalMetrics,
Evidence = request.Evidence?.ToImmutableList() ?? ImmutableList<CvssEvidenceItem>.Empty,
SigningKey = request.SigningKey
};
try
{
var receipt = await receiptBuilder.CreateAsync(createRequest, cancellationToken).ConfigureAwait(false);
return Results.Created($"/api/cvss/receipts/{receipt.ReceiptId}", receipt);
}
catch (Exception ex) when (ex is InvalidOperationException or ArgumentException)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Failed to create CVSS receipt",
Detail = ex.Message,
Status = StatusCodes.Status400BadRequest
});
}
}
private static async Task<IResult> GetReceipt(
HttpContext context,
[FromRoute] string receiptId,
IReceiptRepository repository,
CancellationToken cancellationToken)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.FindingsRead);
if (scopeResult is not null)
{
return scopeResult;
}
var tenantId = ResolveTenantId(context);
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Tenant required",
Detail = "Specify tenant via X-Tenant-Id header or tenant_id claim.",
Status = StatusCodes.Status400BadRequest
});
}
var receipt = await repository.GetAsync(tenantId, receiptId, cancellationToken).ConfigureAwait(false);
if (receipt is null)
{
return Results.NotFound(new ProblemDetails
{
Title = "Receipt not found",
Detail = $"CVSS receipt '{receiptId}' was not found.",
Status = StatusCodes.Status404NotFound
});
}
return Results.Ok(receipt);
}
private static async Task<IResult> AmendReceipt(
HttpContext context,
[FromRoute] string receiptId,
[FromBody] AmendCvssReceiptRequest request,
IReceiptHistoryService historyService,
CancellationToken cancellationToken)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRun);
if (scopeResult is not null)
{
return scopeResult;
}
if (request is null)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Request body required.",
Status = StatusCodes.Status400BadRequest
});
}
var tenantId = ResolveTenantId(context);
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Tenant required",
Detail = "Specify tenant via X-Tenant-Id header or tenant_id claim.",
Status = StatusCodes.Status400BadRequest
});
}
var actor = ResolveActorId(context) ?? request.Actor ?? "system";
var amend = new AmendReceiptRequest
{
ReceiptId = receiptId,
TenantId = tenantId,
Actor = actor,
Field = request.Field,
PreviousValue = request.PreviousValue,
NewValue = request.NewValue,
Reason = request.Reason,
ReferenceUri = request.ReferenceUri,
SigningKey = request.SigningKey
};
try
{
var amended = await historyService.AmendAsync(amend, cancellationToken).ConfigureAwait(false);
return Results.Ok(amended);
}
catch (InvalidOperationException ex)
{
return Results.NotFound(new ProblemDetails
{
Title = "Receipt not found",
Detail = ex.Message,
Status = StatusCodes.Status404NotFound
});
}
catch (Exception ex) when (ex is ArgumentException)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Failed to amend receipt",
Detail = ex.Message,
Status = StatusCodes.Status400BadRequest
});
}
}
private static async Task<IResult> GetReceiptHistory(
HttpContext context,
[FromRoute] string receiptId,
IReceiptRepository repository,
CancellationToken cancellationToken)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.FindingsRead);
if (scopeResult is not null)
{
return scopeResult;
}
var tenantId = ResolveTenantId(context);
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Tenant required",
Detail = "Specify tenant via X-Tenant-Id header or tenant_id claim.",
Status = StatusCodes.Status400BadRequest
});
}
var receipt = await repository.GetAsync(tenantId, receiptId, cancellationToken).ConfigureAwait(false);
if (receipt is null)
{
return Results.NotFound(new ProblemDetails
{
Title = "Receipt not found",
Detail = $"CVSS receipt '{receiptId}' was not found.",
Status = StatusCodes.Status404NotFound
});
}
var orderedHistory = receipt.History
.OrderBy(h => h.Timestamp)
.ToList();
return Results.Ok(orderedHistory);
}
private static IResult ListPolicies()
=> Results.Ok(Array.Empty<CvssPolicy>());
private static string? ResolveTenantId(HttpContext context)
{
if (context.Request.Headers.TryGetValue("X-Tenant-Id", out var tenantHeader) &&
!string.IsNullOrWhiteSpace(tenantHeader))
{
return tenantHeader.ToString();
}
return context.User?.FindFirst("tenant_id")?.Value;
}
private static string? ResolveActorId(HttpContext context)
{
var user = context.User;
return user?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value
?? user?.FindFirst("sub")?.Value;
}
}
internal sealed record CreateCvssReceiptRequest(
string VulnerabilityId,
CvssPolicy Policy,
CvssBaseMetrics BaseMetrics,
CvssThreatMetrics? ThreatMetrics,
CvssEnvironmentalMetrics? EnvironmentalMetrics,
CvssSupplementalMetrics? SupplementalMetrics,
IReadOnlyList<CvssEvidenceItem>? Evidence,
EnvelopeKey? SigningKey,
string? CreatedBy,
DateTimeOffset? CreatedAt);
internal sealed record AmendCvssReceiptRequest(
string Field,
string? PreviousValue,
string? NewValue,
string Reason,
string? ReferenceUri,
EnvelopeKey? SigningKey,
string? Actor);

View File

@@ -0,0 +1,396 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.RiskProfile.Scope;
namespace StellaOps.Policy.Engine.Endpoints;
/// <summary>
/// Endpoints for managing effective policies per CONTRACT-AUTHORITY-EFFECTIVE-WRITE-008.
/// </summary>
internal static class EffectivePolicyEndpoints
{
public static IEndpointRouteBuilder MapEffectivePolicies(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/v1/authority/effective-policies")
.RequireAuthorization()
.WithTags("Effective Policies");
group.MapPost("/", CreateEffectivePolicy)
.WithName("CreateEffectivePolicy")
.WithSummary("Create a new effective policy with subject pattern and priority.")
.Produces<EffectivePolicyResponse>(StatusCodes.Status201Created)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
group.MapGet("/{effectivePolicyId}", GetEffectivePolicy)
.WithName("GetEffectivePolicy")
.WithSummary("Get an effective policy by ID.")
.Produces<EffectivePolicyResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapPut("/{effectivePolicyId}", UpdateEffectivePolicy)
.WithName("UpdateEffectivePolicy")
.WithSummary("Update an effective policy's priority, expiration, or scopes.")
.Produces<EffectivePolicyResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapDelete("/{effectivePolicyId}", DeleteEffectivePolicy)
.WithName("DeleteEffectivePolicy")
.WithSummary("Delete an effective policy.")
.Produces(StatusCodes.Status204NoContent)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapGet("/", ListEffectivePolicies)
.WithName("ListEffectivePolicies")
.WithSummary("List effective policies with optional filtering.")
.Produces<EffectivePolicyListResponse>(StatusCodes.Status200OK);
// Scope attachments
var scopeGroup = endpoints.MapGroup("/api/v1/authority/scope-attachments")
.RequireAuthorization()
.WithTags("Authority Scope Attachments");
scopeGroup.MapPost("/", AttachScope)
.WithName("AttachAuthorityScope")
.WithSummary("Attach an authorization scope to an effective policy.")
.Produces<AuthorityScopeAttachmentResponse>(StatusCodes.Status201Created)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
scopeGroup.MapDelete("/{attachmentId}", DetachScope)
.WithName("DetachAuthorityScope")
.WithSummary("Detach an authorization scope.")
.Produces(StatusCodes.Status204NoContent)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
scopeGroup.MapGet("/policy/{effectivePolicyId}", GetPolicyScopeAttachments)
.WithName("GetPolicyScopeAttachments")
.WithSummary("Get all scope attachments for an effective policy.")
.Produces<AuthorityScopeAttachmentListResponse>(StatusCodes.Status200OK);
// Resolution
var resolveGroup = endpoints.MapGroup("/api/v1/authority")
.RequireAuthorization()
.WithTags("Policy Resolution");
resolveGroup.MapGet("/resolve", ResolveEffectivePolicy)
.WithName("ResolveEffectivePolicy")
.WithSummary("Resolve the effective policy for a subject.")
.Produces<EffectivePolicyResolutionResponse>(StatusCodes.Status200OK);
return endpoints;
}
private static IResult CreateEffectivePolicy(
HttpContext context,
[FromBody] CreateEffectivePolicyRequest request,
EffectivePolicyService policyService,
IEffectivePolicyAuditor auditor)
{
var scopeResult = RequireEffectiveWriteScope(context);
if (scopeResult is not null)
{
return scopeResult;
}
if (request == null)
{
return Results.BadRequest(CreateProblem("Invalid request", "Request body is required."));
}
try
{
var actorId = ResolveActorId(context);
var policy = policyService.Create(request, actorId);
// Audit per CONTRACT-AUTHORITY-EFFECTIVE-WRITE-008
auditor.RecordCreated(policy, actorId);
return Results.Created(
$"/api/v1/authority/effective-policies/{policy.EffectivePolicyId}",
new EffectivePolicyResponse(policy));
}
catch (ArgumentException ex)
{
return Results.BadRequest(CreateProblem("Invalid request", ex.Message, "ERR_AUTH_001"));
}
}
private static IResult GetEffectivePolicy(
HttpContext context,
[FromRoute] string effectivePolicyId,
EffectivePolicyService policyService)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
if (scopeResult is not null)
{
return scopeResult;
}
var policy = policyService.Get(effectivePolicyId);
if (policy == null)
{
return Results.NotFound(CreateProblem(
"Policy not found",
$"Effective policy '{effectivePolicyId}' was not found.",
"ERR_AUTH_002"));
}
return Results.Ok(new EffectivePolicyResponse(policy));
}
private static IResult UpdateEffectivePolicy(
HttpContext context,
[FromRoute] string effectivePolicyId,
[FromBody] UpdateEffectivePolicyRequest request,
EffectivePolicyService policyService,
IEffectivePolicyAuditor auditor)
{
var scopeResult = RequireEffectiveWriteScope(context);
if (scopeResult is not null)
{
return scopeResult;
}
if (request == null)
{
return Results.BadRequest(CreateProblem("Invalid request", "Request body is required."));
}
var actorId = ResolveActorId(context);
var policy = policyService.Update(effectivePolicyId, request, actorId);
if (policy == null)
{
return Results.NotFound(CreateProblem(
"Policy not found",
$"Effective policy '{effectivePolicyId}' was not found.",
"ERR_AUTH_002"));
}
// Audit per CONTRACT-AUTHORITY-EFFECTIVE-WRITE-008
auditor.RecordUpdated(policy, actorId, request);
return Results.Ok(new EffectivePolicyResponse(policy));
}
private static IResult DeleteEffectivePolicy(
HttpContext context,
[FromRoute] string effectivePolicyId,
EffectivePolicyService policyService,
IEffectivePolicyAuditor auditor)
{
var scopeResult = RequireEffectiveWriteScope(context);
if (scopeResult is not null)
{
return scopeResult;
}
if (!policyService.Delete(effectivePolicyId))
{
return Results.NotFound(CreateProblem(
"Policy not found",
$"Effective policy '{effectivePolicyId}' was not found.",
"ERR_AUTH_002"));
}
// Audit per CONTRACT-AUTHORITY-EFFECTIVE-WRITE-008
var actorId = ResolveActorId(context);
auditor.RecordDeleted(effectivePolicyId, actorId);
return Results.NoContent();
}
private static IResult ListEffectivePolicies(
HttpContext context,
[FromQuery] string? tenantId,
[FromQuery] string? policyId,
[FromQuery] bool enabledOnly,
[FromQuery] bool includeExpired,
[FromQuery] int limit,
EffectivePolicyService policyService)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
if (scopeResult is not null)
{
return scopeResult;
}
var query = new EffectivePolicyQuery(
TenantId: tenantId,
PolicyId: policyId,
EnabledOnly: enabledOnly,
IncludeExpired: includeExpired,
Limit: limit > 0 ? limit : 100);
var policies = policyService.Query(query);
return Results.Ok(new EffectivePolicyListResponse(policies, policies.Count));
}
private static IResult AttachScope(
HttpContext context,
[FromBody] AttachAuthorityScopeRequest request,
EffectivePolicyService policyService,
IEffectivePolicyAuditor auditor)
{
var scopeResult = RequireEffectiveWriteScope(context);
if (scopeResult is not null)
{
return scopeResult;
}
if (request == null)
{
return Results.BadRequest(CreateProblem("Invalid request", "Request body is required."));
}
try
{
var attachment = policyService.AttachScope(request);
// Audit per CONTRACT-AUTHORITY-EFFECTIVE-WRITE-008
var actorId = ResolveActorId(context);
auditor.RecordScopeAttached(attachment, actorId);
return Results.Created(
$"/api/v1/authority/scope-attachments/{attachment.AttachmentId}",
new AuthorityScopeAttachmentResponse(attachment));
}
catch (ArgumentException ex)
{
var code = ex.Message.Contains("not found") ? "ERR_AUTH_002" : "ERR_AUTH_004";
return Results.BadRequest(CreateProblem("Invalid request", ex.Message, code));
}
}
private static IResult DetachScope(
HttpContext context,
[FromRoute] string attachmentId,
EffectivePolicyService policyService,
IEffectivePolicyAuditor auditor)
{
var scopeResult = RequireEffectiveWriteScope(context);
if (scopeResult is not null)
{
return scopeResult;
}
if (!policyService.DetachScope(attachmentId))
{
return Results.NotFound(CreateProblem(
"Attachment not found",
$"Scope attachment '{attachmentId}' was not found."));
}
// Audit per CONTRACT-AUTHORITY-EFFECTIVE-WRITE-008
var actorId = ResolveActorId(context);
auditor.RecordScopeDetached(attachmentId, actorId);
return Results.NoContent();
}
private static IResult GetPolicyScopeAttachments(
HttpContext context,
[FromRoute] string effectivePolicyId,
EffectivePolicyService policyService)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
if (scopeResult is not null)
{
return scopeResult;
}
var attachments = policyService.GetScopeAttachments(effectivePolicyId);
return Results.Ok(new AuthorityScopeAttachmentListResponse(attachments));
}
private static IResult ResolveEffectivePolicy(
HttpContext context,
[FromQuery] string subject,
[FromQuery] string? tenantId,
EffectivePolicyService policyService)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
if (scopeResult is not null)
{
return scopeResult;
}
if (string.IsNullOrWhiteSpace(subject))
{
return Results.BadRequest(CreateProblem("Invalid request", "Subject is required."));
}
var result = policyService.Resolve(subject, tenantId);
return Results.Ok(new EffectivePolicyResolutionResponse(result));
}
private static IResult? RequireEffectiveWriteScope(HttpContext context)
{
// Check for effective:write scope per CONTRACT-AUTHORITY-EFFECTIVE-WRITE-008
// Primary scope: effective:write (StellaOpsScopes.EffectiveWrite)
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.EffectiveWrite);
if (scopeResult is not null)
{
// Fall back to policy:edit for backwards compatibility during migration
// TODO: Remove fallback after migration period (track in POLICY-AOC-19-002)
return ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
}
return null;
}
private static string? ResolveActorId(HttpContext context)
{
var user = context.User;
var actor = user?.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? user?.FindFirst(ClaimTypes.Upn)?.Value
?? user?.FindFirst("sub")?.Value;
if (!string.IsNullOrWhiteSpace(actor))
{
return actor;
}
if (context.Request.Headers.TryGetValue("X-StellaOps-Actor", out var header) && !string.IsNullOrWhiteSpace(header))
{
return header.ToString();
}
return null;
}
private static ProblemDetails CreateProblem(string title, string detail, string? errorCode = null)
{
var problem = new ProblemDetails
{
Title = title,
Detail = detail,
Status = StatusCodes.Status400BadRequest
};
if (!string.IsNullOrWhiteSpace(errorCode))
{
problem.Extensions["error_code"] = errorCode;
}
return problem;
}
}
#region Response DTOs
internal sealed record EffectivePolicyResponse(EffectivePolicy EffectivePolicy);
internal sealed record EffectivePolicyListResponse(IReadOnlyList<EffectivePolicy> Items, int Total);
internal sealed record AuthorityScopeAttachmentResponse(AuthorityScopeAttachment Attachment);
internal sealed record AuthorityScopeAttachmentListResponse(IReadOnlyList<AuthorityScopeAttachment> Attachments);
internal sealed record EffectivePolicyResolutionResponse(EffectivePolicyResolutionResult Result);
#endregion

View File

@@ -0,0 +1,241 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Policy.Engine.DeterminismGuard;
namespace StellaOps.Policy.Engine.Endpoints;
/// <summary>
/// Endpoints for policy code linting and determinism analysis.
/// Implements POLICY-AOC-19-001 per docs/modules/policy/design/policy-aoc-linting-rules.md.
/// </summary>
public static class PolicyLintEndpoints
{
public static IEndpointRouteBuilder MapPolicyLint(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/v1/policy/lint");
group.MapPost("/analyze", AnalyzeSourceAsync)
.WithName("Policy.Lint.Analyze")
.WithDescription("Analyze source code for determinism violations")
.RequireAuthorization(policy => policy.RequireClaim("scope", "policy:read"));
group.MapPost("/analyze-batch", AnalyzeBatchAsync)
.WithName("Policy.Lint.AnalyzeBatch")
.WithDescription("Analyze multiple source files for determinism violations")
.RequireAuthorization(policy => policy.RequireClaim("scope", "policy:read"));
group.MapGet("/rules", GetLintRulesAsync)
.WithName("Policy.Lint.GetRules")
.WithDescription("Get available lint rules and their severities")
.AllowAnonymous();
return routes;
}
private static Task<IResult> AnalyzeSourceAsync(
[FromBody] LintSourceRequest request,
CancellationToken cancellationToken)
{
if (request is null || string.IsNullOrWhiteSpace(request.Source))
{
return Task.FromResult(Results.BadRequest(new
{
error = "LINT_SOURCE_REQUIRED",
message = "Source code is required"
}));
}
var analyzer = new ProhibitedPatternAnalyzer();
var options = new DeterminismGuardOptions
{
EnforcementEnabled = request.EnforceErrors ?? true,
FailOnSeverity = ParseSeverity(request.MinSeverity),
EnableStaticAnalysis = true,
EnableRuntimeMonitoring = false
};
var result = analyzer.AnalyzeSource(request.Source, request.FileName, options);
return Task.FromResult(Results.Ok(new LintResultResponse
{
Passed = result.Passed,
Violations = result.Violations.Select(MapViolation).ToList(),
CountBySeverity = result.CountBySeverity.ToDictionary(
kvp => kvp.Key.ToString().ToLowerInvariant(),
kvp => kvp.Value),
AnalysisDurationMs = result.AnalysisDurationMs,
EnforcementEnabled = result.EnforcementEnabled
}));
}
private static Task<IResult> AnalyzeBatchAsync(
[FromBody] LintBatchRequest request,
CancellationToken cancellationToken)
{
if (request?.Files is null || request.Files.Count == 0)
{
return Task.FromResult(Results.BadRequest(new
{
error = "LINT_FILES_REQUIRED",
message = "At least one file is required"
}));
}
var analyzer = new ProhibitedPatternAnalyzer();
var options = new DeterminismGuardOptions
{
EnforcementEnabled = request.EnforceErrors ?? true,
FailOnSeverity = ParseSeverity(request.MinSeverity),
EnableStaticAnalysis = true,
EnableRuntimeMonitoring = false
};
var sources = request.Files.Select(f => (f.Source, f.FileName));
var result = analyzer.AnalyzeMultiple(sources, options);
return Task.FromResult(Results.Ok(new LintResultResponse
{
Passed = result.Passed,
Violations = result.Violations.Select(MapViolation).ToList(),
CountBySeverity = result.CountBySeverity.ToDictionary(
kvp => kvp.Key.ToString().ToLowerInvariant(),
kvp => kvp.Value),
AnalysisDurationMs = result.AnalysisDurationMs,
EnforcementEnabled = result.EnforcementEnabled
}));
}
private static Task<IResult> GetLintRulesAsync(CancellationToken cancellationToken)
{
var rules = new List<LintRuleInfo>
{
// Wall-clock rules
new("DET-001", "DateTime.Now", "error", "WallClock", "Use TimeProvider.GetUtcNow()"),
new("DET-002", "DateTime.UtcNow", "error", "WallClock", "Use TimeProvider.GetUtcNow()"),
new("DET-003", "DateTimeOffset.Now", "error", "WallClock", "Use TimeProvider.GetUtcNow()"),
new("DET-004", "DateTimeOffset.UtcNow", "error", "WallClock", "Use TimeProvider.GetUtcNow()"),
// Random/GUID rules
new("DET-005", "Guid.NewGuid()", "error", "GuidGeneration", "Use StableIdGenerator or content hash"),
new("DET-006", "new Random()", "error", "RandomNumber", "Use seeded random or remove"),
new("DET-007", "RandomNumberGenerator", "error", "RandomNumber", "Remove from evaluation path"),
// Network/Filesystem rules
new("DET-008", "HttpClient in eval", "critical", "NetworkAccess", "Remove network from eval path"),
new("DET-009", "File.Read* in eval", "critical", "FileSystemAccess", "Remove filesystem from eval path"),
// Ordering rules
new("DET-010", "Dictionary iteration", "warning", "UnstableIteration", "Use OrderBy or SortedDictionary"),
new("DET-011", "HashSet iteration", "warning", "UnstableIteration", "Use OrderBy or SortedSet"),
// Environment rules
new("DET-012", "Environment.GetEnvironmentVariable", "error", "EnvironmentAccess", "Use evaluation context"),
new("DET-013", "Environment.MachineName", "warning", "EnvironmentAccess", "Remove host-specific info")
};
return Task.FromResult(Results.Ok(new
{
rules,
categories = new[]
{
"WallClock",
"RandomNumber",
"GuidGeneration",
"NetworkAccess",
"FileSystemAccess",
"EnvironmentAccess",
"UnstableIteration",
"FloatingPointHazard",
"ConcurrencyHazard"
},
severities = new[] { "info", "warning", "error", "critical" }
}));
}
private static DeterminismViolationSeverity ParseSeverity(string? severity)
{
return severity?.ToLowerInvariant() switch
{
"info" => DeterminismViolationSeverity.Info,
"warning" => DeterminismViolationSeverity.Warning,
"error" => DeterminismViolationSeverity.Error,
"critical" => DeterminismViolationSeverity.Critical,
_ => DeterminismViolationSeverity.Error
};
}
private static LintViolationResponse MapViolation(DeterminismViolation v)
{
return new LintViolationResponse
{
Category = v.Category.ToString(),
ViolationType = v.ViolationType,
Message = v.Message,
Severity = v.Severity.ToString().ToLowerInvariant(),
SourceFile = v.SourceFile,
LineNumber = v.LineNumber,
MemberName = v.MemberName,
Remediation = v.Remediation
};
}
}
/// <summary>
/// Request for single source analysis.
/// </summary>
public sealed record LintSourceRequest(
string Source,
string? FileName = null,
string? MinSeverity = null,
bool? EnforceErrors = null);
/// <summary>
/// Request for batch source analysis.
/// </summary>
public sealed record LintBatchRequest(
List<LintFileInput> Files,
string? MinSeverity = null,
bool? EnforceErrors = null);
/// <summary>
/// Single file input for batch analysis.
/// </summary>
public sealed record LintFileInput(
string Source,
string FileName);
/// <summary>
/// Response for lint analysis.
/// </summary>
public sealed record LintResultResponse
{
public required bool Passed { get; init; }
public required List<LintViolationResponse> Violations { get; init; }
public required Dictionary<string, int> CountBySeverity { get; init; }
public required long AnalysisDurationMs { get; init; }
public required bool EnforcementEnabled { get; init; }
}
/// <summary>
/// Single violation in lint response.
/// </summary>
public sealed record LintViolationResponse
{
public required string Category { get; init; }
public required string ViolationType { get; init; }
public required string Message { get; init; }
public required string Severity { get; init; }
public string? SourceFile { get; init; }
public int? LineNumber { get; init; }
public string? MemberName { get; init; }
public string? Remediation { get; init; }
}
/// <summary>
/// Lint rule information.
/// </summary>
public sealed record LintRuleInfo(
string RuleId,
string Name,
string DefaultSeverity,
string Category,
string Remediation);

View File

@@ -0,0 +1,102 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Policy.Engine.AirGap;
namespace StellaOps.Policy.Engine.Endpoints;
/// <summary>
/// Endpoints for policy pack bundle import per CONTRACT-MIRROR-BUNDLE-003.
/// </summary>
public static class PolicyPackBundleEndpoints
{
public static IEndpointRouteBuilder MapPolicyPackBundles(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/v1/airgap/bundles");
group.MapPost("", RegisterBundleAsync)
.WithName("AirGap.RegisterBundle")
.WithDescription("Register a bundle for import")
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status403Forbidden)
.ProducesProblem(StatusCodes.Status412PreconditionFailed);
group.MapGet("{bundleId}", GetBundleStatusAsync)
.WithName("AirGap.GetBundleStatus")
.WithDescription("Get bundle import status")
.ProducesProblem(StatusCodes.Status404NotFound);
group.MapGet("", ListBundlesAsync)
.WithName("AirGap.ListBundles")
.WithDescription("List imported bundles");
return routes;
}
private static async Task<IResult> RegisterBundleAsync(
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
[FromBody] RegisterBundleRequest request,
PolicyPackBundleImportService service,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.Problem(
title: "Tenant ID required",
detail: "X-Tenant-Id header is required",
statusCode: 400,
extensions: new Dictionary<string, object?> { ["code"] = "TENANT_REQUIRED" });
}
try
{
var response = await service.RegisterBundleAsync(tenantId, request, cancellationToken).ConfigureAwait(false);
return Results.Accepted($"/api/v1/airgap/bundles/{response.ImportId}", response);
}
catch (SealedModeException ex)
{
return SealedModeResultHelper.ToProblem(ex);
}
catch (InvalidOperationException ex) when (ex.Message.Contains("Bundle import blocked"))
{
// Sealed-mode enforcement blocked the import
return SealedModeResultHelper.ToProblem(
SealedModeErrorCodes.ImportBlocked,
ex.Message,
"Ensure time anchor is fresh before importing bundles");
}
catch (ArgumentException ex)
{
return SealedModeResultHelper.ToProblem(
SealedModeErrorCodes.BundleInvalid,
ex.Message,
"Verify request parameters are valid");
}
}
private static async Task<IResult> GetBundleStatusAsync(
[FromRoute] string bundleId,
PolicyPackBundleImportService service,
CancellationToken cancellationToken)
{
var status = await service.GetBundleStatusAsync(bundleId, cancellationToken).ConfigureAwait(false);
if (status is null)
{
return Results.Problem(
title: "Bundle not found",
detail: $"Bundle '{bundleId}' not found",
statusCode: 404,
extensions: new Dictionary<string, object?> { ["code"] = "BUNDLE_NOT_FOUND" });
}
return Results.Ok(status);
}
private static async Task<IResult> ListBundlesAsync(
[FromQuery] string? tenant_id,
PolicyPackBundleImportService service,
CancellationToken cancellationToken)
{
var bundles = await service.ListBundlesAsync(tenant_id, cancellationToken).ConfigureAwait(false);
return Results.Ok(new { items = bundles, total = bundles.Count });
}
}

View File

@@ -0,0 +1,283 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Policy.Engine.AirGap;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.RiskProfile.Models;
namespace StellaOps.Policy.Engine.Endpoints;
/// <summary>
/// Endpoints for air-gap risk profile export/import per CONTRACT-MIRROR-BUNDLE-003.
/// </summary>
public static class RiskProfileAirGapEndpoints
{
public static IEndpointRouteBuilder MapRiskProfileAirGap(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/v1/airgap/risk-profiles")
.RequireAuthorization()
.WithTags("Air-Gap Risk Profiles");
group.MapPost("/export", ExportProfilesAsync)
.WithName("AirGap.ExportRiskProfiles")
.WithSummary("Export risk profiles as an air-gap compatible bundle with signatures.")
.Produces<RiskProfileAirGapBundle>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest);
group.MapPost("/export/download", DownloadBundleAsync)
.WithName("AirGap.DownloadRiskProfileBundle")
.WithSummary("Export and download risk profiles as an air-gap compatible JSON file.")
.Produces<FileContentHttpResult>(StatusCodes.Status200OK, contentType: "application/json");
group.MapPost("/import", ImportProfilesAsync)
.WithName("AirGap.ImportRiskProfiles")
.WithSummary("Import risk profiles from an air-gap bundle with sealed-mode enforcement.")
.Produces<RiskProfileAirGapImportResult>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces<ProblemDetails>(StatusCodes.Status403Forbidden)
.Produces<ProblemDetails>(StatusCodes.Status412PreconditionFailed);
group.MapPost("/verify", VerifyBundleAsync)
.WithName("AirGap.VerifyRiskProfileBundle")
.WithSummary("Verify the integrity of an air-gap bundle without importing.")
.Produces<AirGapBundleVerification>(StatusCodes.Status200OK);
return routes;
}
private static async Task<IResult> ExportProfilesAsync(
HttpContext context,
[FromBody] AirGapProfileExportRequest request,
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
RiskProfileConfigurationService profileService,
RiskProfileAirGapExportService exportService,
CancellationToken cancellationToken)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
if (scopeResult is not null)
{
return scopeResult;
}
if (request == null || request.ProfileIds == null || request.ProfileIds.Count == 0)
{
return Results.Problem(
title: "Invalid request",
detail: "At least one profile ID is required.",
statusCode: 400);
}
var profiles = new List<RiskProfileModel>();
var notFound = new List<string>();
foreach (var profileId in request.ProfileIds)
{
var profile = profileService.GetProfile(profileId);
if (profile != null)
{
profiles.Add(profile);
}
else
{
notFound.Add(profileId);
}
}
if (notFound.Count > 0)
{
return Results.Problem(
title: "Profiles not found",
detail: $"The following profiles were not found: {string.Join(", ", notFound)}",
statusCode: 400);
}
var exportRequest = new AirGapExportRequest(
SignBundle: request.SignBundle,
KeyId: request.KeyId,
TargetRepository: request.TargetRepository,
DisplayName: request.DisplayName);
var bundle = await exportService.ExportAsync(
profiles, exportRequest, tenantId, cancellationToken).ConfigureAwait(false);
return Results.Ok(bundle);
}
private static async Task<IResult> DownloadBundleAsync(
HttpContext context,
[FromBody] AirGapProfileExportRequest request,
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
RiskProfileConfigurationService profileService,
RiskProfileAirGapExportService exportService,
CancellationToken cancellationToken)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
if (scopeResult is not null)
{
return scopeResult;
}
if (request == null || request.ProfileIds == null || request.ProfileIds.Count == 0)
{
return Results.Problem(
title: "Invalid request",
detail: "At least one profile ID is required.",
statusCode: 400);
}
var profiles = new List<RiskProfileModel>();
foreach (var profileId in request.ProfileIds)
{
var profile = profileService.GetProfile(profileId);
if (profile != null)
{
profiles.Add(profile);
}
}
var exportRequest = new AirGapExportRequest(
SignBundle: request.SignBundle,
KeyId: request.KeyId,
TargetRepository: request.TargetRepository,
DisplayName: request.DisplayName);
var bundle = await exportService.ExportAsync(
profiles, exportRequest, tenantId, cancellationToken).ConfigureAwait(false);
var json = System.Text.Json.JsonSerializer.Serialize(bundle, new System.Text.Json.JsonSerializerOptions
{
WriteIndented = false,
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase
});
var bytes = System.Text.Encoding.UTF8.GetBytes(json);
var fileName = $"risk-profiles-airgap-{DateTime.UtcNow:yyyyMMddHHmmss}.json";
return Results.File(bytes, "application/json", fileName);
}
private static async Task<IResult> ImportProfilesAsync(
HttpContext context,
[FromBody] AirGapProfileImportRequest request,
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
RiskProfileAirGapExportService exportService,
CancellationToken cancellationToken)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
if (scopeResult is not null)
{
return scopeResult;
}
if (request == null || request.Bundle == null)
{
return Results.Problem(
title: "Invalid request",
detail: "Bundle is required.",
statusCode: 400);
}
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.Problem(
title: "Tenant ID required",
detail: "X-Tenant-Id header is required for air-gap import.",
statusCode: 400,
extensions: new Dictionary<string, object?> { ["code"] = "TENANT_REQUIRED" });
}
var importRequest = new AirGapImportRequest(
VerifySignature: request.VerifySignature,
VerifyMerkle: request.VerifyMerkle,
EnforceSealedMode: request.EnforceSealedMode,
RejectOnSignatureFailure: request.RejectOnSignatureFailure,
RejectOnMerkleFailure: request.RejectOnMerkleFailure);
try
{
var result = await exportService.ImportAsync(
request.Bundle, importRequest, tenantId, cancellationToken).ConfigureAwait(false);
if (!result.Success)
{
var extensions = new Dictionary<string, object?>
{
["errors"] = result.Errors,
["signatureVerified"] = result.SignatureVerified,
["merkleVerified"] = result.MerkleVerified
};
// Check if it's a sealed-mode enforcement failure
if (result.Errors.Any(e => e.Contains("Sealed-mode")))
{
return Results.Problem(
title: "Import blocked by sealed mode",
detail: result.Errors.FirstOrDefault() ?? "Sealed mode enforcement failed",
statusCode: 412,
extensions: extensions);
}
return Results.Problem(
title: "Import failed",
detail: $"Import completed with {result.ErrorCount} errors",
statusCode: 400,
extensions: extensions);
}
return Results.Ok(result);
}
catch (SealedModeException ex)
{
return SealedModeResultHelper.ToProblem(ex);
}
}
private static IResult VerifyBundleAsync(
HttpContext context,
[FromBody] RiskProfileAirGapBundle bundle,
RiskProfileAirGapExportService exportService)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
if (scopeResult is not null)
{
return scopeResult;
}
if (bundle == null)
{
return Results.Problem(
title: "Invalid request",
detail: "Bundle is required.",
statusCode: 400);
}
var verification = exportService.Verify(bundle);
return Results.Ok(verification);
}
}
#region Request DTOs
/// <summary>
/// Request to export profiles as an air-gap bundle.
/// </summary>
public sealed record AirGapProfileExportRequest(
IReadOnlyList<string> ProfileIds,
bool SignBundle = true,
string? KeyId = null,
string? TargetRepository = null,
string? DisplayName = null);
/// <summary>
/// Request to import profiles from an air-gap bundle.
/// </summary>
public sealed record AirGapProfileImportRequest(
RiskProfileAirGapBundle Bundle,
bool VerifySignature = true,
bool VerifyMerkle = true,
bool EnforceSealedMode = true,
bool RejectOnSignatureFailure = true,
bool RejectOnMerkleFailure = true);
#endregion

View File

@@ -7,6 +7,10 @@ using StellaOps.Policy.Engine.Simulation;
namespace StellaOps.Policy.Engine.Endpoints;
/// <summary>
/// Risk simulation endpoints for Policy Engine and Policy Studio.
/// Enhanced with detailed analytics per POLICY-RISK-68-001.
/// </summary>
internal static class RiskSimulationEndpoints
{
public static IEndpointRouteBuilder MapRiskSimulation(this IEndpointRouteBuilder endpoints)
@@ -42,6 +46,28 @@ internal static class RiskSimulationEndpoints
.Produces<WhatIfSimulationResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
// Policy Studio specific endpoints per POLICY-RISK-68-001
group.MapPost("/studio/analyze", RunStudioAnalysis)
.WithName("RunPolicyStudioAnalysis")
.WithSummary("Run a detailed analysis for Policy Studio with full breakdown analytics.")
.WithDescription("Provides comprehensive breakdown including signal analysis, override tracking, score distributions, and component breakdowns for policy authoring.")
.Produces<PolicyStudioAnalysisResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapPost("/studio/compare", CompareProfilesWithBreakdown)
.WithName("CompareProfilesWithBreakdown")
.WithSummary("Compare profiles with full breakdown analytics and trend analysis.")
.Produces<PolicyStudioComparisonResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
group.MapPost("/studio/preview", PreviewProfileChanges)
.WithName("PreviewProfileChanges")
.WithSummary("Preview impact of profile changes before committing.")
.WithDescription("Simulates findings against both current and proposed profile to show impact.")
.Produces<ProfileChangePreviewResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
return endpoints;
}
@@ -355,6 +381,344 @@ internal static class RiskSimulationEndpoints
ToHigher: worsened,
Unchanged: unchanged));
}
#region Policy Studio Endpoints (POLICY-RISK-68-001)
private static IResult RunStudioAnalysis(
HttpContext context,
[FromBody] PolicyStudioAnalysisRequest request,
RiskSimulationService simulationService)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
if (scopeResult is not null)
{
return scopeResult;
}
if (request == null || string.IsNullOrWhiteSpace(request.ProfileId))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid request",
Detail = "ProfileId is required.",
Status = StatusCodes.Status400BadRequest
});
}
if (request.Findings == null || request.Findings.Count == 0)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid request",
Detail = "At least one finding is required.",
Status = StatusCodes.Status400BadRequest
});
}
try
{
var breakdownOptions = request.BreakdownOptions ?? RiskSimulationBreakdownOptions.Default;
var result = simulationService.SimulateWithBreakdown(
new RiskSimulationRequest(
ProfileId: request.ProfileId,
ProfileVersion: request.ProfileVersion,
Findings: request.Findings,
IncludeContributions: true,
IncludeDistribution: true,
Mode: SimulationMode.Full),
breakdownOptions);
return Results.Ok(new PolicyStudioAnalysisResponse(
Result: result.Result,
Breakdown: result.Breakdown,
TotalExecutionTimeMs: result.TotalExecutionTimeMs));
}
catch (InvalidOperationException ex) when (ex.Message.Contains("not found"))
{
return Results.NotFound(new ProblemDetails
{
Title = "Profile not found",
Detail = ex.Message,
Status = StatusCodes.Status404NotFound
});
}
catch (InvalidOperationException ex) when (ex.Message.Contains("Breakdown service"))
{
return Results.Problem(
title: "Service unavailable",
detail: ex.Message,
statusCode: StatusCodes.Status503ServiceUnavailable);
}
}
private static IResult CompareProfilesWithBreakdown(
HttpContext context,
[FromBody] PolicyStudioComparisonRequest request,
RiskSimulationService simulationService)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
if (scopeResult is not null)
{
return scopeResult;
}
if (request == null ||
string.IsNullOrWhiteSpace(request.BaseProfileId) ||
string.IsNullOrWhiteSpace(request.CompareProfileId))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid request",
Detail = "Both BaseProfileId and CompareProfileId are required.",
Status = StatusCodes.Status400BadRequest
});
}
if (request.Findings == null || request.Findings.Count == 0)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid request",
Detail = "At least one finding is required.",
Status = StatusCodes.Status400BadRequest
});
}
try
{
var result = simulationService.CompareProfilesWithBreakdown(
request.BaseProfileId,
request.CompareProfileId,
request.Findings,
request.BreakdownOptions);
return Results.Ok(new PolicyStudioComparisonResponse(
BaselineResult: result.BaselineResult,
CompareResult: result.CompareResult,
Breakdown: result.Breakdown,
ExecutionTimeMs: result.ExecutionTimeMs));
}
catch (InvalidOperationException ex) when (ex.Message.Contains("not found"))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Profile not found",
Detail = ex.Message,
Status = StatusCodes.Status400BadRequest
});
}
catch (InvalidOperationException ex) when (ex.Message.Contains("Breakdown service"))
{
return Results.Problem(
title: "Service unavailable",
detail: ex.Message,
statusCode: StatusCodes.Status503ServiceUnavailable);
}
}
private static IResult PreviewProfileChanges(
HttpContext context,
[FromBody] ProfileChangePreviewRequest request,
RiskSimulationService simulationService)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
if (scopeResult is not null)
{
return scopeResult;
}
if (request == null || string.IsNullOrWhiteSpace(request.CurrentProfileId))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid request",
Detail = "CurrentProfileId is required.",
Status = StatusCodes.Status400BadRequest
});
}
if (string.IsNullOrWhiteSpace(request.ProposedProfileId) &&
(request.ProposedWeightChanges == null || request.ProposedWeightChanges.Count == 0) &&
(request.ProposedOverrideChanges == null || request.ProposedOverrideChanges.Count == 0))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid request",
Detail = "Either ProposedProfileId or at least one proposed change is required.",
Status = StatusCodes.Status400BadRequest
});
}
try
{
// Run simulation against current profile
var currentRequest = new RiskSimulationRequest(
ProfileId: request.CurrentProfileId,
ProfileVersion: request.CurrentProfileVersion,
Findings: request.Findings,
IncludeContributions: true,
IncludeDistribution: true,
Mode: SimulationMode.Full);
var currentResult = simulationService.Simulate(currentRequest);
RiskSimulationResult proposedResult;
if (!string.IsNullOrWhiteSpace(request.ProposedProfileId))
{
// Compare against existing proposed profile
var proposedRequest = new RiskSimulationRequest(
ProfileId: request.ProposedProfileId,
ProfileVersion: request.ProposedProfileVersion,
Findings: request.Findings,
IncludeContributions: true,
IncludeDistribution: true,
Mode: SimulationMode.Full);
proposedResult = simulationService.Simulate(proposedRequest);
}
else
{
// Inline changes not yet supported - return preview of current only
proposedResult = currentResult;
}
var impactSummary = ComputePreviewImpact(currentResult, proposedResult);
return Results.Ok(new ProfileChangePreviewResponse(
CurrentResult: new ProfileSimulationSummary(
currentResult.ProfileId,
currentResult.ProfileVersion,
currentResult.AggregateMetrics),
ProposedResult: new ProfileSimulationSummary(
proposedResult.ProfileId,
proposedResult.ProfileVersion,
proposedResult.AggregateMetrics),
Impact: impactSummary,
HighImpactFindings: ComputeHighImpactFindings(currentResult, proposedResult)));
}
catch (InvalidOperationException ex) when (ex.Message.Contains("not found"))
{
return Results.NotFound(new ProblemDetails
{
Title = "Profile not found",
Detail = ex.Message,
Status = StatusCodes.Status404NotFound
});
}
}
private static ProfileChangeImpact ComputePreviewImpact(
RiskSimulationResult current,
RiskSimulationResult proposed)
{
var currentScores = current.FindingScores.ToDictionary(f => f.FindingId);
var proposedScores = proposed.FindingScores.ToDictionary(f => f.FindingId);
var improved = 0;
var worsened = 0;
var unchanged = 0;
var severityEscalations = 0;
var severityDeescalations = 0;
var actionChanges = 0;
foreach (var (findingId, currentScore) in currentScores)
{
if (!proposedScores.TryGetValue(findingId, out var proposedScore))
continue;
var scoreDelta = proposedScore.NormalizedScore - currentScore.NormalizedScore;
if (Math.Abs(scoreDelta) < 1.0)
unchanged++;
else if (scoreDelta < 0)
improved++;
else
worsened++;
if (proposedScore.Severity > currentScore.Severity)
severityEscalations++;
else if (proposedScore.Severity < currentScore.Severity)
severityDeescalations++;
if (proposedScore.RecommendedAction != currentScore.RecommendedAction)
actionChanges++;
}
return new ProfileChangeImpact(
FindingsImproved: improved,
FindingsWorsened: worsened,
FindingsUnchanged: unchanged,
SeverityEscalations: severityEscalations,
SeverityDeescalations: severityDeescalations,
ActionChanges: actionChanges,
MeanScoreDelta: proposed.AggregateMetrics.MeanScore - current.AggregateMetrics.MeanScore,
CriticalCountDelta: proposed.AggregateMetrics.CriticalCount - current.AggregateMetrics.CriticalCount,
HighCountDelta: proposed.AggregateMetrics.HighCount - current.AggregateMetrics.HighCount);
}
private static IReadOnlyList<HighImpactFindingPreview> ComputeHighImpactFindings(
RiskSimulationResult current,
RiskSimulationResult proposed)
{
var currentScores = current.FindingScores.ToDictionary(f => f.FindingId);
var proposedScores = proposed.FindingScores.ToDictionary(f => f.FindingId);
var highImpact = new List<HighImpactFindingPreview>();
foreach (var (findingId, currentScore) in currentScores)
{
if (!proposedScores.TryGetValue(findingId, out var proposedScore))
continue;
var scoreDelta = Math.Abs(proposedScore.NormalizedScore - currentScore.NormalizedScore);
var severityChanged = proposedScore.Severity != currentScore.Severity;
var actionChanged = proposedScore.RecommendedAction != currentScore.RecommendedAction;
if (scoreDelta > 10 || severityChanged || actionChanged)
{
highImpact.Add(new HighImpactFindingPreview(
FindingId: findingId,
CurrentScore: currentScore.NormalizedScore,
ProposedScore: proposedScore.NormalizedScore,
ScoreDelta: proposedScore.NormalizedScore - currentScore.NormalizedScore,
CurrentSeverity: currentScore.Severity.ToString(),
ProposedSeverity: proposedScore.Severity.ToString(),
CurrentAction: currentScore.RecommendedAction.ToString(),
ProposedAction: proposedScore.RecommendedAction.ToString(),
ImpactReason: DetermineImpactReason(currentScore, proposedScore)));
}
}
return highImpact
.OrderByDescending(f => Math.Abs(f.ScoreDelta))
.Take(20)
.ToList();
}
private static string DetermineImpactReason(FindingScore current, FindingScore proposed)
{
var reasons = new List<string>();
if (proposed.Severity != current.Severity)
{
reasons.Add($"Severity {(proposed.Severity > current.Severity ? "escalated" : "deescalated")} from {current.Severity} to {proposed.Severity}");
}
if (proposed.RecommendedAction != current.RecommendedAction)
{
reasons.Add($"Action changed from {current.RecommendedAction} to {proposed.RecommendedAction}");
}
var scoreDelta = proposed.NormalizedScore - current.NormalizedScore;
if (Math.Abs(scoreDelta) > 10)
{
reasons.Add($"Score {(scoreDelta > 0 ? "increased" : "decreased")} by {Math.Abs(scoreDelta):F1} points");
}
return reasons.Count > 0 ? string.Join("; ", reasons) : "Significant score change";
}
#endregion
}
#region Request/Response DTOs
@@ -433,3 +797,73 @@ internal sealed record SeverityShifts(
int Unchanged);
#endregion
#region Policy Studio DTOs (POLICY-RISK-68-001)
internal sealed record PolicyStudioAnalysisRequest(
string ProfileId,
string? ProfileVersion,
IReadOnlyList<SimulationFinding> Findings,
RiskSimulationBreakdownOptions? BreakdownOptions = null);
internal sealed record PolicyStudioAnalysisResponse(
RiskSimulationResult Result,
RiskSimulationBreakdown Breakdown,
double TotalExecutionTimeMs);
internal sealed record PolicyStudioComparisonRequest(
string BaseProfileId,
string CompareProfileId,
IReadOnlyList<SimulationFinding> Findings,
RiskSimulationBreakdownOptions? BreakdownOptions = null);
internal sealed record PolicyStudioComparisonResponse(
RiskSimulationResult BaselineResult,
RiskSimulationResult CompareResult,
RiskSimulationBreakdown Breakdown,
double ExecutionTimeMs);
internal sealed record ProfileChangePreviewRequest(
string CurrentProfileId,
string? CurrentProfileVersion,
string? ProposedProfileId,
string? ProposedProfileVersion,
IReadOnlyList<SimulationFinding> Findings,
IReadOnlyDictionary<string, double>? ProposedWeightChanges = null,
IReadOnlyList<ProposedOverrideChange>? ProposedOverrideChanges = null);
internal sealed record ProposedOverrideChange(
string OverrideType,
Dictionary<string, object> When,
object Value,
string? Reason = null);
internal sealed record ProfileChangePreviewResponse(
ProfileSimulationSummary CurrentResult,
ProfileSimulationSummary ProposedResult,
ProfileChangeImpact Impact,
IReadOnlyList<HighImpactFindingPreview> HighImpactFindings);
internal sealed record ProfileChangeImpact(
int FindingsImproved,
int FindingsWorsened,
int FindingsUnchanged,
int SeverityEscalations,
int SeverityDeescalations,
int ActionChanges,
double MeanScoreDelta,
int CriticalCountDelta,
int HighCountDelta);
internal sealed record HighImpactFindingPreview(
string FindingId,
double CurrentScore,
double ProposedScore,
double ScoreDelta,
string CurrentSeverity,
string ProposedSeverity,
string CurrentAction,
string ProposedAction,
string ImpactReason);
#endregion

View File

@@ -0,0 +1,159 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Policy.Engine.AirGap;
namespace StellaOps.Policy.Engine.Endpoints;
/// <summary>
/// Endpoints for sealed-mode operations per CONTRACT-SEALED-MODE-004.
/// </summary>
public static class SealedModeEndpoints
{
public static IEndpointRouteBuilder MapSealedMode(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/system/airgap");
group.MapPost("/seal", SealAsync)
.WithName("AirGap.Seal")
.WithDescription("Seal the environment")
.RequireAuthorization(policy => policy.RequireClaim("scope", "airgap:seal"))
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status500InternalServerError);
group.MapPost("/unseal", UnsealAsync)
.WithName("AirGap.Unseal")
.WithDescription("Unseal the environment")
.RequireAuthorization(policy => policy.RequireClaim("scope", "airgap:seal"))
.ProducesProblem(StatusCodes.Status500InternalServerError);
group.MapGet("/status", GetStatusAsync)
.WithName("AirGap.GetStatus")
.WithDescription("Get sealed-mode status")
.RequireAuthorization(policy => policy.RequireClaim("scope", "airgap:status:read"));
group.MapPost("/verify", VerifyBundleAsync)
.WithName("AirGap.VerifyBundle")
.WithDescription("Verify a bundle against trust roots")
.RequireAuthorization(policy => policy.RequireClaim("scope", "airgap:verify"))
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);
return routes;
}
private static async Task<IResult> SealAsync(
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
[FromBody] SealRequest request,
ISealedModeService service,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
tenantId = "default";
}
try
{
var response = await service.SealAsync(tenantId, request, cancellationToken).ConfigureAwait(false);
return Results.Ok(response);
}
catch (SealedModeException ex)
{
return SealedModeResultHelper.ToProblem(ex);
}
catch (ArgumentException ex)
{
return SealedModeResultHelper.ToProblem(
SealedModeErrorCodes.SealFailed,
ex.Message,
"Ensure all required parameters are provided");
}
catch (Exception ex)
{
return SealedModeResultHelper.ToProblem(
SealedModeErrorCodes.SealFailed,
$"Seal operation failed: {ex.Message}");
}
}
private static async Task<IResult> UnsealAsync(
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
ISealedModeService service,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
tenantId = "default";
}
try
{
var response = await service.UnsealAsync(tenantId, cancellationToken).ConfigureAwait(false);
return Results.Ok(response);
}
catch (SealedModeException ex)
{
return SealedModeResultHelper.ToProblem(ex);
}
catch (Exception ex)
{
return SealedModeResultHelper.ToProblem(
SealedModeErrorCodes.UnsealFailed,
$"Unseal operation failed: {ex.Message}");
}
}
private static async Task<IResult> GetStatusAsync(
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
ISealedModeService service,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
tenantId = "default";
}
var status = await service.GetStatusAsync(tenantId, cancellationToken).ConfigureAwait(false);
return Results.Ok(status);
}
private static async Task<IResult> VerifyBundleAsync(
[FromBody] BundleVerifyRequest request,
ISealedModeService service,
CancellationToken cancellationToken)
{
try
{
var response = await service.VerifyBundleAsync(request, cancellationToken).ConfigureAwait(false);
// Return problem details if verification failed
if (!response.Valid && response.VerificationResult.Error is not null)
{
return SealedModeResultHelper.ToProblem(
SealedModeErrorCodes.SignatureInvalid,
response.VerificationResult.Error,
"Verify bundle integrity and trust root configuration",
422);
}
return Results.Ok(response);
}
catch (SealedModeException ex)
{
return SealedModeResultHelper.ToProblem(ex);
}
catch (ArgumentException ex)
{
return SealedModeResultHelper.ToProblem(
SealedModeErrorCodes.BundleInvalid,
ex.Message,
"Ensure bundle path is valid and accessible");
}
catch (FileNotFoundException ex)
{
return SealedModeResultHelper.ToProblem(
SealedModeErrorCodes.BundleInvalid,
$"Bundle file not found: {ex.FileName ?? ex.Message}",
"Verify the bundle path is correct");
}
}
}

View File

@@ -0,0 +1,121 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Policy.Engine.AirGap;
namespace StellaOps.Policy.Engine.Endpoints;
/// <summary>
/// Endpoints for staleness signaling and fallback status per CONTRACT-SEALED-MODE-004.
/// </summary>
public static class StalenessEndpoints
{
public static IEndpointRouteBuilder MapStalenessSignaling(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/system/airgap/staleness");
group.MapGet("/status", GetStalenessStatusAsync)
.WithName("AirGap.GetStalenessStatus")
.WithDescription("Get staleness signal status for health monitoring");
group.MapGet("/fallback", GetFallbackStatusAsync)
.WithName("AirGap.GetFallbackStatus")
.WithDescription("Get fallback mode status and configuration");
group.MapPost("/evaluate", EvaluateStalenessAsync)
.WithName("AirGap.EvaluateStaleness")
.WithDescription("Trigger staleness evaluation and signaling")
.RequireAuthorization(policy => policy.RequireClaim("scope", "airgap:status:read"));
group.MapPost("/recover", SignalRecoveryAsync)
.WithName("AirGap.SignalRecovery")
.WithDescription("Signal staleness recovery after time anchor refresh")
.RequireAuthorization(policy => policy.RequireClaim("scope", "airgap:seal"));
return routes;
}
private static async Task<IResult> GetStalenessStatusAsync(
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
IStalenessSignalingService service,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
tenantId = "default";
}
var status = await service.GetSignalStatusAsync(tenantId, cancellationToken).ConfigureAwait(false);
// Return different status codes based on health
if (status.IsBreach)
{
return Results.Json(status, statusCode: StatusCodes.Status503ServiceUnavailable);
}
if (status.HasWarning)
{
// Return 200 but with warning headers
return Results.Ok(status);
}
return Results.Ok(status);
}
private static async Task<IResult> GetFallbackStatusAsync(
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
IStalenessSignalingService service,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
tenantId = "default";
}
var config = await service.GetFallbackConfigurationAsync(tenantId, cancellationToken).ConfigureAwait(false);
var isActive = await service.IsFallbackActiveAsync(tenantId, cancellationToken).ConfigureAwait(false);
return Results.Ok(new
{
fallbackActive = isActive,
configuration = config
});
}
private static async Task<IResult> EvaluateStalenessAsync(
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
IStalenessSignalingService service,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
tenantId = "default";
}
await service.EvaluateAndSignalAsync(tenantId, cancellationToken).ConfigureAwait(false);
var status = await service.GetSignalStatusAsync(tenantId, cancellationToken).ConfigureAwait(false);
return Results.Ok(new
{
evaluated = true,
status
});
}
private static async Task<IResult> SignalRecoveryAsync(
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
IStalenessSignalingService service,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
tenantId = "default";
}
await service.SignalRecoveryAsync(tenantId, cancellationToken).ConfigureAwait(false);
return Results.Ok(new
{
recovered = true,
tenantId
});
}
}

View File

@@ -0,0 +1,414 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Policy.Engine.Attestation;
namespace StellaOps.Policy.Engine.Endpoints;
/// <summary>
/// Editor endpoints for verification policy management per CONTRACT-VERIFICATION-POLICY-006.
/// </summary>
public static class VerificationPolicyEditorEndpoints
{
public static IEndpointRouteBuilder MapVerificationPolicyEditor(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/v1/attestor/policies/editor")
.WithTags("Verification Policy Editor");
group.MapGet("/metadata", GetEditorMetadata)
.WithName("Attestor.GetEditorMetadata")
.WithSummary("Get editor metadata for verification policy forms")
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
.Produces<VerificationPolicyEditorMetadata>(StatusCodes.Status200OK);
group.MapPost("/validate", ValidatePolicyAsync)
.WithName("Attestor.ValidatePolicy")
.WithSummary("Validate a verification policy without persisting")
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
.Produces<ValidatePolicyResponse>(StatusCodes.Status200OK);
group.MapGet("/{policyId}", GetPolicyEditorViewAsync)
.WithName("Attestor.GetPolicyEditorView")
.WithSummary("Get a verification policy with editor metadata")
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
.Produces<VerificationPolicyEditorView>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapPost("/clone", ClonePolicyAsync)
.WithName("Attestor.ClonePolicy")
.WithSummary("Clone a verification policy")
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyWrite))
.Produces<VerificationPolicy>(StatusCodes.Status201Created)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound)
.Produces<ProblemHttpResult>(StatusCodes.Status409Conflict);
group.MapPost("/compare", ComparePoliciesAsync)
.WithName("Attestor.ComparePolicies")
.WithSummary("Compare two verification policies")
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
.Produces<ComparePoliciesResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
return routes;
}
private static IResult GetEditorMetadata()
{
var metadata = VerificationPolicyEditorMetadataProvider.GetMetadata();
return Results.Ok(metadata);
}
private static IResult ValidatePolicyAsync(
[FromBody] ValidatePolicyRequest request,
VerificationPolicyValidator validator)
{
if (request == null)
{
return Results.Ok(new ValidatePolicyResponse(
Valid: false,
Errors: [new VerificationPolicyValidationError("ERR_VP_000", "request", "Request body is required.")],
Warnings: [],
Suggestions: []));
}
// Convert to CreateVerificationPolicyRequest for validation
var createRequest = new CreateVerificationPolicyRequest(
PolicyId: request.PolicyId ?? string.Empty,
Version: request.Version ?? "1.0.0",
Description: request.Description,
TenantScope: request.TenantScope,
PredicateTypes: request.PredicateTypes ?? [],
SignerRequirements: request.SignerRequirements,
ValidityWindow: request.ValidityWindow,
Metadata: request.Metadata);
var validation = validator.ValidateCreate(createRequest);
var errors = validation.Errors
.Where(e => e.Severity == ValidationSeverity.Error)
.ToList();
var warnings = validation.Errors
.Where(e => e.Severity == ValidationSeverity.Warning)
.ToList();
var suggestions = VerificationPolicyEditorMetadataProvider.GenerateSuggestions(createRequest, validation);
return Results.Ok(new ValidatePolicyResponse(
Valid: errors.Count == 0,
Errors: errors,
Warnings: warnings,
Suggestions: suggestions));
}
private static async Task<IResult> GetPolicyEditorViewAsync(
[FromRoute] string policyId,
IVerificationPolicyStore store,
VerificationPolicyValidator validator,
CancellationToken cancellationToken)
{
var policy = await store.GetAsync(policyId, cancellationToken).ConfigureAwait(false);
if (policy == null)
{
return Results.NotFound(CreateProblem(
"Policy not found",
$"Policy '{policyId}' was not found.",
"ERR_ATTEST_005"));
}
// Re-validate current policy state
var updateRequest = new UpdateVerificationPolicyRequest(
Version: policy.Version,
Description: policy.Description,
PredicateTypes: policy.PredicateTypes,
SignerRequirements: policy.SignerRequirements,
ValidityWindow: policy.ValidityWindow,
Metadata: policy.Metadata);
var validation = validator.ValidateUpdate(updateRequest);
// Generate suggestions
var createRequest = new CreateVerificationPolicyRequest(
PolicyId: policy.PolicyId,
Version: policy.Version,
Description: policy.Description,
TenantScope: policy.TenantScope,
PredicateTypes: policy.PredicateTypes,
SignerRequirements: policy.SignerRequirements,
ValidityWindow: policy.ValidityWindow,
Metadata: policy.Metadata);
var suggestions = VerificationPolicyEditorMetadataProvider.GenerateSuggestions(createRequest, validation);
// TODO: Check if policy is referenced by attestations
var isReferenced = false;
var view = new VerificationPolicyEditorView(
Policy: policy,
Validation: validation,
Suggestions: suggestions,
CanDelete: !isReferenced,
IsReferenced: isReferenced);
return Results.Ok(view);
}
private static async Task<IResult> ClonePolicyAsync(
[FromBody] ClonePolicyRequest request,
IVerificationPolicyStore store,
VerificationPolicyValidator validator,
TimeProvider timeProvider,
CancellationToken cancellationToken)
{
if (request == null)
{
return Results.BadRequest(CreateProblem(
"Invalid request",
"Request body is required.",
"ERR_ATTEST_001"));
}
if (string.IsNullOrWhiteSpace(request.SourcePolicyId))
{
return Results.BadRequest(CreateProblem(
"Invalid request",
"Source policy ID is required.",
"ERR_ATTEST_006"));
}
if (string.IsNullOrWhiteSpace(request.NewPolicyId))
{
return Results.BadRequest(CreateProblem(
"Invalid request",
"New policy ID is required.",
"ERR_ATTEST_007"));
}
var sourcePolicy = await store.GetAsync(request.SourcePolicyId, cancellationToken).ConfigureAwait(false);
if (sourcePolicy == null)
{
return Results.NotFound(CreateProblem(
"Source policy not found",
$"Policy '{request.SourcePolicyId}' was not found.",
"ERR_ATTEST_005"));
}
if (await store.ExistsAsync(request.NewPolicyId, cancellationToken).ConfigureAwait(false))
{
return Results.Conflict(CreateProblem(
"Policy exists",
$"Policy '{request.NewPolicyId}' already exists.",
"ERR_ATTEST_004"));
}
var now = timeProvider.GetUtcNow();
var clonedPolicy = new VerificationPolicy(
PolicyId: request.NewPolicyId,
Version: request.NewVersion ?? sourcePolicy.Version,
Description: sourcePolicy.Description != null
? $"Cloned from {request.SourcePolicyId}: {sourcePolicy.Description}"
: $"Cloned from {request.SourcePolicyId}",
TenantScope: sourcePolicy.TenantScope,
PredicateTypes: sourcePolicy.PredicateTypes,
SignerRequirements: sourcePolicy.SignerRequirements,
ValidityWindow: sourcePolicy.ValidityWindow,
Metadata: sourcePolicy.Metadata,
CreatedAt: now,
UpdatedAt: now);
await store.CreateAsync(clonedPolicy, cancellationToken).ConfigureAwait(false);
return Results.Created(
$"/api/v1/attestor/policies/{clonedPolicy.PolicyId}",
clonedPolicy);
}
private static async Task<IResult> ComparePoliciesAsync(
[FromBody] ComparePoliciesRequest request,
IVerificationPolicyStore store,
CancellationToken cancellationToken)
{
if (request == null)
{
return Results.BadRequest(CreateProblem(
"Invalid request",
"Request body is required.",
"ERR_ATTEST_001"));
}
if (string.IsNullOrWhiteSpace(request.PolicyIdA))
{
return Results.BadRequest(CreateProblem(
"Invalid request",
"Policy ID A is required.",
"ERR_ATTEST_008"));
}
if (string.IsNullOrWhiteSpace(request.PolicyIdB))
{
return Results.BadRequest(CreateProblem(
"Invalid request",
"Policy ID B is required.",
"ERR_ATTEST_009"));
}
var policyA = await store.GetAsync(request.PolicyIdA, cancellationToken).ConfigureAwait(false);
var policyB = await store.GetAsync(request.PolicyIdB, cancellationToken).ConfigureAwait(false);
if (policyA == null)
{
return Results.NotFound(CreateProblem(
"Policy not found",
$"Policy '{request.PolicyIdA}' was not found.",
"ERR_ATTEST_005"));
}
if (policyB == null)
{
return Results.NotFound(CreateProblem(
"Policy not found",
$"Policy '{request.PolicyIdB}' was not found.",
"ERR_ATTEST_005"));
}
var differences = ComputeDifferences(policyA, policyB);
return Results.Ok(new ComparePoliciesResponse(
PolicyA: policyA,
PolicyB: policyB,
Differences: differences));
}
private static IReadOnlyList<PolicyDifference> ComputeDifferences(VerificationPolicy a, VerificationPolicy b)
{
var differences = new List<PolicyDifference>();
if (a.Version != b.Version)
{
differences.Add(new PolicyDifference("version", a.Version, b.Version, DifferenceType.Modified));
}
if (a.Description != b.Description)
{
differences.Add(new PolicyDifference("description", a.Description, b.Description, DifferenceType.Modified));
}
if (a.TenantScope != b.TenantScope)
{
differences.Add(new PolicyDifference("tenant_scope", a.TenantScope, b.TenantScope, DifferenceType.Modified));
}
// Compare predicate types
var predicateTypesA = a.PredicateTypes.ToHashSet();
var predicateTypesB = b.PredicateTypes.ToHashSet();
foreach (var added in predicateTypesB.Except(predicateTypesA))
{
differences.Add(new PolicyDifference("predicate_types", null, added, DifferenceType.Added));
}
foreach (var removed in predicateTypesA.Except(predicateTypesB))
{
differences.Add(new PolicyDifference("predicate_types", removed, null, DifferenceType.Removed));
}
// Compare signer requirements
if (a.SignerRequirements.MinimumSignatures != b.SignerRequirements.MinimumSignatures)
{
differences.Add(new PolicyDifference(
"signer_requirements.minimum_signatures",
a.SignerRequirements.MinimumSignatures,
b.SignerRequirements.MinimumSignatures,
DifferenceType.Modified));
}
if (a.SignerRequirements.RequireRekor != b.SignerRequirements.RequireRekor)
{
differences.Add(new PolicyDifference(
"signer_requirements.require_rekor",
a.SignerRequirements.RequireRekor,
b.SignerRequirements.RequireRekor,
DifferenceType.Modified));
}
// Compare fingerprints
var fingerprintsA = a.SignerRequirements.TrustedKeyFingerprints.ToHashSet(StringComparer.OrdinalIgnoreCase);
var fingerprintsB = b.SignerRequirements.TrustedKeyFingerprints.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var added in fingerprintsB.Except(fingerprintsA))
{
differences.Add(new PolicyDifference("signer_requirements.trusted_key_fingerprints", null, added, DifferenceType.Added));
}
foreach (var removed in fingerprintsA.Except(fingerprintsB))
{
differences.Add(new PolicyDifference("signer_requirements.trusted_key_fingerprints", removed, null, DifferenceType.Removed));
}
// Compare algorithms
var algorithmsA = (a.SignerRequirements.Algorithms ?? []).ToHashSet(StringComparer.OrdinalIgnoreCase);
var algorithmsB = (b.SignerRequirements.Algorithms ?? []).ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var added in algorithmsB.Except(algorithmsA))
{
differences.Add(new PolicyDifference("signer_requirements.algorithms", null, added, DifferenceType.Added));
}
foreach (var removed in algorithmsA.Except(algorithmsB))
{
differences.Add(new PolicyDifference("signer_requirements.algorithms", removed, null, DifferenceType.Removed));
}
// Compare validity window
var validityA = a.ValidityWindow;
var validityB = b.ValidityWindow;
if (validityA == null && validityB != null)
{
differences.Add(new PolicyDifference("validity_window", null, validityB, DifferenceType.Added));
}
else if (validityA != null && validityB == null)
{
differences.Add(new PolicyDifference("validity_window", validityA, null, DifferenceType.Removed));
}
else if (validityA != null && validityB != null)
{
if (validityA.NotBefore != validityB.NotBefore)
{
differences.Add(new PolicyDifference("validity_window.not_before", validityA.NotBefore, validityB.NotBefore, DifferenceType.Modified));
}
if (validityA.NotAfter != validityB.NotAfter)
{
differences.Add(new PolicyDifference("validity_window.not_after", validityA.NotAfter, validityB.NotAfter, DifferenceType.Modified));
}
if (validityA.MaxAttestationAge != validityB.MaxAttestationAge)
{
differences.Add(new PolicyDifference("validity_window.max_attestation_age", validityA.MaxAttestationAge, validityB.MaxAttestationAge, DifferenceType.Modified));
}
}
return differences;
}
private static ProblemDetails CreateProblem(string title, string detail, string? errorCode = null)
{
var problem = new ProblemDetails
{
Title = title,
Detail = detail,
Status = StatusCodes.Status400BadRequest
};
if (!string.IsNullOrWhiteSpace(errorCode))
{
problem.Extensions["error_code"] = errorCode;
}
return problem;
}
}

View File

@@ -0,0 +1,227 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Policy.Engine.Attestation;
namespace StellaOps.Policy.Engine.Endpoints;
/// <summary>
/// Endpoints for verification policy management per CONTRACT-VERIFICATION-POLICY-006.
/// </summary>
public static class VerificationPolicyEndpoints
{
public static IEndpointRouteBuilder MapVerificationPolicies(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/v1/attestor/policies")
.WithTags("Verification Policies");
group.MapPost("/", CreatePolicyAsync)
.WithName("Attestor.CreatePolicy")
.WithSummary("Create a new verification policy")
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyWrite))
.Produces<VerificationPolicy>(StatusCodes.Status201Created)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
.Produces<ProblemHttpResult>(StatusCodes.Status409Conflict);
group.MapGet("/{policyId}", GetPolicyAsync)
.WithName("Attestor.GetPolicy")
.WithSummary("Get a verification policy by ID")
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
.Produces<VerificationPolicy>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapGet("/", ListPoliciesAsync)
.WithName("Attestor.ListPolicies")
.WithSummary("List verification policies")
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
.Produces<VerificationPolicyListResponse>(StatusCodes.Status200OK);
group.MapPut("/{policyId}", UpdatePolicyAsync)
.WithName("Attestor.UpdatePolicy")
.WithSummary("Update a verification policy")
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyWrite))
.Produces<VerificationPolicy>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapDelete("/{policyId}", DeletePolicyAsync)
.WithName("Attestor.DeletePolicy")
.WithSummary("Delete a verification policy")
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyWrite))
.Produces(StatusCodes.Status204NoContent)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
return routes;
}
private static async Task<IResult> CreatePolicyAsync(
[FromBody] CreateVerificationPolicyRequest request,
IVerificationPolicyStore store,
TimeProvider timeProvider,
CancellationToken cancellationToken)
{
if (request == null)
{
return Results.BadRequest(CreateProblem(
"Invalid request",
"Request body is required.",
"ERR_ATTEST_001"));
}
if (string.IsNullOrWhiteSpace(request.PolicyId))
{
return Results.BadRequest(CreateProblem(
"Invalid request",
"Policy ID is required.",
"ERR_ATTEST_002"));
}
if (request.PredicateTypes == null || request.PredicateTypes.Count == 0)
{
return Results.BadRequest(CreateProblem(
"Invalid request",
"At least one predicate type is required.",
"ERR_ATTEST_003"));
}
if (await store.ExistsAsync(request.PolicyId, cancellationToken).ConfigureAwait(false))
{
return Results.Conflict(CreateProblem(
"Policy exists",
$"Policy '{request.PolicyId}' already exists.",
"ERR_ATTEST_004"));
}
var now = timeProvider.GetUtcNow();
var policy = new VerificationPolicy(
PolicyId: request.PolicyId,
Version: request.Version ?? "1.0.0",
Description: request.Description,
TenantScope: request.TenantScope ?? "*",
PredicateTypes: request.PredicateTypes,
SignerRequirements: request.SignerRequirements ?? SignerRequirements.Default,
ValidityWindow: request.ValidityWindow,
Metadata: request.Metadata,
CreatedAt: now,
UpdatedAt: now);
await store.CreateAsync(policy, cancellationToken).ConfigureAwait(false);
return Results.Created(
$"/api/v1/attestor/policies/{policy.PolicyId}",
policy);
}
private static async Task<IResult> GetPolicyAsync(
[FromRoute] string policyId,
IVerificationPolicyStore store,
CancellationToken cancellationToken)
{
var policy = await store.GetAsync(policyId, cancellationToken).ConfigureAwait(false);
if (policy == null)
{
return Results.NotFound(CreateProblem(
"Policy not found",
$"Policy '{policyId}' was not found.",
"ERR_ATTEST_005"));
}
return Results.Ok(policy);
}
private static async Task<IResult> ListPoliciesAsync(
[FromQuery] string? tenantScope,
IVerificationPolicyStore store,
CancellationToken cancellationToken)
{
var policies = await store.ListAsync(tenantScope, cancellationToken).ConfigureAwait(false);
return Results.Ok(new VerificationPolicyListResponse(
Policies: policies,
Total: policies.Count));
}
private static async Task<IResult> UpdatePolicyAsync(
[FromRoute] string policyId,
[FromBody] UpdateVerificationPolicyRequest request,
IVerificationPolicyStore store,
TimeProvider timeProvider,
CancellationToken cancellationToken)
{
if (request == null)
{
return Results.BadRequest(CreateProblem(
"Invalid request",
"Request body is required.",
"ERR_ATTEST_001"));
}
var now = timeProvider.GetUtcNow();
var updated = await store.UpdateAsync(
policyId,
existing => existing with
{
Version = request.Version ?? existing.Version,
Description = request.Description ?? existing.Description,
PredicateTypes = request.PredicateTypes ?? existing.PredicateTypes,
SignerRequirements = request.SignerRequirements ?? existing.SignerRequirements,
ValidityWindow = request.ValidityWindow ?? existing.ValidityWindow,
Metadata = request.Metadata ?? existing.Metadata,
UpdatedAt = now
},
cancellationToken).ConfigureAwait(false);
if (updated == null)
{
return Results.NotFound(CreateProblem(
"Policy not found",
$"Policy '{policyId}' was not found.",
"ERR_ATTEST_005"));
}
return Results.Ok(updated);
}
private static async Task<IResult> DeletePolicyAsync(
[FromRoute] string policyId,
IVerificationPolicyStore store,
CancellationToken cancellationToken)
{
var deleted = await store.DeleteAsync(policyId, cancellationToken).ConfigureAwait(false);
if (!deleted)
{
return Results.NotFound(CreateProblem(
"Policy not found",
$"Policy '{policyId}' was not found.",
"ERR_ATTEST_005"));
}
return Results.NoContent();
}
private static ProblemDetails CreateProblem(string title, string detail, string? errorCode = null)
{
var problem = new ProblemDetails
{
Title = title,
Detail = detail,
Status = StatusCodes.Status400BadRequest
};
if (!string.IsNullOrWhiteSpace(errorCode))
{
problem.Extensions["error_code"] = errorCode;
}
return problem;
}
}
/// <summary>
/// Response for listing verification policies.
/// </summary>
public sealed record VerificationPolicyListResponse(
[property: System.Text.Json.Serialization.JsonPropertyName("policies")] IReadOnlyList<VerificationPolicy> Policies,
[property: System.Text.Json.Serialization.JsonPropertyName("total")] int Total);

View File

@@ -0,0 +1,300 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Engine.Notifications;
/// <summary>
/// Event types for policy profile notifications per docs/modules/policy/notifications.md.
/// </summary>
public static class PolicyProfileNotificationEventTypes
{
public const string ProfileCreated = "policy.profile.created";
public const string ProfileActivated = "policy.profile.activated";
public const string ProfileDeactivated = "policy.profile.deactivated";
public const string ThresholdChanged = "policy.profile.threshold_changed";
public const string OverrideAdded = "policy.profile.override_added";
public const string OverrideRemoved = "policy.profile.override_removed";
public const string SimulationReady = "policy.profile.simulation_ready";
}
/// <summary>
/// Notification event for policy profile lifecycle changes.
/// Follows the contract at docs/modules/policy/notifications.md.
/// </summary>
public sealed record PolicyProfileNotificationEvent
{
/// <summary>
/// Unique event identifier (UUIDv7 for time-ordered deduplication).
/// </summary>
[JsonPropertyName("event_id")]
public required string EventId { get; init; }
/// <summary>
/// Event type from PolicyProfileNotificationEventTypes.
/// </summary>
[JsonPropertyName("event_type")]
public required string EventType { get; init; }
/// <summary>
/// UTC timestamp when the event was emitted.
/// </summary>
[JsonPropertyName("emitted_at")]
public required DateTimeOffset EmittedAt { get; init; }
/// <summary>
/// Tenant identifier.
/// </summary>
[JsonPropertyName("tenant_id")]
public required string TenantId { get; init; }
/// <summary>
/// Profile identifier.
/// </summary>
[JsonPropertyName("profile_id")]
public required string ProfileId { get; init; }
/// <summary>
/// Profile version affected by this event.
/// </summary>
[JsonPropertyName("profile_version")]
public required string ProfileVersion { get; init; }
/// <summary>
/// Human-readable reason for the change.
/// </summary>
[JsonPropertyName("change_reason")]
public string? ChangeReason { get; init; }
/// <summary>
/// Actor who triggered the event.
/// </summary>
[JsonPropertyName("actor")]
public NotificationActor? Actor { get; init; }
/// <summary>
/// Risk thresholds (populated for threshold_changed events).
/// </summary>
[JsonPropertyName("thresholds")]
public NotificationThresholds? Thresholds { get; init; }
/// <summary>
/// Effective scope for the profile.
/// </summary>
[JsonPropertyName("effective_scope")]
public NotificationEffectiveScope? EffectiveScope { get; init; }
/// <summary>
/// Hash of the profile bundle.
/// </summary>
[JsonPropertyName("hash")]
public NotificationHash? Hash { get; init; }
/// <summary>
/// Related URLs for profile, diff, and simulation.
/// </summary>
[JsonPropertyName("links")]
public NotificationLinks? Links { get; init; }
/// <summary>
/// Trace context for observability.
/// </summary>
[JsonPropertyName("trace")]
public NotificationTraceContext? Trace { get; init; }
/// <summary>
/// Override details (populated for override_added/removed events).
/// </summary>
[JsonPropertyName("override_details")]
public NotificationOverrideDetails? OverrideDetails { get; init; }
/// <summary>
/// Simulation details (populated for simulation_ready events).
/// </summary>
[JsonPropertyName("simulation_details")]
public NotificationSimulationDetails? SimulationDetails { get; init; }
}
/// <summary>
/// Actor information for notifications.
/// </summary>
public sealed record NotificationActor
{
/// <summary>
/// Actor type: "user" or "system".
/// </summary>
[JsonPropertyName("type")]
public required string Type { get; init; }
/// <summary>
/// Actor identifier (email, service name, etc.).
/// </summary>
[JsonPropertyName("id")]
public required string Id { get; init; }
}
/// <summary>
/// Risk thresholds for notifications.
/// </summary>
public sealed record NotificationThresholds
{
[JsonPropertyName("info")]
public double? Info { get; init; }
[JsonPropertyName("low")]
public double? Low { get; init; }
[JsonPropertyName("medium")]
public double? Medium { get; init; }
[JsonPropertyName("high")]
public double? High { get; init; }
[JsonPropertyName("critical")]
public double? Critical { get; init; }
}
/// <summary>
/// Effective scope for profile application.
/// </summary>
public sealed record NotificationEffectiveScope
{
[JsonPropertyName("tenants")]
public IReadOnlyList<string>? Tenants { get; init; }
[JsonPropertyName("projects")]
public IReadOnlyList<string>? Projects { get; init; }
[JsonPropertyName("purl_patterns")]
public IReadOnlyList<string>? PurlPatterns { get; init; }
[JsonPropertyName("cpe_patterns")]
public IReadOnlyList<string>? CpePatterns { get; init; }
[JsonPropertyName("tags")]
public IReadOnlyList<string>? Tags { get; init; }
}
/// <summary>
/// Hash information for profile content.
/// </summary>
public sealed record NotificationHash
{
[JsonPropertyName("algorithm")]
public required string Algorithm { get; init; }
[JsonPropertyName("value")]
public required string Value { get; init; }
}
/// <summary>
/// Related URLs for the notification.
/// </summary>
public sealed record NotificationLinks
{
[JsonPropertyName("profile_url")]
public string? ProfileUrl { get; init; }
[JsonPropertyName("diff_url")]
public string? DiffUrl { get; init; }
[JsonPropertyName("simulation_url")]
public string? SimulationUrl { get; init; }
}
/// <summary>
/// Trace context for distributed tracing.
/// </summary>
public sealed record NotificationTraceContext
{
[JsonPropertyName("trace_id")]
public string? TraceId { get; init; }
[JsonPropertyName("span_id")]
public string? SpanId { get; init; }
}
/// <summary>
/// Override details for override_added/removed events.
/// </summary>
public sealed record NotificationOverrideDetails
{
[JsonPropertyName("override_id")]
public string? OverrideId { get; init; }
[JsonPropertyName("override_type")]
public string? OverrideType { get; init; }
[JsonPropertyName("target")]
public string? Target { get; init; }
[JsonPropertyName("action")]
public string? Action { get; init; }
[JsonPropertyName("justification")]
public string? Justification { get; init; }
}
/// <summary>
/// Simulation details for simulation_ready events.
/// </summary>
public sealed record NotificationSimulationDetails
{
[JsonPropertyName("simulation_id")]
public string? SimulationId { get; init; }
[JsonPropertyName("findings_count")]
public int? FindingsCount { get; init; }
[JsonPropertyName("high_impact_count")]
public int? HighImpactCount { get; init; }
[JsonPropertyName("completed_at")]
public DateTimeOffset? CompletedAt { get; init; }
}
/// <summary>
/// Request to publish a notification via webhook.
/// </summary>
public sealed record WebhookDeliveryRequest
{
/// <summary>
/// Target webhook URL.
/// </summary>
public required string Url { get; init; }
/// <summary>
/// The notification event to deliver.
/// </summary>
public required PolicyProfileNotificationEvent Event { get; init; }
/// <summary>
/// Shared secret for HMAC signature (X-Stella-Signature header).
/// </summary>
public string? SharedSecret { get; init; }
}
/// <summary>
/// Configuration options for policy profile notifications.
/// </summary>
public sealed class PolicyProfileNotificationOptions
{
/// <summary>
/// Topic name for notifications service delivery.
/// Default: notifications.policy.profiles
/// </summary>
public string TopicName { get; set; } = "notifications.policy.profiles";
/// <summary>
/// Base URL for generating profile links.
/// </summary>
public string? BaseUrl { get; set; }
/// <summary>
/// Whether to include trace context in notifications.
/// </summary>
public bool IncludeTraceContext { get; set; } = true;
/// <summary>
/// Whether notifications are enabled.
/// </summary>
public bool Enabled { get; set; } = true;
}

View File

@@ -0,0 +1,396 @@
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Policy.Engine.Notifications;
/// <summary>
/// Interface for publishing policy profile notification events.
/// </summary>
public interface IPolicyProfileNotificationPublisher
{
/// <summary>
/// Publishes a notification event to the configured transport.
/// </summary>
Task PublishAsync(PolicyProfileNotificationEvent notification, CancellationToken cancellationToken = default);
/// <summary>
/// Delivers a notification via webhook with HMAC signature.
/// </summary>
Task<bool> DeliverWebhookAsync(WebhookDeliveryRequest request, CancellationToken cancellationToken = default);
}
/// <summary>
/// Logging-based notification publisher for policy profile events.
/// Logs notifications as structured events for downstream consumption.
/// </summary>
internal sealed class LoggingPolicyProfileNotificationPublisher : IPolicyProfileNotificationPublisher
{
private readonly ILogger<LoggingPolicyProfileNotificationPublisher> _logger;
private readonly PolicyProfileNotificationOptions _options;
private readonly TimeProvider _timeProvider;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
WriteIndented = false
};
public LoggingPolicyProfileNotificationPublisher(
ILogger<LoggingPolicyProfileNotificationPublisher> logger,
IOptions<PolicyProfileNotificationOptions> options,
TimeProvider? timeProvider = null)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = options?.Value ?? new PolicyProfileNotificationOptions();
_timeProvider = timeProvider ?? TimeProvider.System;
}
public Task PublishAsync(PolicyProfileNotificationEvent notification, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(notification);
if (!_options.Enabled)
{
_logger.LogDebug(
"Policy profile notifications disabled; skipping event {EventId} type {EventType}",
notification.EventId,
notification.EventType);
return Task.CompletedTask;
}
var payload = JsonSerializer.Serialize(notification, JsonOptions);
_logger.LogInformation(
"PolicyProfileNotification topic={Topic} event_id={EventId} event_type={EventType} tenant={TenantId} profile={ProfileId}@{ProfileVersion} payload={Payload}",
_options.TopicName,
notification.EventId,
notification.EventType,
notification.TenantId,
notification.ProfileId,
notification.ProfileVersion,
payload);
return Task.CompletedTask;
}
public Task<bool> DeliverWebhookAsync(WebhookDeliveryRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var payload = JsonSerializer.Serialize(request.Event, JsonOptions);
var signature = ComputeHmacSignature(payload, request.SharedSecret);
_logger.LogInformation(
"PolicyProfileWebhook url={Url} event_id={EventId} event_type={EventType} signature={Signature}",
request.Url,
request.Event.EventId,
request.Event.EventType,
signature ?? "(no secret)");
return Task.FromResult(true);
}
private static string? ComputeHmacSignature(string payload, string? sharedSecret)
{
if (string.IsNullOrEmpty(sharedSecret))
{
return null;
}
var keyBytes = Encoding.UTF8.GetBytes(sharedSecret);
var payloadBytes = Encoding.UTF8.GetBytes(payload);
using var hmac = new HMACSHA256(keyBytes);
var hashBytes = hmac.ComputeHash(payloadBytes);
return Convert.ToHexStringLower(hashBytes);
}
}
/// <summary>
/// Factory for creating policy profile notification events.
/// </summary>
public sealed class PolicyProfileNotificationFactory
{
private readonly TimeProvider _timeProvider;
private readonly PolicyProfileNotificationOptions _options;
public PolicyProfileNotificationFactory(
TimeProvider? timeProvider = null,
PolicyProfileNotificationOptions? options = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
_options = options ?? new PolicyProfileNotificationOptions();
}
/// <summary>
/// Creates a profile created notification event.
/// </summary>
public PolicyProfileNotificationEvent CreateProfileCreatedEvent(
string tenantId,
string profileId,
string profileVersion,
string? actorId,
string? hash,
NotificationEffectiveScope? scope = null)
{
return CreateEvent(
PolicyProfileNotificationEventTypes.ProfileCreated,
tenantId,
profileId,
profileVersion,
"New profile draft created",
actorId,
hash,
scope: scope);
}
/// <summary>
/// Creates a profile activated notification event.
/// </summary>
public PolicyProfileNotificationEvent CreateProfileActivatedEvent(
string tenantId,
string profileId,
string profileVersion,
string? actorId,
string? hash,
NotificationEffectiveScope? scope = null)
{
return CreateEvent(
PolicyProfileNotificationEventTypes.ProfileActivated,
tenantId,
profileId,
profileVersion,
"Profile version activated",
actorId,
hash,
scope: scope);
}
/// <summary>
/// Creates a profile deactivated notification event.
/// </summary>
public PolicyProfileNotificationEvent CreateProfileDeactivatedEvent(
string tenantId,
string profileId,
string profileVersion,
string? actorId,
string? reason,
string? hash)
{
return CreateEvent(
PolicyProfileNotificationEventTypes.ProfileDeactivated,
tenantId,
profileId,
profileVersion,
reason ?? "Profile version deactivated",
actorId,
hash);
}
/// <summary>
/// Creates a threshold changed notification event.
/// </summary>
public PolicyProfileNotificationEvent CreateThresholdChangedEvent(
string tenantId,
string profileId,
string profileVersion,
string? actorId,
string? reason,
NotificationThresholds thresholds,
string? hash,
NotificationEffectiveScope? scope = null)
{
return CreateEvent(
PolicyProfileNotificationEventTypes.ThresholdChanged,
tenantId,
profileId,
profileVersion,
reason ?? "Risk thresholds updated",
actorId,
hash,
thresholds: thresholds,
scope: scope);
}
/// <summary>
/// Creates an override added notification event.
/// </summary>
public PolicyProfileNotificationEvent CreateOverrideAddedEvent(
string tenantId,
string profileId,
string profileVersion,
string? actorId,
NotificationOverrideDetails overrideDetails,
string? hash)
{
return CreateEvent(
PolicyProfileNotificationEventTypes.OverrideAdded,
tenantId,
profileId,
profileVersion,
$"Override added: {overrideDetails.OverrideType}",
actorId,
hash,
overrideDetails: overrideDetails);
}
/// <summary>
/// Creates an override removed notification event.
/// </summary>
public PolicyProfileNotificationEvent CreateOverrideRemovedEvent(
string tenantId,
string profileId,
string profileVersion,
string? actorId,
NotificationOverrideDetails overrideDetails,
string? hash)
{
return CreateEvent(
PolicyProfileNotificationEventTypes.OverrideRemoved,
tenantId,
profileId,
profileVersion,
$"Override removed: {overrideDetails.OverrideId}",
actorId,
hash,
overrideDetails: overrideDetails);
}
/// <summary>
/// Creates a simulation ready notification event.
/// </summary>
public PolicyProfileNotificationEvent CreateSimulationReadyEvent(
string tenantId,
string profileId,
string profileVersion,
NotificationSimulationDetails simulationDetails,
string? hash)
{
return CreateEvent(
PolicyProfileNotificationEventTypes.SimulationReady,
tenantId,
profileId,
profileVersion,
"Simulation results available",
actorId: null,
hash,
simulationDetails: simulationDetails);
}
private PolicyProfileNotificationEvent CreateEvent(
string eventType,
string tenantId,
string profileId,
string profileVersion,
string changeReason,
string? actorId,
string? hash,
NotificationThresholds? thresholds = null,
NotificationEffectiveScope? scope = null,
NotificationOverrideDetails? overrideDetails = null,
NotificationSimulationDetails? simulationDetails = null)
{
var eventId = GenerateUuidV7();
var now = _timeProvider.GetUtcNow();
NotificationActor? actor = null;
if (!string.IsNullOrWhiteSpace(actorId))
{
actor = new NotificationActor
{
Type = actorId.Contains('@') ? "user" : "system",
Id = actorId
};
}
NotificationHash? hashInfo = null;
if (!string.IsNullOrWhiteSpace(hash))
{
hashInfo = new NotificationHash
{
Algorithm = "sha256",
Value = hash
};
}
NotificationLinks? links = null;
if (!string.IsNullOrWhiteSpace(_options.BaseUrl))
{
links = new NotificationLinks
{
ProfileUrl = $"{_options.BaseUrl}/api/risk/profiles/{profileId}",
DiffUrl = $"{_options.BaseUrl}/api/risk/profiles/{profileId}/diff",
SimulationUrl = simulationDetails?.SimulationId is not null
? $"{_options.BaseUrl}/api/risk/simulations/results/{simulationDetails.SimulationId}"
: null
};
}
NotificationTraceContext? trace = null;
if (_options.IncludeTraceContext)
{
var activity = Activity.Current;
if (activity is not null)
{
trace = new NotificationTraceContext
{
TraceId = activity.TraceId.ToString(),
SpanId = activity.SpanId.ToString()
};
}
}
return new PolicyProfileNotificationEvent
{
EventId = eventId,
EventType = eventType,
EmittedAt = now,
TenantId = tenantId,
ProfileId = profileId,
ProfileVersion = profileVersion,
ChangeReason = changeReason,
Actor = actor,
Thresholds = thresholds,
EffectiveScope = scope,
Hash = hashInfo,
Links = links,
Trace = trace,
OverrideDetails = overrideDetails,
SimulationDetails = simulationDetails
};
}
/// <summary>
/// Generates a UUIDv7 (time-ordered UUID) for event identification.
/// </summary>
private string GenerateUuidV7()
{
var timestamp = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds();
var randomBytes = new byte[10];
RandomNumberGenerator.Fill(randomBytes);
var bytes = new byte[16];
// First 6 bytes: timestamp (48 bits)
bytes[0] = (byte)((timestamp >> 40) & 0xFF);
bytes[1] = (byte)((timestamp >> 32) & 0xFF);
bytes[2] = (byte)((timestamp >> 24) & 0xFF);
bytes[3] = (byte)((timestamp >> 16) & 0xFF);
bytes[4] = (byte)((timestamp >> 8) & 0xFF);
bytes[5] = (byte)(timestamp & 0xFF);
// Version 7 (4 bits) + random (12 bits)
bytes[6] = (byte)(0x70 | (randomBytes[0] & 0x0F));
bytes[7] = randomBytes[1];
// Variant (2 bits) + random (62 bits)
bytes[8] = (byte)(0x80 | (randomBytes[2] & 0x3F));
Array.Copy(randomBytes, 3, bytes, 9, 7);
return new Guid(bytes).ToString();
}
}

View File

@@ -0,0 +1,467 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Policy.RiskProfile.Lifecycle;
using StellaOps.Policy.RiskProfile.Models;
namespace StellaOps.Policy.Engine.Notifications;
/// <summary>
/// Service for publishing policy profile lifecycle notifications.
/// Integrates with the RiskProfileLifecycleService to emit events.
/// </summary>
public sealed class PolicyProfileNotificationService
{
private readonly IPolicyProfileNotificationPublisher _publisher;
private readonly PolicyProfileNotificationFactory _factory;
private readonly PolicyProfileNotificationOptions _options;
private readonly ILogger<PolicyProfileNotificationService> _logger;
public PolicyProfileNotificationService(
IPolicyProfileNotificationPublisher publisher,
PolicyProfileNotificationFactory factory,
IOptions<PolicyProfileNotificationOptions> options,
ILogger<PolicyProfileNotificationService> logger)
{
_publisher = publisher ?? throw new ArgumentNullException(nameof(publisher));
_factory = factory ?? throw new ArgumentNullException(nameof(factory));
_options = options?.Value ?? new PolicyProfileNotificationOptions();
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Notifies that a new profile version was created.
/// </summary>
public async Task NotifyProfileCreatedAsync(
string tenantId,
RiskProfileModel profile,
string? actorId,
string? hash,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(profile);
if (!_options.Enabled)
{
return;
}
try
{
var scope = ExtractEffectiveScope(profile);
var notification = _factory.CreateProfileCreatedEvent(
tenantId,
profile.Id,
profile.Version,
actorId,
hash,
scope);
await _publisher.PublishAsync(notification, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to publish profile created notification for {ProfileId}@{Version}",
profile.Id, profile.Version);
}
}
/// <summary>
/// Notifies that a profile version was activated.
/// </summary>
public async Task NotifyProfileActivatedAsync(
string tenantId,
RiskProfileModel profile,
string? actorId,
string? hash,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(profile);
if (!_options.Enabled)
{
return;
}
try
{
var scope = ExtractEffectiveScope(profile);
var notification = _factory.CreateProfileActivatedEvent(
tenantId,
profile.Id,
profile.Version,
actorId,
hash,
scope);
await _publisher.PublishAsync(notification, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to publish profile activated notification for {ProfileId}@{Version}",
profile.Id, profile.Version);
}
}
/// <summary>
/// Notifies that a profile version was deactivated (deprecated or archived).
/// </summary>
public async Task NotifyProfileDeactivatedAsync(
string tenantId,
string profileId,
string profileVersion,
string? actorId,
string? reason,
string? hash,
CancellationToken cancellationToken = default)
{
if (!_options.Enabled)
{
return;
}
try
{
var notification = _factory.CreateProfileDeactivatedEvent(
tenantId,
profileId,
profileVersion,
actorId,
reason,
hash);
await _publisher.PublishAsync(notification, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to publish profile deactivated notification for {ProfileId}@{Version}",
profileId, profileVersion);
}
}
/// <summary>
/// Notifies that risk thresholds were changed.
/// </summary>
public async Task NotifyThresholdChangedAsync(
string tenantId,
RiskProfileModel profile,
string? actorId,
string? reason,
string? hash,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(profile);
if (!_options.Enabled)
{
return;
}
try
{
var thresholds = ExtractThresholds(profile);
var scope = ExtractEffectiveScope(profile);
var notification = _factory.CreateThresholdChangedEvent(
tenantId,
profile.Id,
profile.Version,
actorId,
reason,
thresholds,
hash,
scope);
await _publisher.PublishAsync(notification, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to publish threshold changed notification for {ProfileId}@{Version}",
profile.Id, profile.Version);
}
}
/// <summary>
/// Notifies that an override was added to a profile.
/// </summary>
public async Task NotifyOverrideAddedAsync(
string tenantId,
string profileId,
string profileVersion,
string? actorId,
string overrideId,
string overrideType,
string? target,
string? action,
string? justification,
string? hash,
CancellationToken cancellationToken = default)
{
if (!_options.Enabled)
{
return;
}
try
{
var overrideDetails = new NotificationOverrideDetails
{
OverrideId = overrideId,
OverrideType = overrideType,
Target = target,
Action = action,
Justification = justification
};
var notification = _factory.CreateOverrideAddedEvent(
tenantId,
profileId,
profileVersion,
actorId,
overrideDetails,
hash);
await _publisher.PublishAsync(notification, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to publish override added notification for {ProfileId}@{Version}",
profileId, profileVersion);
}
}
/// <summary>
/// Notifies that an override was removed from a profile.
/// </summary>
public async Task NotifyOverrideRemovedAsync(
string tenantId,
string profileId,
string profileVersion,
string? actorId,
string overrideId,
string? hash,
CancellationToken cancellationToken = default)
{
if (!_options.Enabled)
{
return;
}
try
{
var overrideDetails = new NotificationOverrideDetails
{
OverrideId = overrideId
};
var notification = _factory.CreateOverrideRemovedEvent(
tenantId,
profileId,
profileVersion,
actorId,
overrideDetails,
hash);
await _publisher.PublishAsync(notification, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to publish override removed notification for {ProfileId}@{Version}",
profileId, profileVersion);
}
}
/// <summary>
/// Notifies that simulation results are ready for consumption.
/// </summary>
public async Task NotifySimulationReadyAsync(
string tenantId,
string profileId,
string profileVersion,
string simulationId,
int findingsCount,
int highImpactCount,
DateTimeOffset completedAt,
string? hash,
CancellationToken cancellationToken = default)
{
if (!_options.Enabled)
{
return;
}
try
{
var simulationDetails = new NotificationSimulationDetails
{
SimulationId = simulationId,
FindingsCount = findingsCount,
HighImpactCount = highImpactCount,
CompletedAt = completedAt
};
var notification = _factory.CreateSimulationReadyEvent(
tenantId,
profileId,
profileVersion,
simulationDetails,
hash);
await _publisher.PublishAsync(notification, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to publish simulation ready notification for {ProfileId}@{Version}",
profileId, profileVersion);
}
}
/// <summary>
/// Notifies based on a lifecycle event from the RiskProfileLifecycleService.
/// </summary>
public async Task NotifyFromLifecycleEventAsync(
string tenantId,
RiskProfileLifecycleEvent lifecycleEvent,
RiskProfileModel? profile,
string? hash,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(lifecycleEvent);
if (!_options.Enabled)
{
return;
}
switch (lifecycleEvent.EventType)
{
case RiskProfileLifecycleEventType.Created:
if (profile is not null)
{
await NotifyProfileCreatedAsync(tenantId, profile, lifecycleEvent.Actor, hash, cancellationToken)
.ConfigureAwait(false);
}
break;
case RiskProfileLifecycleEventType.Activated:
if (profile is not null)
{
await NotifyProfileActivatedAsync(tenantId, profile, lifecycleEvent.Actor, hash, cancellationToken)
.ConfigureAwait(false);
}
break;
case RiskProfileLifecycleEventType.Deprecated:
case RiskProfileLifecycleEventType.Archived:
await NotifyProfileDeactivatedAsync(
tenantId,
lifecycleEvent.ProfileId,
lifecycleEvent.Version,
lifecycleEvent.Actor,
lifecycleEvent.Reason,
hash,
cancellationToken).ConfigureAwait(false);
break;
case RiskProfileLifecycleEventType.Restored:
// Restored profiles go back to deprecated status; no dedicated notification
_logger.LogDebug("Profile {ProfileId}@{Version} restored; no notification emitted",
lifecycleEvent.ProfileId, lifecycleEvent.Version);
break;
default:
_logger.LogDebug("Unhandled lifecycle event type {EventType} for {ProfileId}@{Version}",
lifecycleEvent.EventType, lifecycleEvent.ProfileId, lifecycleEvent.Version);
break;
}
}
private static NotificationEffectiveScope? ExtractEffectiveScope(RiskProfileModel profile)
{
// Extract scope information from profile metadata if available
var metadata = profile.Metadata;
if (metadata is null || metadata.Count == 0)
{
return null;
}
var scope = new NotificationEffectiveScope();
var hasAny = false;
if (metadata.TryGetValue("tenants", out var tenantsObj) && tenantsObj is IEnumerable<object> tenants)
{
scope = scope with { Tenants = tenants.Select(t => t.ToString()!).ToList() };
hasAny = true;
}
if (metadata.TryGetValue("projects", out var projectsObj) && projectsObj is IEnumerable<object> projects)
{
scope = scope with { Projects = projects.Select(p => p.ToString()!).ToList() };
hasAny = true;
}
if (metadata.TryGetValue("purl_patterns", out var purlObj) && purlObj is IEnumerable<object> purls)
{
scope = scope with { PurlPatterns = purls.Select(p => p.ToString()!).ToList() };
hasAny = true;
}
if (metadata.TryGetValue("cpe_patterns", out var cpeObj) && cpeObj is IEnumerable<object> cpes)
{
scope = scope with { CpePatterns = cpes.Select(c => c.ToString()!).ToList() };
hasAny = true;
}
if (metadata.TryGetValue("tags", out var tagsObj) && tagsObj is IEnumerable<object> tags)
{
scope = scope with { Tags = tags.Select(t => t.ToString()!).ToList() };
hasAny = true;
}
return hasAny ? scope : null;
}
private static NotificationThresholds ExtractThresholds(RiskProfileModel profile)
{
// Extract thresholds from profile overrides
var thresholds = new NotificationThresholds();
// Map severity overrides to threshold values
foreach (var severityOverride in profile.Overrides.Severity)
{
var targetSeverity = severityOverride.Set.ToString().ToLowerInvariant();
var threshold = ExtractThresholdValue(severityOverride.When);
thresholds = targetSeverity switch
{
"info" or "informational" => thresholds with { Info = threshold },
"low" => thresholds with { Low = threshold },
"medium" => thresholds with { Medium = threshold },
"high" => thresholds with { High = threshold },
"critical" => thresholds with { Critical = threshold },
_ => thresholds
};
}
return thresholds;
}
private static double? ExtractThresholdValue(Dictionary<string, object> conditions)
{
// Try to extract a numeric threshold from conditions
if (conditions.TryGetValue("score_gte", out var scoreGte) && scoreGte is double d1)
{
return d1;
}
if (conditions.TryGetValue("score_gt", out var scoreGt) && scoreGt is double d2)
{
return d2;
}
if (conditions.TryGetValue("threshold", out var threshold) && threshold is double d3)
{
return d3;
}
return null;
}
}

View File

@@ -0,0 +1,33 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace StellaOps.Policy.Engine.Notifications;
/// <summary>
/// Extension methods for registering policy profile notification services.
/// </summary>
public static class PolicyProfileNotificationServiceCollectionExtensions
{
/// <summary>
/// Adds policy profile notification services to the service collection.
/// </summary>
public static IServiceCollection AddPolicyProfileNotifications(this IServiceCollection services)
{
services.TryAddSingleton<PolicyProfileNotificationFactory>();
services.TryAddSingleton<IPolicyProfileNotificationPublisher, LoggingPolicyProfileNotificationPublisher>();
services.TryAddSingleton<PolicyProfileNotificationService>();
return services;
}
/// <summary>
/// Adds policy profile notification services with configuration.
/// </summary>
public static IServiceCollection AddPolicyProfileNotifications(
this IServiceCollection services,
Action<PolicyProfileNotificationOptions> configure)
{
services.Configure(configure);
return services.AddPolicyProfileNotifications();
}
}

View File

@@ -15,15 +15,18 @@ using StellaOps.Policy.Engine.BatchEvaluation;
using StellaOps.Policy.Engine.DependencyInjection;
using StellaOps.PolicyDsl;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Engine.Workers;
using StellaOps.Policy.Engine.Streaming;
using StellaOps.Policy.Engine.Telemetry;
using StellaOps.Policy.Engine.ConsoleSurface;
using StellaOps.AirGap.Policy;
using StellaOps.Policy.Engine.Orchestration;
using StellaOps.Policy.Engine.ReachabilityFacts;
using StellaOps.Policy.Engine.Storage.InMemory;
using StellaOps.Policy.Engine.Storage.Mongo.Repositories;
using StellaOps.Policy.Engine.Workers;
using StellaOps.Policy.Engine.Streaming;
using StellaOps.Policy.Engine.Telemetry;
using StellaOps.Policy.Engine.ConsoleSurface;
using StellaOps.AirGap.Policy;
using StellaOps.Policy.Engine.Orchestration;
using StellaOps.Policy.Engine.ReachabilityFacts;
using StellaOps.Policy.Engine.Storage.InMemory;
using StellaOps.Policy.Engine.Storage.Mongo.Repositories;
using StellaOps.Policy.Scoring.Engine;
using StellaOps.Policy.Scoring.Receipts;
using StellaOps.Policy.Storage.Postgres;
var builder = WebApplication.CreateBuilder(args);
@@ -92,9 +95,16 @@ var bootstrap = StellaOpsConfigurationBootstrapper.Build<PolicyEngineOptions>(op
builder.Configuration.AddConfiguration(bootstrap.Configuration);
builder.ConfigurePolicyEngineTelemetry(bootstrap.Options);
builder.Services.AddAirGapEgressPolicy(builder.Configuration, sectionName: "AirGap");
builder.ConfigurePolicyEngineTelemetry(bootstrap.Options);
builder.Services.AddAirGapEgressPolicy(builder.Configuration, sectionName: "AirGap");
// CVSS receipts rely on PostgreSQL storage for deterministic persistence.
builder.Services.AddPolicyPostgresStorage(builder.Configuration, sectionName: "Postgres:Policy");
builder.Services.AddSingleton<ICvssV4Engine, CvssV4Engine>();
builder.Services.AddScoped<IReceiptBuilder, ReceiptBuilder>();
builder.Services.AddScoped<IReceiptHistoryService, ReceiptHistoryService>();
builder.Services.AddOptions<PolicyEngineOptions>()
.Bind(builder.Configuration.GetSection(PolicyEngineOptions.SectionName))
@@ -126,6 +136,13 @@ builder.Services.AddSingleton<IncidentModeService>();
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.Scope.EffectivePolicyService>();
builder.Services.AddSingleton<IEffectivePolicyAuditor, EffectivePolicyAuditor>(); // CONTRACT-AUTHORITY-EFFECTIVE-WRITE-008
builder.Services.AddSingleton<StellaOps.Policy.Engine.Attestation.IVerificationPolicyStore, StellaOps.Policy.Engine.Attestation.InMemoryVerificationPolicyStore>(); // CONTRACT-VERIFICATION-POLICY-006
builder.Services.AddSingleton<StellaOps.Policy.Engine.Attestation.VerificationPolicyValidator>(); // CONTRACT-VERIFICATION-POLICY-006 validation
builder.Services.AddSingleton<StellaOps.Policy.Engine.Attestation.IAttestationReportStore, StellaOps.Policy.Engine.Attestation.InMemoryAttestationReportStore>(); // CONTRACT-VERIFICATION-POLICY-006 reports
builder.Services.AddSingleton<StellaOps.Policy.Engine.Attestation.IAttestationReportService, StellaOps.Policy.Engine.Attestation.AttestationReportService>(); // CONTRACT-VERIFICATION-POLICY-006 reports
builder.Services.AddSingleton<StellaOps.Policy.Engine.ConsoleSurface.ConsoleAttestationReportService>(); // CONTRACT-VERIFICATION-POLICY-006 Console integration
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>();
@@ -166,6 +183,35 @@ 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>();
// Console export jobs per CONTRACT-EXPORT-BUNDLE-009
builder.Services.AddSingleton<StellaOps.Policy.Engine.ConsoleExport.IConsoleExportJobStore, StellaOps.Policy.Engine.ConsoleExport.InMemoryConsoleExportJobStore>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.ConsoleExport.IConsoleExportExecutionStore, StellaOps.Policy.Engine.ConsoleExport.InMemoryConsoleExportExecutionStore>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.ConsoleExport.IConsoleExportBundleStore, StellaOps.Policy.Engine.ConsoleExport.InMemoryConsoleExportBundleStore>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.ConsoleExport.ConsoleExportJobService>();
// Air-gap bundle import per CONTRACT-MIRROR-BUNDLE-003
builder.Services.AddSingleton<StellaOps.Policy.Engine.AirGap.IPolicyPackBundleStore, StellaOps.Policy.Engine.AirGap.InMemoryPolicyPackBundleStore>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.AirGap.PolicyPackBundleImportService>();
// Sealed-mode services per CONTRACT-SEALED-MODE-004
builder.Services.AddSingleton<StellaOps.Policy.Engine.AirGap.ISealedModeStateStore, StellaOps.Policy.Engine.AirGap.InMemorySealedModeStateStore>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.AirGap.ISealedModeService, StellaOps.Policy.Engine.AirGap.SealedModeService>();
// Staleness signaling services per CONTRACT-SEALED-MODE-004
builder.Services.AddSingleton<StellaOps.Policy.Engine.AirGap.IStalenessEventSink, StellaOps.Policy.Engine.AirGap.LoggingStalenessEventSink>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.AirGap.IStalenessSignalingService, StellaOps.Policy.Engine.AirGap.StalenessSignalingService>();
// Air-gap notification services
builder.Services.AddSingleton<StellaOps.Policy.Engine.AirGap.IAirGapNotificationChannel, StellaOps.Policy.Engine.AirGap.LoggingNotificationChannel>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.AirGap.IAirGapNotificationService, StellaOps.Policy.Engine.AirGap.AirGapNotificationService>();
// Air-gap risk profile export/import per CONTRACT-MIRROR-BUNDLE-003
builder.Services.AddSingleton<StellaOps.Policy.Engine.AirGap.RiskProfileAirGapExportService>();
// Also register as IStalenessEventSink to auto-notify on staleness events
builder.Services.AddSingleton<StellaOps.Policy.Engine.AirGap.IStalenessEventSink>(sp =>
(StellaOps.Policy.Engine.AirGap.AirGapNotificationService)sp.GetRequiredService<StellaOps.Policy.Engine.AirGap.IAirGapNotificationService>());
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>();
@@ -278,17 +324,30 @@ app.MapAdvisoryAiKnobs();
app.MapBatchContext();
app.MapOrchestratorJobs();
app.MapPolicyWorker();
app.MapLedgerExport();
app.MapSnapshots();
app.MapViolations();
app.MapPolicyDecisions();
app.MapRiskProfiles();
app.MapRiskProfileSchema();
app.MapScopeAttachments();
app.MapRiskSimulation();
app.MapOverrides();
app.MapProfileExport();
app.MapProfileEvents();
app.MapLedgerExport();
app.MapConsoleExportJobs(); // CONTRACT-EXPORT-BUNDLE-009
app.MapPolicyPackBundles(); // CONTRACT-MIRROR-BUNDLE-003
app.MapSealedMode(); // CONTRACT-SEALED-MODE-004
app.MapStalenessSignaling(); // CONTRACT-SEALED-MODE-004 staleness
app.MapAirGapNotifications(); // Air-gap notifications
app.MapPolicyLint(); // POLICY-AOC-19-001 determinism linting
app.MapVerificationPolicies(); // CONTRACT-VERIFICATION-POLICY-006 attestation policies
app.MapVerificationPolicyEditor(); // CONTRACT-VERIFICATION-POLICY-006 editor DTOs/validation
app.MapAttestationReports(); // CONTRACT-VERIFICATION-POLICY-006 attestation reports
app.MapConsoleAttestationReports(); // CONTRACT-VERIFICATION-POLICY-006 Console integration
app.MapSnapshots();
app.MapViolations();
app.MapPolicyDecisions();
app.MapRiskProfiles();
app.MapRiskProfileSchema();
app.MapScopeAttachments();
app.MapEffectivePolicies(); // CONTRACT-AUTHORITY-EFFECTIVE-WRITE-008
app.MapRiskSimulation();
app.MapOverrides();
app.MapProfileExport();
app.MapRiskProfileAirGap(); // CONTRACT-MIRROR-BUNDLE-003 risk profile air-gap
app.MapProfileEvents();
app.MapCvssReceipts(); // CVSS v4 receipt CRUD & history
// Phase 5: Multi-tenant PostgreSQL-backed API endpoints
app.MapPolicySnapshotsApi();

View File

@@ -117,6 +117,20 @@ public enum RiskScoringJobStatus
/// <summary>
/// Result of scoring a single finding.
/// </summary>
/// <param name="FindingId">Unique identifier for the finding.</param>
/// <param name="ProfileId">Risk profile used for scoring.</param>
/// <param name="ProfileVersion">Version of the risk profile.</param>
/// <param name="RawScore">Raw computed score before normalization.</param>
/// <param name="NormalizedScore">
/// DEPRECATED: Legacy normalized score (0-1 range). Use <see cref="Severity"/> instead.
/// Scheduled for removal in v2.0. See DESIGN-POLICY-NORMALIZED-FIELD-REMOVAL-001.
/// </param>
/// <param name="Severity">Canonical severity (critical/high/medium/low/info).</param>
/// <param name="SignalValues">Input signal values used in scoring.</param>
/// <param name="SignalContributions">Contribution of each signal to final score.</param>
/// <param name="OverrideApplied">Override rule that was applied, if any.</param>
/// <param name="OverrideReason">Reason for the override, if any.</param>
/// <param name="ScoredAt">Timestamp when scoring was performed.</param>
public sealed record RiskScoringResult(
[property: JsonPropertyName("finding_id")] string FindingId,
[property: JsonPropertyName("profile_id")] string ProfileId,

View File

@@ -0,0 +1,168 @@
using Microsoft.Extensions.Logging;
using StellaOps.Policy.RiskProfile.Scope;
namespace StellaOps.Policy.Engine.Services;
/// <summary>
/// Audit log interface for effective:write operations per CONTRACT-AUTHORITY-EFFECTIVE-WRITE-008.
/// </summary>
internal interface IEffectivePolicyAuditor
{
/// <summary>
/// Records an effective policy creation event.
/// </summary>
void RecordCreated(EffectivePolicy policy, string? actorId);
/// <summary>
/// Records an effective policy update event.
/// </summary>
void RecordUpdated(EffectivePolicy policy, string? actorId, object? changes);
/// <summary>
/// Records an effective policy deletion event.
/// </summary>
void RecordDeleted(string effectivePolicyId, string? actorId);
/// <summary>
/// Records a scope attachment event.
/// </summary>
void RecordScopeAttached(AuthorityScopeAttachment attachment, string? actorId);
/// <summary>
/// Records a scope detachment event.
/// </summary>
void RecordScopeDetached(string attachmentId, string? actorId);
}
/// <summary>
/// Default implementation of effective policy auditor.
/// Emits structured logs for all effective:write operations per CONTRACT-AUTHORITY-EFFECTIVE-WRITE-008.
/// </summary>
internal sealed class EffectivePolicyAuditor : IEffectivePolicyAuditor
{
private readonly ILogger<EffectivePolicyAuditor> _logger;
private readonly TimeProvider _timeProvider;
public EffectivePolicyAuditor(
ILogger<EffectivePolicyAuditor> logger,
TimeProvider timeProvider)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public void RecordCreated(EffectivePolicy policy, string? actorId)
{
ArgumentNullException.ThrowIfNull(policy);
var scope = CreateBaseScope("effective_policy.created", actorId);
scope["effective_policy_id"] = policy.EffectivePolicyId;
scope["tenant_id"] = policy.TenantId;
scope["policy_id"] = policy.PolicyId;
scope["subject_pattern"] = policy.SubjectPattern;
scope["priority"] = policy.Priority;
if (policy.Scopes is { Count: > 0 })
{
scope["scopes"] = policy.Scopes;
}
using (_logger.BeginScope(scope))
{
_logger.LogInformation(
"Effective policy created: {EffectivePolicyId} for pattern {SubjectPattern}",
policy.EffectivePolicyId,
policy.SubjectPattern);
}
}
public void RecordUpdated(EffectivePolicy policy, string? actorId, object? changes)
{
ArgumentNullException.ThrowIfNull(policy);
var scope = CreateBaseScope("effective_policy.updated", actorId);
scope["effective_policy_id"] = policy.EffectivePolicyId;
scope["tenant_id"] = policy.TenantId;
if (changes is not null)
{
scope["changes"] = changes;
}
using (_logger.BeginScope(scope))
{
_logger.LogInformation(
"Effective policy updated: {EffectivePolicyId}",
policy.EffectivePolicyId);
}
}
public void RecordDeleted(string effectivePolicyId, string? actorId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(effectivePolicyId);
var scope = CreateBaseScope("effective_policy.deleted", actorId);
scope["effective_policy_id"] = effectivePolicyId;
using (_logger.BeginScope(scope))
{
_logger.LogInformation(
"Effective policy deleted: {EffectivePolicyId}",
effectivePolicyId);
}
}
public void RecordScopeAttached(AuthorityScopeAttachment attachment, string? actorId)
{
ArgumentNullException.ThrowIfNull(attachment);
var scope = CreateBaseScope("scope_attachment.created", actorId);
scope["attachment_id"] = attachment.AttachmentId;
scope["effective_policy_id"] = attachment.EffectivePolicyId;
scope["scope"] = attachment.Scope;
if (attachment.Conditions is { Count: > 0 })
{
scope["conditions"] = attachment.Conditions;
}
using (_logger.BeginScope(scope))
{
_logger.LogInformation(
"Scope attached: {Scope} to policy {EffectivePolicyId}",
attachment.Scope,
attachment.EffectivePolicyId);
}
}
public void RecordScopeDetached(string attachmentId, string? actorId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(attachmentId);
var scope = CreateBaseScope("scope_attachment.deleted", actorId);
scope["attachment_id"] = attachmentId;
using (_logger.BeginScope(scope))
{
_logger.LogInformation(
"Scope detached: {AttachmentId}",
attachmentId);
}
}
private Dictionary<string, object?> CreateBaseScope(string eventType, string? actorId)
{
var scope = new Dictionary<string, object?>
{
["event"] = eventType,
["timestamp"] = _timeProvider.GetUtcNow().ToString("O")
};
if (!string.IsNullOrWhiteSpace(actorId))
{
scope["actor"] = actorId;
}
return scope;
}
}

View File

@@ -0,0 +1,295 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
using StellaOps.Policy.RiskProfile.Models;
namespace StellaOps.Policy.Engine.Simulation;
/// <summary>
/// Detailed breakdown of a risk simulation result.
/// Per POLICY-RISK-67-003.
/// </summary>
public sealed record RiskSimulationBreakdown(
[property: JsonPropertyName("simulation_id")] string SimulationId,
[property: JsonPropertyName("profile_ref")] ProfileReference ProfileRef,
[property: JsonPropertyName("signal_analysis")] SignalAnalysis SignalAnalysis,
[property: JsonPropertyName("override_analysis")] OverrideAnalysis OverrideAnalysis,
[property: JsonPropertyName("score_distribution")] ScoreDistributionAnalysis ScoreDistribution,
[property: JsonPropertyName("severity_breakdown")] SeverityBreakdownAnalysis SeverityBreakdown,
[property: JsonPropertyName("action_breakdown")] ActionBreakdownAnalysis ActionBreakdown,
[property: JsonPropertyName("component_breakdown")] ComponentBreakdownAnalysis? ComponentBreakdown,
[property: JsonPropertyName("risk_trends")] RiskTrendAnalysis? RiskTrends,
[property: JsonPropertyName("determinism_hash")] string DeterminismHash);
/// <summary>
/// Reference to the risk profile used in simulation.
/// </summary>
public sealed record ProfileReference(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("version")] string Version,
[property: JsonPropertyName("hash")] string Hash,
[property: JsonPropertyName("description")] string? Description,
[property: JsonPropertyName("extends")] string? Extends);
/// <summary>
/// Analysis of signal contributions to risk scores.
/// </summary>
public sealed record SignalAnalysis(
[property: JsonPropertyName("total_signals")] int TotalSignals,
[property: JsonPropertyName("signals_used")] int SignalsUsed,
[property: JsonPropertyName("signals_missing")] int SignalsMissing,
[property: JsonPropertyName("signal_coverage")] double SignalCoverage,
[property: JsonPropertyName("signal_stats")] ImmutableArray<SignalStatistics> SignalStats,
[property: JsonPropertyName("top_contributors")] ImmutableArray<SignalContributor> TopContributors,
[property: JsonPropertyName("missing_signal_impact")] MissingSignalImpact MissingSignalImpact);
/// <summary>
/// Statistics for a single signal across all findings.
/// </summary>
public sealed record SignalStatistics(
[property: JsonPropertyName("signal_name")] string SignalName,
[property: JsonPropertyName("signal_type")] string SignalType,
[property: JsonPropertyName("weight")] double Weight,
[property: JsonPropertyName("findings_with_signal")] int FindingsWithSignal,
[property: JsonPropertyName("findings_missing_signal")] int FindingsMissingSignal,
[property: JsonPropertyName("coverage_percentage")] double CoveragePercentage,
[property: JsonPropertyName("value_distribution")] ValueDistribution? ValueDistribution,
[property: JsonPropertyName("total_contribution")] double TotalContribution,
[property: JsonPropertyName("avg_contribution")] double AvgContribution);
/// <summary>
/// Distribution of values for a signal.
/// </summary>
public sealed record ValueDistribution(
[property: JsonPropertyName("min")] double? Min,
[property: JsonPropertyName("max")] double? Max,
[property: JsonPropertyName("mean")] double? Mean,
[property: JsonPropertyName("median")] double? Median,
[property: JsonPropertyName("std_dev")] double? StdDev,
[property: JsonPropertyName("histogram")] ImmutableArray<HistogramBucket>? Histogram);
/// <summary>
/// Histogram bucket for value distribution.
/// </summary>
public sealed record HistogramBucket(
[property: JsonPropertyName("range_min")] double RangeMin,
[property: JsonPropertyName("range_max")] double RangeMax,
[property: JsonPropertyName("count")] int Count,
[property: JsonPropertyName("percentage")] double Percentage);
/// <summary>
/// A signal that significantly contributed to risk scores.
/// </summary>
public sealed record SignalContributor(
[property: JsonPropertyName("signal_name")] string SignalName,
[property: JsonPropertyName("total_contribution")] double TotalContribution,
[property: JsonPropertyName("contribution_percentage")] double ContributionPercentage,
[property: JsonPropertyName("avg_value")] double AvgValue,
[property: JsonPropertyName("weight")] double Weight,
[property: JsonPropertyName("impact_direction")] string ImpactDirection);
/// <summary>
/// Impact of missing signals on scoring.
/// </summary>
public sealed record MissingSignalImpact(
[property: JsonPropertyName("findings_with_missing_signals")] int FindingsWithMissingSignals,
[property: JsonPropertyName("avg_missing_signals_per_finding")] double AvgMissingSignalsPerFinding,
[property: JsonPropertyName("estimated_score_impact")] double EstimatedScoreImpact,
[property: JsonPropertyName("most_impactful_missing")] ImmutableArray<string> MostImpactfulMissing);
/// <summary>
/// Analysis of override applications.
/// </summary>
public sealed record OverrideAnalysis(
[property: JsonPropertyName("total_overrides_evaluated")] int TotalOverridesEvaluated,
[property: JsonPropertyName("severity_overrides_applied")] int SeverityOverridesApplied,
[property: JsonPropertyName("decision_overrides_applied")] int DecisionOverridesApplied,
[property: JsonPropertyName("override_application_rate")] double OverrideApplicationRate,
[property: JsonPropertyName("severity_override_details")] ImmutableArray<SeverityOverrideDetail> SeverityOverrideDetails,
[property: JsonPropertyName("decision_override_details")] ImmutableArray<DecisionOverrideDetail> DecisionOverrideDetails,
[property: JsonPropertyName("override_conflicts")] ImmutableArray<OverrideConflict> OverrideConflicts);
/// <summary>
/// Details of severity override applications.
/// </summary>
public sealed record SeverityOverrideDetail(
[property: JsonPropertyName("predicate_hash")] string PredicateHash,
[property: JsonPropertyName("predicate_summary")] string PredicateSummary,
[property: JsonPropertyName("target_severity")] string TargetSeverity,
[property: JsonPropertyName("applications_count")] int ApplicationsCount,
[property: JsonPropertyName("original_severities")] ImmutableDictionary<string, int> OriginalSeverities);
/// <summary>
/// Details of decision override applications.
/// </summary>
public sealed record DecisionOverrideDetail(
[property: JsonPropertyName("predicate_hash")] string PredicateHash,
[property: JsonPropertyName("predicate_summary")] string PredicateSummary,
[property: JsonPropertyName("target_action")] string TargetAction,
[property: JsonPropertyName("reason")] string? Reason,
[property: JsonPropertyName("applications_count")] int ApplicationsCount,
[property: JsonPropertyName("original_actions")] ImmutableDictionary<string, int> OriginalActions);
/// <summary>
/// Override conflict detected during evaluation.
/// </summary>
public sealed record OverrideConflict(
[property: JsonPropertyName("finding_id")] string FindingId,
[property: JsonPropertyName("conflict_type")] string ConflictType,
[property: JsonPropertyName("override_1")] string Override1,
[property: JsonPropertyName("override_2")] string Override2,
[property: JsonPropertyName("resolution")] string Resolution);
/// <summary>
/// Analysis of score distribution.
/// </summary>
public sealed record ScoreDistributionAnalysis(
[property: JsonPropertyName("raw_score_stats")] ScoreStatistics RawScoreStats,
[property: JsonPropertyName("normalized_score_stats")] ScoreStatistics NormalizedScoreStats,
[property: JsonPropertyName("score_buckets")] ImmutableArray<ScoreBucket> ScoreBuckets,
[property: JsonPropertyName("percentiles")] ImmutableDictionary<string, double> Percentiles,
[property: JsonPropertyName("outliers")] OutlierAnalysis Outliers);
/// <summary>
/// Statistical summary of scores.
/// </summary>
public sealed record ScoreStatistics(
[property: JsonPropertyName("count")] int Count,
[property: JsonPropertyName("min")] double Min,
[property: JsonPropertyName("max")] double Max,
[property: JsonPropertyName("mean")] double Mean,
[property: JsonPropertyName("median")] double Median,
[property: JsonPropertyName("std_dev")] double StdDev,
[property: JsonPropertyName("variance")] double Variance,
[property: JsonPropertyName("skewness")] double Skewness,
[property: JsonPropertyName("kurtosis")] double Kurtosis);
/// <summary>
/// Score bucket for distribution.
/// </summary>
public sealed record ScoreBucket(
[property: JsonPropertyName("range_min")] double RangeMin,
[property: JsonPropertyName("range_max")] double RangeMax,
[property: JsonPropertyName("label")] string Label,
[property: JsonPropertyName("count")] int Count,
[property: JsonPropertyName("percentage")] double Percentage);
/// <summary>
/// Outlier analysis for scores.
/// </summary>
public sealed record OutlierAnalysis(
[property: JsonPropertyName("outlier_count")] int OutlierCount,
[property: JsonPropertyName("outlier_threshold")] double OutlierThreshold,
[property: JsonPropertyName("outlier_finding_ids")] ImmutableArray<string> OutlierFindingIds);
/// <summary>
/// Breakdown by severity level.
/// </summary>
public sealed record SeverityBreakdownAnalysis(
[property: JsonPropertyName("by_severity")] ImmutableDictionary<string, SeverityBucket> BySeverity,
[property: JsonPropertyName("severity_flow")] ImmutableArray<SeverityFlow> SeverityFlow,
[property: JsonPropertyName("severity_concentration")] double SeverityConcentration);
/// <summary>
/// Details for a severity bucket.
/// </summary>
public sealed record SeverityBucket(
[property: JsonPropertyName("severity")] string Severity,
[property: JsonPropertyName("count")] int Count,
[property: JsonPropertyName("percentage")] double Percentage,
[property: JsonPropertyName("avg_score")] double AvgScore,
[property: JsonPropertyName("score_range")] ScoreRange ScoreRange,
[property: JsonPropertyName("top_contributors")] ImmutableArray<string> TopContributors);
/// <summary>
/// Score range for a bucket.
/// </summary>
public sealed record ScoreRange(
[property: JsonPropertyName("min")] double Min,
[property: JsonPropertyName("max")] double Max);
/// <summary>
/// Flow from original to final severity after overrides.
/// </summary>
public sealed record SeverityFlow(
[property: JsonPropertyName("from_severity")] string FromSeverity,
[property: JsonPropertyName("to_severity")] string ToSeverity,
[property: JsonPropertyName("count")] int Count,
[property: JsonPropertyName("is_escalation")] bool IsEscalation);
/// <summary>
/// Breakdown by recommended action.
/// </summary>
public sealed record ActionBreakdownAnalysis(
[property: JsonPropertyName("by_action")] ImmutableDictionary<string, ActionBucket> ByAction,
[property: JsonPropertyName("action_flow")] ImmutableArray<ActionFlow> ActionFlow,
[property: JsonPropertyName("decision_stability")] double DecisionStability);
/// <summary>
/// Details for an action bucket.
/// </summary>
public sealed record ActionBucket(
[property: JsonPropertyName("action")] string Action,
[property: JsonPropertyName("count")] int Count,
[property: JsonPropertyName("percentage")] double Percentage,
[property: JsonPropertyName("avg_score")] double AvgScore,
[property: JsonPropertyName("severity_breakdown")] ImmutableDictionary<string, int> SeverityBreakdown);
/// <summary>
/// Flow from original to final action after overrides.
/// </summary>
public sealed record ActionFlow(
[property: JsonPropertyName("from_action")] string FromAction,
[property: JsonPropertyName("to_action")] string ToAction,
[property: JsonPropertyName("count")] int Count);
/// <summary>
/// Breakdown by component/package.
/// </summary>
public sealed record ComponentBreakdownAnalysis(
[property: JsonPropertyName("total_components")] int TotalComponents,
[property: JsonPropertyName("components_with_findings")] int ComponentsWithFindings,
[property: JsonPropertyName("top_risk_components")] ImmutableArray<ComponentRiskSummary> TopRiskComponents,
[property: JsonPropertyName("ecosystem_breakdown")] ImmutableDictionary<string, EcosystemSummary> EcosystemBreakdown);
/// <summary>
/// Risk summary for a component.
/// </summary>
public sealed record ComponentRiskSummary(
[property: JsonPropertyName("component_purl")] string ComponentPurl,
[property: JsonPropertyName("finding_count")] int FindingCount,
[property: JsonPropertyName("max_score")] double MaxScore,
[property: JsonPropertyName("avg_score")] double AvgScore,
[property: JsonPropertyName("highest_severity")] string HighestSeverity,
[property: JsonPropertyName("recommended_action")] string RecommendedAction);
/// <summary>
/// Summary for a package ecosystem.
/// </summary>
public sealed record EcosystemSummary(
[property: JsonPropertyName("ecosystem")] string Ecosystem,
[property: JsonPropertyName("component_count")] int ComponentCount,
[property: JsonPropertyName("finding_count")] int FindingCount,
[property: JsonPropertyName("avg_score")] double AvgScore,
[property: JsonPropertyName("critical_count")] int CriticalCount,
[property: JsonPropertyName("high_count")] int HighCount);
/// <summary>
/// Risk trend analysis (for comparison simulations).
/// </summary>
public sealed record RiskTrendAnalysis(
[property: JsonPropertyName("comparison_type")] string ComparisonType,
[property: JsonPropertyName("score_trend")] TrendMetric ScoreTrend,
[property: JsonPropertyName("severity_trend")] TrendMetric SeverityTrend,
[property: JsonPropertyName("action_trend")] TrendMetric ActionTrend,
[property: JsonPropertyName("findings_improved")] int FindingsImproved,
[property: JsonPropertyName("findings_worsened")] int FindingsWorsened,
[property: JsonPropertyName("findings_unchanged")] int FindingsUnchanged);
/// <summary>
/// Trend metric for comparison.
/// </summary>
public sealed record TrendMetric(
[property: JsonPropertyName("direction")] string Direction,
[property: JsonPropertyName("magnitude")] double Magnitude,
[property: JsonPropertyName("percentage_change")] double PercentageChange,
[property: JsonPropertyName("is_significant")] bool IsSignificant);

View File

@@ -0,0 +1,897 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Policy.RiskProfile.Models;
namespace StellaOps.Policy.Engine.Simulation;
/// <summary>
/// Service for generating detailed breakdowns of risk simulation results.
/// Per POLICY-RISK-67-003.
/// </summary>
public sealed class RiskSimulationBreakdownService
{
private readonly ILogger<RiskSimulationBreakdownService> _logger;
private static readonly ImmutableArray<string> SeverityOrder = ImmutableArray.Create(
"informational", "low", "medium", "high", "critical");
private static readonly ImmutableArray<string> ActionOrder = ImmutableArray.Create(
"allow", "review", "deny");
public RiskSimulationBreakdownService(ILogger<RiskSimulationBreakdownService> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Generates a detailed breakdown of a risk simulation result.
/// </summary>
public RiskSimulationBreakdown GenerateBreakdown(
RiskSimulationResult result,
RiskProfileModel profile,
IReadOnlyList<SimulationFinding> findings,
RiskSimulationBreakdownOptions? options = null)
{
ArgumentNullException.ThrowIfNull(result);
ArgumentNullException.ThrowIfNull(profile);
ArgumentNullException.ThrowIfNull(findings);
options ??= RiskSimulationBreakdownOptions.Default;
_logger.LogDebug(
"Generating breakdown for simulation {SimulationId} with {FindingCount} findings",
result.SimulationId, findings.Count);
var profileRef = new ProfileReference(
profile.Id,
profile.Version,
result.ProfileHash,
profile.Description,
profile.Extends);
var signalAnalysis = ComputeSignalAnalysis(result, profile, findings, options);
var overrideAnalysis = ComputeOverrideAnalysis(result, profile);
var scoreDistribution = ComputeScoreDistributionAnalysis(result, options);
var severityBreakdown = ComputeSeverityBreakdownAnalysis(result);
var actionBreakdown = ComputeActionBreakdownAnalysis(result);
var componentBreakdown = options.IncludeComponentBreakdown
? ComputeComponentBreakdownAnalysis(result, findings, options)
: null;
var determinismHash = ComputeDeterminismHash(result, profile);
return new RiskSimulationBreakdown(
result.SimulationId,
profileRef,
signalAnalysis,
overrideAnalysis,
scoreDistribution,
severityBreakdown,
actionBreakdown,
componentBreakdown,
RiskTrends: null, // Set by comparison operations
determinismHash);
}
/// <summary>
/// Generates a breakdown with trend analysis comparing two simulations.
/// </summary>
public RiskSimulationBreakdown GenerateComparisonBreakdown(
RiskSimulationResult baselineResult,
RiskSimulationResult compareResult,
RiskProfileModel baselineProfile,
RiskProfileModel compareProfile,
IReadOnlyList<SimulationFinding> findings,
RiskSimulationBreakdownOptions? options = null)
{
var breakdown = GenerateBreakdown(compareResult, compareProfile, findings, options);
var trends = ComputeRiskTrends(baselineResult, compareResult);
return breakdown with { RiskTrends = trends };
}
private SignalAnalysis ComputeSignalAnalysis(
RiskSimulationResult result,
RiskProfileModel profile,
IReadOnlyList<SimulationFinding> findings,
RiskSimulationBreakdownOptions options)
{
var signalStats = new List<SignalStatistics>();
var totalContribution = 0.0;
var signalsUsed = 0;
var findingsWithMissingSignals = 0;
var missingSignalCounts = new Dictionary<string, int>();
foreach (var signal in profile.Signals)
{
var weight = profile.Weights.GetValueOrDefault(signal.Name, 0.0);
var contributions = new List<double>();
var values = new List<double>();
var findingsWithSignal = 0;
var findingsMissing = 0;
foreach (var findingScore in result.FindingScores)
{
var contribution = findingScore.Contributions?
.FirstOrDefault(c => c.SignalName == signal.Name);
if (contribution != null)
{
findingsWithSignal++;
contributions.Add(contribution.Contribution);
if (contribution.SignalValue is double dv)
values.Add(dv);
else if (contribution.SignalValue is JsonElement je && je.TryGetDouble(out var jd))
values.Add(jd);
}
else
{
findingsMissing++;
missingSignalCounts.TryGetValue(signal.Name, out var count);
missingSignalCounts[signal.Name] = count + 1;
}
}
if (findingsWithSignal > 0)
{
signalsUsed++;
}
var signalTotalContribution = contributions.Sum();
totalContribution += signalTotalContribution;
var valueDistribution = values.Count > 0 && options.IncludeHistograms
? ComputeValueDistribution(values, options.HistogramBuckets)
: null;
signalStats.Add(new SignalStatistics(
signal.Name,
signal.Type.ToString().ToLowerInvariant(),
weight,
findingsWithSignal,
findingsMissing,
result.FindingScores.Count > 0
? (double)findingsWithSignal / result.FindingScores.Count * 100
: 0,
valueDistribution,
signalTotalContribution,
findingsWithSignal > 0 ? signalTotalContribution / findingsWithSignal : 0));
}
// Compute top contributors
var topContributors = signalStats
.Where(s => s.TotalContribution > 0)
.OrderByDescending(s => s.TotalContribution)
.Take(options.TopContributorsCount)
.Select(s => new SignalContributor(
s.SignalName,
s.TotalContribution,
totalContribution > 0 ? s.TotalContribution / totalContribution * 100 : 0,
s.ValueDistribution?.Mean ?? 0,
s.Weight,
s.Weight >= 0 ? "increase" : "decrease"))
.ToImmutableArray();
// Missing signal impact analysis
var avgMissingPerFinding = result.FindingScores.Count > 0
? missingSignalCounts.Values.Sum() / (double)result.FindingScores.Count
: 0;
var mostImpactfulMissing = missingSignalCounts
.OrderByDescending(kvp => kvp.Value * profile.Weights.GetValueOrDefault(kvp.Key, 0))
.Take(5)
.Select(kvp => kvp.Key)
.ToImmutableArray();
var missingImpact = new MissingSignalImpact(
findingsWithMissingSignals,
avgMissingPerFinding,
EstimateMissingSignalImpact(missingSignalCounts, profile),
mostImpactfulMissing);
return new SignalAnalysis(
profile.Signals.Count,
signalsUsed,
profile.Signals.Count - signalsUsed,
profile.Signals.Count > 0 ? (double)signalsUsed / profile.Signals.Count * 100 : 0,
signalStats.ToImmutableArray(),
topContributors,
missingImpact);
}
private OverrideAnalysis ComputeOverrideAnalysis(
RiskSimulationResult result,
RiskProfileModel profile)
{
var severityOverrideDetails = new Dictionary<string, SeverityOverrideTracker>();
var decisionOverrideDetails = new Dictionary<string, DecisionOverrideTracker>();
var severityOverrideCount = 0;
var decisionOverrideCount = 0;
var conflicts = new List<OverrideConflict>();
foreach (var score in result.FindingScores)
{
if (score.OverridesApplied == null)
continue;
foreach (var applied in score.OverridesApplied)
{
var predicateHash = ComputePredicateHash(applied.Predicate);
if (applied.OverrideType == "severity")
{
severityOverrideCount++;
if (!severityOverrideDetails.TryGetValue(predicateHash, out var tracker))
{
tracker = new SeverityOverrideTracker(
predicateHash,
SummarizePredicate(applied.Predicate),
applied.AppliedValue?.ToString() ?? "unknown");
severityOverrideDetails[predicateHash] = tracker;
}
tracker.Count++;
var origSev = applied.OriginalValue?.ToString() ?? "unknown";
tracker.OriginalSeverities.TryGetValue(origSev, out var count);
tracker.OriginalSeverities[origSev] = count + 1;
}
else if (applied.OverrideType == "decision")
{
decisionOverrideCount++;
if (!decisionOverrideDetails.TryGetValue(predicateHash, out var tracker))
{
tracker = new DecisionOverrideTracker(
predicateHash,
SummarizePredicate(applied.Predicate),
applied.AppliedValue?.ToString() ?? "unknown",
applied.Reason);
decisionOverrideDetails[predicateHash] = tracker;
}
tracker.Count++;
var origAction = applied.OriginalValue?.ToString() ?? "unknown";
tracker.OriginalActions.TryGetValue(origAction, out var count);
tracker.OriginalActions[origAction] = count + 1;
}
}
// Check for conflicts (multiple overrides of same type)
var severityOverrides = score.OverridesApplied.Where(o => o.OverrideType == "severity").ToList();
if (severityOverrides.Count > 1)
{
conflicts.Add(new OverrideConflict(
score.FindingId,
"severity_conflict",
SummarizePredicate(severityOverrides[0].Predicate),
SummarizePredicate(severityOverrides[1].Predicate),
"first_match"));
}
}
var totalOverridesEvaluated = profile.Overrides.Severity.Count + profile.Overrides.Decisions.Count;
var overrideApplicationRate = result.FindingScores.Count > 0
? (double)(severityOverrideCount + decisionOverrideCount) / result.FindingScores.Count * 100
: 0;
return new OverrideAnalysis(
totalOverridesEvaluated * result.FindingScores.Count,
severityOverrideCount,
decisionOverrideCount,
overrideApplicationRate,
severityOverrideDetails.Values
.Select(t => new SeverityOverrideDetail(
t.Hash, t.Summary, t.TargetSeverity, t.Count,
t.OriginalSeverities.ToImmutableDictionary()))
.ToImmutableArray(),
decisionOverrideDetails.Values
.Select(t => new DecisionOverrideDetail(
t.Hash, t.Summary, t.TargetAction, t.Reason, t.Count,
t.OriginalActions.ToImmutableDictionary()))
.ToImmutableArray(),
conflicts.ToImmutableArray());
}
private ScoreDistributionAnalysis ComputeScoreDistributionAnalysis(
RiskSimulationResult result,
RiskSimulationBreakdownOptions options)
{
var rawScores = result.FindingScores.Select(s => s.RawScore).ToList();
var normalizedScores = result.FindingScores.Select(s => s.NormalizedScore).ToList();
var rawStats = ComputeScoreStatistics(rawScores);
var normalizedStats = ComputeScoreStatistics(normalizedScores);
var buckets = ComputeScoreBuckets(normalizedScores, options.ScoreBucketCount);
var percentiles = ComputePercentiles(normalizedScores);
var outliers = ComputeOutliers(result.FindingScores, normalizedStats);
return new ScoreDistributionAnalysis(
rawStats,
normalizedStats,
buckets,
percentiles.ToImmutableDictionary(),
outliers);
}
private SeverityBreakdownAnalysis ComputeSeverityBreakdownAnalysis(RiskSimulationResult result)
{
var bySeverity = new Dictionary<string, SeverityBucketBuilder>();
var severityFlows = new Dictionary<(string from, string to), int>();
foreach (var score in result.FindingScores)
{
var severity = score.Severity.ToString().ToLowerInvariant();
if (!bySeverity.TryGetValue(severity, out var bucket))
{
bucket = new SeverityBucketBuilder(severity);
bySeverity[severity] = bucket;
}
bucket.Count++;
bucket.Scores.Add(score.NormalizedScore);
// Track top contributors
var topContributor = score.Contributions?
.OrderByDescending(c => c.ContributionPercentage)
.FirstOrDefault();
if (topContributor != null)
{
bucket.TopContributors.TryGetValue(topContributor.SignalName, out var count);
bucket.TopContributors[topContributor.SignalName] = count + 1;
}
// Track severity flows (from score-based to override-based)
var originalSeverity = DetermineSeverityFromScore(score.NormalizedScore).ToString().ToLowerInvariant();
if (originalSeverity != severity)
{
var flowKey = (originalSeverity, severity);
severityFlows.TryGetValue(flowKey, out var flowCount);
severityFlows[flowKey] = flowCount + 1;
}
}
var total = result.FindingScores.Count;
var severityBuckets = bySeverity.Values
.Select(b => new SeverityBucket(
b.Severity,
b.Count,
total > 0 ? (double)b.Count / total * 100 : 0,
b.Scores.Count > 0 ? b.Scores.Average() : 0,
new ScoreRange(
b.Scores.Count > 0 ? b.Scores.Min() : 0,
b.Scores.Count > 0 ? b.Scores.Max() : 0),
b.TopContributors
.OrderByDescending(kvp => kvp.Value)
.Take(3)
.Select(kvp => kvp.Key)
.ToImmutableArray()))
.ToImmutableDictionary(b => b.Severity);
var flows = severityFlows
.Select(kvp => new SeverityFlow(
kvp.Key.from,
kvp.Key.to,
kvp.Value,
SeverityOrder.IndexOf(kvp.Key.to) > SeverityOrder.IndexOf(kvp.Key.from)))
.ToImmutableArray();
// Severity concentration (HHI - higher = more concentrated)
var concentration = bySeverity.Values.Sum(b =>
Math.Pow((double)b.Count / (total > 0 ? total : 1), 2));
return new SeverityBreakdownAnalysis(severityBuckets, flows, concentration);
}
private ActionBreakdownAnalysis ComputeActionBreakdownAnalysis(RiskSimulationResult result)
{
var byAction = new Dictionary<string, ActionBucketBuilder>();
var actionFlows = new Dictionary<(string from, string to), int>();
foreach (var score in result.FindingScores)
{
var action = score.RecommendedAction.ToString().ToLowerInvariant();
var severity = score.Severity.ToString().ToLowerInvariant();
if (!byAction.TryGetValue(action, out var bucket))
{
bucket = new ActionBucketBuilder(action);
byAction[action] = bucket;
}
bucket.Count++;
bucket.Scores.Add(score.NormalizedScore);
bucket.SeverityCounts.TryGetValue(severity, out var sevCount);
bucket.SeverityCounts[severity] = sevCount + 1;
// Track action flows
var originalAction = DetermineActionFromSeverity(score.Severity).ToString().ToLowerInvariant();
if (originalAction != action)
{
var flowKey = (originalAction, action);
actionFlows.TryGetValue(flowKey, out var flowCount);
actionFlows[flowKey] = flowCount + 1;
}
}
var total = result.FindingScores.Count;
var actionBuckets = byAction.Values
.Select(b => new ActionBucket(
b.Action,
b.Count,
total > 0 ? (double)b.Count / total * 100 : 0,
b.Scores.Count > 0 ? b.Scores.Average() : 0,
b.SeverityCounts.ToImmutableDictionary()))
.ToImmutableDictionary(b => b.Action);
var flows = actionFlows
.Select(kvp => new ActionFlow(kvp.Key.from, kvp.Key.to, kvp.Value))
.ToImmutableArray();
// Decision stability (1 - flow rate)
var totalFlows = flows.Sum(f => f.Count);
var stability = total > 0 ? 1.0 - (double)totalFlows / total : 1.0;
return new ActionBreakdownAnalysis(actionBuckets, flows, stability);
}
private ComponentBreakdownAnalysis ComputeComponentBreakdownAnalysis(
RiskSimulationResult result,
IReadOnlyList<SimulationFinding> findings,
RiskSimulationBreakdownOptions options)
{
var componentScores = new Dictionary<string, ComponentScoreTracker>();
var ecosystemStats = new Dictionary<string, EcosystemTracker>();
foreach (var score in result.FindingScores)
{
var finding = findings.FirstOrDefault(f => f.FindingId == score.FindingId);
var purl = finding?.ComponentPurl ?? "unknown";
var ecosystem = ExtractEcosystem(purl);
// Component tracking
if (!componentScores.TryGetValue(purl, out var tracker))
{
tracker = new ComponentScoreTracker(purl);
componentScores[purl] = tracker;
}
tracker.Scores.Add(score.NormalizedScore);
tracker.Severities.Add(score.Severity);
tracker.Actions.Add(score.RecommendedAction);
// Ecosystem tracking
if (!ecosystemStats.TryGetValue(ecosystem, out var ecoTracker))
{
ecoTracker = new EcosystemTracker(ecosystem);
ecosystemStats[ecosystem] = ecoTracker;
}
ecoTracker.Components.Add(purl);
ecoTracker.FindingCount++;
ecoTracker.Scores.Add(score.NormalizedScore);
if (score.Severity == RiskSeverity.Critical) ecoTracker.CriticalCount++;
if (score.Severity == RiskSeverity.High) ecoTracker.HighCount++;
}
var topComponents = componentScores.Values
.OrderByDescending(c => c.Scores.Max())
.ThenByDescending(c => c.Scores.Count)
.Take(options.TopComponentsCount)
.Select(c => new ComponentRiskSummary(
c.Purl,
c.Scores.Count,
c.Scores.Max(),
c.Scores.Average(),
GetHighestSeverity(c.Severities),
GetMostRestrictiveAction(c.Actions)))
.ToImmutableArray();
var ecosystemBreakdown = ecosystemStats.Values
.Select(e => new EcosystemSummary(
e.Ecosystem,
e.Components.Count,
e.FindingCount,
e.Scores.Count > 0 ? e.Scores.Average() : 0,
e.CriticalCount,
e.HighCount))
.ToImmutableDictionary(e => e.Ecosystem);
return new ComponentBreakdownAnalysis(
componentScores.Count,
componentScores.Values.Count(c => c.Scores.Count > 0),
topComponents,
ecosystemBreakdown);
}
private RiskTrendAnalysis ComputeRiskTrends(
RiskSimulationResult baseline,
RiskSimulationResult compare)
{
var baselineScores = baseline.FindingScores.ToDictionary(s => s.FindingId);
var compareScores = compare.FindingScores.ToDictionary(s => s.FindingId);
var improved = 0;
var worsened = 0;
var unchanged = 0;
var scoreDeltaSum = 0.0;
var severityEscalations = 0;
var severityDeescalations = 0;
var actionChanges = 0;
foreach (var (findingId, baseScore) in baselineScores)
{
if (!compareScores.TryGetValue(findingId, out var compScore))
continue;
var scoreDelta = compScore.NormalizedScore - baseScore.NormalizedScore;
scoreDeltaSum += scoreDelta;
if (Math.Abs(scoreDelta) < 1.0)
unchanged++;
else if (scoreDelta < 0)
improved++;
else
worsened++;
var baseSevIdx = SeverityOrder.IndexOf(baseScore.Severity.ToString().ToLowerInvariant());
var compSevIdx = SeverityOrder.IndexOf(compScore.Severity.ToString().ToLowerInvariant());
if (compSevIdx > baseSevIdx) severityEscalations++;
else if (compSevIdx < baseSevIdx) severityDeescalations++;
if (baseScore.RecommendedAction != compScore.RecommendedAction)
actionChanges++;
}
var baselineAvg = baseline.AggregateMetrics.MeanScore;
var compareAvg = compare.AggregateMetrics.MeanScore;
var scorePercentChange = baselineAvg > 0
? (compareAvg - baselineAvg) / baselineAvg * 100
: 0;
var scoreTrend = new TrendMetric(
scorePercentChange < -1 ? "improving" : scorePercentChange > 1 ? "worsening" : "stable",
Math.Abs(compareAvg - baselineAvg),
scorePercentChange,
Math.Abs(scorePercentChange) > 5);
var severityTrend = new TrendMetric(
severityDeescalations > severityEscalations ? "improving" :
severityEscalations > severityDeescalations ? "worsening" : "stable",
Math.Abs(severityEscalations - severityDeescalations),
baselineScores.Count > 0
? (double)(severityEscalations - severityDeescalations) / baselineScores.Count * 100
: 0,
Math.Abs(severityEscalations - severityDeescalations) > baselineScores.Count * 0.05);
var actionTrend = new TrendMetric(
"changed",
actionChanges,
baselineScores.Count > 0 ? (double)actionChanges / baselineScores.Count * 100 : 0,
actionChanges > baselineScores.Count * 0.1);
return new RiskTrendAnalysis(
"profile_comparison",
scoreTrend,
severityTrend,
actionTrend,
improved,
worsened,
unchanged);
}
private static ValueDistribution ComputeValueDistribution(List<double> values, int bucketCount)
{
if (values.Count == 0)
return new ValueDistribution(null, null, null, null, null, null);
var sorted = values.OrderBy(v => v).ToList();
var min = sorted.First();
var max = sorted.Last();
var mean = values.Average();
var median = sorted.Count % 2 == 0
? (sorted[sorted.Count / 2 - 1] + sorted[sorted.Count / 2]) / 2
: sorted[sorted.Count / 2];
var variance = values.Average(v => Math.Pow(v - mean, 2));
var stdDev = Math.Sqrt(variance);
var histogram = new List<HistogramBucket>();
if (max > min)
{
var bucketSize = (max - min) / bucketCount;
for (var i = 0; i < bucketCount; i++)
{
var rangeMin = min + i * bucketSize;
var rangeMax = min + (i + 1) * bucketSize;
var count = values.Count(v => v >= rangeMin && (i == bucketCount - 1 ? v <= rangeMax : v < rangeMax));
histogram.Add(new HistogramBucket(rangeMin, rangeMax, count, (double)count / values.Count * 100));
}
}
return new ValueDistribution(min, max, mean, median, stdDev, histogram.ToImmutableArray());
}
private static ScoreStatistics ComputeScoreStatistics(List<double> scores)
{
if (scores.Count == 0)
return new ScoreStatistics(0, 0, 0, 0, 0, 0, 0, 0, 0);
var sorted = scores.OrderBy(s => s).ToList();
var mean = scores.Average();
var median = sorted.Count % 2 == 0
? (sorted[sorted.Count / 2 - 1] + sorted[sorted.Count / 2]) / 2
: sorted[sorted.Count / 2];
var variance = scores.Average(s => Math.Pow(s - mean, 2));
var stdDev = Math.Sqrt(variance);
// Skewness and kurtosis
var skewness = stdDev > 0
? scores.Average(s => Math.Pow((s - mean) / stdDev, 3))
: 0;
var kurtosis = stdDev > 0
? scores.Average(s => Math.Pow((s - mean) / stdDev, 4)) - 3
: 0;
return new ScoreStatistics(
scores.Count,
sorted.First(),
sorted.Last(),
Math.Round(mean, 2),
Math.Round(median, 2),
Math.Round(stdDev, 2),
Math.Round(variance, 2),
Math.Round(skewness, 3),
Math.Round(kurtosis, 3));
}
private static ImmutableArray<ScoreBucket> ComputeScoreBuckets(List<double> scores, int bucketCount)
{
var buckets = new List<ScoreBucket>();
var bucketSize = 100.0 / bucketCount;
for (var i = 0; i < bucketCount; i++)
{
var rangeMin = i * bucketSize;
var rangeMax = (i + 1) * bucketSize;
var count = scores.Count(s => s >= rangeMin && s < rangeMax);
var label = i switch
{
0 => "Very Low",
1 => "Low",
2 => "Low-Medium",
3 => "Medium",
4 => "Medium",
5 => "Medium-High",
6 => "High",
7 => "High",
8 => "Very High",
9 => "Critical",
_ => $"Bucket {i + 1}"
};
buckets.Add(new ScoreBucket(
rangeMin, rangeMax, label, count,
scores.Count > 0 ? (double)count / scores.Count * 100 : 0));
}
return buckets.ToImmutableArray();
}
private static Dictionary<string, double> ComputePercentiles(List<double> scores)
{
var percentiles = new Dictionary<string, double>();
if (scores.Count == 0)
return percentiles;
var sorted = scores.OrderBy(s => s).ToList();
var levels = new[] { 0.25, 0.50, 0.75, 0.90, 0.95, 0.99 };
foreach (var level in levels)
{
var index = (int)(level * (sorted.Count - 1));
percentiles[$"p{(int)(level * 100)}"] = sorted[index];
}
return percentiles;
}
private static OutlierAnalysis ComputeOutliers(
IReadOnlyList<FindingScore> scores,
ScoreStatistics stats)
{
if (scores.Count == 0)
return new OutlierAnalysis(0, 0, ImmutableArray<string>.Empty);
// Use IQR method for outlier detection
var sorted = scores.OrderBy(s => s.NormalizedScore).ToList();
var q1Idx = sorted.Count / 4;
var q3Idx = sorted.Count * 3 / 4;
var q1 = sorted[q1Idx].NormalizedScore;
var q3 = sorted[q3Idx].NormalizedScore;
var iqr = q3 - q1;
var threshold = q3 + 1.5 * iqr;
var outliers = scores
.Where(s => s.NormalizedScore > threshold)
.Select(s => s.FindingId)
.ToImmutableArray();
return new OutlierAnalysis(outliers.Length, threshold, outliers);
}
private static double EstimateMissingSignalImpact(
Dictionary<string, int> missingCounts,
RiskProfileModel profile)
{
var impact = 0.0;
foreach (var (signal, count) in missingCounts)
{
var weight = profile.Weights.GetValueOrDefault(signal, 0.0);
// Estimate impact as weight * average value (0.5) * missing count
impact += Math.Abs(weight) * 0.5 * count;
}
return impact;
}
private static RiskSeverity DetermineSeverityFromScore(double score)
{
return score switch
{
>= 90 => RiskSeverity.Critical,
>= 70 => RiskSeverity.High,
>= 40 => RiskSeverity.Medium,
>= 10 => RiskSeverity.Low,
_ => RiskSeverity.Informational
};
}
private static RiskAction DetermineActionFromSeverity(RiskSeverity severity)
{
return severity switch
{
RiskSeverity.Critical => RiskAction.Deny,
RiskSeverity.High => RiskAction.Deny,
RiskSeverity.Medium => RiskAction.Review,
_ => RiskAction.Allow
};
}
private static string ExtractEcosystem(string purl)
{
if (string.IsNullOrWhiteSpace(purl) || !purl.StartsWith("pkg:"))
return "unknown";
var colonIdx = purl.IndexOf(':', 4);
if (colonIdx < 0)
colonIdx = purl.IndexOf('/');
if (colonIdx < 0)
return "unknown";
return purl[4..colonIdx];
}
private static string GetHighestSeverity(List<RiskSeverity> severities)
{
if (severities.Count == 0) return "unknown";
return severities.Max().ToString().ToLowerInvariant();
}
private static string GetMostRestrictiveAction(List<RiskAction> actions)
{
if (actions.Count == 0) return "unknown";
return actions.Max().ToString().ToLowerInvariant();
}
private static string ComputePredicateHash(Dictionary<string, object> predicate)
{
var json = JsonSerializer.Serialize(predicate, new JsonSerializerOptions
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(json));
return Convert.ToHexString(bytes)[..8].ToLowerInvariant();
}
private static string SummarizePredicate(Dictionary<string, object> predicate)
{
var parts = predicate.Select(kvp => $"{kvp.Key}={kvp.Value}");
return string.Join(", ", parts);
}
private static string ComputeDeterminismHash(RiskSimulationResult result, RiskProfileModel profile)
{
var input = $"{result.SimulationId}:{result.ProfileHash}:{result.FindingScores.Count}";
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return $"sha256:{Convert.ToHexString(bytes)[..16].ToLowerInvariant()}";
}
// Helper classes for tracking state during computation
private sealed class SeverityOverrideTracker(string hash, string summary, string targetSeverity)
{
public string Hash { get; } = hash;
public string Summary { get; } = summary;
public string TargetSeverity { get; } = targetSeverity;
public int Count { get; set; }
public Dictionary<string, int> OriginalSeverities { get; } = new();
}
private sealed class DecisionOverrideTracker(string hash, string summary, string targetAction, string? reason)
{
public string Hash { get; } = hash;
public string Summary { get; } = summary;
public string TargetAction { get; } = targetAction;
public string? Reason { get; } = reason;
public int Count { get; set; }
public Dictionary<string, int> OriginalActions { get; } = new();
}
private sealed class SeverityBucketBuilder(string severity)
{
public string Severity { get; } = severity;
public int Count { get; set; }
public List<double> Scores { get; } = new();
public Dictionary<string, int> TopContributors { get; } = new();
}
private sealed class ActionBucketBuilder(string action)
{
public string Action { get; } = action;
public int Count { get; set; }
public List<double> Scores { get; } = new();
public Dictionary<string, int> SeverityCounts { get; } = new();
}
private sealed class ComponentScoreTracker(string purl)
{
public string Purl { get; } = purl;
public List<double> Scores { get; } = new();
public List<RiskSeverity> Severities { get; } = new();
public List<RiskAction> Actions { get; } = new();
}
private sealed class EcosystemTracker(string ecosystem)
{
public string Ecosystem { get; } = ecosystem;
public HashSet<string> Components { get; } = new();
public int FindingCount { get; set; }
public List<double> Scores { get; } = new();
public int CriticalCount { get; set; }
public int HighCount { get; set; }
}
}
/// <summary>
/// Options for risk simulation breakdown generation.
/// </summary>
public sealed record RiskSimulationBreakdownOptions
{
/// <summary>Whether to include component breakdown analysis.</summary>
public bool IncludeComponentBreakdown { get; init; } = true;
/// <summary>Whether to include value histograms for signals.</summary>
public bool IncludeHistograms { get; init; } = true;
/// <summary>Number of histogram buckets.</summary>
public int HistogramBuckets { get; init; } = 10;
/// <summary>Number of score buckets for distribution.</summary>
public int ScoreBucketCount { get; init; } = 10;
/// <summary>Number of top signal contributors to include.</summary>
public int TopContributorsCount { get; init; } = 10;
/// <summary>Number of top components to include.</summary>
public int TopComponentsCount { get; init; } = 20;
/// <summary>Default options.</summary>
public static RiskSimulationBreakdownOptions Default { get; } = new();
/// <summary>Minimal options for quick analysis.</summary>
public static RiskSimulationBreakdownOptions Quick { get; } = new()
{
IncludeComponentBreakdown = false,
IncludeHistograms = false,
TopContributorsCount = 5,
TopComponentsCount = 10
};
}

View File

@@ -12,6 +12,7 @@ namespace StellaOps.Policy.Engine.Simulation;
/// <summary>
/// Service for running risk simulations with score distributions and contribution breakdowns.
/// Enhanced with detailed breakdown analytics per POLICY-RISK-67-003.
/// </summary>
public sealed class RiskSimulationService
{
@@ -20,6 +21,7 @@ public sealed class RiskSimulationService
private readonly RiskProfileConfigurationService _profileService;
private readonly RiskProfileHasher _hasher;
private readonly ICryptoHash _cryptoHash;
private readonly RiskSimulationBreakdownService? _breakdownService;
private static readonly double[] PercentileLevels = { 0.25, 0.50, 0.75, 0.90, 0.95, 0.99 };
private const int TopMoverCount = 10;
@@ -29,13 +31,15 @@ public sealed class RiskSimulationService
ILogger<RiskSimulationService> logger,
TimeProvider timeProvider,
RiskProfileConfigurationService profileService,
ICryptoHash cryptoHash)
ICryptoHash cryptoHash,
RiskSimulationBreakdownService? breakdownService = null)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_profileService = profileService ?? throw new ArgumentNullException(nameof(profileService));
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
_hasher = new RiskProfileHasher(cryptoHash);
_breakdownService = breakdownService;
}
/// <summary>
@@ -461,4 +465,183 @@ public sealed class RiskSimulationService
var hash = _cryptoHash.ComputeHashHexForPurpose(Encoding.UTF8.GetBytes(seed), HashPurpose.Content);
return $"rsim-{hash[..16]}";
}
/// <summary>
/// Runs a risk simulation with detailed breakdown analytics.
/// Per POLICY-RISK-67-003.
/// </summary>
public RiskSimulationWithBreakdown SimulateWithBreakdown(
RiskSimulationRequest request,
RiskSimulationBreakdownOptions? breakdownOptions = null)
{
ArgumentNullException.ThrowIfNull(request);
if (_breakdownService == null)
{
throw new InvalidOperationException(
"Breakdown service not available. Register RiskSimulationBreakdownService in DI.");
}
using var activity = PolicyEngineTelemetry.ActivitySource.StartActivity("risk_simulation.run_with_breakdown");
activity?.SetTag("profile.id", request.ProfileId);
activity?.SetTag("finding.count", request.Findings.Count);
var sw = Stopwatch.StartNew();
// Run simulation with contributions enabled for breakdown
var simulationRequest = request with { IncludeContributions = true };
var result = Simulate(simulationRequest);
var profile = _profileService.GetProfile(request.ProfileId);
if (profile == null)
{
throw new InvalidOperationException($"Risk profile '{request.ProfileId}' not found.");
}
// Generate breakdown
var breakdown = _breakdownService.GenerateBreakdown(
result,
profile,
request.Findings,
breakdownOptions);
sw.Stop();
_logger.LogInformation(
"Risk simulation with breakdown {SimulationId} completed in {ElapsedMs}ms",
result.SimulationId, sw.Elapsed.TotalMilliseconds);
PolicyEngineTelemetry.RiskSimulationsRun.Add(1);
return new RiskSimulationWithBreakdown(result, breakdown, sw.Elapsed.TotalMilliseconds);
}
/// <summary>
/// Runs a comparison simulation between two profiles with trend analysis.
/// Per POLICY-RISK-67-003.
/// </summary>
public RiskProfileComparisonResult CompareProfilesWithBreakdown(
string baseProfileId,
string compareProfileId,
IReadOnlyList<SimulationFinding> findings,
RiskSimulationBreakdownOptions? breakdownOptions = null)
{
ArgumentNullException.ThrowIfNullOrWhiteSpace(baseProfileId);
ArgumentNullException.ThrowIfNullOrWhiteSpace(compareProfileId);
ArgumentNullException.ThrowIfNull(findings);
if (_breakdownService == null)
{
throw new InvalidOperationException(
"Breakdown service not available. Register RiskSimulationBreakdownService in DI.");
}
using var activity = PolicyEngineTelemetry.ActivitySource.StartActivity("risk_simulation.compare_profiles");
activity?.SetTag("profile.base", baseProfileId);
activity?.SetTag("profile.compare", compareProfileId);
activity?.SetTag("finding.count", findings.Count);
var sw = Stopwatch.StartNew();
// Run baseline simulation
var baselineRequest = new RiskSimulationRequest(
ProfileId: baseProfileId,
ProfileVersion: null,
Findings: findings,
IncludeContributions: true,
IncludeDistribution: true,
Mode: SimulationMode.Full);
var baselineResult = Simulate(baselineRequest);
// Run comparison simulation
var compareRequest = new RiskSimulationRequest(
ProfileId: compareProfileId,
ProfileVersion: null,
Findings: findings,
IncludeContributions: true,
IncludeDistribution: true,
Mode: SimulationMode.Full);
var compareResult = Simulate(compareRequest);
// Get profiles
var baseProfile = _profileService.GetProfile(baseProfileId)
?? throw new InvalidOperationException($"Profile '{baseProfileId}' not found.");
var compareProfile = _profileService.GetProfile(compareProfileId)
?? throw new InvalidOperationException($"Profile '{compareProfileId}' not found.");
// Generate breakdown with trends
var breakdown = _breakdownService.GenerateComparisonBreakdown(
baselineResult,
compareResult,
baseProfile,
compareProfile,
findings,
breakdownOptions);
sw.Stop();
_logger.LogInformation(
"Profile comparison completed between {BaseProfile} and {CompareProfile} in {ElapsedMs}ms",
baseProfileId, compareProfileId, sw.Elapsed.TotalMilliseconds);
return new RiskProfileComparisonResult(
BaselineResult: baselineResult,
CompareResult: compareResult,
Breakdown: breakdown,
ExecutionTimeMs: sw.Elapsed.TotalMilliseconds);
}
/// <summary>
/// Generates a standalone breakdown for an existing simulation result.
/// </summary>
public RiskSimulationBreakdown GenerateBreakdown(
RiskSimulationResult result,
IReadOnlyList<SimulationFinding> findings,
RiskSimulationBreakdownOptions? options = null)
{
ArgumentNullException.ThrowIfNull(result);
ArgumentNullException.ThrowIfNull(findings);
if (_breakdownService == null)
{
throw new InvalidOperationException(
"Breakdown service not available. Register RiskSimulationBreakdownService in DI.");
}
var profile = _profileService.GetProfile(result.ProfileId)
?? throw new InvalidOperationException($"Profile '{result.ProfileId}' not found.");
return _breakdownService.GenerateBreakdown(result, profile, findings, options);
}
}
/// <summary>
/// Risk simulation result with detailed breakdown.
/// Per POLICY-RISK-67-003.
/// </summary>
public sealed record RiskSimulationWithBreakdown(
/// <summary>The simulation result.</summary>
RiskSimulationResult Result,
/// <summary>Detailed breakdown analytics.</summary>
RiskSimulationBreakdown Breakdown,
/// <summary>Total execution time including breakdown generation.</summary>
double TotalExecutionTimeMs);
/// <summary>
/// Result of comparing two risk profiles.
/// Per POLICY-RISK-67-003.
/// </summary>
public sealed record RiskProfileComparisonResult(
/// <summary>Baseline simulation result.</summary>
RiskSimulationResult BaselineResult,
/// <summary>Comparison simulation result.</summary>
RiskSimulationResult CompareResult,
/// <summary>Breakdown with trend analysis.</summary>
RiskSimulationBreakdown Breakdown,
/// <summary>Total execution time.</summary>
double ExecutionTimeMs);

View File

@@ -1,44 +1,44 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
<PackageReference Include="StackExchange.Redis" Version="2.8.37" />
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
<ProjectReference Include="../StellaOps.PolicyDsl/StellaOps.PolicyDsl.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
<ProjectReference Include="../../AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy.csproj" />
<ProjectReference Include="../../Telemetry/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core.csproj" />
<ProjectReference Include="../../Attestor/StellaOps.Attestor.Envelope/StellaOps.Attestor.Envelope.csproj" />
<ProjectReference Include="../StellaOps.Policy.RiskProfile/StellaOps.Policy.RiskProfile.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Policy.Storage.Postgres/StellaOps.Policy.Storage.Postgres.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="StellaOps.Policy.Engine.Tests" />
</ItemGroup>
</Project>
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
<PackageReference Include="StackExchange.Redis" Version="2.8.37" />
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
<ProjectReference Include="../StellaOps.PolicyDsl/StellaOps.PolicyDsl.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
<ProjectReference Include="../../AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy.csproj" />
<ProjectReference Include="../../Telemetry/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core.csproj" />
<ProjectReference Include="../../Attestor/StellaOps.Attestor.Envelope/StellaOps.Attestor.Envelope.csproj" />
<ProjectReference Include="../StellaOps.Policy.RiskProfile/StellaOps.Policy.RiskProfile.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Policy.Storage.Postgres/StellaOps.Policy.Storage.Postgres.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="StellaOps.Policy.Engine.Tests" />
</ItemGroup>
</Project>

View File

@@ -585,6 +585,72 @@ public static class PolicyEngineTelemetry
#endregion
#region AirGap/Staleness Metrics
// Counter: policy_airgap_staleness_events_total{tenant,event_type}
private static readonly Counter<long> StalenessEventsCounter =
Meter.CreateCounter<long>(
"policy_airgap_staleness_events_total",
unit: "events",
description: "Total staleness events by type (warning, breach, recovered, anchor_missing).");
// Gauge: policy_airgap_sealed
private static readonly ObservableGauge<int> AirGapSealedGauge =
Meter.CreateObservableGauge<int>(
"policy_airgap_sealed",
observeValues: () => AirGapSealedObservations ?? Enumerable.Empty<Measurement<int>>(),
unit: "boolean",
description: "1 if sealed, 0 if unsealed.");
// Gauge: policy_airgap_anchor_age_seconds
private static readonly ObservableGauge<int> AnchorAgeGauge =
Meter.CreateObservableGauge<int>(
"policy_airgap_anchor_age_seconds",
observeValues: () => AnchorAgeObservations ?? Enumerable.Empty<Measurement<int>>(),
unit: "s",
description: "Current age of the time anchor in seconds.");
private static IEnumerable<Measurement<int>> AirGapSealedObservations = Enumerable.Empty<Measurement<int>>();
private static IEnumerable<Measurement<int>> AnchorAgeObservations = Enumerable.Empty<Measurement<int>>();
/// <summary>
/// Records a staleness event.
/// </summary>
/// <param name="tenant">Tenant identifier.</param>
/// <param name="eventType">Event type (warning, breach, recovered, anchor_missing).</param>
public static void RecordStalenessEvent(string tenant, string eventType)
{
var tags = new TagList
{
{ "tenant", NormalizeTenant(tenant) },
{ "event_type", NormalizeTag(eventType) },
};
StalenessEventsCounter.Add(1, tags);
}
/// <summary>
/// Registers a callback to observe air-gap sealed state.
/// </summary>
/// <param name="observeFunc">Function that returns current sealed state measurements.</param>
public static void RegisterAirGapSealedObservation(Func<IEnumerable<Measurement<int>>> observeFunc)
{
ArgumentNullException.ThrowIfNull(observeFunc);
AirGapSealedObservations = observeFunc();
}
/// <summary>
/// Registers a callback to observe time anchor age.
/// </summary>
/// <param name="observeFunc">Function that returns current anchor age measurements.</param>
public static void RegisterAnchorAgeObservation(Func<IEnumerable<Measurement<int>>> observeFunc)
{
ArgumentNullException.ThrowIfNull(observeFunc);
AnchorAgeObservations = observeFunc();
}
#endregion
// Storage for observable gauge observations
private static IEnumerable<Measurement<int>> QueueDepthObservations = Enumerable.Empty<Measurement<int>>();
private static IEnumerable<Measurement<int>> ConcurrentEvaluationsObservations = Enumerable.Empty<Measurement<int>>();

View File

@@ -0,0 +1,251 @@
using System.Security.Claims;
using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Options;
namespace StellaOps.Policy.Engine.Tenancy;
/// <summary>
/// Middleware that extracts tenant context from request headers and validates tenant access.
/// Per RLS design at docs/modules/policy/prep/tenant-rls.md.
/// </summary>
public sealed partial class TenantContextMiddleware
{
private readonly RequestDelegate _next;
private readonly TenantContextOptions _options;
private readonly ILogger<TenantContextMiddleware> _logger;
// Valid tenant/project ID pattern: alphanumeric, dashes, underscores
[GeneratedRegex("^[a-zA-Z0-9_-]+$", RegexOptions.Compiled)]
private static partial Regex ValidIdPattern();
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
WriteIndented = false
};
public TenantContextMiddleware(
RequestDelegate next,
IOptions<TenantContextOptions> options,
ILogger<TenantContextMiddleware> logger)
{
_next = next ?? throw new ArgumentNullException(nameof(next));
_options = options?.Value ?? new TenantContextOptions();
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task InvokeAsync(HttpContext context, ITenantContextAccessor tenantContextAccessor)
{
// Skip tenant validation for excluded paths
if (!_options.Enabled || IsExcludedPath(context.Request.Path))
{
await _next(context);
return;
}
var validationResult = ValidateTenantContext(context);
if (!validationResult.IsValid)
{
await WriteTenantErrorResponse(context, validationResult);
return;
}
// Set tenant context for the request
tenantContextAccessor.TenantContext = validationResult.Context;
using (_logger.BeginScope(new Dictionary<string, object?>
{
["tenant_id"] = validationResult.Context?.TenantId,
["project_id"] = validationResult.Context?.ProjectId
}))
{
await _next(context);
}
}
private bool IsExcludedPath(PathString path)
{
var pathValue = path.Value ?? string.Empty;
return _options.ExcludedPaths.Any(excluded =>
pathValue.StartsWith(excluded, StringComparison.OrdinalIgnoreCase));
}
private TenantValidationResult ValidateTenantContext(HttpContext context)
{
// Extract tenant header
var tenantHeader = context.Request.Headers[TenantContextConstants.TenantHeader].FirstOrDefault();
if (string.IsNullOrWhiteSpace(tenantHeader))
{
if (_options.RequireTenantHeader)
{
_logger.LogWarning(
"Missing required {Header} header for {Path}",
TenantContextConstants.TenantHeader,
context.Request.Path);
return TenantValidationResult.Failure(
TenantContextConstants.MissingTenantHeaderErrorCode,
$"The {TenantContextConstants.TenantHeader} header is required.");
}
// Use default tenant ID when header is not required
tenantHeader = TenantContextConstants.DefaultTenantId;
}
// Validate tenant ID format
if (!IsValidTenantId(tenantHeader))
{
_logger.LogWarning(
"Invalid tenant ID format: {TenantId}",
tenantHeader);
return TenantValidationResult.Failure(
TenantContextConstants.InvalidTenantIdErrorCode,
"Invalid tenant ID format. Must be alphanumeric with dashes and underscores.");
}
// Extract project header (optional)
var projectHeader = context.Request.Headers[TenantContextConstants.ProjectHeader].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(projectHeader) && !IsValidProjectId(projectHeader))
{
_logger.LogWarning(
"Invalid project ID format: {ProjectId}",
projectHeader);
return TenantValidationResult.Failure(
TenantContextConstants.InvalidTenantIdErrorCode,
"Invalid project ID format. Must be alphanumeric with dashes and underscores.");
}
// Determine write permission from scopes/claims
var canWrite = DetermineWritePermission(context);
// Extract actor ID
var actorId = ExtractActorId(context);
var tenantContext = TenantContext.ForTenant(
tenantHeader,
string.IsNullOrWhiteSpace(projectHeader) ? null : projectHeader,
canWrite,
actorId);
_logger.LogDebug(
"Tenant context established: tenant={TenantId}, project={ProjectId}, canWrite={CanWrite}, actor={ActorId}",
tenantContext.TenantId,
tenantContext.ProjectId ?? "(none)",
tenantContext.CanWrite,
tenantContext.ActorId ?? "(anonymous)");
return TenantValidationResult.Success(tenantContext);
}
private bool IsValidTenantId(string tenantId)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return false;
}
if (tenantId.Length > _options.MaxTenantIdLength)
{
return false;
}
return ValidIdPattern().IsMatch(tenantId);
}
private bool IsValidProjectId(string projectId)
{
if (string.IsNullOrWhiteSpace(projectId))
{
return true; // Project ID is optional
}
if (projectId.Length > _options.MaxProjectIdLength)
{
return false;
}
return ValidIdPattern().IsMatch(projectId);
}
private static bool DetermineWritePermission(HttpContext context)
{
var user = context.User;
if (user?.Identity?.IsAuthenticated != true)
{
return false;
}
// Check for write-related scopes
var hasWriteScope = user.Claims.Any(c =>
c.Type == "scope" &&
(c.Value.Contains("policy:write", StringComparison.OrdinalIgnoreCase) ||
c.Value.Contains("policy:edit", StringComparison.OrdinalIgnoreCase) ||
c.Value.Contains("policy:activate", StringComparison.OrdinalIgnoreCase)));
if (hasWriteScope)
{
return true;
}
// Check for admin role
var hasAdminRole = user.IsInRole("admin") ||
user.IsInRole("policy-admin") ||
user.HasClaim("role", "admin") ||
user.HasClaim("role", "policy-admin");
return hasAdminRole;
}
private static string? ExtractActorId(HttpContext context)
{
var user = context.User;
// Try standard claims
var actorId = user?.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? user?.FindFirst(ClaimTypes.Upn)?.Value
?? user?.FindFirst("sub")?.Value
?? user?.FindFirst("client_id")?.Value;
if (!string.IsNullOrWhiteSpace(actorId))
{
return actorId;
}
// Fall back to header
if (context.Request.Headers.TryGetValue("X-StellaOps-Actor", out var header) &&
!string.IsNullOrWhiteSpace(header))
{
return header.ToString();
}
return null;
}
private static async Task WriteTenantErrorResponse(HttpContext context, TenantValidationResult result)
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
context.Response.ContentType = "application/json";
var errorResponse = new TenantErrorResponse(
result.ErrorCode ?? "UNKNOWN_ERROR",
result.ErrorMessage ?? "An unknown error occurred.",
context.Request.Path.Value ?? "/");
await context.Response.WriteAsync(
JsonSerializer.Serialize(errorResponse, JsonOptions));
}
}
/// <summary>
/// Error response for tenant validation failures.
/// </summary>
internal sealed record TenantErrorResponse(
string ErrorCode,
string Message,
string Path);

View File

@@ -0,0 +1,233 @@
namespace StellaOps.Policy.Engine.Tenancy;
/// <summary>
/// Constants for tenant context headers and GUCs (PostgreSQL Grand Unified Configuration).
/// Per RLS design at docs/modules/policy/prep/tenant-rls.md.
/// </summary>
public static class TenantContextConstants
{
/// <summary>
/// HTTP header for tenant ID (mandatory).
/// </summary>
public const string TenantHeader = "X-Stella-Tenant";
/// <summary>
/// HTTP header for project ID (optional).
/// </summary>
public const string ProjectHeader = "X-Stella-Project";
/// <summary>
/// PostgreSQL GUC for tenant ID.
/// </summary>
public const string TenantGuc = "app.tenant_id";
/// <summary>
/// PostgreSQL GUC for project ID.
/// </summary>
public const string ProjectGuc = "app.project_id";
/// <summary>
/// PostgreSQL GUC for write permission.
/// </summary>
public const string CanWriteGuc = "app.can_write";
/// <summary>
/// Default tenant ID for legacy data migration.
/// </summary>
public const string DefaultTenantId = "public";
/// <summary>
/// Error code for missing tenant header (deterministic).
/// </summary>
public const string MissingTenantHeaderErrorCode = "POLICY_TENANT_HEADER_REQUIRED";
/// <summary>
/// Error code for invalid tenant ID format.
/// </summary>
public const string InvalidTenantIdErrorCode = "POLICY_TENANT_ID_INVALID";
/// <summary>
/// Error code for tenant access denied (403).
/// </summary>
public const string TenantAccessDeniedErrorCode = "POLICY_TENANT_ACCESS_DENIED";
}
/// <summary>
/// Represents the current tenant and project context for a request.
/// </summary>
public sealed record TenantContext
{
/// <summary>
/// The tenant ID for the current request.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// The project ID for the current request (optional; null for tenant-wide operations).
/// </summary>
public string? ProjectId { get; init; }
/// <summary>
/// Whether the current request has write permission.
/// </summary>
public bool CanWrite { get; init; }
/// <summary>
/// The actor ID (user or system) making the request.
/// </summary>
public string? ActorId { get; init; }
/// <summary>
/// Timestamp when the context was created.
/// </summary>
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
/// <summary>
/// Creates a tenant context for a specific tenant.
/// </summary>
public static TenantContext ForTenant(string tenantId, string? projectId = null, bool canWrite = false, string? actorId = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
return new TenantContext
{
TenantId = tenantId,
ProjectId = projectId,
CanWrite = canWrite,
ActorId = actorId,
CreatedAt = DateTimeOffset.UtcNow
};
}
}
/// <summary>
/// Options for tenant context middleware configuration.
/// </summary>
public sealed class TenantContextOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "PolicyEngine:Tenancy";
/// <summary>
/// Whether tenant validation is enabled (default: true).
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Whether to require tenant header on all endpoints (default: true).
/// When false, missing tenant header defaults to <see cref="TenantContextConstants.DefaultTenantId"/>.
/// </summary>
public bool RequireTenantHeader { get; set; } = true;
/// <summary>
/// Paths to exclude from tenant validation (e.g., health checks).
/// </summary>
public List<string> ExcludedPaths { get; set; } = new()
{
"/healthz",
"/readyz",
"/.well-known"
};
/// <summary>
/// Maximum length for tenant ID (default: 256).
/// </summary>
public int MaxTenantIdLength { get; set; } = 256;
/// <summary>
/// Maximum length for project ID (default: 256).
/// </summary>
public int MaxProjectIdLength { get; set; } = 256;
/// <summary>
/// Whether to allow multi-tenant queries (default: false).
/// When true, users with appropriate scopes can query across tenants.
/// </summary>
public bool AllowMultiTenantQueries { get; set; } = false;
}
/// <summary>
/// Interface for accessing the current tenant context.
/// </summary>
public interface ITenantContextAccessor
{
/// <summary>
/// Gets or sets the current tenant context.
/// </summary>
TenantContext? TenantContext { get; set; }
}
/// <summary>
/// Default implementation of <see cref="ITenantContextAccessor"/> using AsyncLocal.
/// </summary>
public sealed class TenantContextAccessor : ITenantContextAccessor
{
private static readonly AsyncLocal<TenantContextHolder> _tenantContextCurrent = new();
/// <inheritdoc />
public TenantContext? TenantContext
{
get => _tenantContextCurrent.Value?.Context;
set
{
var holder = _tenantContextCurrent.Value;
if (holder is not null)
{
// Clear current context trapped in the AsyncLocals, as its done.
holder.Context = null;
}
if (value is not null)
{
// Use an object to hold the context in the AsyncLocal,
// so it can be cleared in all ExecutionContexts when its cleared.
_tenantContextCurrent.Value = new TenantContextHolder { Context = value };
}
}
}
private sealed class TenantContextHolder
{
public TenantContext? Context;
}
}
/// <summary>
/// Result of tenant context validation.
/// </summary>
public sealed record TenantValidationResult
{
/// <summary>
/// Whether the validation succeeded.
/// </summary>
public bool IsValid { get; init; }
/// <summary>
/// Error code if validation failed.
/// </summary>
public string? ErrorCode { get; init; }
/// <summary>
/// Error message if validation failed.
/// </summary>
public string? ErrorMessage { get; init; }
/// <summary>
/// The validated tenant context if successful.
/// </summary>
public TenantContext? Context { get; init; }
/// <summary>
/// Creates a successful validation result.
/// </summary>
public static TenantValidationResult Success(TenantContext context) =>
new() { IsValid = true, Context = context };
/// <summary>
/// Creates a failed validation result.
/// </summary>
public static TenantValidationResult Failure(string errorCode, string errorMessage) =>
new() { IsValid = false, ErrorCode = errorCode, ErrorMessage = errorMessage };
}

View File

@@ -0,0 +1,109 @@
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace StellaOps.Policy.Engine.Tenancy;
/// <summary>
/// Extension methods for registering tenant context services.
/// </summary>
public static class TenantContextServiceCollectionExtensions
{
/// <summary>
/// Adds tenant context services to the service collection.
/// </summary>
public static IServiceCollection AddTenantContext(this IServiceCollection services)
{
services.TryAddSingleton<ITenantContextAccessor, TenantContextAccessor>();
return services;
}
/// <summary>
/// Adds tenant context services with configuration.
/// </summary>
public static IServiceCollection AddTenantContext(
this IServiceCollection services,
Action<TenantContextOptions> configure)
{
services.Configure(configure);
return services.AddTenantContext();
}
/// <summary>
/// Adds tenant context services with configuration from configuration section.
/// </summary>
public static IServiceCollection AddTenantContext(
this IServiceCollection services,
IConfiguration configuration,
string sectionName = TenantContextOptions.SectionName)
{
services.Configure<TenantContextOptions>(configuration.GetSection(sectionName));
return services.AddTenantContext();
}
}
/// <summary>
/// Extension methods for configuring tenant context middleware.
/// </summary>
public static class TenantContextApplicationBuilderExtensions
{
/// <summary>
/// Adds the tenant context middleware to the application pipeline.
/// This middleware extracts tenant/project headers and validates tenant access.
/// </summary>
public static IApplicationBuilder UseTenantContext(this IApplicationBuilder app)
{
return app.UseMiddleware<TenantContextMiddleware>();
}
}
/// <summary>
/// Extension methods for endpoint routing to apply tenant requirements.
/// </summary>
public static class TenantContextEndpointExtensions
{
/// <summary>
/// Requires tenant context for the endpoint group.
/// </summary>
public static RouteGroupBuilder RequireTenantContext(this RouteGroupBuilder group)
{
group.AddEndpointFilter<TenantContextEndpointFilter>();
return group;
}
/// <summary>
/// Adds a tenant context requirement filter to a route handler.
/// </summary>
public static RouteHandlerBuilder RequireTenantContext(this RouteHandlerBuilder builder)
{
builder.AddEndpointFilter<TenantContextEndpointFilter>();
return builder;
}
}
/// <summary>
/// Endpoint filter that validates tenant context is present.
/// </summary>
internal sealed class TenantContextEndpointFilter : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var tenantAccessor = context.HttpContext.RequestServices
.GetService<ITenantContextAccessor>();
if (tenantAccessor?.TenantContext is null)
{
return Results.Problem(
title: "Tenant context required",
detail: $"The {TenantContextConstants.TenantHeader} header is required for this endpoint.",
statusCode: StatusCodes.Status400BadRequest,
extensions: new Dictionary<string, object?>
{
["error_code"] = TenantContextConstants.MissingTenantHeaderErrorCode
});
}
return await next(context);
}
}

View File

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

View File

@@ -1,15 +1,27 @@
using StellaOps.Policy.Gateway.Contracts;
using StellaOps.Policy.Gateway.Infrastructure;
namespace StellaOps.Policy.Gateway.Clients;
internal interface IPolicyEngineClient
{
using StellaOps.Policy.Gateway.Contracts;
using StellaOps.Policy.Gateway.Infrastructure;
using StellaOps.Policy.Scoring;
using StellaOps.Policy.Scoring.Receipts;
namespace StellaOps.Policy.Gateway.Clients;
internal interface IPolicyEngineClient
{
Task<PolicyEngineResponse<IReadOnlyList<PolicyPackSummaryDto>>> ListPolicyPacksAsync(GatewayForwardingContext? forwardingContext, CancellationToken cancellationToken);
Task<PolicyEngineResponse<PolicyPackDto>> CreatePolicyPackAsync(GatewayForwardingContext? forwardingContext, CreatePolicyPackRequest request, CancellationToken cancellationToken);
Task<PolicyEngineResponse<PolicyRevisionDto>> CreatePolicyRevisionAsync(GatewayForwardingContext? forwardingContext, string packId, CreatePolicyRevisionRequest request, CancellationToken cancellationToken);
Task<PolicyEngineResponse<PolicyRevisionActivationDto>> ActivatePolicyRevisionAsync(GatewayForwardingContext? forwardingContext, string packId, int version, ActivatePolicyRevisionRequest request, CancellationToken cancellationToken);
}
Task<PolicyEngineResponse<PolicyPackDto>> CreatePolicyPackAsync(GatewayForwardingContext? forwardingContext, CreatePolicyPackRequest request, CancellationToken cancellationToken);
Task<PolicyEngineResponse<PolicyRevisionDto>> CreatePolicyRevisionAsync(GatewayForwardingContext? forwardingContext, string packId, CreatePolicyRevisionRequest request, CancellationToken cancellationToken);
Task<PolicyEngineResponse<PolicyRevisionActivationDto>> ActivatePolicyRevisionAsync(GatewayForwardingContext? forwardingContext, string packId, int version, ActivatePolicyRevisionRequest request, CancellationToken cancellationToken);
Task<PolicyEngineResponse<CvssScoreReceipt>> CreateCvssReceiptAsync(GatewayForwardingContext? forwardingContext, CreateCvssReceiptRequest request, CancellationToken cancellationToken);
Task<PolicyEngineResponse<CvssScoreReceipt>> GetCvssReceiptAsync(GatewayForwardingContext? forwardingContext, string receiptId, CancellationToken cancellationToken);
Task<PolicyEngineResponse<CvssScoreReceipt>> AmendCvssReceiptAsync(GatewayForwardingContext? forwardingContext, string receiptId, AmendCvssReceiptRequest request, CancellationToken cancellationToken);
Task<PolicyEngineResponse<IReadOnlyList<ReceiptHistoryEntry>>> GetCvssReceiptHistoryAsync(GatewayForwardingContext? forwardingContext, string receiptId, CancellationToken cancellationToken);
Task<PolicyEngineResponse<IReadOnlyList<CvssPolicy>>> ListCvssPoliciesAsync(GatewayForwardingContext? forwardingContext, CancellationToken cancellationToken);
}

View File

@@ -5,13 +5,15 @@ using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Gateway.Contracts;
using StellaOps.Policy.Gateway.Infrastructure;
using StellaOps.Policy.Gateway.Options;
using StellaOps.Policy.Gateway.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Gateway.Contracts;
using StellaOps.Policy.Gateway.Infrastructure;
using StellaOps.Policy.Gateway.Options;
using StellaOps.Policy.Gateway.Services;
using StellaOps.Policy.Scoring;
using StellaOps.Policy.Scoring.Receipts;
namespace StellaOps.Policy.Gateway.Clients;
@@ -85,18 +87,73 @@ internal sealed class PolicyEngineClient : IPolicyEngineClient
request,
cancellationToken);
public Task<PolicyEngineResponse<PolicyRevisionActivationDto>> ActivatePolicyRevisionAsync(
GatewayForwardingContext? forwardingContext,
string packId,
int version,
ActivatePolicyRevisionRequest request,
CancellationToken cancellationToken)
=> SendAsync<PolicyRevisionActivationDto>(
HttpMethod.Post,
$"api/policy/packs/{Uri.EscapeDataString(packId)}/revisions/{version}:activate",
forwardingContext,
request,
cancellationToken);
public Task<PolicyEngineResponse<PolicyRevisionActivationDto>> ActivatePolicyRevisionAsync(
GatewayForwardingContext? forwardingContext,
string packId,
int version,
ActivatePolicyRevisionRequest request,
CancellationToken cancellationToken)
=> SendAsync<PolicyRevisionActivationDto>(
HttpMethod.Post,
$"api/policy/packs/{Uri.EscapeDataString(packId)}/revisions/{version}:activate",
forwardingContext,
request,
cancellationToken);
public Task<PolicyEngineResponse<CvssScoreReceipt>> CreateCvssReceiptAsync(
GatewayForwardingContext? forwardingContext,
CreateCvssReceiptRequest request,
CancellationToken cancellationToken)
=> SendAsync<CvssScoreReceipt>(
HttpMethod.Post,
"api/cvss/receipts",
forwardingContext,
request,
cancellationToken);
public Task<PolicyEngineResponse<CvssScoreReceipt>> GetCvssReceiptAsync(
GatewayForwardingContext? forwardingContext,
string receiptId,
CancellationToken cancellationToken)
=> SendAsync<CvssScoreReceipt>(
HttpMethod.Get,
$"api/cvss/receipts/{Uri.EscapeDataString(receiptId)}",
forwardingContext,
content: null,
cancellationToken);
public Task<PolicyEngineResponse<CvssScoreReceipt>> AmendCvssReceiptAsync(
GatewayForwardingContext? forwardingContext,
string receiptId,
AmendCvssReceiptRequest request,
CancellationToken cancellationToken)
=> SendAsync<CvssScoreReceipt>(
HttpMethod.Put,
$"api/cvss/receipts/{Uri.EscapeDataString(receiptId)}/amend",
forwardingContext,
request,
cancellationToken);
public Task<PolicyEngineResponse<IReadOnlyList<ReceiptHistoryEntry>>> GetCvssReceiptHistoryAsync(
GatewayForwardingContext? forwardingContext,
string receiptId,
CancellationToken cancellationToken)
=> SendAsync<IReadOnlyList<ReceiptHistoryEntry>>(
HttpMethod.Get,
$"api/cvss/receipts/{Uri.EscapeDataString(receiptId)}/history",
forwardingContext,
content: null,
cancellationToken);
public Task<PolicyEngineResponse<IReadOnlyList<CvssPolicy>>> ListCvssPoliciesAsync(
GatewayForwardingContext? forwardingContext,
CancellationToken cancellationToken)
=> SendAsync<IReadOnlyList<CvssPolicy>>(
HttpMethod.Get,
"api/cvss/policies",
forwardingContext,
content: null,
cancellationToken);
private async Task<PolicyEngineResponse<TSuccess>> SendAsync<TSuccess>(
HttpMethod method,

View File

@@ -0,0 +1,33 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using StellaOps.Attestor.Envelope;
using StellaOps.Policy.Scoring;
using StellaOps.Policy.Scoring.Receipts;
namespace StellaOps.Policy.Gateway.Contracts;
public sealed record CreateCvssReceiptRequest(
[Required] string VulnerabilityId,
[Required] CvssPolicy Policy,
[Required] CvssBaseMetrics BaseMetrics,
CvssThreatMetrics? ThreatMetrics,
CvssEnvironmentalMetrics? EnvironmentalMetrics,
CvssSupplementalMetrics? SupplementalMetrics,
IReadOnlyList<CvssEvidenceItem>? Evidence,
EnvelopeKey? SigningKey,
string? CreatedBy,
DateTimeOffset? CreatedAt);
public sealed record AmendCvssReceiptRequest(
[Required] string Field,
string? PreviousValue,
string? NewValue,
[Required] string Reason,
string? ReferenceUri,
EnvelopeKey? SigningKey,
string? Actor);
public sealed record CvssReceiptHistoryResponse(
string ReceiptId,
IReadOnlyList<ReceiptHistoryEntry> History);

View File

@@ -279,11 +279,11 @@ policyPacks.MapPost("/{packId}/revisions", async Task<IResult> (
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor));
policyPacks.MapPost("/{packId}/revisions/{version:int}:activate", async Task<IResult> (
HttpContext context,
string packId,
int version,
ActivatePolicyRevisionRequest request,
policyPacks.MapPost("/{packId}/revisions/{version:int}:activate", async Task<IResult> (
HttpContext context,
string packId,
int version,
ActivatePolicyRevisionRequest request,
IPolicyEngineClient client,
PolicyEngineTokenProvider tokenProvider,
PolicyGatewayMetrics metrics,
@@ -330,13 +330,144 @@ policyPacks.MapPost("/{packId}/revisions/{version:int}:activate", async Task<IRe
var logger = loggerFactory.CreateLogger("StellaOps.Policy.Gateway.Activation");
LogActivation(logger, packId, version, outcome, source, response.StatusCode);
return response.ToMinimalResult();
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(
StellaOpsScopes.PolicyOperate,
StellaOpsScopes.PolicyActivate));
app.Run();
return response.ToMinimalResult();
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(
StellaOpsScopes.PolicyOperate,
StellaOpsScopes.PolicyActivate));
var cvss = app.MapGroup("/api/cvss")
.WithTags("CVSS Receipts");
cvss.MapPost("/receipts", async Task<IResult>(
HttpContext context,
CreateCvssReceiptRequest request,
IPolicyEngineClient client,
PolicyEngineTokenProvider tokenProvider,
CancellationToken cancellationToken) =>
{
if (request is null)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Request body required.",
Status = StatusCodes.Status400BadRequest
});
}
GatewayForwardingContext? forwardingContext = null;
if (GatewayForwardingContext.TryCreate(context, out var callerContext))
{
forwardingContext = callerContext;
}
else if (!tokenProvider.IsEnabled)
{
return Results.Unauthorized();
}
var response = await client.CreateCvssReceiptAsync(forwardingContext, request, cancellationToken).ConfigureAwait(false);
return response.ToMinimalResult();
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRun));
cvss.MapGet("/receipts/{receiptId}", async Task<IResult>(
HttpContext context,
string receiptId,
IPolicyEngineClient client,
PolicyEngineTokenProvider tokenProvider,
CancellationToken cancellationToken) =>
{
GatewayForwardingContext? forwardingContext = null;
if (GatewayForwardingContext.TryCreate(context, out var callerContext))
{
forwardingContext = callerContext;
}
else if (!tokenProvider.IsEnabled)
{
return Results.Unauthorized();
}
var response = await client.GetCvssReceiptAsync(forwardingContext, receiptId, cancellationToken).ConfigureAwait(false);
return response.ToMinimalResult();
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.FindingsRead));
cvss.MapPut("/receipts/{receiptId}/amend", async Task<IResult>(
HttpContext context,
string receiptId,
AmendCvssReceiptRequest request,
IPolicyEngineClient client,
PolicyEngineTokenProvider tokenProvider,
CancellationToken cancellationToken) =>
{
if (request is null)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Request body required.",
Status = StatusCodes.Status400BadRequest
});
}
GatewayForwardingContext? forwardingContext = null;
if (GatewayForwardingContext.TryCreate(context, out var callerContext))
{
forwardingContext = callerContext;
}
else if (!tokenProvider.IsEnabled)
{
return Results.Unauthorized();
}
var response = await client.AmendCvssReceiptAsync(forwardingContext, receiptId, request, cancellationToken).ConfigureAwait(false);
return response.ToMinimalResult();
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRun));
cvss.MapGet("/receipts/{receiptId}/history", async Task<IResult>(
HttpContext context,
string receiptId,
IPolicyEngineClient client,
PolicyEngineTokenProvider tokenProvider,
CancellationToken cancellationToken) =>
{
GatewayForwardingContext? forwardingContext = null;
if (GatewayForwardingContext.TryCreate(context, out var callerContext))
{
forwardingContext = callerContext;
}
else if (!tokenProvider.IsEnabled)
{
return Results.Unauthorized();
}
var response = await client.GetCvssReceiptHistoryAsync(forwardingContext, receiptId, cancellationToken).ConfigureAwait(false);
return response.ToMinimalResult();
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.FindingsRead));
cvss.MapGet("/policies", async Task<IResult>(
HttpContext context,
IPolicyEngineClient client,
PolicyEngineTokenProvider tokenProvider,
CancellationToken cancellationToken) =>
{
GatewayForwardingContext? forwardingContext = null;
if (GatewayForwardingContext.TryCreate(context, out var callerContext))
{
forwardingContext = callerContext;
}
else if (!tokenProvider.IsEnabled)
{
return Results.Unauthorized();
}
var response = await client.ListCvssPoliciesAsync(forwardingContext, cancellationToken).ConfigureAwait(false);
return response.ToMinimalResult();
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.FindingsRead));
app.Run();
static IAsyncPolicy<HttpResponseMessage> CreateAuthorityRetryPolicy(IServiceProvider provider)
{

View File

@@ -5,7 +5,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel>
</PropertyGroup>
@@ -16,9 +16,10 @@
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
<ProjectReference Include="../../AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy.csproj" />
<ProjectReference Include="../StellaOps.Policy.Scoring/StellaOps.Policy.Scoring.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.15.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,70 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Registry.Contracts;
/// <summary>
/// Severity level.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<Severity>))]
public enum Severity
{
[JsonPropertyName("critical")]
Critical,
[JsonPropertyName("high")]
High,
[JsonPropertyName("medium")]
Medium,
[JsonPropertyName("low")]
Low,
[JsonPropertyName("info")]
Info
}
/// <summary>
/// RFC 7807 Problem Details for HTTP APIs.
/// </summary>
public sealed record ProblemDetails
{
[JsonPropertyName("type")]
public required string Type { get; init; }
[JsonPropertyName("title")]
public required string Title { get; init; }
[JsonPropertyName("status")]
public required int Status { get; init; }
[JsonPropertyName("detail")]
public string? Detail { get; init; }
[JsonPropertyName("instance")]
public string? Instance { get; init; }
[JsonPropertyName("errors")]
public IReadOnlyList<ValidationError>? Errors { get; init; }
}
/// <summary>
/// Validation error.
/// </summary>
public sealed record ValidationError
{
[JsonPropertyName("field")]
public string? Field { get; init; }
[JsonPropertyName("message")]
public string? Message { get; init; }
}
/// <summary>
/// Common pagination parameters.
/// </summary>
public sealed record PaginationParams
{
public int PageSize { get; init; } = 20;
public string? PageToken { get; init; }
}

View File

@@ -0,0 +1,109 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Registry.Contracts;
/// <summary>
/// Policy override.
/// </summary>
public sealed record Override
{
[JsonPropertyName("override_id")]
public required Guid OverrideId { get; init; }
[JsonPropertyName("profile_id")]
public Guid? ProfileId { get; init; }
[JsonPropertyName("rule_id")]
public required string RuleId { get; init; }
[JsonPropertyName("status")]
public required OverrideStatus Status { get; init; }
[JsonPropertyName("reason")]
public string? Reason { get; init; }
[JsonPropertyName("scope")]
public OverrideScope? Scope { get; init; }
[JsonPropertyName("expires_at")]
public DateTimeOffset? ExpiresAt { get; init; }
[JsonPropertyName("approved_by")]
public string? ApprovedBy { get; init; }
[JsonPropertyName("approved_at")]
public DateTimeOffset? ApprovedAt { get; init; }
[JsonPropertyName("created_at")]
public required DateTimeOffset CreatedAt { get; init; }
[JsonPropertyName("created_by")]
public string? CreatedBy { get; init; }
}
/// <summary>
/// Override status.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<OverrideStatus>))]
public enum OverrideStatus
{
[JsonPropertyName("pending")]
Pending,
[JsonPropertyName("approved")]
Approved,
[JsonPropertyName("disabled")]
Disabled,
[JsonPropertyName("expired")]
Expired
}
/// <summary>
/// Override scope.
/// </summary>
public sealed record OverrideScope
{
[JsonPropertyName("purl")]
public string? Purl { get; init; }
[JsonPropertyName("cve_id")]
public string? CveId { get; init; }
[JsonPropertyName("component")]
public string? Component { get; init; }
[JsonPropertyName("environment")]
public string? Environment { get; init; }
}
/// <summary>
/// Request to create an override.
/// </summary>
public sealed record CreateOverrideRequest
{
[JsonPropertyName("profile_id")]
public Guid? ProfileId { get; init; }
[JsonPropertyName("rule_id")]
public required string RuleId { get; init; }
[JsonPropertyName("reason")]
public required string Reason { get; init; }
[JsonPropertyName("scope")]
public OverrideScope? Scope { get; init; }
[JsonPropertyName("expires_at")]
public DateTimeOffset? ExpiresAt { get; init; }
}
/// <summary>
/// Request to approve an override.
/// </summary>
public sealed record ApproveOverrideRequest
{
[JsonPropertyName("comment")]
public string? Comment { get; init; }
}

View File

@@ -0,0 +1,287 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Registry.Contracts;
/// <summary>
/// Policy pack workspace entity.
/// </summary>
public sealed record PolicyPack
{
[JsonPropertyName("pack_id")]
public required Guid PackId { get; init; }
[JsonPropertyName("name")]
public required string Name { get; init; }
[JsonPropertyName("version")]
public required string Version { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("status")]
public required PolicyPackStatus Status { get; init; }
[JsonPropertyName("rules")]
public IReadOnlyList<PolicyRule>? Rules { get; init; }
[JsonPropertyName("metadata")]
public IReadOnlyDictionary<string, object>? Metadata { get; init; }
[JsonPropertyName("created_at")]
public required DateTimeOffset CreatedAt { get; init; }
[JsonPropertyName("updated_at")]
public required DateTimeOffset UpdatedAt { get; init; }
[JsonPropertyName("published_at")]
public DateTimeOffset? PublishedAt { get; init; }
[JsonPropertyName("digest")]
public string? Digest { get; init; }
}
/// <summary>
/// Policy pack status.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<PolicyPackStatus>))]
public enum PolicyPackStatus
{
[JsonPropertyName("draft")]
Draft,
[JsonPropertyName("pending_review")]
PendingReview,
[JsonPropertyName("published")]
Published,
[JsonPropertyName("archived")]
Archived
}
/// <summary>
/// Individual policy rule within a pack.
/// </summary>
public sealed record PolicyRule
{
[JsonPropertyName("rule_id")]
public required string RuleId { get; init; }
[JsonPropertyName("name")]
public required string Name { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("severity")]
public required Severity Severity { get; init; }
[JsonPropertyName("rego")]
public string? Rego { get; init; }
[JsonPropertyName("enabled")]
public bool Enabled { get; init; } = true;
}
/// <summary>
/// Request to create a policy pack.
/// </summary>
public sealed record CreatePolicyPackRequest
{
[JsonPropertyName("name")]
public required string Name { get; init; }
[JsonPropertyName("version")]
public required string Version { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("rules")]
public IReadOnlyList<PolicyRule>? Rules { get; init; }
[JsonPropertyName("metadata")]
public IReadOnlyDictionary<string, object>? Metadata { get; init; }
}
/// <summary>
/// Request to update a policy pack.
/// </summary>
public sealed record UpdatePolicyPackRequest
{
[JsonPropertyName("name")]
public string? Name { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("rules")]
public IReadOnlyList<PolicyRule>? Rules { get; init; }
[JsonPropertyName("metadata")]
public IReadOnlyDictionary<string, object>? Metadata { get; init; }
}
/// <summary>
/// Paginated list of policy packs.
/// </summary>
public sealed record PolicyPackList
{
[JsonPropertyName("items")]
public required IReadOnlyList<PolicyPack> Items { get; init; }
[JsonPropertyName("next_page_token")]
public string? NextPageToken { get; init; }
}
/// <summary>
/// Compilation result for a policy pack.
/// </summary>
public sealed record CompilationResult
{
[JsonPropertyName("success")]
public required bool Success { get; init; }
[JsonPropertyName("errors")]
public IReadOnlyList<CompilationError>? Errors { get; init; }
[JsonPropertyName("warnings")]
public IReadOnlyList<CompilationWarning>? Warnings { get; init; }
[JsonPropertyName("digest")]
public string? Digest { get; init; }
}
/// <summary>
/// Compilation error.
/// </summary>
public sealed record CompilationError
{
[JsonPropertyName("rule_id")]
public string? RuleId { get; init; }
[JsonPropertyName("line")]
public int? Line { get; init; }
[JsonPropertyName("column")]
public int? Column { get; init; }
[JsonPropertyName("message")]
public required string Message { get; init; }
}
/// <summary>
/// Compilation warning.
/// </summary>
public sealed record CompilationWarning
{
[JsonPropertyName("rule_id")]
public string? RuleId { get; init; }
[JsonPropertyName("message")]
public required string Message { get; init; }
}
/// <summary>
/// Request to simulate a policy pack.
/// </summary>
public sealed record SimulationRequest
{
[JsonPropertyName("input")]
public required IReadOnlyDictionary<string, object> Input { get; init; }
[JsonPropertyName("options")]
public SimulationOptions? Options { get; init; }
}
/// <summary>
/// Simulation options.
/// </summary>
public sealed record SimulationOptions
{
[JsonPropertyName("trace")]
public bool Trace { get; init; }
[JsonPropertyName("explain")]
public bool Explain { get; init; }
}
/// <summary>
/// Simulation result.
/// </summary>
public sealed record SimulationResult
{
[JsonPropertyName("result")]
public required IReadOnlyDictionary<string, object> Result { get; init; }
[JsonPropertyName("violations")]
public IReadOnlyList<SimulatedViolation>? Violations { get; init; }
[JsonPropertyName("trace")]
public IReadOnlyList<string>? Trace { get; init; }
[JsonPropertyName("explain")]
public PolicyExplainTrace? Explain { get; init; }
}
/// <summary>
/// Simulated violation.
/// </summary>
public sealed record SimulatedViolation
{
[JsonPropertyName("rule_id")]
public required string RuleId { get; init; }
[JsonPropertyName("severity")]
public required string Severity { get; init; }
[JsonPropertyName("message")]
public required string Message { get; init; }
[JsonPropertyName("context")]
public IReadOnlyDictionary<string, object>? Context { get; init; }
}
/// <summary>
/// Policy explain trace.
/// </summary>
public sealed record PolicyExplainTrace
{
[JsonPropertyName("steps")]
public IReadOnlyList<object>? Steps { get; init; }
}
/// <summary>
/// Request to publish a policy pack.
/// </summary>
public sealed record PublishRequest
{
[JsonPropertyName("approval_id")]
public string? ApprovalId { get; init; }
}
/// <summary>
/// Request to promote a policy pack.
/// </summary>
public sealed record PromoteRequest
{
[JsonPropertyName("target_environment")]
public TargetEnvironment? TargetEnvironment { get; init; }
[JsonPropertyName("approval_id")]
public string? ApprovalId { get; init; }
}
/// <summary>
/// Target environment for promotion.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<TargetEnvironment>))]
public enum TargetEnvironment
{
[JsonPropertyName("staging")]
Staging,
[JsonPropertyName("production")]
Production
}

View File

@@ -0,0 +1,121 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Registry.Contracts;
/// <summary>
/// Sealed mode status (air-gap operation).
/// </summary>
public sealed record SealedModeStatus
{
[JsonPropertyName("sealed")]
public required bool Sealed { get; init; }
[JsonPropertyName("mode")]
public required SealedMode Mode { get; init; }
[JsonPropertyName("sealed_at")]
public DateTimeOffset? SealedAt { get; init; }
[JsonPropertyName("sealed_by")]
public string? SealedBy { get; init; }
[JsonPropertyName("bundle_version")]
public string? BundleVersion { get; init; }
[JsonPropertyName("last_advisory_update")]
public DateTimeOffset? LastAdvisoryUpdate { get; init; }
[JsonPropertyName("time_anchor")]
public TimeAnchor? TimeAnchor { get; init; }
}
/// <summary>
/// Sealed mode state.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<SealedMode>))]
public enum SealedMode
{
[JsonPropertyName("online")]
Online,
[JsonPropertyName("sealed")]
Sealed,
[JsonPropertyName("transitioning")]
Transitioning
}
/// <summary>
/// Time anchor for sealed mode operations.
/// </summary>
public sealed record TimeAnchor
{
[JsonPropertyName("timestamp")]
public required DateTimeOffset Timestamp { get; init; }
[JsonPropertyName("signature")]
public string? Signature { get; init; }
[JsonPropertyName("valid")]
public required bool Valid { get; init; }
[JsonPropertyName("expires_at")]
public DateTimeOffset? ExpiresAt { get; init; }
}
/// <summary>
/// Request to seal the environment.
/// </summary>
public sealed record SealRequest
{
[JsonPropertyName("reason")]
public string? Reason { get; init; }
[JsonPropertyName("time_anchor")]
public DateTimeOffset? TimeAnchor { get; init; }
}
/// <summary>
/// Request to unseal the environment.
/// </summary>
public sealed record UnsealRequest
{
[JsonPropertyName("reason")]
public required string Reason { get; init; }
[JsonPropertyName("audit_note")]
public string? AuditNote { get; init; }
}
/// <summary>
/// Request to verify an air-gap bundle.
/// </summary>
public sealed record VerifyBundleRequest
{
[JsonPropertyName("bundle_digest")]
public required string BundleDigest { get; init; }
[JsonPropertyName("public_key")]
public string? PublicKey { get; init; }
}
/// <summary>
/// Result of bundle verification.
/// </summary>
public sealed record BundleVerificationResult
{
[JsonPropertyName("valid")]
public required bool Valid { get; init; }
[JsonPropertyName("bundle_digest")]
public string? BundleDigest { get; init; }
[JsonPropertyName("signed_at")]
public DateTimeOffset? SignedAt { get; init; }
[JsonPropertyName("signer_fingerprint")]
public string? SignerFingerprint { get; init; }
[JsonPropertyName("errors")]
public IReadOnlyList<string>? Errors { get; init; }
}

View File

@@ -0,0 +1,57 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Registry.Contracts;
/// <summary>
/// Policy snapshot.
/// </summary>
public sealed record Snapshot
{
[JsonPropertyName("snapshot_id")]
public required Guid SnapshotId { get; init; }
[JsonPropertyName("digest")]
public required string Digest { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("pack_ids")]
public IReadOnlyList<Guid>? PackIds { get; init; }
[JsonPropertyName("metadata")]
public IReadOnlyDictionary<string, object>? Metadata { get; init; }
[JsonPropertyName("created_at")]
public required DateTimeOffset CreatedAt { get; init; }
[JsonPropertyName("created_by")]
public string? CreatedBy { get; init; }
}
/// <summary>
/// Request to create a snapshot.
/// </summary>
public sealed record CreateSnapshotRequest
{
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("pack_ids")]
public required IReadOnlyList<Guid> PackIds { get; init; }
[JsonPropertyName("metadata")]
public IReadOnlyDictionary<string, object>? Metadata { get; init; }
}
/// <summary>
/// Paginated list of snapshots.
/// </summary>
public sealed record SnapshotList
{
[JsonPropertyName("items")]
public required IReadOnlyList<Snapshot> Items { get; init; }
[JsonPropertyName("next_page_token")]
public string? NextPageToken { get; init; }
}

View File

@@ -0,0 +1,94 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Registry.Contracts;
/// <summary>
/// Overall staleness status.
/// </summary>
public sealed record StalenessStatus
{
[JsonPropertyName("overall_status")]
public required StalenessLevel OverallStatus { get; init; }
[JsonPropertyName("sources")]
public required IReadOnlyList<SourceStaleness> Sources { get; init; }
[JsonPropertyName("last_check")]
public DateTimeOffset? LastCheck { get; init; }
}
/// <summary>
/// Staleness level.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<StalenessLevel>))]
public enum StalenessLevel
{
[JsonPropertyName("fresh")]
Fresh,
[JsonPropertyName("stale")]
Stale,
[JsonPropertyName("critical")]
Critical,
[JsonPropertyName("unknown")]
Unknown
}
/// <summary>
/// Staleness status for an individual source.
/// </summary>
public sealed record SourceStaleness
{
[JsonPropertyName("source_id")]
public required string SourceId { get; init; }
[JsonPropertyName("source_name")]
public string? SourceName { get; init; }
[JsonPropertyName("status")]
public required StalenessLevel Status { get; init; }
[JsonPropertyName("last_update")]
public required DateTimeOffset LastUpdate { get; init; }
[JsonPropertyName("max_age_hours")]
public int? MaxAgeHours { get; init; }
[JsonPropertyName("age_hours")]
public double? AgeHours { get; init; }
}
/// <summary>
/// Request to evaluate staleness.
/// </summary>
public sealed record EvaluateStalenessRequest
{
[JsonPropertyName("source_id")]
public required string SourceId { get; init; }
[JsonPropertyName("threshold_hours")]
public int? ThresholdHours { get; init; }
}
/// <summary>
/// Result of staleness evaluation.
/// </summary>
public sealed record StalenessEvaluation
{
[JsonPropertyName("source_id")]
public required string SourceId { get; init; }
[JsonPropertyName("is_stale")]
public required bool IsStale { get; init; }
[JsonPropertyName("age_hours")]
public double? AgeHours { get; init; }
[JsonPropertyName("threshold_hours")]
public int? ThresholdHours { get; init; }
[JsonPropertyName("recommendation")]
public string? Recommendation { get; init; }
}

View File

@@ -0,0 +1,145 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Registry.Contracts;
/// <summary>
/// Verification policy for attestation validation.
/// Based on OpenAPI: docs/schemas/policy-registry-api.openapi.yaml
/// </summary>
public sealed record VerificationPolicy
{
[JsonPropertyName("policy_id")]
public required string PolicyId { get; init; }
[JsonPropertyName("version")]
public required string Version { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("tenant_scope")]
public required string TenantScope { get; init; }
[JsonPropertyName("predicate_types")]
public required IReadOnlyList<string> PredicateTypes { get; init; }
[JsonPropertyName("signer_requirements")]
public required SignerRequirements SignerRequirements { get; init; }
[JsonPropertyName("validity_window")]
public ValidityWindow? ValidityWindow { get; init; }
[JsonPropertyName("metadata")]
public IReadOnlyDictionary<string, object>? Metadata { get; init; }
[JsonPropertyName("created_at")]
public required DateTimeOffset CreatedAt { get; init; }
[JsonPropertyName("updated_at")]
public required DateTimeOffset UpdatedAt { get; init; }
}
/// <summary>
/// Requirements for attestation signers.
/// </summary>
public sealed record SignerRequirements
{
[JsonPropertyName("minimum_signatures")]
public int MinimumSignatures { get; init; } = 1;
[JsonPropertyName("trusted_key_fingerprints")]
public required IReadOnlyList<string> TrustedKeyFingerprints { get; init; }
[JsonPropertyName("trusted_issuers")]
public IReadOnlyList<string>? TrustedIssuers { get; init; }
[JsonPropertyName("require_rekor")]
public bool RequireRekor { get; init; }
[JsonPropertyName("algorithms")]
public IReadOnlyList<string>? Algorithms { get; init; }
}
/// <summary>
/// Validity window for attestations.
/// </summary>
public sealed record ValidityWindow
{
[JsonPropertyName("not_before")]
public DateTimeOffset? NotBefore { get; init; }
[JsonPropertyName("not_after")]
public DateTimeOffset? NotAfter { get; init; }
[JsonPropertyName("max_attestation_age")]
public int? MaxAttestationAge { get; init; }
}
/// <summary>
/// Request to create a verification policy.
/// </summary>
public sealed record CreateVerificationPolicyRequest
{
[JsonPropertyName("policy_id")]
public required string PolicyId { get; init; }
[JsonPropertyName("version")]
public required string Version { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("tenant_scope")]
public string? TenantScope { get; init; }
[JsonPropertyName("predicate_types")]
public required IReadOnlyList<string> PredicateTypes { get; init; }
[JsonPropertyName("signer_requirements")]
public SignerRequirements? SignerRequirements { get; init; }
[JsonPropertyName("validity_window")]
public ValidityWindow? ValidityWindow { get; init; }
[JsonPropertyName("metadata")]
public IReadOnlyDictionary<string, object>? Metadata { get; init; }
}
/// <summary>
/// Request to update a verification policy.
/// </summary>
public sealed record UpdateVerificationPolicyRequest
{
[JsonPropertyName("version")]
public string? Version { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("predicate_types")]
public IReadOnlyList<string>? PredicateTypes { get; init; }
[JsonPropertyName("signer_requirements")]
public SignerRequirements? SignerRequirements { get; init; }
[JsonPropertyName("validity_window")]
public ValidityWindow? ValidityWindow { get; init; }
[JsonPropertyName("metadata")]
public IReadOnlyDictionary<string, object>? Metadata { get; init; }
}
/// <summary>
/// Paginated list of verification policies.
/// </summary>
public sealed record VerificationPolicyList
{
[JsonPropertyName("items")]
public required IReadOnlyList<VerificationPolicy> Items { get; init; }
[JsonPropertyName("next_page_token")]
public string? NextPageToken { get; init; }
[JsonPropertyName("total_count")]
public int? TotalCount { get; init; }
}

View File

@@ -0,0 +1,114 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Registry.Contracts;
/// <summary>
/// Policy violation.
/// </summary>
public sealed record Violation
{
[JsonPropertyName("violation_id")]
public required Guid ViolationId { get; init; }
[JsonPropertyName("policy_id")]
public string? PolicyId { get; init; }
[JsonPropertyName("rule_id")]
public required string RuleId { get; init; }
[JsonPropertyName("severity")]
public required Severity Severity { get; init; }
[JsonPropertyName("message")]
public required string Message { get; init; }
[JsonPropertyName("purl")]
public string? Purl { get; init; }
[JsonPropertyName("cve_id")]
public string? CveId { get; init; }
[JsonPropertyName("context")]
public IReadOnlyDictionary<string, object>? Context { get; init; }
[JsonPropertyName("created_at")]
public required DateTimeOffset CreatedAt { get; init; }
}
/// <summary>
/// Request to create a violation.
/// </summary>
public sealed record CreateViolationRequest
{
[JsonPropertyName("policy_id")]
public string? PolicyId { get; init; }
[JsonPropertyName("rule_id")]
public required string RuleId { get; init; }
[JsonPropertyName("severity")]
public required Severity Severity { get; init; }
[JsonPropertyName("message")]
public required string Message { get; init; }
[JsonPropertyName("purl")]
public string? Purl { get; init; }
[JsonPropertyName("cve_id")]
public string? CveId { get; init; }
[JsonPropertyName("context")]
public IReadOnlyDictionary<string, object>? Context { get; init; }
}
/// <summary>
/// Batch request to create violations.
/// </summary>
public sealed record ViolationBatchRequest
{
[JsonPropertyName("violations")]
public required IReadOnlyList<CreateViolationRequest> Violations { get; init; }
}
/// <summary>
/// Result of batch violation creation.
/// </summary>
public sealed record ViolationBatchResult
{
[JsonPropertyName("created")]
public required int Created { get; init; }
[JsonPropertyName("failed")]
public required int Failed { get; init; }
[JsonPropertyName("errors")]
public IReadOnlyList<BatchError>? Errors { get; init; }
}
/// <summary>
/// Error from batch operation.
/// </summary>
public sealed record BatchError
{
[JsonPropertyName("index")]
public int? Index { get; init; }
[JsonPropertyName("error")]
public string? Error { get; init; }
}
/// <summary>
/// Paginated list of violations.
/// </summary>
public sealed record ViolationList
{
[JsonPropertyName("items")]
public required IReadOnlyList<Violation> Items { get; init; }
[JsonPropertyName("next_page_token")]
public string? NextPageToken { get; init; }
[JsonPropertyName("total_count")]
public int? TotalCount { get; init; }
}

View File

@@ -0,0 +1,214 @@
using StellaOps.Policy.Registry.Contracts;
namespace StellaOps.Policy.Registry;
/// <summary>
/// Typed HTTP client for Policy Registry API.
/// Based on OpenAPI: docs/schemas/policy-registry-api.openapi.yaml
/// </summary>
public interface IPolicyRegistryClient
{
// ============================================================
// VERIFICATION POLICY OPERATIONS
// ============================================================
Task<VerificationPolicyList> ListVerificationPoliciesAsync(
Guid tenantId,
PaginationParams? pagination = null,
CancellationToken cancellationToken = default);
Task<VerificationPolicy> CreateVerificationPolicyAsync(
Guid tenantId,
CreateVerificationPolicyRequest request,
CancellationToken cancellationToken = default);
Task<VerificationPolicy> GetVerificationPolicyAsync(
Guid tenantId,
string policyId,
CancellationToken cancellationToken = default);
Task<VerificationPolicy> UpdateVerificationPolicyAsync(
Guid tenantId,
string policyId,
UpdateVerificationPolicyRequest request,
CancellationToken cancellationToken = default);
Task DeleteVerificationPolicyAsync(
Guid tenantId,
string policyId,
CancellationToken cancellationToken = default);
// ============================================================
// POLICY PACK OPERATIONS
// ============================================================
Task<PolicyPackList> ListPolicyPacksAsync(
Guid tenantId,
PolicyPackStatus? status = null,
PaginationParams? pagination = null,
CancellationToken cancellationToken = default);
Task<PolicyPack> CreatePolicyPackAsync(
Guid tenantId,
CreatePolicyPackRequest request,
CancellationToken cancellationToken = default);
Task<PolicyPack> GetPolicyPackAsync(
Guid tenantId,
Guid packId,
CancellationToken cancellationToken = default);
Task<PolicyPack> UpdatePolicyPackAsync(
Guid tenantId,
Guid packId,
UpdatePolicyPackRequest request,
CancellationToken cancellationToken = default);
Task DeletePolicyPackAsync(
Guid tenantId,
Guid packId,
CancellationToken cancellationToken = default);
Task<CompilationResult> CompilePolicyPackAsync(
Guid tenantId,
Guid packId,
CancellationToken cancellationToken = default);
Task<SimulationResult> SimulatePolicyPackAsync(
Guid tenantId,
Guid packId,
SimulationRequest request,
CancellationToken cancellationToken = default);
Task<PolicyPack> PublishPolicyPackAsync(
Guid tenantId,
Guid packId,
PublishRequest? request = null,
CancellationToken cancellationToken = default);
Task<PolicyPack> PromotePolicyPackAsync(
Guid tenantId,
Guid packId,
PromoteRequest? request = null,
CancellationToken cancellationToken = default);
// ============================================================
// SNAPSHOT OPERATIONS
// ============================================================
Task<SnapshotList> ListSnapshotsAsync(
Guid tenantId,
PaginationParams? pagination = null,
CancellationToken cancellationToken = default);
Task<Snapshot> CreateSnapshotAsync(
Guid tenantId,
CreateSnapshotRequest request,
CancellationToken cancellationToken = default);
Task<Snapshot> GetSnapshotAsync(
Guid tenantId,
Guid snapshotId,
CancellationToken cancellationToken = default);
Task DeleteSnapshotAsync(
Guid tenantId,
Guid snapshotId,
CancellationToken cancellationToken = default);
Task<Snapshot> GetSnapshotByDigestAsync(
Guid tenantId,
string digest,
CancellationToken cancellationToken = default);
// ============================================================
// VIOLATION OPERATIONS
// ============================================================
Task<ViolationList> ListViolationsAsync(
Guid tenantId,
Severity? severity = null,
PaginationParams? pagination = null,
CancellationToken cancellationToken = default);
Task<Violation> AppendViolationAsync(
Guid tenantId,
CreateViolationRequest request,
CancellationToken cancellationToken = default);
Task<ViolationBatchResult> AppendViolationBatchAsync(
Guid tenantId,
ViolationBatchRequest request,
CancellationToken cancellationToken = default);
Task<Violation> GetViolationAsync(
Guid tenantId,
Guid violationId,
CancellationToken cancellationToken = default);
// ============================================================
// OVERRIDE OPERATIONS
// ============================================================
Task<Override> CreateOverrideAsync(
Guid tenantId,
CreateOverrideRequest request,
CancellationToken cancellationToken = default);
Task<Override> GetOverrideAsync(
Guid tenantId,
Guid overrideId,
CancellationToken cancellationToken = default);
Task DeleteOverrideAsync(
Guid tenantId,
Guid overrideId,
CancellationToken cancellationToken = default);
Task<Override> ApproveOverrideAsync(
Guid tenantId,
Guid overrideId,
ApproveOverrideRequest? request = null,
CancellationToken cancellationToken = default);
Task<Override> DisableOverrideAsync(
Guid tenantId,
Guid overrideId,
CancellationToken cancellationToken = default);
// ============================================================
// SEALED MODE OPERATIONS
// ============================================================
Task<SealedModeStatus> GetSealedModeStatusAsync(
Guid tenantId,
CancellationToken cancellationToken = default);
Task<SealedModeStatus> SealAsync(
Guid tenantId,
SealRequest? request = null,
CancellationToken cancellationToken = default);
Task<SealedModeStatus> UnsealAsync(
Guid tenantId,
UnsealRequest request,
CancellationToken cancellationToken = default);
Task<BundleVerificationResult> VerifyBundleAsync(
Guid tenantId,
VerifyBundleRequest request,
CancellationToken cancellationToken = default);
// ============================================================
// STALENESS OPERATIONS
// ============================================================
Task<StalenessStatus> GetStalenessStatusAsync(
Guid tenantId,
CancellationToken cancellationToken = default);
Task<StalenessEvaluation> EvaluateStalenessAsync(
Guid tenantId,
EvaluateStalenessRequest request,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,634 @@
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Registry.Contracts;
namespace StellaOps.Policy.Registry;
/// <summary>
/// HTTP client implementation for Policy Registry API.
/// </summary>
public sealed class PolicyRegistryClient : IPolicyRegistryClient
{
private readonly HttpClient _httpClient;
private readonly JsonSerializerOptions _jsonOptions;
public PolicyRegistryClient(HttpClient httpClient, IOptions<PolicyRegistryClientOptions>? options = null)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
PropertyNameCaseInsensitive = true
};
if (options?.Value?.BaseUrl is not null && _httpClient.BaseAddress is null)
{
_httpClient.BaseAddress = new Uri(options.Value.BaseUrl);
}
}
private static void AddTenantHeader(HttpRequestMessage request, Guid tenantId)
{
request.Headers.Add("X-Tenant-Id", tenantId.ToString());
}
private static string BuildQueryString(PaginationParams? pagination, params (string name, string? value)[] additional)
{
var parts = new List<string>();
if (pagination is not null)
{
if (pagination.PageSize != 20)
{
parts.Add($"page_size={pagination.PageSize}");
}
if (!string.IsNullOrWhiteSpace(pagination.PageToken))
{
parts.Add($"page_token={Uri.EscapeDataString(pagination.PageToken)}");
}
}
foreach (var (name, value) in additional)
{
if (!string.IsNullOrWhiteSpace(value))
{
parts.Add($"{name}={Uri.EscapeDataString(value)}");
}
}
return parts.Count > 0 ? "?" + string.Join("&", parts) : string.Empty;
}
// ============================================================
// VERIFICATION POLICY OPERATIONS
// ============================================================
public async Task<VerificationPolicyList> ListVerificationPoliciesAsync(
Guid tenantId,
PaginationParams? pagination = null,
CancellationToken cancellationToken = default)
{
var query = BuildQueryString(pagination);
using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/policy/verification-policies{query}");
AddTenantHeader(request, tenantId);
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<VerificationPolicyList>(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task<VerificationPolicy> CreateVerificationPolicyAsync(
Guid tenantId,
CreateVerificationPolicyRequest request,
CancellationToken cancellationToken = default)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/policy/verification-policies");
AddTenantHeader(httpRequest, tenantId);
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<VerificationPolicy>(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task<VerificationPolicy> GetVerificationPolicyAsync(
Guid tenantId,
string policyId,
CancellationToken cancellationToken = default)
{
using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/policy/verification-policies/{Uri.EscapeDataString(policyId)}");
AddTenantHeader(request, tenantId);
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<VerificationPolicy>(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task<VerificationPolicy> UpdateVerificationPolicyAsync(
Guid tenantId,
string policyId,
UpdateVerificationPolicyRequest request,
CancellationToken cancellationToken = default)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Put, $"/api/v1/policy/verification-policies/{Uri.EscapeDataString(policyId)}");
AddTenantHeader(httpRequest, tenantId);
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<VerificationPolicy>(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task DeleteVerificationPolicyAsync(
Guid tenantId,
string policyId,
CancellationToken cancellationToken = default)
{
using var request = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/policy/verification-policies/{Uri.EscapeDataString(policyId)}");
AddTenantHeader(request, tenantId);
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
}
// ============================================================
// POLICY PACK OPERATIONS
// ============================================================
public async Task<PolicyPackList> ListPolicyPacksAsync(
Guid tenantId,
PolicyPackStatus? status = null,
PaginationParams? pagination = null,
CancellationToken cancellationToken = default)
{
var query = BuildQueryString(pagination, ("status", status?.ToString().ToLowerInvariant()));
using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/policy/packs{query}");
AddTenantHeader(request, tenantId);
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<PolicyPackList>(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task<PolicyPack> CreatePolicyPackAsync(
Guid tenantId,
CreatePolicyPackRequest request,
CancellationToken cancellationToken = default)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/policy/packs");
AddTenantHeader(httpRequest, tenantId);
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<PolicyPack>(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task<PolicyPack> GetPolicyPackAsync(
Guid tenantId,
Guid packId,
CancellationToken cancellationToken = default)
{
using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/policy/packs/{packId}");
AddTenantHeader(request, tenantId);
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<PolicyPack>(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task<PolicyPack> UpdatePolicyPackAsync(
Guid tenantId,
Guid packId,
UpdatePolicyPackRequest request,
CancellationToken cancellationToken = default)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Put, $"/api/v1/policy/packs/{packId}");
AddTenantHeader(httpRequest, tenantId);
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<PolicyPack>(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task DeletePolicyPackAsync(
Guid tenantId,
Guid packId,
CancellationToken cancellationToken = default)
{
using var request = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/policy/packs/{packId}");
AddTenantHeader(request, tenantId);
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
}
public async Task<CompilationResult> CompilePolicyPackAsync(
Guid tenantId,
Guid packId,
CancellationToken cancellationToken = default)
{
using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/policy/packs/{packId}/compile");
AddTenantHeader(request, tenantId);
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
// Note: 422 also returns CompilationResult, so we read regardless of status
return await response.Content.ReadFromJsonAsync<CompilationResult>(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task<SimulationResult> SimulatePolicyPackAsync(
Guid tenantId,
Guid packId,
SimulationRequest request,
CancellationToken cancellationToken = default)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/policy/packs/{packId}/simulate");
AddTenantHeader(httpRequest, tenantId);
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<SimulationResult>(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task<PolicyPack> PublishPolicyPackAsync(
Guid tenantId,
Guid packId,
PublishRequest? request = null,
CancellationToken cancellationToken = default)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/policy/packs/{packId}/publish");
AddTenantHeader(httpRequest, tenantId);
if (request is not null)
{
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
}
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<PolicyPack>(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task<PolicyPack> PromotePolicyPackAsync(
Guid tenantId,
Guid packId,
PromoteRequest? request = null,
CancellationToken cancellationToken = default)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/policy/packs/{packId}/promote");
AddTenantHeader(httpRequest, tenantId);
if (request is not null)
{
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
}
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<PolicyPack>(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
// ============================================================
// SNAPSHOT OPERATIONS
// ============================================================
public async Task<SnapshotList> ListSnapshotsAsync(
Guid tenantId,
PaginationParams? pagination = null,
CancellationToken cancellationToken = default)
{
var query = BuildQueryString(pagination);
using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/policy/snapshots{query}");
AddTenantHeader(request, tenantId);
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<SnapshotList>(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task<Snapshot> CreateSnapshotAsync(
Guid tenantId,
CreateSnapshotRequest request,
CancellationToken cancellationToken = default)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/policy/snapshots");
AddTenantHeader(httpRequest, tenantId);
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<Snapshot>(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task<Snapshot> GetSnapshotAsync(
Guid tenantId,
Guid snapshotId,
CancellationToken cancellationToken = default)
{
using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/policy/snapshots/{snapshotId}");
AddTenantHeader(request, tenantId);
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<Snapshot>(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task DeleteSnapshotAsync(
Guid tenantId,
Guid snapshotId,
CancellationToken cancellationToken = default)
{
using var request = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/policy/snapshots/{snapshotId}");
AddTenantHeader(request, tenantId);
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
}
public async Task<Snapshot> GetSnapshotByDigestAsync(
Guid tenantId,
string digest,
CancellationToken cancellationToken = default)
{
using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/policy/snapshots/by-digest/{Uri.EscapeDataString(digest)}");
AddTenantHeader(request, tenantId);
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<Snapshot>(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
// ============================================================
// VIOLATION OPERATIONS
// ============================================================
public async Task<ViolationList> ListViolationsAsync(
Guid tenantId,
Severity? severity = null,
PaginationParams? pagination = null,
CancellationToken cancellationToken = default)
{
var query = BuildQueryString(pagination, ("severity", severity?.ToString().ToLowerInvariant()));
using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/policy/violations{query}");
AddTenantHeader(request, tenantId);
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<ViolationList>(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task<Violation> AppendViolationAsync(
Guid tenantId,
CreateViolationRequest request,
CancellationToken cancellationToken = default)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/policy/violations");
AddTenantHeader(httpRequest, tenantId);
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<Violation>(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task<ViolationBatchResult> AppendViolationBatchAsync(
Guid tenantId,
ViolationBatchRequest request,
CancellationToken cancellationToken = default)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/policy/violations/batch");
AddTenantHeader(httpRequest, tenantId);
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<ViolationBatchResult>(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task<Violation> GetViolationAsync(
Guid tenantId,
Guid violationId,
CancellationToken cancellationToken = default)
{
using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/policy/violations/{violationId}");
AddTenantHeader(request, tenantId);
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<Violation>(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
// ============================================================
// OVERRIDE OPERATIONS
// ============================================================
public async Task<Override> CreateOverrideAsync(
Guid tenantId,
CreateOverrideRequest request,
CancellationToken cancellationToken = default)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/policy/overrides");
AddTenantHeader(httpRequest, tenantId);
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<Override>(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task<Override> GetOverrideAsync(
Guid tenantId,
Guid overrideId,
CancellationToken cancellationToken = default)
{
using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/policy/overrides/{overrideId}");
AddTenantHeader(request, tenantId);
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<Override>(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task DeleteOverrideAsync(
Guid tenantId,
Guid overrideId,
CancellationToken cancellationToken = default)
{
using var request = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/policy/overrides/{overrideId}");
AddTenantHeader(request, tenantId);
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
}
public async Task<Override> ApproveOverrideAsync(
Guid tenantId,
Guid overrideId,
ApproveOverrideRequest? request = null,
CancellationToken cancellationToken = default)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/policy/overrides/{overrideId}:approve");
AddTenantHeader(httpRequest, tenantId);
if (request is not null)
{
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
}
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<Override>(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task<Override> DisableOverrideAsync(
Guid tenantId,
Guid overrideId,
CancellationToken cancellationToken = default)
{
using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/policy/overrides/{overrideId}:disable");
AddTenantHeader(request, tenantId);
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<Override>(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
// ============================================================
// SEALED MODE OPERATIONS
// ============================================================
public async Task<SealedModeStatus> GetSealedModeStatusAsync(
Guid tenantId,
CancellationToken cancellationToken = default)
{
using var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/policy/sealed-mode/status");
AddTenantHeader(request, tenantId);
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<SealedModeStatus>(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task<SealedModeStatus> SealAsync(
Guid tenantId,
SealRequest? request = null,
CancellationToken cancellationToken = default)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/policy/sealed-mode/seal");
AddTenantHeader(httpRequest, tenantId);
if (request is not null)
{
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
}
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<SealedModeStatus>(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task<SealedModeStatus> UnsealAsync(
Guid tenantId,
UnsealRequest request,
CancellationToken cancellationToken = default)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/policy/sealed-mode/unseal");
AddTenantHeader(httpRequest, tenantId);
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<SealedModeStatus>(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task<BundleVerificationResult> VerifyBundleAsync(
Guid tenantId,
VerifyBundleRequest request,
CancellationToken cancellationToken = default)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/policy/sealed-mode/verify");
AddTenantHeader(httpRequest, tenantId);
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<BundleVerificationResult>(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
// ============================================================
// STALENESS OPERATIONS
// ============================================================
public async Task<StalenessStatus> GetStalenessStatusAsync(
Guid tenantId,
CancellationToken cancellationToken = default)
{
using var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/policy/staleness/status");
AddTenantHeader(request, tenantId);
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<StalenessStatus>(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task<StalenessEvaluation> EvaluateStalenessAsync(
Guid tenantId,
EvaluateStalenessRequest request,
CancellationToken cancellationToken = default)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/policy/staleness/evaluate");
AddTenantHeader(httpRequest, tenantId);
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<StalenessEvaluation>(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
}
/// <summary>
/// Configuration options for Policy Registry client.
/// </summary>
public sealed class PolicyRegistryClientOptions
{
public string? BaseUrl { get; set; }
}

View File

@@ -0,0 +1,184 @@
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Policy.Registry.Services;
using StellaOps.Policy.Registry.Storage;
namespace StellaOps.Policy.Registry;
/// <summary>
/// Extension methods for registering Policy Registry services.
/// </summary>
public static class PolicyRegistryServiceCollectionExtensions
{
/// <summary>
/// Adds the Policy Registry typed HTTP client to the service collection.
/// </summary>
public static IServiceCollection AddPolicyRegistryClient(
this IServiceCollection services,
Action<PolicyRegistryClientOptions>? configureOptions = null)
{
if (configureOptions is not null)
{
services.Configure(configureOptions);
}
services.AddHttpClient<IPolicyRegistryClient, PolicyRegistryClient>();
return services;
}
/// <summary>
/// Adds the Policy Registry typed HTTP client with a custom base address.
/// </summary>
public static IServiceCollection AddPolicyRegistryClient(
this IServiceCollection services,
string baseUrl)
{
services.Configure<PolicyRegistryClientOptions>(options =>
{
options.BaseUrl = baseUrl;
});
services.AddHttpClient<IPolicyRegistryClient, PolicyRegistryClient>(client =>
{
client.BaseAddress = new Uri(baseUrl);
});
return services;
}
/// <summary>
/// Adds the in-memory storage implementations for testing and development.
/// </summary>
public static IServiceCollection AddPolicyRegistryInMemoryStorage(this IServiceCollection services)
{
services.AddSingleton<IPolicyPackStore, InMemoryPolicyPackStore>();
services.AddSingleton<IVerificationPolicyStore, InMemoryVerificationPolicyStore>();
services.AddSingleton<ISnapshotStore, InMemorySnapshotStore>();
services.AddSingleton<IViolationStore, InMemoryViolationStore>();
services.AddSingleton<IOverrideStore, InMemoryOverrideStore>();
// Add compiler service
services.AddSingleton<IPolicyPackCompiler, PolicyPackCompiler>();
// Add simulation service
services.AddSingleton<IPolicySimulationService, PolicySimulationService>();
// Add batch simulation orchestrator
services.AddSingleton<IBatchSimulationOrchestrator, BatchSimulationOrchestrator>();
// Add review workflow service
services.AddSingleton<IReviewWorkflowService, ReviewWorkflowService>();
// Add publish pipeline service
services.AddSingleton<IPublishPipelineService, PublishPipelineService>();
// Add promotion service
services.AddSingleton<IPromotionService, PromotionService>();
return services;
}
/// <summary>
/// Adds the policy pack compiler service.
/// </summary>
public static IServiceCollection AddPolicyPackCompiler(this IServiceCollection services)
{
services.AddSingleton<IPolicyPackCompiler, PolicyPackCompiler>();
return services;
}
/// <summary>
/// Adds the policy simulation service.
/// </summary>
public static IServiceCollection AddPolicySimulationService(this IServiceCollection services)
{
services.AddSingleton<IPolicySimulationService, PolicySimulationService>();
return services;
}
/// <summary>
/// Adds the batch simulation orchestrator service.
/// </summary>
public static IServiceCollection AddBatchSimulationOrchestrator(this IServiceCollection services)
{
services.AddSingleton<IBatchSimulationOrchestrator, BatchSimulationOrchestrator>();
return services;
}
/// <summary>
/// Adds the review workflow service.
/// </summary>
public static IServiceCollection AddReviewWorkflowService(this IServiceCollection services)
{
services.AddSingleton<IReviewWorkflowService, ReviewWorkflowService>();
return services;
}
/// <summary>
/// Adds the publish pipeline service.
/// </summary>
public static IServiceCollection AddPublishPipelineService(this IServiceCollection services)
{
services.AddSingleton<IPublishPipelineService, PublishPipelineService>();
return services;
}
/// <summary>
/// Adds the promotion service.
/// </summary>
public static IServiceCollection AddPromotionService(this IServiceCollection services)
{
services.AddSingleton<IPromotionService, PromotionService>();
return services;
}
/// <summary>
/// Adds a custom policy pack store implementation.
/// </summary>
public static IServiceCollection AddPolicyPackStore<TStore>(this IServiceCollection services)
where TStore : class, IPolicyPackStore
{
services.AddSingleton<IPolicyPackStore, TStore>();
return services;
}
/// <summary>
/// Adds a custom verification policy store implementation.
/// </summary>
public static IServiceCollection AddVerificationPolicyStore<TStore>(this IServiceCollection services)
where TStore : class, IVerificationPolicyStore
{
services.AddSingleton<IVerificationPolicyStore, TStore>();
return services;
}
/// <summary>
/// Adds a custom snapshot store implementation.
/// </summary>
public static IServiceCollection AddSnapshotStore<TStore>(this IServiceCollection services)
where TStore : class, ISnapshotStore
{
services.AddSingleton<ISnapshotStore, TStore>();
return services;
}
/// <summary>
/// Adds a custom violation store implementation.
/// </summary>
public static IServiceCollection AddViolationStore<TStore>(this IServiceCollection services)
where TStore : class, IViolationStore
{
services.AddSingleton<IViolationStore, TStore>();
return services;
}
/// <summary>
/// Adds a custom override store implementation.
/// </summary>
public static IServiceCollection AddOverrideStore<TStore>(this IServiceCollection services)
where TStore : class, IOverrideStore
{
services.AddSingleton<IOverrideStore, TStore>();
return services;
}
}

View File

@@ -0,0 +1,406 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using StellaOps.Policy.Registry.Contracts;
namespace StellaOps.Policy.Registry.Services;
/// <summary>
/// Default implementation of batch simulation orchestrator.
/// Uses in-memory job queue with background processing.
/// </summary>
public sealed class BatchSimulationOrchestrator : IBatchSimulationOrchestrator, IDisposable
{
private readonly IPolicySimulationService _simulationService;
private readonly TimeProvider _timeProvider;
private readonly ConcurrentDictionary<(Guid TenantId, string JobId), BatchSimulationJob> _jobs = new();
private readonly ConcurrentDictionary<(Guid TenantId, string JobId), List<BatchSimulationInputResult>> _results = new();
private readonly ConcurrentDictionary<string, string> _idempotencyKeys = new();
private readonly ConcurrentQueue<(Guid TenantId, string JobId, BatchSimulationRequest Request)> _jobQueue = new();
private readonly CancellationTokenSource _disposalCts = new();
private readonly Task _processingTask;
public BatchSimulationOrchestrator(
IPolicySimulationService simulationService,
TimeProvider? timeProvider = null)
{
_simulationService = simulationService ?? throw new ArgumentNullException(nameof(simulationService));
_timeProvider = timeProvider ?? TimeProvider.System;
// Start background processing
_processingTask = Task.Run(ProcessJobsAsync);
}
public Task<BatchSimulationJob> SubmitBatchAsync(
Guid tenantId,
BatchSimulationRequest request,
CancellationToken cancellationToken = default)
{
// Check idempotency key
if (!string.IsNullOrEmpty(request.IdempotencyKey))
{
if (_idempotencyKeys.TryGetValue(request.IdempotencyKey, out var existingJobId))
{
var existingJob = _jobs.Values.FirstOrDefault(j => j.JobId == existingJobId && j.TenantId == tenantId);
if (existingJob is not null)
{
return Task.FromResult(existingJob);
}
}
}
var now = _timeProvider.GetUtcNow();
var jobId = GenerateJobId(tenantId, now);
var job = new BatchSimulationJob
{
JobId = jobId,
TenantId = tenantId,
PackId = request.PackId,
Status = BatchJobStatus.Pending,
Description = request.Description,
TotalInputs = request.Inputs.Count,
ProcessedInputs = 0,
SucceededInputs = 0,
FailedInputs = 0,
CreatedAt = now,
Progress = new BatchJobProgress
{
PercentComplete = 0,
EstimatedRemainingSeconds = null,
CurrentBatchIndex = 0,
TotalBatches = 1
}
};
_jobs[(tenantId, jobId)] = job;
_results[(tenantId, jobId)] = [];
if (!string.IsNullOrEmpty(request.IdempotencyKey))
{
_idempotencyKeys[request.IdempotencyKey] = jobId;
}
// Queue job for processing
_jobQueue.Enqueue((tenantId, jobId, request));
return Task.FromResult(job);
}
public Task<BatchSimulationJob?> GetJobAsync(
Guid tenantId,
string jobId,
CancellationToken cancellationToken = default)
{
_jobs.TryGetValue((tenantId, jobId), out var job);
return Task.FromResult(job);
}
public Task<BatchSimulationJobList> ListJobsAsync(
Guid tenantId,
BatchJobStatus? status = null,
int pageSize = 20,
string? pageToken = null,
CancellationToken cancellationToken = default)
{
var query = _jobs.Values.Where(j => j.TenantId == tenantId);
if (status.HasValue)
{
query = query.Where(j => j.Status == status.Value);
}
var items = query
.OrderByDescending(j => j.CreatedAt)
.ToList();
int skip = 0;
if (!string.IsNullOrEmpty(pageToken) && int.TryParse(pageToken, out var offset))
{
skip = offset;
}
var pagedItems = items.Skip(skip).Take(pageSize).ToList();
string? nextToken = skip + pagedItems.Count < items.Count
? (skip + pagedItems.Count).ToString()
: null;
return Task.FromResult(new BatchSimulationJobList
{
Items = pagedItems,
NextPageToken = nextToken,
TotalCount = items.Count
});
}
public Task<bool> CancelJobAsync(
Guid tenantId,
string jobId,
CancellationToken cancellationToken = default)
{
if (!_jobs.TryGetValue((tenantId, jobId), out var job))
{
return Task.FromResult(false);
}
if (job.Status is not (BatchJobStatus.Pending or BatchJobStatus.Running))
{
return Task.FromResult(false);
}
var cancelledJob = job with
{
Status = BatchJobStatus.Cancelled,
CompletedAt = _timeProvider.GetUtcNow()
};
_jobs[(tenantId, jobId)] = cancelledJob;
return Task.FromResult(true);
}
public Task<BatchSimulationResults?> GetResultsAsync(
Guid tenantId,
string jobId,
int pageSize = 100,
string? pageToken = null,
CancellationToken cancellationToken = default)
{
if (!_jobs.TryGetValue((tenantId, jobId), out var job))
{
return Task.FromResult<BatchSimulationResults?>(null);
}
if (!_results.TryGetValue((tenantId, jobId), out var results))
{
return Task.FromResult<BatchSimulationResults?>(null);
}
int skip = 0;
if (!string.IsNullOrEmpty(pageToken) && int.TryParse(pageToken, out var offset))
{
skip = offset;
}
var pagedResults = results.Skip(skip).Take(pageSize).ToList();
string? nextToken = skip + pagedResults.Count < results.Count
? (skip + pagedResults.Count).ToString()
: null;
var summary = job.Status == BatchJobStatus.Completed ? ComputeSummary(results) : null;
return Task.FromResult<BatchSimulationResults?>(new BatchSimulationResults
{
JobId = jobId,
Results = pagedResults,
Summary = summary,
NextPageToken = nextToken
});
}
private async Task ProcessJobsAsync()
{
while (!_disposalCts.Token.IsCancellationRequested)
{
if (_jobQueue.TryDequeue(out var item))
{
var (tenantId, jobId, request) = item;
// Check if job was cancelled
if (_jobs.TryGetValue((tenantId, jobId), out var job) && job.Status == BatchJobStatus.Cancelled)
{
continue;
}
await ProcessJobAsync(tenantId, jobId, request, _disposalCts.Token);
}
else
{
await Task.Delay(100, _disposalCts.Token).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
}
}
}
private async Task ProcessJobAsync(
Guid tenantId,
string jobId,
BatchSimulationRequest request,
CancellationToken cancellationToken)
{
var startedAt = _timeProvider.GetUtcNow();
var results = _results[(tenantId, jobId)];
// Update job to running
UpdateJob(tenantId, jobId, job => job with
{
Status = BatchJobStatus.Running,
StartedAt = startedAt
});
int processed = 0;
int succeeded = 0;
int failed = 0;
foreach (var input in request.Inputs)
{
if (cancellationToken.IsCancellationRequested)
{
break;
}
// Check if job was cancelled
if (_jobs.TryGetValue((tenantId, jobId), out var currentJob) && currentJob.Status == BatchJobStatus.Cancelled)
{
break;
}
try
{
var simRequest = new SimulationRequest
{
Input = input.Input,
Options = request.Options is not null ? new SimulationOptions
{
Trace = request.Options.IncludeTrace,
Explain = request.Options.IncludeExplain
} : null
};
var response = await _simulationService.SimulateAsync(
tenantId,
request.PackId,
simRequest,
cancellationToken);
results.Add(new BatchSimulationInputResult
{
InputId = input.InputId,
Success = response.Success,
Response = response,
DurationMilliseconds = response.DurationMilliseconds
});
if (response.Success)
{
succeeded++;
}
else
{
failed++;
if (!request.Options?.ContinueOnError ?? false)
{
break;
}
}
}
catch (Exception ex)
{
failed++;
results.Add(new BatchSimulationInputResult
{
InputId = input.InputId,
Success = false,
Error = ex.Message,
DurationMilliseconds = 0
});
if (!request.Options?.ContinueOnError ?? false)
{
break;
}
}
processed++;
// Update progress
var progress = (double)processed / request.Inputs.Count * 100;
UpdateJob(tenantId, jobId, job => job with
{
ProcessedInputs = processed,
SucceededInputs = succeeded,
FailedInputs = failed,
Progress = new BatchJobProgress
{
PercentComplete = progress,
CurrentBatchIndex = processed,
TotalBatches = request.Inputs.Count
}
});
}
// Finalize job
var completedAt = _timeProvider.GetUtcNow();
var finalStatus = failed > 0 && succeeded == 0
? BatchJobStatus.Failed
: BatchJobStatus.Completed;
UpdateJob(tenantId, jobId, job => job with
{
Status = finalStatus,
ProcessedInputs = processed,
SucceededInputs = succeeded,
FailedInputs = failed,
CompletedAt = completedAt,
Progress = new BatchJobProgress
{
PercentComplete = 100,
CurrentBatchIndex = processed,
TotalBatches = request.Inputs.Count
}
});
}
private void UpdateJob(Guid tenantId, string jobId, Func<BatchSimulationJob, BatchSimulationJob> update)
{
if (_jobs.TryGetValue((tenantId, jobId), out var current))
{
_jobs[(tenantId, jobId)] = update(current);
}
}
private static BatchSimulationSummary ComputeSummary(List<BatchSimulationInputResult> results)
{
var totalViolations = 0;
var severityCounts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
long totalDuration = 0;
foreach (var result in results)
{
totalDuration += result.DurationMilliseconds;
if (result.Response?.Summary?.ViolationsFound > 0)
{
totalViolations += result.Response.Summary.ViolationsFound;
foreach (var (severity, count) in result.Response.Summary.ViolationsBySeverity)
{
severityCounts[severity] = severityCounts.GetValueOrDefault(severity) + count;
}
}
}
return new BatchSimulationSummary
{
TotalInputs = results.Count,
Succeeded = results.Count(r => r.Success),
Failed = results.Count(r => !r.Success),
TotalViolations = totalViolations,
ViolationsBySeverity = severityCounts,
TotalDurationMilliseconds = totalDuration,
AverageDurationMilliseconds = results.Count > 0 ? (double)totalDuration / results.Count : 0
};
}
private static string GenerateJobId(Guid tenantId, DateTimeOffset timestamp)
{
var content = $"{tenantId}:{timestamp.ToUnixTimeMilliseconds()}:{Guid.NewGuid()}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
return $"batch_{Convert.ToHexString(hash)[..16].ToLowerInvariant()}";
}
public void Dispose()
{
_disposalCts.Cancel();
_processingTask.Wait(TimeSpan.FromSeconds(5));
_disposalCts.Dispose();
}
}

View File

@@ -0,0 +1,180 @@
using StellaOps.Policy.Registry.Contracts;
namespace StellaOps.Policy.Registry.Services;
/// <summary>
/// Service for orchestrating batch policy simulations.
/// Implements REGISTRY-API-27-005: Batch simulation orchestration.
/// </summary>
public interface IBatchSimulationOrchestrator
{
/// <summary>
/// Submits a batch simulation job.
/// </summary>
Task<BatchSimulationJob> SubmitBatchAsync(
Guid tenantId,
BatchSimulationRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the status of a batch simulation job.
/// </summary>
Task<BatchSimulationJob?> GetJobAsync(
Guid tenantId,
string jobId,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists batch simulation jobs for a tenant.
/// </summary>
Task<BatchSimulationJobList> ListJobsAsync(
Guid tenantId,
BatchJobStatus? status = null,
int pageSize = 20,
string? pageToken = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Cancels a pending or running batch simulation job.
/// </summary>
Task<bool> CancelJobAsync(
Guid tenantId,
string jobId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets results for a completed batch simulation job.
/// </summary>
Task<BatchSimulationResults?> GetResultsAsync(
Guid tenantId,
string jobId,
int pageSize = 100,
string? pageToken = null,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Request to submit a batch simulation.
/// </summary>
public sealed record BatchSimulationRequest
{
public required Guid PackId { get; init; }
public required IReadOnlyList<BatchSimulationInput> Inputs { get; init; }
public BatchSimulationOptions? Options { get; init; }
public string? Description { get; init; }
public int? Priority { get; init; }
public string? IdempotencyKey { get; init; }
}
/// <summary>
/// Single input for batch simulation.
/// </summary>
public sealed record BatchSimulationInput
{
public required string InputId { get; init; }
public required IReadOnlyDictionary<string, object> Input { get; init; }
public IReadOnlyDictionary<string, string>? Tags { get; init; }
}
/// <summary>
/// Options for batch simulation.
/// </summary>
public sealed record BatchSimulationOptions
{
public bool ContinueOnError { get; init; } = true;
public int? MaxConcurrency { get; init; }
public int? TimeoutSeconds { get; init; }
public bool IncludeTrace { get; init; }
public bool IncludeExplain { get; init; }
}
/// <summary>
/// Batch simulation job status.
/// </summary>
public enum BatchJobStatus
{
Pending,
Running,
Completed,
Failed,
Cancelled
}
/// <summary>
/// Batch simulation job.
/// </summary>
public sealed record BatchSimulationJob
{
public required string JobId { get; init; }
public required Guid TenantId { get; init; }
public required Guid PackId { get; init; }
public required BatchJobStatus Status { get; init; }
public string? Description { get; init; }
public required int TotalInputs { get; init; }
public int ProcessedInputs { get; init; }
public int SucceededInputs { get; init; }
public int FailedInputs { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? StartedAt { get; init; }
public DateTimeOffset? CompletedAt { get; init; }
public string? Error { get; init; }
public BatchJobProgress? Progress { get; init; }
}
/// <summary>
/// Progress information for a batch job.
/// </summary>
public sealed record BatchJobProgress
{
public required double PercentComplete { get; init; }
public long? EstimatedRemainingSeconds { get; init; }
public int? CurrentBatchIndex { get; init; }
public int? TotalBatches { get; init; }
}
/// <summary>
/// List of batch simulation jobs.
/// </summary>
public sealed record BatchSimulationJobList
{
public required IReadOnlyList<BatchSimulationJob> Items { get; init; }
public string? NextPageToken { get; init; }
public int TotalCount { get; init; }
}
/// <summary>
/// Results from a completed batch simulation.
/// </summary>
public sealed record BatchSimulationResults
{
public required string JobId { get; init; }
public required IReadOnlyList<BatchSimulationInputResult> Results { get; init; }
public BatchSimulationSummary? Summary { get; init; }
public string? NextPageToken { get; init; }
}
/// <summary>
/// Result for a single input in batch simulation.
/// </summary>
public sealed record BatchSimulationInputResult
{
public required string InputId { get; init; }
public required bool Success { get; init; }
public PolicySimulationResponse? Response { get; init; }
public string? Error { get; init; }
public long DurationMilliseconds { get; init; }
}
/// <summary>
/// Summary of batch simulation results.
/// </summary>
public sealed record BatchSimulationSummary
{
public required int TotalInputs { get; init; }
public required int Succeeded { get; init; }
public required int Failed { get; init; }
public required int TotalViolations { get; init; }
public required IReadOnlyDictionary<string, int> ViolationsBySeverity { get; init; }
public required long TotalDurationMilliseconds { get; init; }
public required double AverageDurationMilliseconds { get; init; }
}

View File

@@ -0,0 +1,115 @@
using StellaOps.Policy.Registry.Contracts;
using StellaOps.Policy.Registry.Storage;
namespace StellaOps.Policy.Registry.Services;
/// <summary>
/// Service for compiling and validating policy packs.
/// Implements REGISTRY-API-27-003: Compile endpoint integration.
/// </summary>
public interface IPolicyPackCompiler
{
/// <summary>
/// Compiles a policy pack, validating all rules and computing a digest.
/// </summary>
Task<PolicyPackCompilationResult> CompileAsync(
Guid tenantId,
Guid packId,
CancellationToken cancellationToken = default);
/// <summary>
/// Validates a single Rego rule without persisting.
/// </summary>
Task<RuleValidationResult> ValidateRuleAsync(
string ruleId,
string? rego,
CancellationToken cancellationToken = default);
/// <summary>
/// Validates all rules in a policy pack without persisting.
/// </summary>
Task<PolicyPackCompilationResult> ValidatePackAsync(
CreatePolicyPackRequest request,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of policy pack compilation.
/// </summary>
public sealed record PolicyPackCompilationResult
{
public required bool Success { get; init; }
public string? Digest { get; init; }
public IReadOnlyList<CompilationError>? Errors { get; init; }
public IReadOnlyList<CompilationWarning>? Warnings { get; init; }
public PolicyPackCompilationStatistics? Statistics { get; init; }
public long DurationMilliseconds { get; init; }
public static PolicyPackCompilationResult FromSuccess(
string digest,
PolicyPackCompilationStatistics statistics,
IReadOnlyList<CompilationWarning>? warnings,
long durationMs) => new()
{
Success = true,
Digest = digest,
Statistics = statistics,
Warnings = warnings,
DurationMilliseconds = durationMs
};
public static PolicyPackCompilationResult FromFailure(
IReadOnlyList<CompilationError> errors,
IReadOnlyList<CompilationWarning>? warnings,
long durationMs) => new()
{
Success = false,
Errors = errors,
Warnings = warnings,
DurationMilliseconds = durationMs
};
}
/// <summary>
/// Result of single rule validation.
/// </summary>
public sealed record RuleValidationResult
{
public required bool Success { get; init; }
public string? RuleId { get; init; }
public IReadOnlyList<CompilationError>? Errors { get; init; }
public IReadOnlyList<CompilationWarning>? Warnings { get; init; }
public static RuleValidationResult FromSuccess(
string ruleId,
IReadOnlyList<CompilationWarning>? warnings = null) => new()
{
Success = true,
RuleId = ruleId,
Warnings = warnings
};
public static RuleValidationResult FromFailure(
string ruleId,
IReadOnlyList<CompilationError> errors,
IReadOnlyList<CompilationWarning>? warnings = null) => new()
{
Success = false,
RuleId = ruleId,
Errors = errors,
Warnings = warnings
};
}
/// <summary>
/// Statistics from policy pack compilation.
/// </summary>
public sealed record PolicyPackCompilationStatistics
{
public required int TotalRules { get; init; }
public required int EnabledRules { get; init; }
public required int DisabledRules { get; init; }
public required int RulesWithRego { get; init; }
public required int RulesWithoutRego { get; init; }
public required IReadOnlyDictionary<string, int> SeverityCounts { get; init; }
}

View File

@@ -0,0 +1,97 @@
using StellaOps.Policy.Registry.Contracts;
namespace StellaOps.Policy.Registry.Services;
/// <summary>
/// Service for quick policy pack simulation.
/// Implements REGISTRY-API-27-004: Quick simulation API.
/// </summary>
public interface IPolicySimulationService
{
/// <summary>
/// Simulates a policy pack against provided input.
/// </summary>
Task<PolicySimulationResponse> SimulateAsync(
Guid tenantId,
Guid packId,
SimulationRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Simulates rules directly without requiring a persisted pack.
/// Useful for testing rules during development.
/// </summary>
Task<PolicySimulationResponse> SimulateRulesAsync(
Guid tenantId,
IReadOnlyList<PolicyRule> rules,
SimulationRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Validates simulation input structure.
/// </summary>
Task<InputValidationResult> ValidateInputAsync(
IReadOnlyDictionary<string, object> input,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Response from policy simulation.
/// </summary>
public sealed record PolicySimulationResponse
{
public required string SimulationId { get; init; }
public required bool Success { get; init; }
public required DateTimeOffset ExecutedAt { get; init; }
public required long DurationMilliseconds { get; init; }
public SimulationResult? Result { get; init; }
public SimulationSummary? Summary { get; init; }
public IReadOnlyList<SimulationError>? Errors { get; init; }
}
/// <summary>
/// Summary of simulation execution.
/// </summary>
public sealed record SimulationSummary
{
public required int TotalRulesEvaluated { get; init; }
public required int RulesMatched { get; init; }
public required int ViolationsFound { get; init; }
public required IReadOnlyDictionary<string, int> ViolationsBySeverity { get; init; }
}
/// <summary>
/// Error during simulation.
/// </summary>
public sealed record SimulationError
{
public string? RuleId { get; init; }
public required string Code { get; init; }
public required string Message { get; init; }
}
/// <summary>
/// Result of input validation.
/// </summary>
public sealed record InputValidationResult
{
public required bool IsValid { get; init; }
public IReadOnlyList<InputValidationError>? Errors { get; init; }
public static InputValidationResult Valid() => new() { IsValid = true };
public static InputValidationResult Invalid(IReadOnlyList<InputValidationError> errors) => new()
{
IsValid = false,
Errors = errors
};
}
/// <summary>
/// Input validation error.
/// </summary>
public sealed record InputValidationError
{
public required string Path { get; init; }
public required string Message { get; init; }
}

View File

@@ -0,0 +1,276 @@
using StellaOps.Policy.Registry.Contracts;
namespace StellaOps.Policy.Registry.Services;
/// <summary>
/// Service for managing policy pack promotions across environments.
/// Implements REGISTRY-API-27-008: Promotion bindings per tenant/environment.
/// </summary>
public interface IPromotionService
{
/// <summary>
/// Creates a promotion binding for a policy pack to an environment.
/// </summary>
Task<PromotionBinding> CreateBindingAsync(
Guid tenantId,
CreatePromotionBindingRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Promotes a policy pack to a target environment.
/// </summary>
Task<PromotionResult> PromoteAsync(
Guid tenantId,
Guid packId,
PromoteRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the current binding for a pack/environment combination.
/// </summary>
Task<PromotionBinding?> GetBindingAsync(
Guid tenantId,
Guid packId,
string environment,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists all bindings for a tenant.
/// </summary>
Task<PromotionBindingList> ListBindingsAsync(
Guid tenantId,
string? environment = null,
Guid? packId = null,
int pageSize = 20,
string? pageToken = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the active policy pack for an environment.
/// </summary>
Task<ActiveEnvironmentPolicy?> GetActiveForEnvironmentAsync(
Guid tenantId,
string environment,
CancellationToken cancellationToken = default);
/// <summary>
/// Rolls back to a previous promotion for an environment.
/// </summary>
Task<RollbackResult> RollbackAsync(
Guid tenantId,
string environment,
RollbackRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the promotion history for an environment.
/// </summary>
Task<IReadOnlyList<PromotionHistoryEntry>> GetHistoryAsync(
Guid tenantId,
string environment,
int limit = 50,
CancellationToken cancellationToken = default);
/// <summary>
/// Validates a promotion is allowed before executing.
/// </summary>
Task<PromotionValidationResult> ValidatePromotionAsync(
Guid tenantId,
Guid packId,
string targetEnvironment,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Request to create a promotion binding.
/// </summary>
public sealed record CreatePromotionBindingRequest
{
public required Guid PackId { get; init; }
public required string Environment { get; init; }
public PromotionBindingMode Mode { get; init; } = PromotionBindingMode.Manual;
public PromotionBindingRules? Rules { get; init; }
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
public string? CreatedBy { get; init; }
}
/// <summary>
/// Request to promote a policy pack.
/// </summary>
public sealed record PromoteRequest
{
public required string TargetEnvironment { get; init; }
public string? ApprovalId { get; init; }
public string? PromotedBy { get; init; }
public string? Comment { get; init; }
public bool Force { get; init; }
}
/// <summary>
/// Request to rollback a promotion.
/// </summary>
public sealed record RollbackRequest
{
public string? TargetBindingId { get; init; }
public int? StepsBack { get; init; }
public string? RolledBackBy { get; init; }
public string? Reason { get; init; }
}
/// <summary>
/// Promotion binding mode.
/// </summary>
public enum PromotionBindingMode
{
Manual,
AutomaticOnApproval,
Scheduled,
Canary
}
/// <summary>
/// Rules for automatic promotion.
/// </summary>
public sealed record PromotionBindingRules
{
public IReadOnlyList<string>? RequiredApprovers { get; init; }
public int? MinimumApprovals { get; init; }
public bool RequireSuccessfulSimulation { get; init; }
public int? MinimumSimulationInputs { get; init; }
public TimeSpan? MinimumSoakPeriod { get; init; }
public IReadOnlyList<string>? AllowedSourceEnvironments { get; init; }
}
/// <summary>
/// Promotion binding.
/// </summary>
public sealed record PromotionBinding
{
public required string BindingId { get; init; }
public required Guid TenantId { get; init; }
public required Guid PackId { get; init; }
public required string PackVersion { get; init; }
public required string Environment { get; init; }
public required PromotionBindingMode Mode { get; init; }
public required PromotionBindingStatus Status { get; init; }
public PromotionBindingRules? Rules { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? ActivatedAt { get; init; }
public DateTimeOffset? DeactivatedAt { get; init; }
public string? CreatedBy { get; init; }
public string? ActivatedBy { get; init; }
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Promotion binding status.
/// </summary>
public enum PromotionBindingStatus
{
Pending,
Active,
Superseded,
RolledBack,
Disabled
}
/// <summary>
/// Result of a promotion operation.
/// </summary>
public sealed record PromotionResult
{
public required bool Success { get; init; }
public PromotionBinding? Binding { get; init; }
public string? PreviousBindingId { get; init; }
public string? Error { get; init; }
public IReadOnlyList<string>? Warnings { get; init; }
}
/// <summary>
/// List of promotion bindings.
/// </summary>
public sealed record PromotionBindingList
{
public required IReadOnlyList<PromotionBinding> Items { get; init; }
public string? NextPageToken { get; init; }
public int TotalCount { get; init; }
}
/// <summary>
/// Active policy pack for an environment.
/// </summary>
public sealed record ActiveEnvironmentPolicy
{
public required string Environment { get; init; }
public required Guid PackId { get; init; }
public required string PackVersion { get; init; }
public required string PackDigest { get; init; }
public required string BindingId { get; init; }
public required DateTimeOffset ActivatedAt { get; init; }
public string? ActivatedBy { get; init; }
}
/// <summary>
/// Result of a rollback operation.
/// </summary>
public sealed record RollbackResult
{
public required bool Success { get; init; }
public PromotionBinding? RestoredBinding { get; init; }
public string? RolledBackBindingId { get; init; }
public string? Error { get; init; }
}
/// <summary>
/// Promotion history entry.
/// </summary>
public sealed record PromotionHistoryEntry
{
public required string BindingId { get; init; }
public required Guid PackId { get; init; }
public required string PackVersion { get; init; }
public required PromotionHistoryAction Action { get; init; }
public required DateTimeOffset Timestamp { get; init; }
public string? PerformedBy { get; init; }
public string? Comment { get; init; }
public string? PreviousBindingId { get; init; }
}
/// <summary>
/// Promotion history action types.
/// </summary>
public enum PromotionHistoryAction
{
Promoted,
RolledBack,
Disabled,
Superseded
}
/// <summary>
/// Result of promotion validation.
/// </summary>
public sealed record PromotionValidationResult
{
public required bool IsValid { get; init; }
public IReadOnlyList<PromotionValidationError>? Errors { get; init; }
public IReadOnlyList<PromotionValidationWarning>? Warnings { get; init; }
}
/// <summary>
/// Promotion validation error.
/// </summary>
public sealed record PromotionValidationError
{
public required string Code { get; init; }
public required string Message { get; init; }
}
/// <summary>
/// Promotion validation warning.
/// </summary>
public sealed record PromotionValidationWarning
{
public required string Code { get; init; }
public required string Message { get; init; }
}

View File

@@ -0,0 +1,286 @@
using StellaOps.Policy.Registry.Contracts;
namespace StellaOps.Policy.Registry.Services;
/// <summary>
/// Service for publishing policy packs with signing and attestations.
/// Implements REGISTRY-API-27-007: Publish pipeline with signing/attestations.
/// </summary>
public interface IPublishPipelineService
{
/// <summary>
/// Publishes an approved policy pack.
/// </summary>
Task<PublishResult> PublishAsync(
Guid tenantId,
Guid packId,
PublishPackRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the publication status of a policy pack.
/// </summary>
Task<PublicationStatus?> GetPublicationStatusAsync(
Guid tenantId,
Guid packId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the attestation for a published policy pack.
/// </summary>
Task<PolicyPackAttestation?> GetAttestationAsync(
Guid tenantId,
Guid packId,
CancellationToken cancellationToken = default);
/// <summary>
/// Verifies the signature and attestation of a published policy pack.
/// </summary>
Task<AttestationVerificationResult> VerifyAttestationAsync(
Guid tenantId,
Guid packId,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists published policy packs for a tenant.
/// </summary>
Task<PublishedPackList> ListPublishedAsync(
Guid tenantId,
int pageSize = 20,
string? pageToken = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Revokes a published policy pack.
/// </summary>
Task<RevokeResult> RevokeAsync(
Guid tenantId,
Guid packId,
RevokePackRequest request,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Request to publish a policy pack.
/// </summary>
public sealed record PublishPackRequest
{
public string? ApprovalId { get; init; }
public string? PublishedBy { get; init; }
public SigningOptions? SigningOptions { get; init; }
public AttestationOptions? AttestationOptions { get; init; }
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Signing options for policy pack publication.
/// </summary>
public sealed record SigningOptions
{
public required string KeyId { get; init; }
public SigningAlgorithm Algorithm { get; init; } = SigningAlgorithm.ECDSA_P256_SHA256;
public bool IncludeTimestamp { get; init; } = true;
public bool IncludeRekorEntry { get; init; }
}
/// <summary>
/// Attestation options for policy pack publication.
/// </summary>
public sealed record AttestationOptions
{
public required string PredicateType { get; init; }
public bool IncludeCompilationResult { get; init; } = true;
public bool IncludeReviewHistory { get; init; } = true;
public bool IncludeSimulationResults { get; init; }
public IReadOnlyDictionary<string, object>? CustomClaims { get; init; }
}
/// <summary>
/// Supported signing algorithms.
/// </summary>
public enum SigningAlgorithm
{
ECDSA_P256_SHA256,
ECDSA_P384_SHA384,
RSA_PKCS1_SHA256,
RSA_PSS_SHA256,
Ed25519
}
/// <summary>
/// Result of policy pack publication.
/// </summary>
public sealed record PublishResult
{
public required bool Success { get; init; }
public Guid? PackId { get; init; }
public string? Digest { get; init; }
public PublicationStatus? Status { get; init; }
public PolicyPackAttestation? Attestation { get; init; }
public string? Error { get; init; }
}
/// <summary>
/// Publication status of a policy pack.
/// </summary>
public sealed record PublicationStatus
{
public required Guid PackId { get; init; }
public required string PackVersion { get; init; }
public required string Digest { get; init; }
public required PublishState State { get; init; }
public required DateTimeOffset PublishedAt { get; init; }
public string? PublishedBy { get; init; }
public DateTimeOffset? RevokedAt { get; init; }
public string? RevokedBy { get; init; }
public string? RevokeReason { get; init; }
public string? SignatureKeyId { get; init; }
public SigningAlgorithm? SignatureAlgorithm { get; init; }
public string? RekorLogId { get; init; }
}
/// <summary>
/// Publication state.
/// </summary>
public enum PublishState
{
Published,
Revoked,
Superseded
}
/// <summary>
/// Policy pack attestation following in-toto/DSSE format.
/// </summary>
public sealed record PolicyPackAttestation
{
public required string PayloadType { get; init; }
public required string Payload { get; init; }
public required IReadOnlyList<AttestationSignature> Signatures { get; init; }
}
/// <summary>
/// Attestation signature.
/// </summary>
public sealed record AttestationSignature
{
public required string KeyId { get; init; }
public required string Signature { get; init; }
public DateTimeOffset? Timestamp { get; init; }
public string? RekorLogIndex { get; init; }
}
/// <summary>
/// Attestation payload in SLSA provenance format.
/// </summary>
public sealed record AttestationPayload
{
public required string Type { get; init; }
public required string PredicateType { get; init; }
public required AttestationSubject Subject { get; init; }
public required AttestationPredicate Predicate { get; init; }
}
/// <summary>
/// Attestation subject (the policy pack).
/// </summary>
public sealed record AttestationSubject
{
public required string Name { get; init; }
public required IReadOnlyDictionary<string, string> Digest { get; init; }
}
/// <summary>
/// Attestation predicate containing provenance metadata.
/// </summary>
public sealed record AttestationPredicate
{
public required string BuildType { get; init; }
public required AttestationBuilder Builder { get; init; }
public DateTimeOffset? BuildStartedOn { get; init; }
public DateTimeOffset? BuildFinishedOn { get; init; }
public PolicyPackCompilationMetadata? Compilation { get; init; }
public PolicyPackReviewMetadata? Review { get; init; }
public IReadOnlyDictionary<string, object>? Metadata { get; init; }
}
/// <summary>
/// Attestation builder information.
/// </summary>
public sealed record AttestationBuilder
{
public required string Id { get; init; }
public string? Version { get; init; }
}
/// <summary>
/// Compilation metadata in attestation.
/// </summary>
public sealed record PolicyPackCompilationMetadata
{
public required string Digest { get; init; }
public required int RuleCount { get; init; }
public DateTimeOffset? CompiledAt { get; init; }
public IReadOnlyDictionary<string, int>? Statistics { get; init; }
}
/// <summary>
/// Review metadata in attestation.
/// </summary>
public sealed record PolicyPackReviewMetadata
{
public required string ReviewId { get; init; }
public required DateTimeOffset ApprovedAt { get; init; }
public string? ApprovedBy { get; init; }
public IReadOnlyList<string>? Reviewers { get; init; }
}
/// <summary>
/// Result of attestation verification.
/// </summary>
public sealed record AttestationVerificationResult
{
public required bool Valid { get; init; }
public IReadOnlyList<VerificationCheck>? Checks { get; init; }
public IReadOnlyList<string>? Errors { get; init; }
public IReadOnlyList<string>? Warnings { get; init; }
}
/// <summary>
/// Individual verification check result.
/// </summary>
public sealed record VerificationCheck
{
public required string Name { get; init; }
public required bool Passed { get; init; }
public string? Details { get; init; }
}
/// <summary>
/// List of published policy packs.
/// </summary>
public sealed record PublishedPackList
{
public required IReadOnlyList<PublicationStatus> Items { get; init; }
public string? NextPageToken { get; init; }
public int TotalCount { get; init; }
}
/// <summary>
/// Request to revoke a published policy pack.
/// </summary>
public sealed record RevokePackRequest
{
public required string Reason { get; init; }
public string? RevokedBy { get; init; }
}
/// <summary>
/// Result of policy pack revocation.
/// </summary>
public sealed record RevokeResult
{
public required bool Success { get; init; }
public PublicationStatus? Status { get; init; }
public string? Error { get; init; }
}

View File

@@ -0,0 +1,242 @@
using StellaOps.Policy.Registry.Contracts;
namespace StellaOps.Policy.Registry.Services;
/// <summary>
/// Service for managing policy pack review workflows with audit trails.
/// Implements REGISTRY-API-27-006: Review workflow with audit trails.
/// </summary>
public interface IReviewWorkflowService
{
/// <summary>
/// Submits a policy pack for review.
/// </summary>
Task<ReviewRequest> SubmitForReviewAsync(
Guid tenantId,
Guid packId,
SubmitReviewRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Approves a review request.
/// </summary>
Task<ReviewDecision> ApproveAsync(
Guid tenantId,
string reviewId,
ApproveReviewRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Rejects a review request.
/// </summary>
Task<ReviewDecision> RejectAsync(
Guid tenantId,
string reviewId,
RejectReviewRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Requests changes to a policy pack under review.
/// </summary>
Task<ReviewDecision> RequestChangesAsync(
Guid tenantId,
string reviewId,
RequestChangesRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets a review request by ID.
/// </summary>
Task<ReviewRequest?> GetReviewAsync(
Guid tenantId,
string reviewId,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists review requests for a tenant.
/// </summary>
Task<ReviewRequestList> ListReviewsAsync(
Guid tenantId,
ReviewStatus? status = null,
Guid? packId = null,
int pageSize = 20,
string? pageToken = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the audit trail for a review.
/// </summary>
Task<IReadOnlyList<ReviewAuditEntry>> GetAuditTrailAsync(
Guid tenantId,
string reviewId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the audit trail for a policy pack across all reviews.
/// </summary>
Task<IReadOnlyList<ReviewAuditEntry>> GetPackAuditTrailAsync(
Guid tenantId,
Guid packId,
int limit = 100,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Request to submit a policy pack for review.
/// </summary>
public sealed record SubmitReviewRequest
{
public string? Description { get; init; }
public IReadOnlyList<string>? Reviewers { get; init; }
public ReviewUrgency Urgency { get; init; } = ReviewUrgency.Normal;
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to approve a review.
/// </summary>
public sealed record ApproveReviewRequest
{
public string? Comment { get; init; }
public string? ApprovedBy { get; init; }
}
/// <summary>
/// Request to reject a review.
/// </summary>
public sealed record RejectReviewRequest
{
public required string Reason { get; init; }
public string? RejectedBy { get; init; }
}
/// <summary>
/// Request to request changes.
/// </summary>
public sealed record RequestChangesRequest
{
public required IReadOnlyList<ReviewComment> Comments { get; init; }
public string? RequestedBy { get; init; }
}
/// <summary>
/// Review comment.
/// </summary>
public sealed record ReviewComment
{
public string? RuleId { get; init; }
public required string Comment { get; init; }
public ReviewCommentSeverity Severity { get; init; } = ReviewCommentSeverity.Suggestion;
}
/// <summary>
/// Review comment severity.
/// </summary>
public enum ReviewCommentSeverity
{
Suggestion,
Warning,
Blocking
}
/// <summary>
/// Review urgency level.
/// </summary>
public enum ReviewUrgency
{
Low,
Normal,
High,
Critical
}
/// <summary>
/// Review request status.
/// </summary>
public enum ReviewStatus
{
Pending,
InReview,
ChangesRequested,
Approved,
Rejected,
Cancelled
}
/// <summary>
/// Review request.
/// </summary>
public sealed record ReviewRequest
{
public required string ReviewId { get; init; }
public required Guid TenantId { get; init; }
public required Guid PackId { get; init; }
public required string PackVersion { get; init; }
public required ReviewStatus Status { get; init; }
public string? Description { get; init; }
public IReadOnlyList<string>? Reviewers { get; init; }
public ReviewUrgency Urgency { get; init; }
public string? SubmittedBy { get; init; }
public required DateTimeOffset SubmittedAt { get; init; }
public DateTimeOffset? ResolvedAt { get; init; }
public string? ResolvedBy { get; init; }
public IReadOnlyList<ReviewComment>? PendingComments { get; init; }
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Review decision result.
/// </summary>
public sealed record ReviewDecision
{
public required string ReviewId { get; init; }
public required ReviewStatus NewStatus { get; init; }
public required DateTimeOffset DecidedAt { get; init; }
public string? DecidedBy { get; init; }
public string? Comment { get; init; }
public IReadOnlyList<ReviewComment>? Comments { get; init; }
}
/// <summary>
/// List of review requests.
/// </summary>
public sealed record ReviewRequestList
{
public required IReadOnlyList<ReviewRequest> Items { get; init; }
public string? NextPageToken { get; init; }
public int TotalCount { get; init; }
}
/// <summary>
/// Audit entry for review actions.
/// </summary>
public sealed record ReviewAuditEntry
{
public required string AuditId { get; init; }
public required string ReviewId { get; init; }
public required Guid PackId { get; init; }
public required ReviewAuditAction Action { get; init; }
public required DateTimeOffset Timestamp { get; init; }
public string? PerformedBy { get; init; }
public ReviewStatus? PreviousStatus { get; init; }
public ReviewStatus? NewStatus { get; init; }
public string? Comment { get; init; }
public IReadOnlyDictionary<string, object>? Details { get; init; }
}
/// <summary>
/// Review audit action types.
/// </summary>
public enum ReviewAuditAction
{
Submitted,
AssignedReviewer,
RemovedReviewer,
CommentAdded,
ChangesRequested,
Approved,
Rejected,
Cancelled,
Reopened,
StatusChanged
}

View File

@@ -0,0 +1,299 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using StellaOps.Policy.Registry.Contracts;
using StellaOps.Policy.Registry.Storage;
namespace StellaOps.Policy.Registry.Services;
/// <summary>
/// Default implementation of policy pack compiler.
/// Validates Rego syntax and computes content digest.
/// </summary>
public sealed partial class PolicyPackCompiler : IPolicyPackCompiler
{
private readonly IPolicyPackStore _packStore;
private readonly TimeProvider _timeProvider;
// Basic Rego syntax patterns for validation
[GeneratedRegex(@"^package\s+[\w.]+", RegexOptions.Multiline)]
private static partial Regex PackageDeclarationRegex();
[GeneratedRegex(@"^\s*#.*$", RegexOptions.Multiline)]
private static partial Regex CommentLineRegex();
[GeneratedRegex(@"^\s*(default\s+)?\w+\s*(=|:=|\[)", RegexOptions.Multiline)]
private static partial Regex RuleDefinitionRegex();
[GeneratedRegex(@"input\.\w+", RegexOptions.None)]
private static partial Regex InputReferenceRegex();
[GeneratedRegex(@"\{[^}]*\}", RegexOptions.None)]
private static partial Regex SetLiteralRegex();
[GeneratedRegex(@"\[[^\]]*\]", RegexOptions.None)]
private static partial Regex ArrayLiteralRegex();
public PolicyPackCompiler(IPolicyPackStore packStore, TimeProvider? timeProvider = null)
{
_packStore = packStore ?? throw new ArgumentNullException(nameof(packStore));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<PolicyPackCompilationResult> CompileAsync(
Guid tenantId,
Guid packId,
CancellationToken cancellationToken = default)
{
var start = _timeProvider.GetTimestamp();
var pack = await _packStore.GetByIdAsync(tenantId, packId, cancellationToken);
if (pack is null)
{
return PolicyPackCompilationResult.FromFailure(
[new CompilationError { Message = $"Policy pack {packId} not found" }],
null,
GetElapsedMs(start));
}
return await CompilePackRulesAsync(pack.PackId.ToString(), pack.Rules, start, cancellationToken);
}
public Task<RuleValidationResult> ValidateRuleAsync(
string ruleId,
string? rego,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(rego))
{
// Rules without Rego are valid (might use DSL or other syntax)
return Task.FromResult(RuleValidationResult.FromSuccess(ruleId));
}
var errors = new List<CompilationError>();
var warnings = new List<CompilationWarning>();
ValidateRegoSyntax(ruleId, rego, errors, warnings);
if (errors.Count > 0)
{
return Task.FromResult(RuleValidationResult.FromFailure(ruleId, errors, warnings.Count > 0 ? warnings : null));
}
return Task.FromResult(RuleValidationResult.FromSuccess(ruleId, warnings.Count > 0 ? warnings : null));
}
public async Task<PolicyPackCompilationResult> ValidatePackAsync(
CreatePolicyPackRequest request,
CancellationToken cancellationToken = default)
{
var start = _timeProvider.GetTimestamp();
return await CompilePackRulesAsync(request.Name, request.Rules, start, cancellationToken);
}
private async Task<PolicyPackCompilationResult> CompilePackRulesAsync(
string packIdentifier,
IReadOnlyList<PolicyRule>? rules,
long startTimestamp,
CancellationToken cancellationToken)
{
if (rules is null || rules.Count == 0)
{
// Empty pack is valid
var emptyStats = CreateStatistics([]);
var emptyDigest = ComputeDigest([]);
return PolicyPackCompilationResult.FromSuccess(emptyDigest, emptyStats, null, GetElapsedMs(startTimestamp));
}
var allErrors = new List<CompilationError>();
var allWarnings = new List<CompilationWarning>();
var validatedRules = new List<PolicyRule>();
foreach (var rule in rules)
{
cancellationToken.ThrowIfCancellationRequested();
var result = await ValidateRuleAsync(rule.RuleId, rule.Rego, cancellationToken);
if (result.Errors is { Count: > 0 })
{
allErrors.AddRange(result.Errors);
}
if (result.Warnings is { Count: > 0 })
{
allWarnings.AddRange(result.Warnings);
}
validatedRules.Add(rule);
}
var elapsed = GetElapsedMs(startTimestamp);
if (allErrors.Count > 0)
{
return PolicyPackCompilationResult.FromFailure(allErrors, allWarnings.Count > 0 ? allWarnings : null, elapsed);
}
var statistics = CreateStatistics(rules);
var digest = ComputeDigest(rules);
return PolicyPackCompilationResult.FromSuccess(
digest,
statistics,
allWarnings.Count > 0 ? allWarnings : null,
elapsed);
}
private void ValidateRegoSyntax(
string ruleId,
string rego,
List<CompilationError> errors,
List<CompilationWarning> warnings)
{
// Strip comments for analysis
var codeWithoutComments = CommentLineRegex().Replace(rego, "");
var trimmedCode = codeWithoutComments.Trim();
if (string.IsNullOrWhiteSpace(trimmedCode))
{
errors.Add(new CompilationError
{
RuleId = ruleId,
Message = "Rego code contains only comments or whitespace"
});
return;
}
// Check for basic Rego structure
var hasPackage = PackageDeclarationRegex().IsMatch(rego);
var hasRuleDefinition = RuleDefinitionRegex().IsMatch(codeWithoutComments);
if (!hasPackage && !hasRuleDefinition)
{
errors.Add(new CompilationError
{
RuleId = ruleId,
Message = "Rego code must contain either a package declaration or at least one rule definition"
});
}
// Check for unmatched braces
var openBraces = trimmedCode.Count(c => c == '{');
var closeBraces = trimmedCode.Count(c => c == '}');
if (openBraces != closeBraces)
{
errors.Add(new CompilationError
{
RuleId = ruleId,
Message = $"Unmatched braces: {openBraces} open, {closeBraces} close"
});
}
// Check for unmatched brackets
var openBrackets = trimmedCode.Count(c => c == '[');
var closeBrackets = trimmedCode.Count(c => c == ']');
if (openBrackets != closeBrackets)
{
errors.Add(new CompilationError
{
RuleId = ruleId,
Message = $"Unmatched brackets: {openBrackets} open, {closeBrackets} close"
});
}
// Check for unmatched parentheses
var openParens = trimmedCode.Count(c => c == '(');
var closeParens = trimmedCode.Count(c => c == ')');
if (openParens != closeParens)
{
errors.Add(new CompilationError
{
RuleId = ruleId,
Message = $"Unmatched parentheses: {openParens} open, {closeParens} close"
});
}
// Warnings for common issues
if (!InputReferenceRegex().IsMatch(rego) && hasRuleDefinition)
{
warnings.Add(new CompilationWarning
{
RuleId = ruleId,
Message = "Rule does not reference 'input' - may not receive evaluation context"
});
}
// Check for deprecated or unsafe patterns
if (rego.Contains("http.send"))
{
warnings.Add(new CompilationWarning
{
RuleId = ruleId,
Message = "Use of http.send may cause non-deterministic behavior in offline/air-gapped environments"
});
}
if (rego.Contains("time.now_ns"))
{
warnings.Add(new CompilationWarning
{
RuleId = ruleId,
Message = "Use of time.now_ns may cause non-deterministic results across evaluations"
});
}
}
private static PolicyPackCompilationStatistics CreateStatistics(IReadOnlyList<PolicyRule> rules)
{
var severityCounts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var rule in rules)
{
var severityKey = rule.Severity.ToString().ToLowerInvariant();
severityCounts[severityKey] = severityCounts.GetValueOrDefault(severityKey) + 1;
}
return new PolicyPackCompilationStatistics
{
TotalRules = rules.Count,
EnabledRules = rules.Count(r => r.Enabled),
DisabledRules = rules.Count(r => !r.Enabled),
RulesWithRego = rules.Count(r => !string.IsNullOrWhiteSpace(r.Rego)),
RulesWithoutRego = rules.Count(r => string.IsNullOrWhiteSpace(r.Rego)),
SeverityCounts = severityCounts
};
}
private static string ComputeDigest(IReadOnlyList<PolicyRule> rules)
{
// Create deterministic representation for hashing
var orderedRules = rules
.OrderBy(r => r.RuleId, StringComparer.Ordinal)
.Select(r => new
{
r.RuleId,
r.Name,
r.Severity,
r.Rego,
r.Enabled
})
.ToList();
var json = JsonSerializer.Serialize(orderedRules, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
WriteIndented = false
});
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
private long GetElapsedMs(long startTimestamp)
{
var elapsed = _timeProvider.GetElapsedTime(startTimestamp, _timeProvider.GetTimestamp());
return (long)Math.Ceiling(elapsed.TotalMilliseconds);
}
}

View File

@@ -0,0 +1,401 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using StellaOps.Policy.Registry.Contracts;
using StellaOps.Policy.Registry.Storage;
namespace StellaOps.Policy.Registry.Services;
/// <summary>
/// Default implementation of quick policy simulation service.
/// Evaluates policy rules against provided input and returns violations.
/// </summary>
public sealed partial class PolicySimulationService : IPolicySimulationService
{
private readonly IPolicyPackStore _packStore;
private readonly TimeProvider _timeProvider;
// Regex patterns for input reference extraction
[GeneratedRegex(@"input\.(\w+(?:\.\w+)*)", RegexOptions.None)]
private static partial Regex InputReferenceRegex();
[GeneratedRegex(@"input\[""([^""]+)""\]", RegexOptions.None)]
private static partial Regex InputBracketReferenceRegex();
public PolicySimulationService(IPolicyPackStore packStore, TimeProvider? timeProvider = null)
{
_packStore = packStore ?? throw new ArgumentNullException(nameof(packStore));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<PolicySimulationResponse> SimulateAsync(
Guid tenantId,
Guid packId,
SimulationRequest request,
CancellationToken cancellationToken = default)
{
var start = _timeProvider.GetTimestamp();
var executedAt = _timeProvider.GetUtcNow();
var simulationId = GenerateSimulationId(tenantId, packId, executedAt);
var pack = await _packStore.GetByIdAsync(tenantId, packId, cancellationToken);
if (pack is null)
{
return new PolicySimulationResponse
{
SimulationId = simulationId,
Success = false,
ExecutedAt = executedAt,
DurationMilliseconds = GetElapsedMs(start),
Errors = [new SimulationError { Code = "PACK_NOT_FOUND", Message = $"Policy pack {packId} not found" }]
};
}
return await SimulateRulesInternalAsync(
simulationId,
pack.Rules ?? [],
request,
start,
executedAt,
cancellationToken);
}
public async Task<PolicySimulationResponse> SimulateRulesAsync(
Guid tenantId,
IReadOnlyList<PolicyRule> rules,
SimulationRequest request,
CancellationToken cancellationToken = default)
{
var start = _timeProvider.GetTimestamp();
var executedAt = _timeProvider.GetUtcNow();
var simulationId = GenerateSimulationId(tenantId, Guid.Empty, executedAt);
return await SimulateRulesInternalAsync(
simulationId,
rules,
request,
start,
executedAt,
cancellationToken);
}
public Task<InputValidationResult> ValidateInputAsync(
IReadOnlyDictionary<string, object> input,
CancellationToken cancellationToken = default)
{
var errors = new List<InputValidationError>();
if (input.Count == 0)
{
errors.Add(new InputValidationError
{
Path = "$",
Message = "Input must contain at least one property"
});
}
// Check for common required fields
var commonFields = new[] { "subject", "resource", "action", "context" };
var missingFields = commonFields.Where(f => !input.ContainsKey(f)).ToList();
if (missingFields.Count == commonFields.Length)
{
// Warn if none of the common fields are present
errors.Add(new InputValidationError
{
Path = "$",
Message = $"Input should contain at least one of: {string.Join(", ", commonFields)}"
});
}
return Task.FromResult(errors.Count > 0
? InputValidationResult.Invalid(errors)
: InputValidationResult.Valid());
}
private async Task<PolicySimulationResponse> SimulateRulesInternalAsync(
string simulationId,
IReadOnlyList<PolicyRule> rules,
SimulationRequest request,
long startTimestamp,
DateTimeOffset executedAt,
CancellationToken cancellationToken)
{
var violations = new List<SimulatedViolation>();
var errors = new List<SimulationError>();
var trace = new List<string>();
int rulesMatched = 0;
var enabledRules = rules.Where(r => r.Enabled).ToList();
foreach (var rule in enabledRules)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var (matched, violation, traceEntry) = EvaluateRule(rule, request.Input, request.Options);
if (request.Options?.Trace == true && traceEntry is not null)
{
trace.Add(traceEntry);
}
if (matched)
{
rulesMatched++;
if (violation is not null)
{
violations.Add(violation);
}
}
}
catch (Exception ex)
{
errors.Add(new SimulationError
{
RuleId = rule.RuleId,
Code = "EVALUATION_ERROR",
Message = ex.Message
});
}
}
var elapsed = GetElapsedMs(startTimestamp);
var severityCounts = violations
.GroupBy(v => v.Severity.ToLowerInvariant())
.ToDictionary(g => g.Key, g => g.Count());
var summary = new SimulationSummary
{
TotalRulesEvaluated = enabledRules.Count,
RulesMatched = rulesMatched,
ViolationsFound = violations.Count,
ViolationsBySeverity = severityCounts
};
var result = new SimulationResult
{
Result = new Dictionary<string, object>
{
["allow"] = violations.Count == 0,
["violations_count"] = violations.Count
},
Violations = violations.Count > 0 ? violations : null,
Trace = request.Options?.Trace == true && trace.Count > 0 ? trace : null,
Explain = request.Options?.Explain == true ? BuildExplainTrace(enabledRules, request.Input) : null
};
return new PolicySimulationResponse
{
SimulationId = simulationId,
Success = errors.Count == 0,
ExecutedAt = executedAt,
DurationMilliseconds = elapsed,
Result = result,
Summary = summary,
Errors = errors.Count > 0 ? errors : null
};
}
private (bool matched, SimulatedViolation? violation, string? trace) EvaluateRule(
PolicyRule rule,
IReadOnlyDictionary<string, object> input,
SimulationOptions? options)
{
// If no Rego code, use basic rule matching based on severity and name
if (string.IsNullOrWhiteSpace(rule.Rego))
{
// Without Rego, we do pattern-based matching on rule name/description
var matched = MatchRuleByName(rule, input);
var trace = options?.Trace == true
? $"Rule {rule.RuleId}: matched={matched} (no Rego, name-based)"
: null;
if (matched)
{
var violation = new SimulatedViolation
{
RuleId = rule.RuleId,
Severity = rule.Severity.ToString().ToLowerInvariant(),
Message = rule.Description ?? $"Violation of rule {rule.Name}"
};
return (true, violation, trace);
}
return (false, null, trace);
}
// Evaluate Rego-based rule
var regoResult = EvaluateRegoRule(rule, input);
var regoTrace = options?.Trace == true
? $"Rule {rule.RuleId}: matched={regoResult.matched}, inputs_used={string.Join(",", regoResult.inputsUsed)}"
: null;
if (regoResult.matched)
{
var violation = new SimulatedViolation
{
RuleId = rule.RuleId,
Severity = rule.Severity.ToString().ToLowerInvariant(),
Message = rule.Description ?? $"Violation of rule {rule.Name}",
Context = regoResult.context
};
return (true, violation, regoTrace);
}
return (false, null, regoTrace);
}
private static bool MatchRuleByName(PolicyRule rule, IReadOnlyDictionary<string, object> input)
{
// Simple heuristic matching for rules without Rego
var ruleName = rule.Name.ToLowerInvariant();
var ruleDesc = rule.Description?.ToLowerInvariant() ?? "";
// Check if any input key matches rule keywords
foreach (var (key, value) in input)
{
var keyLower = key.ToLowerInvariant();
var valueLower = value?.ToString()?.ToLowerInvariant() ?? "";
if (ruleName.Contains(keyLower) || ruleDesc.Contains(keyLower))
{
return true;
}
if (ruleName.Contains(valueLower) || ruleDesc.Contains(valueLower))
{
return true;
}
}
return false;
}
private (bool matched, HashSet<string> inputsUsed, IReadOnlyDictionary<string, object>? context) EvaluateRegoRule(
PolicyRule rule,
IReadOnlyDictionary<string, object> input)
{
// Extract input references from Rego code
var inputRefs = ExtractInputReferences(rule.Rego!);
var inputsUsed = new HashSet<string>();
var context = new Dictionary<string, object>();
// Simple evaluation: check if referenced inputs exist and have values
bool allInputsPresent = true;
foreach (var inputRef in inputRefs)
{
var value = GetNestedValue(input, inputRef);
if (value is not null)
{
inputsUsed.Add(inputRef);
context[inputRef] = value;
}
else
{
allInputsPresent = false;
}
}
// For this simplified simulation:
// - Rule matches if all referenced inputs are present
// - This simulates the rule being able to evaluate
var matched = inputRefs.Count > 0 && allInputsPresent;
return (matched, inputsUsed, context.Count > 0 ? context : null);
}
private static HashSet<string> ExtractInputReferences(string rego)
{
var refs = new HashSet<string>(StringComparer.Ordinal);
// Match input.field.subfield pattern
foreach (Match match in InputReferenceRegex().Matches(rego))
{
refs.Add(match.Groups[1].Value);
}
// Match input["field"] pattern
foreach (Match match in InputBracketReferenceRegex().Matches(rego))
{
refs.Add(match.Groups[1].Value);
}
return refs;
}
private static object? GetNestedValue(IReadOnlyDictionary<string, object> input, string path)
{
var parts = path.Split('.');
object? current = input;
foreach (var part in parts)
{
if (current is IReadOnlyDictionary<string, object> dict)
{
if (!dict.TryGetValue(part, out current))
{
return null;
}
}
else if (current is JsonElement jsonElement)
{
if (jsonElement.ValueKind == JsonValueKind.Object &&
jsonElement.TryGetProperty(part, out var prop))
{
current = prop;
}
else
{
return null;
}
}
else
{
return null;
}
}
return current;
}
private static PolicyExplainTrace BuildExplainTrace(
IReadOnlyList<PolicyRule> rules,
IReadOnlyDictionary<string, object> input)
{
var steps = new List<object>();
steps.Add(new { type = "input_received", keys = input.Keys.ToList() });
foreach (var rule in rules)
{
steps.Add(new
{
type = "rule_evaluation",
rule_id = rule.RuleId,
rule_name = rule.Name,
severity = rule.Severity.ToString(),
has_rego = !string.IsNullOrWhiteSpace(rule.Rego)
});
}
steps.Add(new { type = "evaluation_complete", rules_count = rules.Count });
return new PolicyExplainTrace { Steps = steps };
}
private static string GenerateSimulationId(Guid tenantId, Guid packId, DateTimeOffset timestamp)
{
var content = $"{tenantId}:{packId}:{timestamp.ToUnixTimeMilliseconds()}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
return $"sim_{Convert.ToHexString(hash)[..16].ToLowerInvariant()}";
}
private long GetElapsedMs(long startTimestamp)
{
var elapsed = _timeProvider.GetElapsedTime(startTimestamp, _timeProvider.GetTimestamp());
return (long)Math.Ceiling(elapsed.TotalMilliseconds);
}
}

View File

@@ -0,0 +1,477 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using StellaOps.Policy.Registry.Contracts;
using StellaOps.Policy.Registry.Storage;
namespace StellaOps.Policy.Registry.Services;
/// <summary>
/// Default implementation of promotion service for managing environment bindings.
/// </summary>
public sealed class PromotionService : IPromotionService
{
private readonly IPolicyPackStore _packStore;
private readonly IPublishPipelineService _publishService;
private readonly TimeProvider _timeProvider;
private readonly ConcurrentDictionary<(Guid TenantId, string BindingId), PromotionBinding> _bindings = new();
private readonly ConcurrentDictionary<(Guid TenantId, string Environment), string> _activeBindings = new();
private readonly ConcurrentDictionary<(Guid TenantId, string Environment), List<PromotionHistoryEntry>> _history = new();
public PromotionService(
IPolicyPackStore packStore,
IPublishPipelineService publishService,
TimeProvider? timeProvider = null)
{
_packStore = packStore ?? throw new ArgumentNullException(nameof(packStore));
_publishService = publishService ?? throw new ArgumentNullException(nameof(publishService));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<PromotionBinding> CreateBindingAsync(
Guid tenantId,
CreatePromotionBindingRequest request,
CancellationToken cancellationToken = default)
{
var pack = await _packStore.GetByIdAsync(tenantId, request.PackId, cancellationToken);
if (pack is null)
{
throw new InvalidOperationException($"Policy pack {request.PackId} not found");
}
var now = _timeProvider.GetUtcNow();
var bindingId = GenerateBindingId(tenantId, request.PackId, request.Environment, now);
var binding = new PromotionBinding
{
BindingId = bindingId,
TenantId = tenantId,
PackId = request.PackId,
PackVersion = pack.Version,
Environment = request.Environment,
Mode = request.Mode,
Status = PromotionBindingStatus.Pending,
Rules = request.Rules,
CreatedAt = now,
CreatedBy = request.CreatedBy,
Metadata = request.Metadata
};
_bindings[(tenantId, bindingId)] = binding;
return binding;
}
public async Task<PromotionResult> PromoteAsync(
Guid tenantId,
Guid packId,
PromoteRequest request,
CancellationToken cancellationToken = default)
{
// Validate promotion
var validation = await ValidatePromotionAsync(tenantId, packId, request.TargetEnvironment, cancellationToken);
if (!validation.IsValid && !request.Force)
{
return new PromotionResult
{
Success = false,
Error = string.Join("; ", validation.Errors?.Select(e => e.Message) ?? [])
};
}
var pack = await _packStore.GetByIdAsync(tenantId, packId, cancellationToken);
if (pack is null)
{
return new PromotionResult
{
Success = false,
Error = $"Policy pack {packId} not found"
};
}
// Check pack is published
var publicationStatus = await _publishService.GetPublicationStatusAsync(tenantId, packId, cancellationToken);
if (publicationStatus is null || publicationStatus.State != PublishState.Published)
{
return new PromotionResult
{
Success = false,
Error = "Policy pack must be published before promotion"
};
}
var now = _timeProvider.GetUtcNow();
var bindingId = GenerateBindingId(tenantId, packId, request.TargetEnvironment, now);
// Deactivate current binding if exists
string? previousBindingId = null;
if (_activeBindings.TryGetValue((tenantId, request.TargetEnvironment), out var currentBindingId))
{
if (_bindings.TryGetValue((tenantId, currentBindingId), out var currentBinding))
{
previousBindingId = currentBindingId;
var supersededBinding = currentBinding with
{
Status = PromotionBindingStatus.Superseded,
DeactivatedAt = now
};
_bindings[(tenantId, currentBindingId)] = supersededBinding;
AddHistoryEntry(tenantId, request.TargetEnvironment, new PromotionHistoryEntry
{
BindingId = currentBindingId,
PackId = currentBinding.PackId,
PackVersion = currentBinding.PackVersion,
Action = PromotionHistoryAction.Superseded,
Timestamp = now,
PerformedBy = request.PromotedBy,
Comment = $"Superseded by promotion of {packId}"
});
}
}
// Create new binding
var binding = new PromotionBinding
{
BindingId = bindingId,
TenantId = tenantId,
PackId = packId,
PackVersion = pack.Version,
Environment = request.TargetEnvironment,
Mode = PromotionBindingMode.Manual,
Status = PromotionBindingStatus.Active,
CreatedAt = now,
ActivatedAt = now,
CreatedBy = request.PromotedBy,
ActivatedBy = request.PromotedBy
};
_bindings[(tenantId, bindingId)] = binding;
_activeBindings[(tenantId, request.TargetEnvironment)] = bindingId;
AddHistoryEntry(tenantId, request.TargetEnvironment, new PromotionHistoryEntry
{
BindingId = bindingId,
PackId = packId,
PackVersion = pack.Version,
Action = PromotionHistoryAction.Promoted,
Timestamp = now,
PerformedBy = request.PromotedBy,
Comment = request.Comment,
PreviousBindingId = previousBindingId
});
var warnings = validation.Warnings?.Select(w => w.Message).ToList();
return new PromotionResult
{
Success = true,
Binding = binding,
PreviousBindingId = previousBindingId,
Warnings = warnings?.Count > 0 ? warnings : null
};
}
public Task<PromotionBinding?> GetBindingAsync(
Guid tenantId,
Guid packId,
string environment,
CancellationToken cancellationToken = default)
{
var binding = _bindings.Values
.Where(b => b.TenantId == tenantId && b.PackId == packId && b.Environment == environment)
.OrderByDescending(b => b.CreatedAt)
.FirstOrDefault();
return Task.FromResult(binding);
}
public Task<PromotionBindingList> ListBindingsAsync(
Guid tenantId,
string? environment = null,
Guid? packId = null,
int pageSize = 20,
string? pageToken = null,
CancellationToken cancellationToken = default)
{
var query = _bindings.Values.Where(b => b.TenantId == tenantId);
if (!string.IsNullOrEmpty(environment))
{
query = query.Where(b => b.Environment == environment);
}
if (packId.HasValue)
{
query = query.Where(b => b.PackId == packId.Value);
}
var items = query.OrderByDescending(b => b.CreatedAt).ToList();
int skip = 0;
if (!string.IsNullOrEmpty(pageToken) && int.TryParse(pageToken, out var offset))
{
skip = offset;
}
var pagedItems = items.Skip(skip).Take(pageSize).ToList();
string? nextToken = skip + pagedItems.Count < items.Count
? (skip + pagedItems.Count).ToString()
: null;
return Task.FromResult(new PromotionBindingList
{
Items = pagedItems,
NextPageToken = nextToken,
TotalCount = items.Count
});
}
public async Task<ActiveEnvironmentPolicy?> GetActiveForEnvironmentAsync(
Guid tenantId,
string environment,
CancellationToken cancellationToken = default)
{
if (!_activeBindings.TryGetValue((tenantId, environment), out var bindingId))
{
return null;
}
if (!_bindings.TryGetValue((tenantId, bindingId), out var binding))
{
return null;
}
var publicationStatus = await _publishService.GetPublicationStatusAsync(tenantId, binding.PackId, cancellationToken);
return new ActiveEnvironmentPolicy
{
Environment = environment,
PackId = binding.PackId,
PackVersion = binding.PackVersion,
PackDigest = publicationStatus?.Digest ?? "",
BindingId = bindingId,
ActivatedAt = binding.ActivatedAt ?? binding.CreatedAt,
ActivatedBy = binding.ActivatedBy
};
}
public Task<RollbackResult> RollbackAsync(
Guid tenantId,
string environment,
RollbackRequest request,
CancellationToken cancellationToken = default)
{
if (!_history.TryGetValue((tenantId, environment), out var history) || history.Count < 2)
{
return Task.FromResult(new RollbackResult
{
Success = false,
Error = "No rollback target available"
});
}
// Find target binding
PromotionHistoryEntry? targetEntry = null;
if (!string.IsNullOrEmpty(request.TargetBindingId))
{
targetEntry = history.FirstOrDefault(h => h.BindingId == request.TargetBindingId);
}
else
{
var stepsBack = request.StepsBack ?? 1;
var promotions = history.Where(h => h.Action == PromotionHistoryAction.Promoted).ToList();
if (promotions.Count > stepsBack)
{
targetEntry = promotions[stepsBack];
}
}
if (targetEntry is null)
{
return Task.FromResult(new RollbackResult
{
Success = false,
Error = "Target binding not found"
});
}
if (!_bindings.TryGetValue((tenantId, targetEntry.BindingId), out var targetBinding))
{
return Task.FromResult(new RollbackResult
{
Success = false,
Error = "Target binding no longer exists"
});
}
var now = _timeProvider.GetUtcNow();
// Deactivate current binding
string? rolledBackBindingId = null;
if (_activeBindings.TryGetValue((tenantId, environment), out var currentBindingId))
{
if (_bindings.TryGetValue((tenantId, currentBindingId), out var currentBinding))
{
rolledBackBindingId = currentBindingId;
var rolledBackBinding = currentBinding with
{
Status = PromotionBindingStatus.RolledBack,
DeactivatedAt = now
};
_bindings[(tenantId, currentBindingId)] = rolledBackBinding;
AddHistoryEntry(tenantId, environment, new PromotionHistoryEntry
{
BindingId = currentBindingId,
PackId = currentBinding.PackId,
PackVersion = currentBinding.PackVersion,
Action = PromotionHistoryAction.RolledBack,
Timestamp = now,
PerformedBy = request.RolledBackBy,
Comment = request.Reason
});
}
}
// Restore target binding
var restoredBinding = targetBinding with
{
Status = PromotionBindingStatus.Active,
ActivatedAt = now,
ActivatedBy = request.RolledBackBy,
DeactivatedAt = null
};
_bindings[(tenantId, targetBinding.BindingId)] = restoredBinding;
_activeBindings[(tenantId, environment)] = targetBinding.BindingId;
AddHistoryEntry(tenantId, environment, new PromotionHistoryEntry
{
BindingId = targetBinding.BindingId,
PackId = targetBinding.PackId,
PackVersion = targetBinding.PackVersion,
Action = PromotionHistoryAction.Promoted,
Timestamp = now,
PerformedBy = request.RolledBackBy,
Comment = $"Restored via rollback: {request.Reason}",
PreviousBindingId = rolledBackBindingId
});
return Task.FromResult(new RollbackResult
{
Success = true,
RestoredBinding = restoredBinding,
RolledBackBindingId = rolledBackBindingId
});
}
public Task<IReadOnlyList<PromotionHistoryEntry>> GetHistoryAsync(
Guid tenantId,
string environment,
int limit = 50,
CancellationToken cancellationToken = default)
{
if (!_history.TryGetValue((tenantId, environment), out var history))
{
return Task.FromResult<IReadOnlyList<PromotionHistoryEntry>>(Array.Empty<PromotionHistoryEntry>());
}
var entries = history.OrderByDescending(h => h.Timestamp).Take(limit).ToList();
return Task.FromResult<IReadOnlyList<PromotionHistoryEntry>>(entries);
}
public async Task<PromotionValidationResult> ValidatePromotionAsync(
Guid tenantId,
Guid packId,
string targetEnvironment,
CancellationToken cancellationToken = default)
{
var errors = new List<PromotionValidationError>();
var warnings = new List<PromotionValidationWarning>();
// Check pack exists
var pack = await _packStore.GetByIdAsync(tenantId, packId, cancellationToken);
if (pack is null)
{
errors.Add(new PromotionValidationError
{
Code = "PACK_NOT_FOUND",
Message = $"Policy pack {packId} not found"
});
return new PromotionValidationResult { IsValid = false, Errors = errors };
}
// Check pack is published
var publicationStatus = await _publishService.GetPublicationStatusAsync(tenantId, packId, cancellationToken);
if (publicationStatus is null)
{
errors.Add(new PromotionValidationError
{
Code = "NOT_PUBLISHED",
Message = "Policy pack must be published before promotion"
});
}
else if (publicationStatus.State == PublishState.Revoked)
{
errors.Add(new PromotionValidationError
{
Code = "REVOKED",
Message = "Cannot promote a revoked policy pack"
});
}
// Check environment rules
if (targetEnvironment.Equals("production", StringComparison.OrdinalIgnoreCase))
{
// Production requires additional validation
var activeStaging = await GetActiveForEnvironmentAsync(tenantId, "staging", cancellationToken);
if (activeStaging is null || activeStaging.PackId != packId)
{
warnings.Add(new PromotionValidationWarning
{
Code = "NOT_IN_STAGING",
Message = "Policy pack has not been validated in staging environment"
});
}
}
// Check for existing active binding with same pack
var currentActive = await GetActiveForEnvironmentAsync(tenantId, targetEnvironment, cancellationToken);
if (currentActive is not null && currentActive.PackId == packId && currentActive.PackVersion == pack.Version)
{
warnings.Add(new PromotionValidationWarning
{
Code = "ALREADY_ACTIVE",
Message = "Same version is already active in this environment"
});
}
return new PromotionValidationResult
{
IsValid = errors.Count == 0,
Errors = errors.Count > 0 ? errors : null,
Warnings = warnings.Count > 0 ? warnings : null
};
}
private void AddHistoryEntry(Guid tenantId, string environment, PromotionHistoryEntry entry)
{
_history.AddOrUpdate(
(tenantId, environment),
_ => [entry],
(_, list) =>
{
list.Insert(0, entry);
return list;
});
}
private static string GenerateBindingId(Guid tenantId, Guid packId, string environment, DateTimeOffset timestamp)
{
var content = $"{tenantId}:{packId}:{environment}:{timestamp.ToUnixTimeMilliseconds()}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
return $"bind_{Convert.ToHexString(hash)[..16].ToLowerInvariant()}";
}
}

View File

@@ -0,0 +1,443 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using StellaOps.Policy.Registry.Contracts;
using StellaOps.Policy.Registry.Storage;
namespace StellaOps.Policy.Registry.Services;
/// <summary>
/// Default implementation of publish pipeline service.
/// Handles policy pack publication with attestation generation.
/// </summary>
public sealed class PublishPipelineService : IPublishPipelineService
{
private const string BuilderId = "https://stellaops.io/policy-registry/v1";
private const string BuildType = "https://stellaops.io/policy-registry/v1/publish";
private const string AttestationPredicateType = "https://slsa.dev/provenance/v1";
private readonly IPolicyPackStore _packStore;
private readonly IPolicyPackCompiler _compiler;
private readonly IReviewWorkflowService _reviewService;
private readonly TimeProvider _timeProvider;
private readonly ConcurrentDictionary<(Guid TenantId, Guid PackId), PublicationStatus> _publications = new();
private readonly ConcurrentDictionary<(Guid TenantId, Guid PackId), PolicyPackAttestation> _attestations = new();
public PublishPipelineService(
IPolicyPackStore packStore,
IPolicyPackCompiler compiler,
IReviewWorkflowService reviewService,
TimeProvider? timeProvider = null)
{
_packStore = packStore ?? throw new ArgumentNullException(nameof(packStore));
_compiler = compiler ?? throw new ArgumentNullException(nameof(compiler));
_reviewService = reviewService ?? throw new ArgumentNullException(nameof(reviewService));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<PublishResult> PublishAsync(
Guid tenantId,
Guid packId,
PublishPackRequest request,
CancellationToken cancellationToken = default)
{
// Get the policy pack
var pack = await _packStore.GetByIdAsync(tenantId, packId, cancellationToken);
if (pack is null)
{
return new PublishResult
{
Success = false,
Error = $"Policy pack {packId} not found"
};
}
// Verify pack is in correct state
if (pack.Status != PolicyPackStatus.PendingReview)
{
return new PublishResult
{
Success = false,
Error = $"Policy pack must be in PendingReview status to publish. Current status: {pack.Status}"
};
}
// Compile to get digest
var compilationResult = await _compiler.CompileAsync(tenantId, packId, cancellationToken);
if (!compilationResult.Success)
{
return new PublishResult
{
Success = false,
Error = "Policy pack compilation failed. Cannot publish."
};
}
var now = _timeProvider.GetUtcNow();
var digest = compilationResult.Digest!;
// Get review information if available
var reviews = await _reviewService.ListReviewsAsync(tenantId, ReviewStatus.Approved, packId, 1, null, cancellationToken);
var review = reviews.Items.FirstOrDefault();
// Build attestation
var attestation = BuildAttestation(
pack,
digest,
compilationResult,
review,
request,
now);
// Update pack status to Published
var updatedPack = await _packStore.UpdateStatusAsync(tenantId, packId, PolicyPackStatus.Published, request.PublishedBy, cancellationToken);
if (updatedPack is null)
{
return new PublishResult
{
Success = false,
Error = "Failed to update policy pack status"
};
}
// Create publication status
var status = new PublicationStatus
{
PackId = packId,
PackVersion = pack.Version,
Digest = digest,
State = PublishState.Published,
PublishedAt = now,
PublishedBy = request.PublishedBy,
SignatureKeyId = request.SigningOptions?.KeyId,
SignatureAlgorithm = request.SigningOptions?.Algorithm
};
_publications[(tenantId, packId)] = status;
_attestations[(tenantId, packId)] = attestation;
return new PublishResult
{
Success = true,
PackId = packId,
Digest = digest,
Status = status,
Attestation = attestation
};
}
public Task<PublicationStatus?> GetPublicationStatusAsync(
Guid tenantId,
Guid packId,
CancellationToken cancellationToken = default)
{
_publications.TryGetValue((tenantId, packId), out var status);
return Task.FromResult(status);
}
public Task<PolicyPackAttestation?> GetAttestationAsync(
Guid tenantId,
Guid packId,
CancellationToken cancellationToken = default)
{
_attestations.TryGetValue((tenantId, packId), out var attestation);
return Task.FromResult(attestation);
}
public async Task<AttestationVerificationResult> VerifyAttestationAsync(
Guid tenantId,
Guid packId,
CancellationToken cancellationToken = default)
{
var checks = new List<VerificationCheck>();
var errors = new List<string>();
var warnings = new List<string>();
// Check publication exists
if (!_publications.TryGetValue((tenantId, packId), out var status))
{
return new AttestationVerificationResult
{
Valid = false,
Errors = ["Policy pack is not published"]
};
}
checks.Add(new VerificationCheck
{
Name = "publication_exists",
Passed = true,
Details = $"Published at {status.PublishedAt:O}"
});
// Check not revoked
if (status.State == PublishState.Revoked)
{
errors.Add($"Policy pack was revoked at {status.RevokedAt:O}: {status.RevokeReason}");
checks.Add(new VerificationCheck
{
Name = "not_revoked",
Passed = false,
Details = status.RevokeReason
});
}
else
{
checks.Add(new VerificationCheck
{
Name = "not_revoked",
Passed = true,
Details = "Policy pack has not been revoked"
});
}
// Check attestation exists
if (!_attestations.TryGetValue((tenantId, packId), out var attestation))
{
errors.Add("Attestation not found");
checks.Add(new VerificationCheck
{
Name = "attestation_exists",
Passed = false,
Details = "No attestation on record"
});
}
else
{
checks.Add(new VerificationCheck
{
Name = "attestation_exists",
Passed = true,
Details = $"Found {attestation.Signatures.Count} signature(s)"
});
// Verify signatures
foreach (var sig in attestation.Signatures)
{
// In a real implementation, this would verify the actual cryptographic signature
var sigValid = !string.IsNullOrEmpty(sig.Signature);
checks.Add(new VerificationCheck
{
Name = $"signature_{sig.KeyId}",
Passed = sigValid,
Details = sigValid ? $"Signature verified for key {sig.KeyId}" : "Invalid signature"
});
if (!sigValid)
{
errors.Add($"Invalid signature for key {sig.KeyId}");
}
}
}
// Verify pack still exists and matches digest
var pack = await _packStore.GetByIdAsync(tenantId, packId, cancellationToken);
if (pack is null)
{
errors.Add("Policy pack no longer exists");
checks.Add(new VerificationCheck
{
Name = "pack_exists",
Passed = false,
Details = "Policy pack has been deleted"
});
}
else
{
checks.Add(new VerificationCheck
{
Name = "pack_exists",
Passed = true,
Details = $"Pack version: {pack.Version}"
});
// Verify digest matches
var digestMatch = pack.Digest == status.Digest;
checks.Add(new VerificationCheck
{
Name = "digest_match",
Passed = digestMatch,
Details = digestMatch ? "Digest matches" : $"Digest mismatch: expected {status.Digest}, got {pack.Digest}"
});
if (!digestMatch)
{
errors.Add("Policy pack has been modified since publication");
}
}
return new AttestationVerificationResult
{
Valid = errors.Count == 0,
Checks = checks,
Errors = errors.Count > 0 ? errors : null,
Warnings = warnings.Count > 0 ? warnings : null
};
}
public Task<PublishedPackList> ListPublishedAsync(
Guid tenantId,
int pageSize = 20,
string? pageToken = null,
CancellationToken cancellationToken = default)
{
var items = _publications
.Where(kv => kv.Key.TenantId == tenantId)
.Select(kv => kv.Value)
.OrderByDescending(p => p.PublishedAt)
.ToList();
int skip = 0;
if (!string.IsNullOrEmpty(pageToken) && int.TryParse(pageToken, out var offset))
{
skip = offset;
}
var pagedItems = items.Skip(skip).Take(pageSize).ToList();
string? nextToken = skip + pagedItems.Count < items.Count
? (skip + pagedItems.Count).ToString()
: null;
return Task.FromResult(new PublishedPackList
{
Items = pagedItems,
NextPageToken = nextToken,
TotalCount = items.Count
});
}
public async Task<RevokeResult> RevokeAsync(
Guid tenantId,
Guid packId,
RevokePackRequest request,
CancellationToken cancellationToken = default)
{
if (!_publications.TryGetValue((tenantId, packId), out var status))
{
return new RevokeResult
{
Success = false,
Error = "Policy pack is not published"
};
}
if (status.State == PublishState.Revoked)
{
return new RevokeResult
{
Success = false,
Error = "Policy pack is already revoked"
};
}
var now = _timeProvider.GetUtcNow();
var updatedStatus = status with
{
State = PublishState.Revoked,
RevokedAt = now,
RevokedBy = request.RevokedBy,
RevokeReason = request.Reason
};
_publications[(tenantId, packId)] = updatedStatus;
// Update pack status to archived
await _packStore.UpdateStatusAsync(tenantId, packId, PolicyPackStatus.Archived, request.RevokedBy, cancellationToken);
return new RevokeResult
{
Success = true,
Status = updatedStatus
};
}
private PolicyPackAttestation BuildAttestation(
PolicyPackEntity pack,
string digest,
PolicyPackCompilationResult compilationResult,
ReviewRequest? review,
PublishPackRequest request,
DateTimeOffset now)
{
var subject = new AttestationSubject
{
Name = $"policy-pack/{pack.Name}",
Digest = new Dictionary<string, string>
{
["sha256"] = digest.Replace("sha256:", "")
}
};
var predicate = new AttestationPredicate
{
BuildType = BuildType,
Builder = new AttestationBuilder
{
Id = BuilderId,
Version = "1.0.0"
},
BuildStartedOn = pack.CreatedAt,
BuildFinishedOn = now,
Compilation = new PolicyPackCompilationMetadata
{
Digest = digest,
RuleCount = compilationResult.Statistics?.TotalRules ?? 0,
CompiledAt = now,
Statistics = compilationResult.Statistics?.SeverityCounts
},
Review = review is not null ? new PolicyPackReviewMetadata
{
ReviewId = review.ReviewId,
ApprovedAt = review.ResolvedAt ?? now,
ApprovedBy = review.ResolvedBy,
Reviewers = review.Reviewers
} : null,
Metadata = request.Metadata?.ToDictionary(kv => kv.Key, kv => (object)kv.Value)
};
var payload = new AttestationPayload
{
Type = "https://in-toto.io/Statement/v1",
PredicateType = request.AttestationOptions?.PredicateType ?? AttestationPredicateType,
Subject = subject,
Predicate = predicate
};
var payloadJson = JsonSerializer.Serialize(payload, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
WriteIndented = false
});
var payloadBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(payloadJson));
// Generate signature (simulated - in production would use actual signing)
var signature = GenerateSignature(payloadBase64, request.SigningOptions);
return new PolicyPackAttestation
{
PayloadType = "application/vnd.in-toto+json",
Payload = payloadBase64,
Signatures =
[
new AttestationSignature
{
KeyId = request.SigningOptions?.KeyId ?? "default",
Signature = signature,
Timestamp = request.SigningOptions?.IncludeTimestamp == true ? now : null
}
]
};
}
private static string GenerateSignature(string payload, SigningOptions? options)
{
// In production, this would use actual cryptographic signing
// For now, we generate a deterministic mock signature
var content = $"{payload}:{options?.KeyId ?? "default"}:{options?.Algorithm ?? SigningAlgorithm.ECDSA_P256_SHA256}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
return Convert.ToBase64String(hash);
}
}

View File

@@ -0,0 +1,354 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using StellaOps.Policy.Registry.Contracts;
using StellaOps.Policy.Registry.Storage;
namespace StellaOps.Policy.Registry.Services;
/// <summary>
/// Default implementation of review workflow service with in-memory storage.
/// </summary>
public sealed class ReviewWorkflowService : IReviewWorkflowService
{
private readonly IPolicyPackStore _packStore;
private readonly TimeProvider _timeProvider;
private readonly ConcurrentDictionary<(Guid TenantId, string ReviewId), ReviewRequest> _reviews = new();
private readonly ConcurrentDictionary<(Guid TenantId, string ReviewId), List<ReviewAuditEntry>> _auditTrails = new();
public ReviewWorkflowService(IPolicyPackStore packStore, TimeProvider? timeProvider = null)
{
_packStore = packStore ?? throw new ArgumentNullException(nameof(packStore));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<ReviewRequest> SubmitForReviewAsync(
Guid tenantId,
Guid packId,
SubmitReviewRequest request,
CancellationToken cancellationToken = default)
{
var pack = await _packStore.GetByIdAsync(tenantId, packId, cancellationToken);
if (pack is null)
{
throw new InvalidOperationException($"Policy pack {packId} not found");
}
if (pack.Status != PolicyPackStatus.Draft)
{
throw new InvalidOperationException($"Only draft policy packs can be submitted for review. Current status: {pack.Status}");
}
var now = _timeProvider.GetUtcNow();
var reviewId = GenerateReviewId(tenantId, packId, now);
var review = new ReviewRequest
{
ReviewId = reviewId,
TenantId = tenantId,
PackId = packId,
PackVersion = pack.Version,
Status = ReviewStatus.Pending,
Description = request.Description,
Reviewers = request.Reviewers,
Urgency = request.Urgency,
SubmittedBy = pack.CreatedBy,
SubmittedAt = now,
Metadata = request.Metadata
};
_reviews[(tenantId, reviewId)] = review;
// Update pack status to pending review
await _packStore.UpdateStatusAsync(tenantId, packId, PolicyPackStatus.PendingReview, pack.CreatedBy, cancellationToken);
// Add audit entry
AddAuditEntry(tenantId, reviewId, packId, ReviewAuditAction.Submitted, now, pack.CreatedBy,
null, ReviewStatus.Pending, $"Submitted for review: {request.Description ?? "No description"}");
// Add reviewer assignment audit entries
if (request.Reviewers is { Count: > 0 })
{
foreach (var reviewer in request.Reviewers)
{
AddAuditEntry(tenantId, reviewId, packId, ReviewAuditAction.AssignedReviewer, now, pack.CreatedBy,
null, null, $"Assigned reviewer: {reviewer}",
new Dictionary<string, object> { ["reviewer"] = reviewer });
}
}
return review;
}
public async Task<ReviewDecision> ApproveAsync(
Guid tenantId,
string reviewId,
ApproveReviewRequest request,
CancellationToken cancellationToken = default)
{
if (!_reviews.TryGetValue((tenantId, reviewId), out var review))
{
throw new InvalidOperationException($"Review {reviewId} not found");
}
if (review.Status is not (ReviewStatus.Pending or ReviewStatus.InReview or ReviewStatus.ChangesRequested))
{
throw new InvalidOperationException($"Review cannot be approved in status: {review.Status}");
}
var now = _timeProvider.GetUtcNow();
var previousStatus = review.Status;
var updatedReview = review with
{
Status = ReviewStatus.Approved,
ResolvedAt = now,
ResolvedBy = request.ApprovedBy
};
_reviews[(tenantId, reviewId)] = updatedReview;
// Add audit entry
AddAuditEntry(tenantId, reviewId, review.PackId, ReviewAuditAction.Approved, now, request.ApprovedBy,
previousStatus, ReviewStatus.Approved, request.Comment ?? "Approved");
return new ReviewDecision
{
ReviewId = reviewId,
NewStatus = ReviewStatus.Approved,
DecidedAt = now,
DecidedBy = request.ApprovedBy,
Comment = request.Comment
};
}
public async Task<ReviewDecision> RejectAsync(
Guid tenantId,
string reviewId,
RejectReviewRequest request,
CancellationToken cancellationToken = default)
{
if (!_reviews.TryGetValue((tenantId, reviewId), out var review))
{
throw new InvalidOperationException($"Review {reviewId} not found");
}
if (review.Status is not (ReviewStatus.Pending or ReviewStatus.InReview or ReviewStatus.ChangesRequested))
{
throw new InvalidOperationException($"Review cannot be rejected in status: {review.Status}");
}
var now = _timeProvider.GetUtcNow();
var previousStatus = review.Status;
var updatedReview = review with
{
Status = ReviewStatus.Rejected,
ResolvedAt = now,
ResolvedBy = request.RejectedBy
};
_reviews[(tenantId, reviewId)] = updatedReview;
// Revert pack to draft
await _packStore.UpdateStatusAsync(tenantId, review.PackId, PolicyPackStatus.Draft, request.RejectedBy, cancellationToken);
// Add audit entry
AddAuditEntry(tenantId, reviewId, review.PackId, ReviewAuditAction.Rejected, now, request.RejectedBy,
previousStatus, ReviewStatus.Rejected, request.Reason);
return new ReviewDecision
{
ReviewId = reviewId,
NewStatus = ReviewStatus.Rejected,
DecidedAt = now,
DecidedBy = request.RejectedBy,
Comment = request.Reason
};
}
public Task<ReviewDecision> RequestChangesAsync(
Guid tenantId,
string reviewId,
RequestChangesRequest request,
CancellationToken cancellationToken = default)
{
if (!_reviews.TryGetValue((tenantId, reviewId), out var review))
{
throw new InvalidOperationException($"Review {reviewId} not found");
}
if (review.Status is not (ReviewStatus.Pending or ReviewStatus.InReview))
{
throw new InvalidOperationException($"Changes cannot be requested in status: {review.Status}");
}
var now = _timeProvider.GetUtcNow();
var previousStatus = review.Status;
var updatedReview = review with
{
Status = ReviewStatus.ChangesRequested,
PendingComments = request.Comments
};
_reviews[(tenantId, reviewId)] = updatedReview;
// Add audit entry for status change
AddAuditEntry(tenantId, reviewId, review.PackId, ReviewAuditAction.ChangesRequested, now, request.RequestedBy,
previousStatus, ReviewStatus.ChangesRequested, $"Requested {request.Comments.Count} change(s)");
// Add audit entries for each comment
foreach (var comment in request.Comments)
{
AddAuditEntry(tenantId, reviewId, review.PackId, ReviewAuditAction.CommentAdded, now, request.RequestedBy,
null, null, comment.Comment,
new Dictionary<string, object>
{
["rule_id"] = comment.RuleId ?? "general",
["severity"] = comment.Severity.ToString()
});
}
return Task.FromResult(new ReviewDecision
{
ReviewId = reviewId,
NewStatus = ReviewStatus.ChangesRequested,
DecidedAt = now,
DecidedBy = request.RequestedBy,
Comments = request.Comments
});
}
public Task<ReviewRequest?> GetReviewAsync(
Guid tenantId,
string reviewId,
CancellationToken cancellationToken = default)
{
_reviews.TryGetValue((tenantId, reviewId), out var review);
return Task.FromResult(review);
}
public Task<ReviewRequestList> ListReviewsAsync(
Guid tenantId,
ReviewStatus? status = null,
Guid? packId = null,
int pageSize = 20,
string? pageToken = null,
CancellationToken cancellationToken = default)
{
var query = _reviews.Values.Where(r => r.TenantId == tenantId);
if (status.HasValue)
{
query = query.Where(r => r.Status == status.Value);
}
if (packId.HasValue)
{
query = query.Where(r => r.PackId == packId.Value);
}
var items = query.OrderByDescending(r => r.SubmittedAt).ToList();
int skip = 0;
if (!string.IsNullOrEmpty(pageToken) && int.TryParse(pageToken, out var offset))
{
skip = offset;
}
var pagedItems = items.Skip(skip).Take(pageSize).ToList();
string? nextToken = skip + pagedItems.Count < items.Count
? (skip + pagedItems.Count).ToString()
: null;
return Task.FromResult(new ReviewRequestList
{
Items = pagedItems,
NextPageToken = nextToken,
TotalCount = items.Count
});
}
public Task<IReadOnlyList<ReviewAuditEntry>> GetAuditTrailAsync(
Guid tenantId,
string reviewId,
CancellationToken cancellationToken = default)
{
if (!_auditTrails.TryGetValue((tenantId, reviewId), out var trail))
{
return Task.FromResult<IReadOnlyList<ReviewAuditEntry>>(Array.Empty<ReviewAuditEntry>());
}
var entries = trail.OrderByDescending(e => e.Timestamp).ToList();
return Task.FromResult<IReadOnlyList<ReviewAuditEntry>>(entries);
}
public Task<IReadOnlyList<ReviewAuditEntry>> GetPackAuditTrailAsync(
Guid tenantId,
Guid packId,
int limit = 100,
CancellationToken cancellationToken = default)
{
var entries = _auditTrails
.Where(kv => kv.Key.TenantId == tenantId)
.SelectMany(kv => kv.Value)
.Where(e => e.PackId == packId)
.OrderByDescending(e => e.Timestamp)
.Take(limit)
.ToList();
return Task.FromResult<IReadOnlyList<ReviewAuditEntry>>(entries);
}
private void AddAuditEntry(
Guid tenantId,
string reviewId,
Guid packId,
ReviewAuditAction action,
DateTimeOffset timestamp,
string? performedBy,
ReviewStatus? previousStatus,
ReviewStatus? newStatus,
string? comment,
IReadOnlyDictionary<string, object>? details = null)
{
var auditId = GenerateAuditId(tenantId, reviewId, timestamp);
var entry = new ReviewAuditEntry
{
AuditId = auditId,
ReviewId = reviewId,
PackId = packId,
Action = action,
Timestamp = timestamp,
PerformedBy = performedBy,
PreviousStatus = previousStatus,
NewStatus = newStatus,
Comment = comment,
Details = details
};
_auditTrails.AddOrUpdate(
(tenantId, reviewId),
_ => [entry],
(_, list) =>
{
list.Add(entry);
return list;
});
}
private static string GenerateReviewId(Guid tenantId, Guid packId, DateTimeOffset timestamp)
{
var content = $"{tenantId}:{packId}:{timestamp.ToUnixTimeMilliseconds()}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
return $"rev_{Convert.ToHexString(hash)[..16].ToLowerInvariant()}";
}
private static string GenerateAuditId(Guid tenantId, string reviewId, DateTimeOffset timestamp)
{
var content = $"{tenantId}:{reviewId}:{timestamp.ToUnixTimeMilliseconds()}:{Guid.NewGuid()}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
return $"aud_{Convert.ToHexString(hash)[..12].ToLowerInvariant()}";
}
}

View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<RootNamespace>StellaOps.Policy.Registry</RootNamespace>
<AssemblyName>StellaOps.Policy.Registry</AssemblyName>
<Description>Policy Registry typed clients and contracts for StellaOps Policy Engine</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,232 @@
using StellaOps.Policy.Registry.Contracts;
namespace StellaOps.Policy.Registry.Storage;
/// <summary>
/// Storage entity for policy pack.
/// </summary>
public sealed record PolicyPackEntity
{
public required Guid TenantId { get; init; }
public required Guid PackId { get; init; }
public required string Name { get; init; }
public required string Version { get; init; }
public string? Description { get; init; }
public required PolicyPackStatus Status { get; init; }
public IReadOnlyList<PolicyRule>? Rules { get; init; }
public IReadOnlyDictionary<string, object>? Metadata { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public required DateTimeOffset UpdatedAt { get; init; }
public DateTimeOffset? PublishedAt { get; init; }
public string? Digest { get; init; }
public string? CreatedBy { get; init; }
public string? UpdatedBy { get; init; }
/// <summary>
/// Converts to API contract.
/// </summary>
public PolicyPack ToContract() => new()
{
PackId = PackId,
Name = Name,
Version = Version,
Description = Description,
Status = Status,
Rules = Rules,
Metadata = Metadata,
CreatedAt = CreatedAt,
UpdatedAt = UpdatedAt,
PublishedAt = PublishedAt,
Digest = Digest
};
}
/// <summary>
/// Storage entity for verification policy.
/// </summary>
public sealed record VerificationPolicyEntity
{
public required Guid TenantId { get; init; }
public required string PolicyId { get; init; }
public required string Version { get; init; }
public string? Description { get; init; }
public required string TenantScope { get; init; }
public required IReadOnlyList<string> PredicateTypes { get; init; }
public required SignerRequirements SignerRequirements { get; init; }
public ValidityWindow? ValidityWindow { get; init; }
public IReadOnlyDictionary<string, object>? Metadata { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public required DateTimeOffset UpdatedAt { get; init; }
public string? CreatedBy { get; init; }
public string? UpdatedBy { get; init; }
/// <summary>
/// Converts to API contract.
/// </summary>
public VerificationPolicy ToContract() => new()
{
PolicyId = PolicyId,
Version = Version,
Description = Description,
TenantScope = TenantScope,
PredicateTypes = PredicateTypes,
SignerRequirements = SignerRequirements,
ValidityWindow = ValidityWindow,
Metadata = Metadata,
CreatedAt = CreatedAt,
UpdatedAt = UpdatedAt
};
}
/// <summary>
/// Storage entity for snapshot.
/// </summary>
public sealed record SnapshotEntity
{
public required Guid TenantId { get; init; }
public required Guid SnapshotId { get; init; }
public required string Digest { get; init; }
public string? Description { get; init; }
public IReadOnlyList<Guid>? PackIds { get; init; }
public IReadOnlyDictionary<string, object>? Metadata { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public string? CreatedBy { get; init; }
/// <summary>
/// Converts to API contract.
/// </summary>
public Snapshot ToContract() => new()
{
SnapshotId = SnapshotId,
Digest = Digest,
Description = Description,
PackIds = PackIds,
Metadata = Metadata,
CreatedAt = CreatedAt,
CreatedBy = CreatedBy
};
}
/// <summary>
/// Storage entity for violation.
/// </summary>
public sealed record ViolationEntity
{
public required Guid TenantId { get; init; }
public required Guid ViolationId { get; init; }
public string? PolicyId { get; init; }
public required string RuleId { get; init; }
public required Severity Severity { get; init; }
public required string Message { get; init; }
public string? Purl { get; init; }
public string? CveId { get; init; }
public IReadOnlyDictionary<string, object>? Context { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Converts to API contract.
/// </summary>
public Violation ToContract() => new()
{
ViolationId = ViolationId,
PolicyId = PolicyId,
RuleId = RuleId,
Severity = Severity,
Message = Message,
Purl = Purl,
CveId = CveId,
Context = Context,
CreatedAt = CreatedAt
};
}
/// <summary>
/// Storage entity for override.
/// </summary>
public sealed record OverrideEntity
{
public required Guid TenantId { get; init; }
public required Guid OverrideId { get; init; }
public Guid? ProfileId { get; init; }
public required string RuleId { get; init; }
public required OverrideStatus Status { get; init; }
public string? Reason { get; init; }
public OverrideScope? Scope { get; init; }
public DateTimeOffset? ExpiresAt { get; init; }
public string? ApprovedBy { get; init; }
public DateTimeOffset? ApprovedAt { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public string? CreatedBy { get; init; }
/// <summary>
/// Converts to API contract.
/// </summary>
public Override ToContract() => new()
{
OverrideId = OverrideId,
ProfileId = ProfileId,
RuleId = RuleId,
Status = Status,
Reason = Reason,
Scope = Scope,
ExpiresAt = ExpiresAt,
ApprovedBy = ApprovedBy,
ApprovedAt = ApprovedAt,
CreatedAt = CreatedAt,
CreatedBy = CreatedBy
};
}
/// <summary>
/// History entry for policy pack changes.
/// </summary>
public sealed record PolicyPackHistoryEntry
{
public required Guid PackId { get; init; }
public required string Action { get; init; }
public required DateTimeOffset Timestamp { get; init; }
public string? PerformedBy { get; init; }
public PolicyPackStatus? PreviousStatus { get; init; }
public PolicyPackStatus? NewStatus { get; init; }
public string? Description { get; init; }
}
/// <summary>
/// Result for paginated policy pack list.
/// </summary>
public sealed record PolicyPackListResult
{
public required IReadOnlyList<PolicyPackEntity> Items { get; init; }
public string? NextPageToken { get; init; }
public int TotalCount { get; init; }
}
/// <summary>
/// Result for paginated verification policy list.
/// </summary>
public sealed record VerificationPolicyListResult
{
public required IReadOnlyList<VerificationPolicyEntity> Items { get; init; }
public string? NextPageToken { get; init; }
public int TotalCount { get; init; }
}
/// <summary>
/// Result for paginated snapshot list.
/// </summary>
public sealed record SnapshotListResult
{
public required IReadOnlyList<SnapshotEntity> Items { get; init; }
public string? NextPageToken { get; init; }
public int TotalCount { get; init; }
}
/// <summary>
/// Result for paginated violation list.
/// </summary>
public sealed record ViolationListResult
{
public required IReadOnlyList<ViolationEntity> Items { get; init; }
public string? NextPageToken { get; init; }
public int TotalCount { get; init; }
}

View File

@@ -0,0 +1,212 @@
using StellaOps.Policy.Registry.Contracts;
namespace StellaOps.Policy.Registry.Storage;
/// <summary>
/// Store interface for policy pack workspace operations with history tracking.
/// Implements REGISTRY-API-27-002: Workspace storage with CRUD + history.
/// </summary>
public interface IPolicyPackStore
{
/// <summary>
/// Creates a new policy pack.
/// </summary>
Task<PolicyPackEntity> CreateAsync(
Guid tenantId,
CreatePolicyPackRequest request,
string? createdBy = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets a policy pack by ID.
/// </summary>
Task<PolicyPackEntity?> GetByIdAsync(
Guid tenantId,
Guid packId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets a policy pack by name.
/// </summary>
Task<PolicyPackEntity?> GetByNameAsync(
Guid tenantId,
string name,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists policy packs with optional filtering.
/// </summary>
Task<PolicyPackListResult> ListAsync(
Guid tenantId,
PolicyPackStatus? status = null,
int pageSize = 20,
string? pageToken = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Updates a policy pack.
/// </summary>
Task<PolicyPackEntity?> UpdateAsync(
Guid tenantId,
Guid packId,
UpdatePolicyPackRequest request,
string? updatedBy = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a policy pack (only drafts can be deleted).
/// </summary>
Task<bool> DeleteAsync(
Guid tenantId,
Guid packId,
CancellationToken cancellationToken = default);
/// <summary>
/// Updates the status of a policy pack.
/// </summary>
Task<PolicyPackEntity?> UpdateStatusAsync(
Guid tenantId,
Guid packId,
PolicyPackStatus newStatus,
string? updatedBy = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the history of changes for a policy pack.
/// </summary>
Task<IReadOnlyList<PolicyPackHistoryEntry>> GetHistoryAsync(
Guid tenantId,
Guid packId,
int limit = 50,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Store interface for verification policy operations.
/// </summary>
public interface IVerificationPolicyStore
{
Task<VerificationPolicyEntity> CreateAsync(
Guid tenantId,
CreateVerificationPolicyRequest request,
string? createdBy = null,
CancellationToken cancellationToken = default);
Task<VerificationPolicyEntity?> GetByIdAsync(
Guid tenantId,
string policyId,
CancellationToken cancellationToken = default);
Task<VerificationPolicyListResult> ListAsync(
Guid tenantId,
int pageSize = 20,
string? pageToken = null,
CancellationToken cancellationToken = default);
Task<VerificationPolicyEntity?> UpdateAsync(
Guid tenantId,
string policyId,
UpdateVerificationPolicyRequest request,
string? updatedBy = null,
CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(
Guid tenantId,
string policyId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Store interface for policy snapshot operations.
/// </summary>
public interface ISnapshotStore
{
Task<SnapshotEntity> CreateAsync(
Guid tenantId,
CreateSnapshotRequest request,
string? createdBy = null,
CancellationToken cancellationToken = default);
Task<SnapshotEntity?> GetByIdAsync(
Guid tenantId,
Guid snapshotId,
CancellationToken cancellationToken = default);
Task<SnapshotEntity?> GetByDigestAsync(
Guid tenantId,
string digest,
CancellationToken cancellationToken = default);
Task<SnapshotListResult> ListAsync(
Guid tenantId,
int pageSize = 20,
string? pageToken = null,
CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(
Guid tenantId,
Guid snapshotId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Store interface for violation operations.
/// </summary>
public interface IViolationStore
{
Task<ViolationEntity> AppendAsync(
Guid tenantId,
CreateViolationRequest request,
CancellationToken cancellationToken = default);
Task<ViolationBatchResult> AppendBatchAsync(
Guid tenantId,
IReadOnlyList<CreateViolationRequest> requests,
CancellationToken cancellationToken = default);
Task<ViolationEntity?> GetByIdAsync(
Guid tenantId,
Guid violationId,
CancellationToken cancellationToken = default);
Task<ViolationListResult> ListAsync(
Guid tenantId,
Severity? severity = null,
int pageSize = 20,
string? pageToken = null,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Store interface for override operations.
/// </summary>
public interface IOverrideStore
{
Task<OverrideEntity> CreateAsync(
Guid tenantId,
CreateOverrideRequest request,
string? createdBy = null,
CancellationToken cancellationToken = default);
Task<OverrideEntity?> GetByIdAsync(
Guid tenantId,
Guid overrideId,
CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(
Guid tenantId,
Guid overrideId,
CancellationToken cancellationToken = default);
Task<OverrideEntity?> ApproveAsync(
Guid tenantId,
Guid overrideId,
string? approvedBy = null,
string? comment = null,
CancellationToken cancellationToken = default);
Task<OverrideEntity?> DisableAsync(
Guid tenantId,
Guid overrideId,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,108 @@
using System.Collections.Concurrent;
using StellaOps.Policy.Registry.Contracts;
namespace StellaOps.Policy.Registry.Storage;
/// <summary>
/// In-memory implementation of IOverrideStore for testing and development.
/// </summary>
public sealed class InMemoryOverrideStore : IOverrideStore
{
private readonly ConcurrentDictionary<(Guid TenantId, Guid OverrideId), OverrideEntity> _overrides = new();
public Task<OverrideEntity> CreateAsync(
Guid tenantId,
CreateOverrideRequest request,
string? createdBy = null,
CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var overrideId = Guid.NewGuid();
var entity = new OverrideEntity
{
TenantId = tenantId,
OverrideId = overrideId,
ProfileId = request.ProfileId,
RuleId = request.RuleId,
Status = OverrideStatus.Pending,
Reason = request.Reason,
Scope = request.Scope,
ExpiresAt = request.ExpiresAt,
CreatedAt = now,
CreatedBy = createdBy
};
_overrides[(tenantId, overrideId)] = entity;
return Task.FromResult(entity);
}
public Task<OverrideEntity?> GetByIdAsync(
Guid tenantId,
Guid overrideId,
CancellationToken cancellationToken = default)
{
_overrides.TryGetValue((tenantId, overrideId), out var entity);
return Task.FromResult(entity);
}
public Task<bool> DeleteAsync(
Guid tenantId,
Guid overrideId,
CancellationToken cancellationToken = default)
{
return Task.FromResult(_overrides.TryRemove((tenantId, overrideId), out _));
}
public Task<OverrideEntity?> ApproveAsync(
Guid tenantId,
Guid overrideId,
string? approvedBy = null,
string? comment = null,
CancellationToken cancellationToken = default)
{
if (!_overrides.TryGetValue((tenantId, overrideId), out var existing))
{
return Task.FromResult<OverrideEntity?>(null);
}
// Only pending overrides can be approved
if (existing.Status != OverrideStatus.Pending)
{
return Task.FromResult<OverrideEntity?>(null);
}
var now = DateTimeOffset.UtcNow;
var updated = existing with
{
Status = OverrideStatus.Approved,
ApprovedBy = approvedBy,
ApprovedAt = now
};
_overrides[(tenantId, overrideId)] = updated;
return Task.FromResult<OverrideEntity?>(updated);
}
public Task<OverrideEntity?> DisableAsync(
Guid tenantId,
Guid overrideId,
CancellationToken cancellationToken = default)
{
if (!_overrides.TryGetValue((tenantId, overrideId), out var existing))
{
return Task.FromResult<OverrideEntity?>(null);
}
var updated = existing with
{
Status = OverrideStatus.Disabled
};
_overrides[(tenantId, overrideId)] = updated;
return Task.FromResult<OverrideEntity?>(updated);
}
}

View File

@@ -0,0 +1,260 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using StellaOps.Policy.Registry.Contracts;
namespace StellaOps.Policy.Registry.Storage;
/// <summary>
/// In-memory implementation of IPolicyPackStore for testing and development.
/// </summary>
public sealed class InMemoryPolicyPackStore : IPolicyPackStore
{
private readonly ConcurrentDictionary<(Guid TenantId, Guid PackId), PolicyPackEntity> _packs = new();
private readonly ConcurrentDictionary<(Guid TenantId, Guid PackId), List<PolicyPackHistoryEntry>> _history = new();
public Task<PolicyPackEntity> CreateAsync(
Guid tenantId,
CreatePolicyPackRequest request,
string? createdBy = null,
CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var packId = Guid.NewGuid();
var entity = new PolicyPackEntity
{
TenantId = tenantId,
PackId = packId,
Name = request.Name,
Version = request.Version,
Description = request.Description,
Status = PolicyPackStatus.Draft,
Rules = request.Rules,
Metadata = request.Metadata,
CreatedAt = now,
UpdatedAt = now,
CreatedBy = createdBy
};
_packs[(tenantId, packId)] = entity;
// Add history entry
AddHistoryEntry(tenantId, packId, "created", createdBy, null, PolicyPackStatus.Draft, "Policy pack created");
return Task.FromResult(entity);
}
public Task<PolicyPackEntity?> GetByIdAsync(
Guid tenantId,
Guid packId,
CancellationToken cancellationToken = default)
{
_packs.TryGetValue((tenantId, packId), out var entity);
return Task.FromResult(entity);
}
public Task<PolicyPackEntity?> GetByNameAsync(
Guid tenantId,
string name,
CancellationToken cancellationToken = default)
{
var entity = _packs.Values
.Where(p => p.TenantId == tenantId && p.Name == name)
.OrderByDescending(p => p.UpdatedAt)
.FirstOrDefault();
return Task.FromResult(entity);
}
public Task<PolicyPackListResult> ListAsync(
Guid tenantId,
PolicyPackStatus? status = null,
int pageSize = 20,
string? pageToken = null,
CancellationToken cancellationToken = default)
{
var query = _packs.Values
.Where(p => p.TenantId == tenantId);
if (status.HasValue)
{
query = query.Where(p => p.Status == status.Value);
}
var items = query
.OrderByDescending(p => p.UpdatedAt)
.ToList();
int skip = 0;
if (!string.IsNullOrEmpty(pageToken) && int.TryParse(pageToken, out var offset))
{
skip = offset;
}
var pagedItems = items.Skip(skip).Take(pageSize).ToList();
string? nextToken = skip + pagedItems.Count < items.Count
? (skip + pagedItems.Count).ToString()
: null;
return Task.FromResult(new PolicyPackListResult
{
Items = pagedItems,
NextPageToken = nextToken,
TotalCount = items.Count
});
}
public Task<PolicyPackEntity?> UpdateAsync(
Guid tenantId,
Guid packId,
UpdatePolicyPackRequest request,
string? updatedBy = null,
CancellationToken cancellationToken = default)
{
if (!_packs.TryGetValue((tenantId, packId), out var existing))
{
return Task.FromResult<PolicyPackEntity?>(null);
}
// Only allow updates to drafts
if (existing.Status != PolicyPackStatus.Draft)
{
return Task.FromResult<PolicyPackEntity?>(null);
}
var updated = existing with
{
Name = request.Name ?? existing.Name,
Description = request.Description ?? existing.Description,
Rules = request.Rules ?? existing.Rules,
Metadata = request.Metadata ?? existing.Metadata,
UpdatedAt = DateTimeOffset.UtcNow,
UpdatedBy = updatedBy
};
_packs[(tenantId, packId)] = updated;
AddHistoryEntry(tenantId, packId, "updated", updatedBy, existing.Status, updated.Status, "Policy pack updated");
return Task.FromResult<PolicyPackEntity?>(updated);
}
public Task<bool> DeleteAsync(
Guid tenantId,
Guid packId,
CancellationToken cancellationToken = default)
{
if (!_packs.TryGetValue((tenantId, packId), out var existing))
{
return Task.FromResult(false);
}
// Only allow deletion of drafts
if (existing.Status != PolicyPackStatus.Draft)
{
return Task.FromResult(false);
}
var removed = _packs.TryRemove((tenantId, packId), out _);
if (removed)
{
_history.TryRemove((tenantId, packId), out _);
}
return Task.FromResult(removed);
}
public Task<PolicyPackEntity?> UpdateStatusAsync(
Guid tenantId,
Guid packId,
PolicyPackStatus newStatus,
string? updatedBy = null,
CancellationToken cancellationToken = default)
{
if (!_packs.TryGetValue((tenantId, packId), out var existing))
{
return Task.FromResult<PolicyPackEntity?>(null);
}
var now = DateTimeOffset.UtcNow;
var updated = existing with
{
Status = newStatus,
UpdatedAt = now,
UpdatedBy = updatedBy,
PublishedAt = newStatus == PolicyPackStatus.Published ? now : existing.PublishedAt,
Digest = newStatus == PolicyPackStatus.Published ? ComputeDigest(existing) : existing.Digest
};
_packs[(tenantId, packId)] = updated;
AddHistoryEntry(tenantId, packId, $"status_changed_to_{newStatus}", updatedBy, existing.Status, newStatus,
$"Status changed from {existing.Status} to {newStatus}");
return Task.FromResult<PolicyPackEntity?>(updated);
}
public Task<IReadOnlyList<PolicyPackHistoryEntry>> GetHistoryAsync(
Guid tenantId,
Guid packId,
int limit = 50,
CancellationToken cancellationToken = default)
{
if (!_history.TryGetValue((tenantId, packId), out var history))
{
return Task.FromResult<IReadOnlyList<PolicyPackHistoryEntry>>(Array.Empty<PolicyPackHistoryEntry>());
}
var entries = history
.OrderByDescending(h => h.Timestamp)
.Take(limit)
.ToList();
return Task.FromResult<IReadOnlyList<PolicyPackHistoryEntry>>(entries);
}
private void AddHistoryEntry(
Guid tenantId,
Guid packId,
string action,
string? performedBy,
PolicyPackStatus? previousStatus,
PolicyPackStatus? newStatus,
string? description)
{
var entry = new PolicyPackHistoryEntry
{
PackId = packId,
Action = action,
Timestamp = DateTimeOffset.UtcNow,
PerformedBy = performedBy,
PreviousStatus = previousStatus,
NewStatus = newStatus,
Description = description
};
_history.AddOrUpdate(
(tenantId, packId),
_ => [entry],
(_, list) =>
{
list.Add(entry);
return list;
});
}
private static string ComputeDigest(PolicyPackEntity pack)
{
var content = JsonSerializer.Serialize(new
{
pack.Name,
pack.Version,
pack.Rules
});
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}

View File

@@ -0,0 +1,115 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using StellaOps.Policy.Registry.Contracts;
namespace StellaOps.Policy.Registry.Storage;
/// <summary>
/// In-memory implementation of ISnapshotStore for testing and development.
/// </summary>
public sealed class InMemorySnapshotStore : ISnapshotStore
{
private readonly ConcurrentDictionary<(Guid TenantId, Guid SnapshotId), SnapshotEntity> _snapshots = new();
public Task<SnapshotEntity> CreateAsync(
Guid tenantId,
CreateSnapshotRequest request,
string? createdBy = null,
CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var snapshotId = Guid.NewGuid();
// Compute digest from pack IDs and timestamp for uniqueness
var digest = ComputeDigest(request.PackIds, now);
var entity = new SnapshotEntity
{
TenantId = tenantId,
SnapshotId = snapshotId,
Digest = digest,
Description = request.Description,
PackIds = request.PackIds,
Metadata = request.Metadata,
CreatedAt = now,
CreatedBy = createdBy
};
_snapshots[(tenantId, snapshotId)] = entity;
return Task.FromResult(entity);
}
public Task<SnapshotEntity?> GetByIdAsync(
Guid tenantId,
Guid snapshotId,
CancellationToken cancellationToken = default)
{
_snapshots.TryGetValue((tenantId, snapshotId), out var entity);
return Task.FromResult(entity);
}
public Task<SnapshotEntity?> GetByDigestAsync(
Guid tenantId,
string digest,
CancellationToken cancellationToken = default)
{
var entity = _snapshots.Values
.Where(s => s.TenantId == tenantId && s.Digest == digest)
.FirstOrDefault();
return Task.FromResult(entity);
}
public Task<SnapshotListResult> ListAsync(
Guid tenantId,
int pageSize = 20,
string? pageToken = null,
CancellationToken cancellationToken = default)
{
var items = _snapshots.Values
.Where(s => s.TenantId == tenantId)
.OrderByDescending(s => s.CreatedAt)
.ToList();
int skip = 0;
if (!string.IsNullOrEmpty(pageToken) && int.TryParse(pageToken, out var offset))
{
skip = offset;
}
var pagedItems = items.Skip(skip).Take(pageSize).ToList();
string? nextToken = skip + pagedItems.Count < items.Count
? (skip + pagedItems.Count).ToString()
: null;
return Task.FromResult(new SnapshotListResult
{
Items = pagedItems,
NextPageToken = nextToken,
TotalCount = items.Count
});
}
public Task<bool> DeleteAsync(
Guid tenantId,
Guid snapshotId,
CancellationToken cancellationToken = default)
{
return Task.FromResult(_snapshots.TryRemove((tenantId, snapshotId), out _));
}
private static string ComputeDigest(IReadOnlyList<Guid> packIds, DateTimeOffset timestamp)
{
var content = JsonSerializer.Serialize(new
{
PackIds = packIds.OrderBy(id => id).ToList(),
Timestamp = timestamp.ToUnixTimeMilliseconds()
});
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}

View File

@@ -0,0 +1,121 @@
using System.Collections.Concurrent;
using StellaOps.Policy.Registry.Contracts;
namespace StellaOps.Policy.Registry.Storage;
/// <summary>
/// In-memory implementation of IVerificationPolicyStore for testing and development.
/// </summary>
public sealed class InMemoryVerificationPolicyStore : IVerificationPolicyStore
{
private readonly ConcurrentDictionary<(Guid TenantId, string PolicyId), VerificationPolicyEntity> _policies = new();
public Task<VerificationPolicyEntity> CreateAsync(
Guid tenantId,
CreateVerificationPolicyRequest request,
string? createdBy = null,
CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var entity = new VerificationPolicyEntity
{
TenantId = tenantId,
PolicyId = request.PolicyId,
Version = request.Version,
Description = request.Description,
TenantScope = request.TenantScope ?? tenantId.ToString(),
PredicateTypes = request.PredicateTypes,
SignerRequirements = request.SignerRequirements ?? new SignerRequirements
{
MinimumSignatures = 1,
TrustedKeyFingerprints = Array.Empty<string>()
},
ValidityWindow = request.ValidityWindow,
Metadata = request.Metadata,
CreatedAt = now,
UpdatedAt = now,
CreatedBy = createdBy
};
_policies[(tenantId, request.PolicyId)] = entity;
return Task.FromResult(entity);
}
public Task<VerificationPolicyEntity?> GetByIdAsync(
Guid tenantId,
string policyId,
CancellationToken cancellationToken = default)
{
_policies.TryGetValue((tenantId, policyId), out var entity);
return Task.FromResult(entity);
}
public Task<VerificationPolicyListResult> ListAsync(
Guid tenantId,
int pageSize = 20,
string? pageToken = null,
CancellationToken cancellationToken = default)
{
var items = _policies.Values
.Where(p => p.TenantId == tenantId)
.OrderByDescending(p => p.UpdatedAt)
.ToList();
int skip = 0;
if (!string.IsNullOrEmpty(pageToken) && int.TryParse(pageToken, out var offset))
{
skip = offset;
}
var pagedItems = items.Skip(skip).Take(pageSize).ToList();
string? nextToken = skip + pagedItems.Count < items.Count
? (skip + pagedItems.Count).ToString()
: null;
return Task.FromResult(new VerificationPolicyListResult
{
Items = pagedItems,
NextPageToken = nextToken,
TotalCount = items.Count
});
}
public Task<VerificationPolicyEntity?> UpdateAsync(
Guid tenantId,
string policyId,
UpdateVerificationPolicyRequest request,
string? updatedBy = null,
CancellationToken cancellationToken = default)
{
if (!_policies.TryGetValue((tenantId, policyId), out var existing))
{
return Task.FromResult<VerificationPolicyEntity?>(null);
}
var updated = existing with
{
Version = request.Version ?? existing.Version,
Description = request.Description ?? existing.Description,
PredicateTypes = request.PredicateTypes ?? existing.PredicateTypes,
SignerRequirements = request.SignerRequirements ?? existing.SignerRequirements,
ValidityWindow = request.ValidityWindow ?? existing.ValidityWindow,
Metadata = request.Metadata ?? existing.Metadata,
UpdatedAt = DateTimeOffset.UtcNow,
UpdatedBy = updatedBy
};
_policies[(tenantId, policyId)] = updated;
return Task.FromResult<VerificationPolicyEntity?>(updated);
}
public Task<bool> DeleteAsync(
Guid tenantId,
string policyId,
CancellationToken cancellationToken = default)
{
return Task.FromResult(_policies.TryRemove((tenantId, policyId), out _));
}
}

View File

@@ -0,0 +1,139 @@
using System.Collections.Concurrent;
using StellaOps.Policy.Registry.Contracts;
namespace StellaOps.Policy.Registry.Storage;
/// <summary>
/// In-memory implementation of IViolationStore for testing and development.
/// </summary>
public sealed class InMemoryViolationStore : IViolationStore
{
private readonly ConcurrentDictionary<(Guid TenantId, Guid ViolationId), ViolationEntity> _violations = new();
public Task<ViolationEntity> AppendAsync(
Guid tenantId,
CreateViolationRequest request,
CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var violationId = Guid.NewGuid();
var entity = new ViolationEntity
{
TenantId = tenantId,
ViolationId = violationId,
PolicyId = request.PolicyId,
RuleId = request.RuleId,
Severity = request.Severity,
Message = request.Message,
Purl = request.Purl,
CveId = request.CveId,
Context = request.Context,
CreatedAt = now
};
_violations[(tenantId, violationId)] = entity;
return Task.FromResult(entity);
}
public Task<ViolationBatchResult> AppendBatchAsync(
Guid tenantId,
IReadOnlyList<CreateViolationRequest> requests,
CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
int created = 0;
int failed = 0;
var errors = new List<BatchError>();
for (int i = 0; i < requests.Count; i++)
{
try
{
var request = requests[i];
var violationId = Guid.NewGuid();
var entity = new ViolationEntity
{
TenantId = tenantId,
ViolationId = violationId,
PolicyId = request.PolicyId,
RuleId = request.RuleId,
Severity = request.Severity,
Message = request.Message,
Purl = request.Purl,
CveId = request.CveId,
Context = request.Context,
CreatedAt = now
};
_violations[(tenantId, violationId)] = entity;
created++;
}
catch (Exception ex)
{
failed++;
errors.Add(new BatchError
{
Index = i,
Error = ex.Message
});
}
}
return Task.FromResult(new ViolationBatchResult
{
Created = created,
Failed = failed,
Errors = errors.Count > 0 ? errors : null
});
}
public Task<ViolationEntity?> GetByIdAsync(
Guid tenantId,
Guid violationId,
CancellationToken cancellationToken = default)
{
_violations.TryGetValue((tenantId, violationId), out var entity);
return Task.FromResult(entity);
}
public Task<ViolationListResult> ListAsync(
Guid tenantId,
Severity? severity = null,
int pageSize = 20,
string? pageToken = null,
CancellationToken cancellationToken = default)
{
var query = _violations.Values
.Where(v => v.TenantId == tenantId);
if (severity.HasValue)
{
query = query.Where(v => v.Severity == severity.Value);
}
var items = query
.OrderByDescending(v => v.CreatedAt)
.ToList();
int skip = 0;
if (!string.IsNullOrEmpty(pageToken) && int.TryParse(pageToken, out var offset))
{
skip = offset;
}
var pagedItems = items.Skip(skip).Take(pageSize).ToList();
string? nextToken = skip + pagedItems.Count < items.Count
? (skip + pagedItems.Count).ToString()
: null;
return Task.FromResult(new ViolationListResult
{
Items = pagedItems,
NextPageToken = nextToken,
TotalCount = items.Count
});
}
}

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More