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
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
This commit is contained in:
89
src/Policy/StellaOps.Policy.Engine/.editorconfig
Normal file
89
src/Policy/StellaOps.Policy.Engine/.editorconfig
Normal 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
|
||||
421
src/Policy/StellaOps.Policy.Engine/AirGap/AirGapNotifications.cs
Normal file
421
src/Policy/StellaOps.Policy.Engine/AirGap/AirGapNotifications.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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
|
||||
255
src/Policy/StellaOps.Policy.Engine/AirGap/SealedModeErrors.cs
Normal file
255
src/Policy/StellaOps.Policy.Engine/AirGap/SealedModeErrors.cs
Normal 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"
|
||||
};
|
||||
}
|
||||
}
|
||||
114
src/Policy/StellaOps.Policy.Engine/AirGap/SealedModeModels.cs
Normal file
114
src/Policy/StellaOps.Policy.Engine/AirGap/SealedModeModels.cs
Normal 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";
|
||||
}
|
||||
216
src/Policy/StellaOps.Policy.Engine/AirGap/SealedModeService.cs
Normal file
216
src/Policy/StellaOps.Policy.Engine/AirGap/SealedModeService.cs
Normal 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)));
|
||||
}
|
||||
}
|
||||
327
src/Policy/StellaOps.Policy.Engine/AirGap/StalenessSignaling.cs
Normal file
327
src/Policy/StellaOps.Policy.Engine/AirGap/StalenessSignaling.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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(':');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
|
||||
@@ -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);
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>>();
|
||||
|
||||
@@ -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);
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
27
src/Policy/StellaOps.Policy.Gateway/AGENTS.md
Normal file
27
src/Policy/StellaOps.Policy.Gateway/AGENTS.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# StellaOps.Policy.Gateway — AGENTS Charter
|
||||
|
||||
## Working Directory & Mission
|
||||
- Working directory: `src/Policy/StellaOps.Policy.Gateway/**`.
|
||||
- Mission: expose policy APIs (incl. CVSS v4.0 receipt endpoints) with tenant-safe, deterministic responses, DSSE-backed receipts, and offline-friendly defaults.
|
||||
|
||||
## Roles
|
||||
- **Backend engineer (.NET 10 / ASP.NET Core minimal API):** endpoints, auth scopes, persistence wiring.
|
||||
- **QA engineer:** WebApplicationFactory integration slices; deterministic contract tests (status codes, schema, ordering, hashes).
|
||||
|
||||
## Required Reading (treat as read before DOING)
|
||||
- `docs/modules/policy/architecture.md`
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
- `docs/modules/policy/cvss-v4.md`
|
||||
- `docs/product-advisories/25-Nov-2025 - Add CVSS v4.0 Score Receipts for Transparency.md`
|
||||
- Sprint tracker: `docs/implplan/SPRINT_0190_0001_0001_cvss_v4_receipts.md`
|
||||
|
||||
## Working Agreements
|
||||
- Enforce tenant isolation and `policy:*`/`cvss:*`/`effective:write` scopes on all endpoints.
|
||||
- Determinism: stable ordering, UTC ISO-8601 timestamps, canonical JSON for receipts and exports; include scorer version/hash in responses.
|
||||
- Offline-first: no outbound calls beyond configured internal services; feature flags default to offline-safe.
|
||||
- DSSE: receipt create/amend routes must emit DSSE (`stella.ops/cvssReceipt@v1`) and persist references.
|
||||
- Schema governance: keep OpenAPI/JSON schemas in sync with models; update docs and sprint Decisions & Risks when contracts change.
|
||||
|
||||
## Testing
|
||||
- Prefer integration tests via WebApplicationFactory (in a `StellaOps.Policy.Gateway.Tests` project) covering auth, tenancy, determinism, DSSE presence, and schema validation.
|
||||
- No network; seed deterministic fixtures; assert consistent hashes across runs.
|
||||
@@ -1,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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
214
src/Policy/StellaOps.Policy.Registry/IPolicyRegistryClient.cs
Normal file
214
src/Policy/StellaOps.Policy.Registry/IPolicyRegistryClient.cs
Normal 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);
|
||||
}
|
||||
634
src/Policy/StellaOps.Policy.Registry/PolicyRegistryClient.cs
Normal file
634
src/Policy/StellaOps.Policy.Registry/PolicyRegistryClient.cs
Normal 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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Policy.Registry.Contracts;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of batch simulation orchestrator.
|
||||
/// Uses in-memory job queue with background processing.
|
||||
/// </summary>
|
||||
public sealed class BatchSimulationOrchestrator : IBatchSimulationOrchestrator, IDisposable
|
||||
{
|
||||
private readonly IPolicySimulationService _simulationService;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ConcurrentDictionary<(Guid TenantId, string JobId), BatchSimulationJob> _jobs = new();
|
||||
private readonly ConcurrentDictionary<(Guid TenantId, string JobId), List<BatchSimulationInputResult>> _results = new();
|
||||
private readonly ConcurrentDictionary<string, string> _idempotencyKeys = new();
|
||||
private readonly ConcurrentQueue<(Guid TenantId, string JobId, BatchSimulationRequest Request)> _jobQueue = new();
|
||||
private readonly CancellationTokenSource _disposalCts = new();
|
||||
private readonly Task _processingTask;
|
||||
|
||||
public BatchSimulationOrchestrator(
|
||||
IPolicySimulationService simulationService,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_simulationService = simulationService ?? throw new ArgumentNullException(nameof(simulationService));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
|
||||
// Start background processing
|
||||
_processingTask = Task.Run(ProcessJobsAsync);
|
||||
}
|
||||
|
||||
public Task<BatchSimulationJob> SubmitBatchAsync(
|
||||
Guid tenantId,
|
||||
BatchSimulationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Check idempotency key
|
||||
if (!string.IsNullOrEmpty(request.IdempotencyKey))
|
||||
{
|
||||
if (_idempotencyKeys.TryGetValue(request.IdempotencyKey, out var existingJobId))
|
||||
{
|
||||
var existingJob = _jobs.Values.FirstOrDefault(j => j.JobId == existingJobId && j.TenantId == tenantId);
|
||||
if (existingJob is not null)
|
||||
{
|
||||
return Task.FromResult(existingJob);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var jobId = GenerateJobId(tenantId, now);
|
||||
|
||||
var job = new BatchSimulationJob
|
||||
{
|
||||
JobId = jobId,
|
||||
TenantId = tenantId,
|
||||
PackId = request.PackId,
|
||||
Status = BatchJobStatus.Pending,
|
||||
Description = request.Description,
|
||||
TotalInputs = request.Inputs.Count,
|
||||
ProcessedInputs = 0,
|
||||
SucceededInputs = 0,
|
||||
FailedInputs = 0,
|
||||
CreatedAt = now,
|
||||
Progress = new BatchJobProgress
|
||||
{
|
||||
PercentComplete = 0,
|
||||
EstimatedRemainingSeconds = null,
|
||||
CurrentBatchIndex = 0,
|
||||
TotalBatches = 1
|
||||
}
|
||||
};
|
||||
|
||||
_jobs[(tenantId, jobId)] = job;
|
||||
_results[(tenantId, jobId)] = [];
|
||||
|
||||
if (!string.IsNullOrEmpty(request.IdempotencyKey))
|
||||
{
|
||||
_idempotencyKeys[request.IdempotencyKey] = jobId;
|
||||
}
|
||||
|
||||
// Queue job for processing
|
||||
_jobQueue.Enqueue((tenantId, jobId, request));
|
||||
|
||||
return Task.FromResult(job);
|
||||
}
|
||||
|
||||
public Task<BatchSimulationJob?> GetJobAsync(
|
||||
Guid tenantId,
|
||||
string jobId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_jobs.TryGetValue((tenantId, jobId), out var job);
|
||||
return Task.FromResult(job);
|
||||
}
|
||||
|
||||
public Task<BatchSimulationJobList> ListJobsAsync(
|
||||
Guid tenantId,
|
||||
BatchJobStatus? status = null,
|
||||
int pageSize = 20,
|
||||
string? pageToken = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _jobs.Values.Where(j => j.TenantId == tenantId);
|
||||
|
||||
if (status.HasValue)
|
||||
{
|
||||
query = query.Where(j => j.Status == status.Value);
|
||||
}
|
||||
|
||||
var items = query
|
||||
.OrderByDescending(j => j.CreatedAt)
|
||||
.ToList();
|
||||
|
||||
int skip = 0;
|
||||
if (!string.IsNullOrEmpty(pageToken) && int.TryParse(pageToken, out var offset))
|
||||
{
|
||||
skip = offset;
|
||||
}
|
||||
|
||||
var pagedItems = items.Skip(skip).Take(pageSize).ToList();
|
||||
string? nextToken = skip + pagedItems.Count < items.Count
|
||||
? (skip + pagedItems.Count).ToString()
|
||||
: null;
|
||||
|
||||
return Task.FromResult(new BatchSimulationJobList
|
||||
{
|
||||
Items = pagedItems,
|
||||
NextPageToken = nextToken,
|
||||
TotalCount = items.Count
|
||||
});
|
||||
}
|
||||
|
||||
public Task<bool> CancelJobAsync(
|
||||
Guid tenantId,
|
||||
string jobId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_jobs.TryGetValue((tenantId, jobId), out var job))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
if (job.Status is not (BatchJobStatus.Pending or BatchJobStatus.Running))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
var cancelledJob = job with
|
||||
{
|
||||
Status = BatchJobStatus.Cancelled,
|
||||
CompletedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
_jobs[(tenantId, jobId)] = cancelledJob;
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public Task<BatchSimulationResults?> GetResultsAsync(
|
||||
Guid tenantId,
|
||||
string jobId,
|
||||
int pageSize = 100,
|
||||
string? pageToken = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_jobs.TryGetValue((tenantId, jobId), out var job))
|
||||
{
|
||||
return Task.FromResult<BatchSimulationResults?>(null);
|
||||
}
|
||||
|
||||
if (!_results.TryGetValue((tenantId, jobId), out var results))
|
||||
{
|
||||
return Task.FromResult<BatchSimulationResults?>(null);
|
||||
}
|
||||
|
||||
int skip = 0;
|
||||
if (!string.IsNullOrEmpty(pageToken) && int.TryParse(pageToken, out var offset))
|
||||
{
|
||||
skip = offset;
|
||||
}
|
||||
|
||||
var pagedResults = results.Skip(skip).Take(pageSize).ToList();
|
||||
string? nextToken = skip + pagedResults.Count < results.Count
|
||||
? (skip + pagedResults.Count).ToString()
|
||||
: null;
|
||||
|
||||
var summary = job.Status == BatchJobStatus.Completed ? ComputeSummary(results) : null;
|
||||
|
||||
return Task.FromResult<BatchSimulationResults?>(new BatchSimulationResults
|
||||
{
|
||||
JobId = jobId,
|
||||
Results = pagedResults,
|
||||
Summary = summary,
|
||||
NextPageToken = nextToken
|
||||
});
|
||||
}
|
||||
|
||||
private async Task ProcessJobsAsync()
|
||||
{
|
||||
while (!_disposalCts.Token.IsCancellationRequested)
|
||||
{
|
||||
if (_jobQueue.TryDequeue(out var item))
|
||||
{
|
||||
var (tenantId, jobId, request) = item;
|
||||
|
||||
// Check if job was cancelled
|
||||
if (_jobs.TryGetValue((tenantId, jobId), out var job) && job.Status == BatchJobStatus.Cancelled)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await ProcessJobAsync(tenantId, jobId, request, _disposalCts.Token);
|
||||
}
|
||||
else
|
||||
{
|
||||
await Task.Delay(100, _disposalCts.Token).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessJobAsync(
|
||||
Guid tenantId,
|
||||
string jobId,
|
||||
BatchSimulationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var startedAt = _timeProvider.GetUtcNow();
|
||||
var results = _results[(tenantId, jobId)];
|
||||
|
||||
// Update job to running
|
||||
UpdateJob(tenantId, jobId, job => job with
|
||||
{
|
||||
Status = BatchJobStatus.Running,
|
||||
StartedAt = startedAt
|
||||
});
|
||||
|
||||
int processed = 0;
|
||||
int succeeded = 0;
|
||||
int failed = 0;
|
||||
|
||||
foreach (var input in request.Inputs)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if job was cancelled
|
||||
if (_jobs.TryGetValue((tenantId, jobId), out var currentJob) && currentJob.Status == BatchJobStatus.Cancelled)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var simRequest = new SimulationRequest
|
||||
{
|
||||
Input = input.Input,
|
||||
Options = request.Options is not null ? new SimulationOptions
|
||||
{
|
||||
Trace = request.Options.IncludeTrace,
|
||||
Explain = request.Options.IncludeExplain
|
||||
} : null
|
||||
};
|
||||
|
||||
var response = await _simulationService.SimulateAsync(
|
||||
tenantId,
|
||||
request.PackId,
|
||||
simRequest,
|
||||
cancellationToken);
|
||||
|
||||
results.Add(new BatchSimulationInputResult
|
||||
{
|
||||
InputId = input.InputId,
|
||||
Success = response.Success,
|
||||
Response = response,
|
||||
DurationMilliseconds = response.DurationMilliseconds
|
||||
});
|
||||
|
||||
if (response.Success)
|
||||
{
|
||||
succeeded++;
|
||||
}
|
||||
else
|
||||
{
|
||||
failed++;
|
||||
if (!request.Options?.ContinueOnError ?? false)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failed++;
|
||||
results.Add(new BatchSimulationInputResult
|
||||
{
|
||||
InputId = input.InputId,
|
||||
Success = false,
|
||||
Error = ex.Message,
|
||||
DurationMilliseconds = 0
|
||||
});
|
||||
|
||||
if (!request.Options?.ContinueOnError ?? false)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
processed++;
|
||||
|
||||
// Update progress
|
||||
var progress = (double)processed / request.Inputs.Count * 100;
|
||||
UpdateJob(tenantId, jobId, job => job with
|
||||
{
|
||||
ProcessedInputs = processed,
|
||||
SucceededInputs = succeeded,
|
||||
FailedInputs = failed,
|
||||
Progress = new BatchJobProgress
|
||||
{
|
||||
PercentComplete = progress,
|
||||
CurrentBatchIndex = processed,
|
||||
TotalBatches = request.Inputs.Count
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Finalize job
|
||||
var completedAt = _timeProvider.GetUtcNow();
|
||||
var finalStatus = failed > 0 && succeeded == 0
|
||||
? BatchJobStatus.Failed
|
||||
: BatchJobStatus.Completed;
|
||||
|
||||
UpdateJob(tenantId, jobId, job => job with
|
||||
{
|
||||
Status = finalStatus,
|
||||
ProcessedInputs = processed,
|
||||
SucceededInputs = succeeded,
|
||||
FailedInputs = failed,
|
||||
CompletedAt = completedAt,
|
||||
Progress = new BatchJobProgress
|
||||
{
|
||||
PercentComplete = 100,
|
||||
CurrentBatchIndex = processed,
|
||||
TotalBatches = request.Inputs.Count
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void UpdateJob(Guid tenantId, string jobId, Func<BatchSimulationJob, BatchSimulationJob> update)
|
||||
{
|
||||
if (_jobs.TryGetValue((tenantId, jobId), out var current))
|
||||
{
|
||||
_jobs[(tenantId, jobId)] = update(current);
|
||||
}
|
||||
}
|
||||
|
||||
private static BatchSimulationSummary ComputeSummary(List<BatchSimulationInputResult> results)
|
||||
{
|
||||
var totalViolations = 0;
|
||||
var severityCounts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
long totalDuration = 0;
|
||||
|
||||
foreach (var result in results)
|
||||
{
|
||||
totalDuration += result.DurationMilliseconds;
|
||||
|
||||
if (result.Response?.Summary?.ViolationsFound > 0)
|
||||
{
|
||||
totalViolations += result.Response.Summary.ViolationsFound;
|
||||
|
||||
foreach (var (severity, count) in result.Response.Summary.ViolationsBySeverity)
|
||||
{
|
||||
severityCounts[severity] = severityCounts.GetValueOrDefault(severity) + count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new BatchSimulationSummary
|
||||
{
|
||||
TotalInputs = results.Count,
|
||||
Succeeded = results.Count(r => r.Success),
|
||||
Failed = results.Count(r => !r.Success),
|
||||
TotalViolations = totalViolations,
|
||||
ViolationsBySeverity = severityCounts,
|
||||
TotalDurationMilliseconds = totalDuration,
|
||||
AverageDurationMilliseconds = results.Count > 0 ? (double)totalDuration / results.Count : 0
|
||||
};
|
||||
}
|
||||
|
||||
private static string GenerateJobId(Guid tenantId, DateTimeOffset timestamp)
|
||||
{
|
||||
var content = $"{tenantId}:{timestamp.ToUnixTimeMilliseconds()}:{Guid.NewGuid()}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
|
||||
return $"batch_{Convert.ToHexString(hash)[..16].ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_disposalCts.Cancel();
|
||||
_processingTask.Wait(TimeSpan.FromSeconds(5));
|
||||
_disposalCts.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
using StellaOps.Policy.Registry.Contracts;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for orchestrating batch policy simulations.
|
||||
/// Implements REGISTRY-API-27-005: Batch simulation orchestration.
|
||||
/// </summary>
|
||||
public interface IBatchSimulationOrchestrator
|
||||
{
|
||||
/// <summary>
|
||||
/// Submits a batch simulation job.
|
||||
/// </summary>
|
||||
Task<BatchSimulationJob> SubmitBatchAsync(
|
||||
Guid tenantId,
|
||||
BatchSimulationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the status of a batch simulation job.
|
||||
/// </summary>
|
||||
Task<BatchSimulationJob?> GetJobAsync(
|
||||
Guid tenantId,
|
||||
string jobId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists batch simulation jobs for a tenant.
|
||||
/// </summary>
|
||||
Task<BatchSimulationJobList> ListJobsAsync(
|
||||
Guid tenantId,
|
||||
BatchJobStatus? status = null,
|
||||
int pageSize = 20,
|
||||
string? pageToken = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Cancels a pending or running batch simulation job.
|
||||
/// </summary>
|
||||
Task<bool> CancelJobAsync(
|
||||
Guid tenantId,
|
||||
string jobId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets results for a completed batch simulation job.
|
||||
/// </summary>
|
||||
Task<BatchSimulationResults?> GetResultsAsync(
|
||||
Guid tenantId,
|
||||
string jobId,
|
||||
int pageSize = 100,
|
||||
string? pageToken = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to submit a batch simulation.
|
||||
/// </summary>
|
||||
public sealed record BatchSimulationRequest
|
||||
{
|
||||
public required Guid PackId { get; init; }
|
||||
public required IReadOnlyList<BatchSimulationInput> Inputs { get; init; }
|
||||
public BatchSimulationOptions? Options { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public int? Priority { get; init; }
|
||||
public string? IdempotencyKey { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single input for batch simulation.
|
||||
/// </summary>
|
||||
public sealed record BatchSimulationInput
|
||||
{
|
||||
public required string InputId { get; init; }
|
||||
public required IReadOnlyDictionary<string, object> Input { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Tags { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for batch simulation.
|
||||
/// </summary>
|
||||
public sealed record BatchSimulationOptions
|
||||
{
|
||||
public bool ContinueOnError { get; init; } = true;
|
||||
public int? MaxConcurrency { get; init; }
|
||||
public int? TimeoutSeconds { get; init; }
|
||||
public bool IncludeTrace { get; init; }
|
||||
public bool IncludeExplain { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Batch simulation job status.
|
||||
/// </summary>
|
||||
public enum BatchJobStatus
|
||||
{
|
||||
Pending,
|
||||
Running,
|
||||
Completed,
|
||||
Failed,
|
||||
Cancelled
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Batch simulation job.
|
||||
/// </summary>
|
||||
public sealed record BatchSimulationJob
|
||||
{
|
||||
public required string JobId { get; init; }
|
||||
public required Guid TenantId { get; init; }
|
||||
public required Guid PackId { get; init; }
|
||||
public required BatchJobStatus Status { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public required int TotalInputs { get; init; }
|
||||
public int ProcessedInputs { get; init; }
|
||||
public int SucceededInputs { get; init; }
|
||||
public int FailedInputs { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? StartedAt { get; init; }
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
public string? Error { get; init; }
|
||||
public BatchJobProgress? Progress { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Progress information for a batch job.
|
||||
/// </summary>
|
||||
public sealed record BatchJobProgress
|
||||
{
|
||||
public required double PercentComplete { get; init; }
|
||||
public long? EstimatedRemainingSeconds { get; init; }
|
||||
public int? CurrentBatchIndex { get; init; }
|
||||
public int? TotalBatches { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// List of batch simulation jobs.
|
||||
/// </summary>
|
||||
public sealed record BatchSimulationJobList
|
||||
{
|
||||
public required IReadOnlyList<BatchSimulationJob> Items { get; init; }
|
||||
public string? NextPageToken { get; init; }
|
||||
public int TotalCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Results from a completed batch simulation.
|
||||
/// </summary>
|
||||
public sealed record BatchSimulationResults
|
||||
{
|
||||
public required string JobId { get; init; }
|
||||
public required IReadOnlyList<BatchSimulationInputResult> Results { get; init; }
|
||||
public BatchSimulationSummary? Summary { get; init; }
|
||||
public string? NextPageToken { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result for a single input in batch simulation.
|
||||
/// </summary>
|
||||
public sealed record BatchSimulationInputResult
|
||||
{
|
||||
public required string InputId { get; init; }
|
||||
public required bool Success { get; init; }
|
||||
public PolicySimulationResponse? Response { get; init; }
|
||||
public string? Error { get; init; }
|
||||
public long DurationMilliseconds { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of batch simulation results.
|
||||
/// </summary>
|
||||
public sealed record BatchSimulationSummary
|
||||
{
|
||||
public required int TotalInputs { get; init; }
|
||||
public required int Succeeded { get; init; }
|
||||
public required int Failed { get; init; }
|
||||
public required int TotalViolations { get; init; }
|
||||
public required IReadOnlyDictionary<string, int> ViolationsBySeverity { get; init; }
|
||||
public required long TotalDurationMilliseconds { get; init; }
|
||||
public required double AverageDurationMilliseconds { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using StellaOps.Policy.Registry.Contracts;
|
||||
using StellaOps.Policy.Registry.Storage;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for compiling and validating policy packs.
|
||||
/// Implements REGISTRY-API-27-003: Compile endpoint integration.
|
||||
/// </summary>
|
||||
public interface IPolicyPackCompiler
|
||||
{
|
||||
/// <summary>
|
||||
/// Compiles a policy pack, validating all rules and computing a digest.
|
||||
/// </summary>
|
||||
Task<PolicyPackCompilationResult> CompileAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Validates a single Rego rule without persisting.
|
||||
/// </summary>
|
||||
Task<RuleValidationResult> ValidateRuleAsync(
|
||||
string ruleId,
|
||||
string? rego,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Validates all rules in a policy pack without persisting.
|
||||
/// </summary>
|
||||
Task<PolicyPackCompilationResult> ValidatePackAsync(
|
||||
CreatePolicyPackRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of policy pack compilation.
|
||||
/// </summary>
|
||||
public sealed record PolicyPackCompilationResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
public string? Digest { get; init; }
|
||||
public IReadOnlyList<CompilationError>? Errors { get; init; }
|
||||
public IReadOnlyList<CompilationWarning>? Warnings { get; init; }
|
||||
public PolicyPackCompilationStatistics? Statistics { get; init; }
|
||||
public long DurationMilliseconds { get; init; }
|
||||
|
||||
public static PolicyPackCompilationResult FromSuccess(
|
||||
string digest,
|
||||
PolicyPackCompilationStatistics statistics,
|
||||
IReadOnlyList<CompilationWarning>? warnings,
|
||||
long durationMs) => new()
|
||||
{
|
||||
Success = true,
|
||||
Digest = digest,
|
||||
Statistics = statistics,
|
||||
Warnings = warnings,
|
||||
DurationMilliseconds = durationMs
|
||||
};
|
||||
|
||||
public static PolicyPackCompilationResult FromFailure(
|
||||
IReadOnlyList<CompilationError> errors,
|
||||
IReadOnlyList<CompilationWarning>? warnings,
|
||||
long durationMs) => new()
|
||||
{
|
||||
Success = false,
|
||||
Errors = errors,
|
||||
Warnings = warnings,
|
||||
DurationMilliseconds = durationMs
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of single rule validation.
|
||||
/// </summary>
|
||||
public sealed record RuleValidationResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
public string? RuleId { get; init; }
|
||||
public IReadOnlyList<CompilationError>? Errors { get; init; }
|
||||
public IReadOnlyList<CompilationWarning>? Warnings { get; init; }
|
||||
|
||||
public static RuleValidationResult FromSuccess(
|
||||
string ruleId,
|
||||
IReadOnlyList<CompilationWarning>? warnings = null) => new()
|
||||
{
|
||||
Success = true,
|
||||
RuleId = ruleId,
|
||||
Warnings = warnings
|
||||
};
|
||||
|
||||
public static RuleValidationResult FromFailure(
|
||||
string ruleId,
|
||||
IReadOnlyList<CompilationError> errors,
|
||||
IReadOnlyList<CompilationWarning>? warnings = null) => new()
|
||||
{
|
||||
Success = false,
|
||||
RuleId = ruleId,
|
||||
Errors = errors,
|
||||
Warnings = warnings
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Statistics from policy pack compilation.
|
||||
/// </summary>
|
||||
public sealed record PolicyPackCompilationStatistics
|
||||
{
|
||||
public required int TotalRules { get; init; }
|
||||
public required int EnabledRules { get; init; }
|
||||
public required int DisabledRules { get; init; }
|
||||
public required int RulesWithRego { get; init; }
|
||||
public required int RulesWithoutRego { get; init; }
|
||||
public required IReadOnlyDictionary<string, int> SeverityCounts { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using StellaOps.Policy.Registry.Contracts;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for quick policy pack simulation.
|
||||
/// Implements REGISTRY-API-27-004: Quick simulation API.
|
||||
/// </summary>
|
||||
public interface IPolicySimulationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Simulates a policy pack against provided input.
|
||||
/// </summary>
|
||||
Task<PolicySimulationResponse> SimulateAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
SimulationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Simulates rules directly without requiring a persisted pack.
|
||||
/// Useful for testing rules during development.
|
||||
/// </summary>
|
||||
Task<PolicySimulationResponse> SimulateRulesAsync(
|
||||
Guid tenantId,
|
||||
IReadOnlyList<PolicyRule> rules,
|
||||
SimulationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Validates simulation input structure.
|
||||
/// </summary>
|
||||
Task<InputValidationResult> ValidateInputAsync(
|
||||
IReadOnlyDictionary<string, object> input,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from policy simulation.
|
||||
/// </summary>
|
||||
public sealed record PolicySimulationResponse
|
||||
{
|
||||
public required string SimulationId { get; init; }
|
||||
public required bool Success { get; init; }
|
||||
public required DateTimeOffset ExecutedAt { get; init; }
|
||||
public required long DurationMilliseconds { get; init; }
|
||||
public SimulationResult? Result { get; init; }
|
||||
public SimulationSummary? Summary { get; init; }
|
||||
public IReadOnlyList<SimulationError>? Errors { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of simulation execution.
|
||||
/// </summary>
|
||||
public sealed record SimulationSummary
|
||||
{
|
||||
public required int TotalRulesEvaluated { get; init; }
|
||||
public required int RulesMatched { get; init; }
|
||||
public required int ViolationsFound { get; init; }
|
||||
public required IReadOnlyDictionary<string, int> ViolationsBySeverity { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Error during simulation.
|
||||
/// </summary>
|
||||
public sealed record SimulationError
|
||||
{
|
||||
public string? RuleId { get; init; }
|
||||
public required string Code { get; init; }
|
||||
public required string Message { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of input validation.
|
||||
/// </summary>
|
||||
public sealed record InputValidationResult
|
||||
{
|
||||
public required bool IsValid { get; init; }
|
||||
public IReadOnlyList<InputValidationError>? Errors { get; init; }
|
||||
|
||||
public static InputValidationResult Valid() => new() { IsValid = true };
|
||||
|
||||
public static InputValidationResult Invalid(IReadOnlyList<InputValidationError> errors) => new()
|
||||
{
|
||||
IsValid = false,
|
||||
Errors = errors
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Input validation error.
|
||||
/// </summary>
|
||||
public sealed record InputValidationError
|
||||
{
|
||||
public required string Path { get; init; }
|
||||
public required string Message { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
using StellaOps.Policy.Registry.Contracts;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing policy pack promotions across environments.
|
||||
/// Implements REGISTRY-API-27-008: Promotion bindings per tenant/environment.
|
||||
/// </summary>
|
||||
public interface IPromotionService
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a promotion binding for a policy pack to an environment.
|
||||
/// </summary>
|
||||
Task<PromotionBinding> CreateBindingAsync(
|
||||
Guid tenantId,
|
||||
CreatePromotionBindingRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Promotes a policy pack to a target environment.
|
||||
/// </summary>
|
||||
Task<PromotionResult> PromoteAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
PromoteRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current binding for a pack/environment combination.
|
||||
/// </summary>
|
||||
Task<PromotionBinding?> GetBindingAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
string environment,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists all bindings for a tenant.
|
||||
/// </summary>
|
||||
Task<PromotionBindingList> ListBindingsAsync(
|
||||
Guid tenantId,
|
||||
string? environment = null,
|
||||
Guid? packId = null,
|
||||
int pageSize = 20,
|
||||
string? pageToken = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the active policy pack for an environment.
|
||||
/// </summary>
|
||||
Task<ActiveEnvironmentPolicy?> GetActiveForEnvironmentAsync(
|
||||
Guid tenantId,
|
||||
string environment,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Rolls back to a previous promotion for an environment.
|
||||
/// </summary>
|
||||
Task<RollbackResult> RollbackAsync(
|
||||
Guid tenantId,
|
||||
string environment,
|
||||
RollbackRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the promotion history for an environment.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<PromotionHistoryEntry>> GetHistoryAsync(
|
||||
Guid tenantId,
|
||||
string environment,
|
||||
int limit = 50,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Validates a promotion is allowed before executing.
|
||||
/// </summary>
|
||||
Task<PromotionValidationResult> ValidatePromotionAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
string targetEnvironment,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a promotion binding.
|
||||
/// </summary>
|
||||
public sealed record CreatePromotionBindingRequest
|
||||
{
|
||||
public required Guid PackId { get; init; }
|
||||
public required string Environment { get; init; }
|
||||
public PromotionBindingMode Mode { get; init; } = PromotionBindingMode.Manual;
|
||||
public PromotionBindingRules? Rules { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
public string? CreatedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to promote a policy pack.
|
||||
/// </summary>
|
||||
public sealed record PromoteRequest
|
||||
{
|
||||
public required string TargetEnvironment { get; init; }
|
||||
public string? ApprovalId { get; init; }
|
||||
public string? PromotedBy { get; init; }
|
||||
public string? Comment { get; init; }
|
||||
public bool Force { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to rollback a promotion.
|
||||
/// </summary>
|
||||
public sealed record RollbackRequest
|
||||
{
|
||||
public string? TargetBindingId { get; init; }
|
||||
public int? StepsBack { get; init; }
|
||||
public string? RolledBackBy { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Promotion binding mode.
|
||||
/// </summary>
|
||||
public enum PromotionBindingMode
|
||||
{
|
||||
Manual,
|
||||
AutomaticOnApproval,
|
||||
Scheduled,
|
||||
Canary
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rules for automatic promotion.
|
||||
/// </summary>
|
||||
public sealed record PromotionBindingRules
|
||||
{
|
||||
public IReadOnlyList<string>? RequiredApprovers { get; init; }
|
||||
public int? MinimumApprovals { get; init; }
|
||||
public bool RequireSuccessfulSimulation { get; init; }
|
||||
public int? MinimumSimulationInputs { get; init; }
|
||||
public TimeSpan? MinimumSoakPeriod { get; init; }
|
||||
public IReadOnlyList<string>? AllowedSourceEnvironments { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Promotion binding.
|
||||
/// </summary>
|
||||
public sealed record PromotionBinding
|
||||
{
|
||||
public required string BindingId { get; init; }
|
||||
public required Guid TenantId { get; init; }
|
||||
public required Guid PackId { get; init; }
|
||||
public required string PackVersion { get; init; }
|
||||
public required string Environment { get; init; }
|
||||
public required PromotionBindingMode Mode { get; init; }
|
||||
public required PromotionBindingStatus Status { get; init; }
|
||||
public PromotionBindingRules? Rules { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? ActivatedAt { get; init; }
|
||||
public DateTimeOffset? DeactivatedAt { get; init; }
|
||||
public string? CreatedBy { get; init; }
|
||||
public string? ActivatedBy { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Promotion binding status.
|
||||
/// </summary>
|
||||
public enum PromotionBindingStatus
|
||||
{
|
||||
Pending,
|
||||
Active,
|
||||
Superseded,
|
||||
RolledBack,
|
||||
Disabled
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a promotion operation.
|
||||
/// </summary>
|
||||
public sealed record PromotionResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
public PromotionBinding? Binding { get; init; }
|
||||
public string? PreviousBindingId { get; init; }
|
||||
public string? Error { get; init; }
|
||||
public IReadOnlyList<string>? Warnings { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// List of promotion bindings.
|
||||
/// </summary>
|
||||
public sealed record PromotionBindingList
|
||||
{
|
||||
public required IReadOnlyList<PromotionBinding> Items { get; init; }
|
||||
public string? NextPageToken { get; init; }
|
||||
public int TotalCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Active policy pack for an environment.
|
||||
/// </summary>
|
||||
public sealed record ActiveEnvironmentPolicy
|
||||
{
|
||||
public required string Environment { get; init; }
|
||||
public required Guid PackId { get; init; }
|
||||
public required string PackVersion { get; init; }
|
||||
public required string PackDigest { get; init; }
|
||||
public required string BindingId { get; init; }
|
||||
public required DateTimeOffset ActivatedAt { get; init; }
|
||||
public string? ActivatedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a rollback operation.
|
||||
/// </summary>
|
||||
public sealed record RollbackResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
public PromotionBinding? RestoredBinding { get; init; }
|
||||
public string? RolledBackBindingId { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Promotion history entry.
|
||||
/// </summary>
|
||||
public sealed record PromotionHistoryEntry
|
||||
{
|
||||
public required string BindingId { get; init; }
|
||||
public required Guid PackId { get; init; }
|
||||
public required string PackVersion { get; init; }
|
||||
public required PromotionHistoryAction Action { get; init; }
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
public string? PerformedBy { get; init; }
|
||||
public string? Comment { get; init; }
|
||||
public string? PreviousBindingId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Promotion history action types.
|
||||
/// </summary>
|
||||
public enum PromotionHistoryAction
|
||||
{
|
||||
Promoted,
|
||||
RolledBack,
|
||||
Disabled,
|
||||
Superseded
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of promotion validation.
|
||||
/// </summary>
|
||||
public sealed record PromotionValidationResult
|
||||
{
|
||||
public required bool IsValid { get; init; }
|
||||
public IReadOnlyList<PromotionValidationError>? Errors { get; init; }
|
||||
public IReadOnlyList<PromotionValidationWarning>? Warnings { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Promotion validation error.
|
||||
/// </summary>
|
||||
public sealed record PromotionValidationError
|
||||
{
|
||||
public required string Code { get; init; }
|
||||
public required string Message { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Promotion validation warning.
|
||||
/// </summary>
|
||||
public sealed record PromotionValidationWarning
|
||||
{
|
||||
public required string Code { get; init; }
|
||||
public required string Message { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
using StellaOps.Policy.Registry.Contracts;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for publishing policy packs with signing and attestations.
|
||||
/// Implements REGISTRY-API-27-007: Publish pipeline with signing/attestations.
|
||||
/// </summary>
|
||||
public interface IPublishPipelineService
|
||||
{
|
||||
/// <summary>
|
||||
/// Publishes an approved policy pack.
|
||||
/// </summary>
|
||||
Task<PublishResult> PublishAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
PublishPackRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the publication status of a policy pack.
|
||||
/// </summary>
|
||||
Task<PublicationStatus?> GetPublicationStatusAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the attestation for a published policy pack.
|
||||
/// </summary>
|
||||
Task<PolicyPackAttestation?> GetAttestationAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the signature and attestation of a published policy pack.
|
||||
/// </summary>
|
||||
Task<AttestationVerificationResult> VerifyAttestationAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists published policy packs for a tenant.
|
||||
/// </summary>
|
||||
Task<PublishedPackList> ListPublishedAsync(
|
||||
Guid tenantId,
|
||||
int pageSize = 20,
|
||||
string? pageToken = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Revokes a published policy pack.
|
||||
/// </summary>
|
||||
Task<RevokeResult> RevokeAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
RevokePackRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to publish a policy pack.
|
||||
/// </summary>
|
||||
public sealed record PublishPackRequest
|
||||
{
|
||||
public string? ApprovalId { get; init; }
|
||||
public string? PublishedBy { get; init; }
|
||||
public SigningOptions? SigningOptions { get; init; }
|
||||
public AttestationOptions? AttestationOptions { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signing options for policy pack publication.
|
||||
/// </summary>
|
||||
public sealed record SigningOptions
|
||||
{
|
||||
public required string KeyId { get; init; }
|
||||
public SigningAlgorithm Algorithm { get; init; } = SigningAlgorithm.ECDSA_P256_SHA256;
|
||||
public bool IncludeTimestamp { get; init; } = true;
|
||||
public bool IncludeRekorEntry { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attestation options for policy pack publication.
|
||||
/// </summary>
|
||||
public sealed record AttestationOptions
|
||||
{
|
||||
public required string PredicateType { get; init; }
|
||||
public bool IncludeCompilationResult { get; init; } = true;
|
||||
public bool IncludeReviewHistory { get; init; } = true;
|
||||
public bool IncludeSimulationResults { get; init; }
|
||||
public IReadOnlyDictionary<string, object>? CustomClaims { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Supported signing algorithms.
|
||||
/// </summary>
|
||||
public enum SigningAlgorithm
|
||||
{
|
||||
ECDSA_P256_SHA256,
|
||||
ECDSA_P384_SHA384,
|
||||
RSA_PKCS1_SHA256,
|
||||
RSA_PSS_SHA256,
|
||||
Ed25519
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of policy pack publication.
|
||||
/// </summary>
|
||||
public sealed record PublishResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
public Guid? PackId { get; init; }
|
||||
public string? Digest { get; init; }
|
||||
public PublicationStatus? Status { get; init; }
|
||||
public PolicyPackAttestation? Attestation { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publication status of a policy pack.
|
||||
/// </summary>
|
||||
public sealed record PublicationStatus
|
||||
{
|
||||
public required Guid PackId { get; init; }
|
||||
public required string PackVersion { get; init; }
|
||||
public required string Digest { get; init; }
|
||||
public required PublishState State { get; init; }
|
||||
public required DateTimeOffset PublishedAt { get; init; }
|
||||
public string? PublishedBy { get; init; }
|
||||
public DateTimeOffset? RevokedAt { get; init; }
|
||||
public string? RevokedBy { get; init; }
|
||||
public string? RevokeReason { get; init; }
|
||||
public string? SignatureKeyId { get; init; }
|
||||
public SigningAlgorithm? SignatureAlgorithm { get; init; }
|
||||
public string? RekorLogId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publication state.
|
||||
/// </summary>
|
||||
public enum PublishState
|
||||
{
|
||||
Published,
|
||||
Revoked,
|
||||
Superseded
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Policy pack attestation following in-toto/DSSE format.
|
||||
/// </summary>
|
||||
public sealed record PolicyPackAttestation
|
||||
{
|
||||
public required string PayloadType { get; init; }
|
||||
public required string Payload { get; init; }
|
||||
public required IReadOnlyList<AttestationSignature> Signatures { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attestation signature.
|
||||
/// </summary>
|
||||
public sealed record AttestationSignature
|
||||
{
|
||||
public required string KeyId { get; init; }
|
||||
public required string Signature { get; init; }
|
||||
public DateTimeOffset? Timestamp { get; init; }
|
||||
public string? RekorLogIndex { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attestation payload in SLSA provenance format.
|
||||
/// </summary>
|
||||
public sealed record AttestationPayload
|
||||
{
|
||||
public required string Type { get; init; }
|
||||
public required string PredicateType { get; init; }
|
||||
public required AttestationSubject Subject { get; init; }
|
||||
public required AttestationPredicate Predicate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attestation subject (the policy pack).
|
||||
/// </summary>
|
||||
public sealed record AttestationSubject
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required IReadOnlyDictionary<string, string> Digest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attestation predicate containing provenance metadata.
|
||||
/// </summary>
|
||||
public sealed record AttestationPredicate
|
||||
{
|
||||
public required string BuildType { get; init; }
|
||||
public required AttestationBuilder Builder { get; init; }
|
||||
public DateTimeOffset? BuildStartedOn { get; init; }
|
||||
public DateTimeOffset? BuildFinishedOn { get; init; }
|
||||
public PolicyPackCompilationMetadata? Compilation { get; init; }
|
||||
public PolicyPackReviewMetadata? Review { get; init; }
|
||||
public IReadOnlyDictionary<string, object>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attestation builder information.
|
||||
/// </summary>
|
||||
public sealed record AttestationBuilder
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public string? Version { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compilation metadata in attestation.
|
||||
/// </summary>
|
||||
public sealed record PolicyPackCompilationMetadata
|
||||
{
|
||||
public required string Digest { get; init; }
|
||||
public required int RuleCount { get; init; }
|
||||
public DateTimeOffset? CompiledAt { get; init; }
|
||||
public IReadOnlyDictionary<string, int>? Statistics { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review metadata in attestation.
|
||||
/// </summary>
|
||||
public sealed record PolicyPackReviewMetadata
|
||||
{
|
||||
public required string ReviewId { get; init; }
|
||||
public required DateTimeOffset ApprovedAt { get; init; }
|
||||
public string? ApprovedBy { get; init; }
|
||||
public IReadOnlyList<string>? Reviewers { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of attestation verification.
|
||||
/// </summary>
|
||||
public sealed record AttestationVerificationResult
|
||||
{
|
||||
public required bool Valid { get; init; }
|
||||
public IReadOnlyList<VerificationCheck>? Checks { get; init; }
|
||||
public IReadOnlyList<string>? Errors { get; init; }
|
||||
public IReadOnlyList<string>? Warnings { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual verification check result.
|
||||
/// </summary>
|
||||
public sealed record VerificationCheck
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required bool Passed { get; init; }
|
||||
public string? Details { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// List of published policy packs.
|
||||
/// </summary>
|
||||
public sealed record PublishedPackList
|
||||
{
|
||||
public required IReadOnlyList<PublicationStatus> Items { get; init; }
|
||||
public string? NextPageToken { get; init; }
|
||||
public int TotalCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to revoke a published policy pack.
|
||||
/// </summary>
|
||||
public sealed record RevokePackRequest
|
||||
{
|
||||
public required string Reason { get; init; }
|
||||
public string? RevokedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of policy pack revocation.
|
||||
/// </summary>
|
||||
public sealed record RevokeResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
public PublicationStatus? Status { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
using StellaOps.Policy.Registry.Contracts;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing policy pack review workflows with audit trails.
|
||||
/// Implements REGISTRY-API-27-006: Review workflow with audit trails.
|
||||
/// </summary>
|
||||
public interface IReviewWorkflowService
|
||||
{
|
||||
/// <summary>
|
||||
/// Submits a policy pack for review.
|
||||
/// </summary>
|
||||
Task<ReviewRequest> SubmitForReviewAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
SubmitReviewRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Approves a review request.
|
||||
/// </summary>
|
||||
Task<ReviewDecision> ApproveAsync(
|
||||
Guid tenantId,
|
||||
string reviewId,
|
||||
ApproveReviewRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Rejects a review request.
|
||||
/// </summary>
|
||||
Task<ReviewDecision> RejectAsync(
|
||||
Guid tenantId,
|
||||
string reviewId,
|
||||
RejectReviewRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Requests changes to a policy pack under review.
|
||||
/// </summary>
|
||||
Task<ReviewDecision> RequestChangesAsync(
|
||||
Guid tenantId,
|
||||
string reviewId,
|
||||
RequestChangesRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a review request by ID.
|
||||
/// </summary>
|
||||
Task<ReviewRequest?> GetReviewAsync(
|
||||
Guid tenantId,
|
||||
string reviewId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists review requests for a tenant.
|
||||
/// </summary>
|
||||
Task<ReviewRequestList> ListReviewsAsync(
|
||||
Guid tenantId,
|
||||
ReviewStatus? status = null,
|
||||
Guid? packId = null,
|
||||
int pageSize = 20,
|
||||
string? pageToken = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the audit trail for a review.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ReviewAuditEntry>> GetAuditTrailAsync(
|
||||
Guid tenantId,
|
||||
string reviewId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the audit trail for a policy pack across all reviews.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ReviewAuditEntry>> GetPackAuditTrailAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to submit a policy pack for review.
|
||||
/// </summary>
|
||||
public sealed record SubmitReviewRequest
|
||||
{
|
||||
public string? Description { get; init; }
|
||||
public IReadOnlyList<string>? Reviewers { get; init; }
|
||||
public ReviewUrgency Urgency { get; init; } = ReviewUrgency.Normal;
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to approve a review.
|
||||
/// </summary>
|
||||
public sealed record ApproveReviewRequest
|
||||
{
|
||||
public string? Comment { get; init; }
|
||||
public string? ApprovedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to reject a review.
|
||||
/// </summary>
|
||||
public sealed record RejectReviewRequest
|
||||
{
|
||||
public required string Reason { get; init; }
|
||||
public string? RejectedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to request changes.
|
||||
/// </summary>
|
||||
public sealed record RequestChangesRequest
|
||||
{
|
||||
public required IReadOnlyList<ReviewComment> Comments { get; init; }
|
||||
public string? RequestedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review comment.
|
||||
/// </summary>
|
||||
public sealed record ReviewComment
|
||||
{
|
||||
public string? RuleId { get; init; }
|
||||
public required string Comment { get; init; }
|
||||
public ReviewCommentSeverity Severity { get; init; } = ReviewCommentSeverity.Suggestion;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review comment severity.
|
||||
/// </summary>
|
||||
public enum ReviewCommentSeverity
|
||||
{
|
||||
Suggestion,
|
||||
Warning,
|
||||
Blocking
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review urgency level.
|
||||
/// </summary>
|
||||
public enum ReviewUrgency
|
||||
{
|
||||
Low,
|
||||
Normal,
|
||||
High,
|
||||
Critical
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review request status.
|
||||
/// </summary>
|
||||
public enum ReviewStatus
|
||||
{
|
||||
Pending,
|
||||
InReview,
|
||||
ChangesRequested,
|
||||
Approved,
|
||||
Rejected,
|
||||
Cancelled
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review request.
|
||||
/// </summary>
|
||||
public sealed record ReviewRequest
|
||||
{
|
||||
public required string ReviewId { get; init; }
|
||||
public required Guid TenantId { get; init; }
|
||||
public required Guid PackId { get; init; }
|
||||
public required string PackVersion { get; init; }
|
||||
public required ReviewStatus Status { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public IReadOnlyList<string>? Reviewers { get; init; }
|
||||
public ReviewUrgency Urgency { get; init; }
|
||||
public string? SubmittedBy { get; init; }
|
||||
public required DateTimeOffset SubmittedAt { get; init; }
|
||||
public DateTimeOffset? ResolvedAt { get; init; }
|
||||
public string? ResolvedBy { get; init; }
|
||||
public IReadOnlyList<ReviewComment>? PendingComments { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review decision result.
|
||||
/// </summary>
|
||||
public sealed record ReviewDecision
|
||||
{
|
||||
public required string ReviewId { get; init; }
|
||||
public required ReviewStatus NewStatus { get; init; }
|
||||
public required DateTimeOffset DecidedAt { get; init; }
|
||||
public string? DecidedBy { get; init; }
|
||||
public string? Comment { get; init; }
|
||||
public IReadOnlyList<ReviewComment>? Comments { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// List of review requests.
|
||||
/// </summary>
|
||||
public sealed record ReviewRequestList
|
||||
{
|
||||
public required IReadOnlyList<ReviewRequest> Items { get; init; }
|
||||
public string? NextPageToken { get; init; }
|
||||
public int TotalCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit entry for review actions.
|
||||
/// </summary>
|
||||
public sealed record ReviewAuditEntry
|
||||
{
|
||||
public required string AuditId { get; init; }
|
||||
public required string ReviewId { get; init; }
|
||||
public required Guid PackId { get; init; }
|
||||
public required ReviewAuditAction Action { get; init; }
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
public string? PerformedBy { get; init; }
|
||||
public ReviewStatus? PreviousStatus { get; init; }
|
||||
public ReviewStatus? NewStatus { get; init; }
|
||||
public string? Comment { get; init; }
|
||||
public IReadOnlyDictionary<string, object>? Details { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review audit action types.
|
||||
/// </summary>
|
||||
public enum ReviewAuditAction
|
||||
{
|
||||
Submitted,
|
||||
AssignedReviewer,
|
||||
RemovedReviewer,
|
||||
CommentAdded,
|
||||
ChangesRequested,
|
||||
Approved,
|
||||
Rejected,
|
||||
Cancelled,
|
||||
Reopened,
|
||||
StatusChanged
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using StellaOps.Policy.Registry.Contracts;
|
||||
using StellaOps.Policy.Registry.Storage;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of policy pack compiler.
|
||||
/// Validates Rego syntax and computes content digest.
|
||||
/// </summary>
|
||||
public sealed partial class PolicyPackCompiler : IPolicyPackCompiler
|
||||
{
|
||||
private readonly IPolicyPackStore _packStore;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
// Basic Rego syntax patterns for validation
|
||||
[GeneratedRegex(@"^package\s+[\w.]+", RegexOptions.Multiline)]
|
||||
private static partial Regex PackageDeclarationRegex();
|
||||
|
||||
[GeneratedRegex(@"^\s*#.*$", RegexOptions.Multiline)]
|
||||
private static partial Regex CommentLineRegex();
|
||||
|
||||
[GeneratedRegex(@"^\s*(default\s+)?\w+\s*(=|:=|\[)", RegexOptions.Multiline)]
|
||||
private static partial Regex RuleDefinitionRegex();
|
||||
|
||||
[GeneratedRegex(@"input\.\w+", RegexOptions.None)]
|
||||
private static partial Regex InputReferenceRegex();
|
||||
|
||||
[GeneratedRegex(@"\{[^}]*\}", RegexOptions.None)]
|
||||
private static partial Regex SetLiteralRegex();
|
||||
|
||||
[GeneratedRegex(@"\[[^\]]*\]", RegexOptions.None)]
|
||||
private static partial Regex ArrayLiteralRegex();
|
||||
|
||||
public PolicyPackCompiler(IPolicyPackStore packStore, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_packStore = packStore ?? throw new ArgumentNullException(nameof(packStore));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<PolicyPackCompilationResult> CompileAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var start = _timeProvider.GetTimestamp();
|
||||
|
||||
var pack = await _packStore.GetByIdAsync(tenantId, packId, cancellationToken);
|
||||
if (pack is null)
|
||||
{
|
||||
return PolicyPackCompilationResult.FromFailure(
|
||||
[new CompilationError { Message = $"Policy pack {packId} not found" }],
|
||||
null,
|
||||
GetElapsedMs(start));
|
||||
}
|
||||
|
||||
return await CompilePackRulesAsync(pack.PackId.ToString(), pack.Rules, start, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<RuleValidationResult> ValidateRuleAsync(
|
||||
string ruleId,
|
||||
string? rego,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rego))
|
||||
{
|
||||
// Rules without Rego are valid (might use DSL or other syntax)
|
||||
return Task.FromResult(RuleValidationResult.FromSuccess(ruleId));
|
||||
}
|
||||
|
||||
var errors = new List<CompilationError>();
|
||||
var warnings = new List<CompilationWarning>();
|
||||
|
||||
ValidateRegoSyntax(ruleId, rego, errors, warnings);
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return Task.FromResult(RuleValidationResult.FromFailure(ruleId, errors, warnings.Count > 0 ? warnings : null));
|
||||
}
|
||||
|
||||
return Task.FromResult(RuleValidationResult.FromSuccess(ruleId, warnings.Count > 0 ? warnings : null));
|
||||
}
|
||||
|
||||
public async Task<PolicyPackCompilationResult> ValidatePackAsync(
|
||||
CreatePolicyPackRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var start = _timeProvider.GetTimestamp();
|
||||
return await CompilePackRulesAsync(request.Name, request.Rules, start, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<PolicyPackCompilationResult> CompilePackRulesAsync(
|
||||
string packIdentifier,
|
||||
IReadOnlyList<PolicyRule>? rules,
|
||||
long startTimestamp,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (rules is null || rules.Count == 0)
|
||||
{
|
||||
// Empty pack is valid
|
||||
var emptyStats = CreateStatistics([]);
|
||||
var emptyDigest = ComputeDigest([]);
|
||||
return PolicyPackCompilationResult.FromSuccess(emptyDigest, emptyStats, null, GetElapsedMs(startTimestamp));
|
||||
}
|
||||
|
||||
var allErrors = new List<CompilationError>();
|
||||
var allWarnings = new List<CompilationWarning>();
|
||||
var validatedRules = new List<PolicyRule>();
|
||||
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var result = await ValidateRuleAsync(rule.RuleId, rule.Rego, cancellationToken);
|
||||
|
||||
if (result.Errors is { Count: > 0 })
|
||||
{
|
||||
allErrors.AddRange(result.Errors);
|
||||
}
|
||||
|
||||
if (result.Warnings is { Count: > 0 })
|
||||
{
|
||||
allWarnings.AddRange(result.Warnings);
|
||||
}
|
||||
|
||||
validatedRules.Add(rule);
|
||||
}
|
||||
|
||||
var elapsed = GetElapsedMs(startTimestamp);
|
||||
|
||||
if (allErrors.Count > 0)
|
||||
{
|
||||
return PolicyPackCompilationResult.FromFailure(allErrors, allWarnings.Count > 0 ? allWarnings : null, elapsed);
|
||||
}
|
||||
|
||||
var statistics = CreateStatistics(rules);
|
||||
var digest = ComputeDigest(rules);
|
||||
|
||||
return PolicyPackCompilationResult.FromSuccess(
|
||||
digest,
|
||||
statistics,
|
||||
allWarnings.Count > 0 ? allWarnings : null,
|
||||
elapsed);
|
||||
}
|
||||
|
||||
private void ValidateRegoSyntax(
|
||||
string ruleId,
|
||||
string rego,
|
||||
List<CompilationError> errors,
|
||||
List<CompilationWarning> warnings)
|
||||
{
|
||||
// Strip comments for analysis
|
||||
var codeWithoutComments = CommentLineRegex().Replace(rego, "");
|
||||
var trimmedCode = codeWithoutComments.Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(trimmedCode))
|
||||
{
|
||||
errors.Add(new CompilationError
|
||||
{
|
||||
RuleId = ruleId,
|
||||
Message = "Rego code contains only comments or whitespace"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for basic Rego structure
|
||||
var hasPackage = PackageDeclarationRegex().IsMatch(rego);
|
||||
var hasRuleDefinition = RuleDefinitionRegex().IsMatch(codeWithoutComments);
|
||||
|
||||
if (!hasPackage && !hasRuleDefinition)
|
||||
{
|
||||
errors.Add(new CompilationError
|
||||
{
|
||||
RuleId = ruleId,
|
||||
Message = "Rego code must contain either a package declaration or at least one rule definition"
|
||||
});
|
||||
}
|
||||
|
||||
// Check for unmatched braces
|
||||
var openBraces = trimmedCode.Count(c => c == '{');
|
||||
var closeBraces = trimmedCode.Count(c => c == '}');
|
||||
if (openBraces != closeBraces)
|
||||
{
|
||||
errors.Add(new CompilationError
|
||||
{
|
||||
RuleId = ruleId,
|
||||
Message = $"Unmatched braces: {openBraces} open, {closeBraces} close"
|
||||
});
|
||||
}
|
||||
|
||||
// Check for unmatched brackets
|
||||
var openBrackets = trimmedCode.Count(c => c == '[');
|
||||
var closeBrackets = trimmedCode.Count(c => c == ']');
|
||||
if (openBrackets != closeBrackets)
|
||||
{
|
||||
errors.Add(new CompilationError
|
||||
{
|
||||
RuleId = ruleId,
|
||||
Message = $"Unmatched brackets: {openBrackets} open, {closeBrackets} close"
|
||||
});
|
||||
}
|
||||
|
||||
// Check for unmatched parentheses
|
||||
var openParens = trimmedCode.Count(c => c == '(');
|
||||
var closeParens = trimmedCode.Count(c => c == ')');
|
||||
if (openParens != closeParens)
|
||||
{
|
||||
errors.Add(new CompilationError
|
||||
{
|
||||
RuleId = ruleId,
|
||||
Message = $"Unmatched parentheses: {openParens} open, {closeParens} close"
|
||||
});
|
||||
}
|
||||
|
||||
// Warnings for common issues
|
||||
if (!InputReferenceRegex().IsMatch(rego) && hasRuleDefinition)
|
||||
{
|
||||
warnings.Add(new CompilationWarning
|
||||
{
|
||||
RuleId = ruleId,
|
||||
Message = "Rule does not reference 'input' - may not receive evaluation context"
|
||||
});
|
||||
}
|
||||
|
||||
// Check for deprecated or unsafe patterns
|
||||
if (rego.Contains("http.send"))
|
||||
{
|
||||
warnings.Add(new CompilationWarning
|
||||
{
|
||||
RuleId = ruleId,
|
||||
Message = "Use of http.send may cause non-deterministic behavior in offline/air-gapped environments"
|
||||
});
|
||||
}
|
||||
|
||||
if (rego.Contains("time.now_ns"))
|
||||
{
|
||||
warnings.Add(new CompilationWarning
|
||||
{
|
||||
RuleId = ruleId,
|
||||
Message = "Use of time.now_ns may cause non-deterministic results across evaluations"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static PolicyPackCompilationStatistics CreateStatistics(IReadOnlyList<PolicyRule> rules)
|
||||
{
|
||||
var severityCounts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
var severityKey = rule.Severity.ToString().ToLowerInvariant();
|
||||
severityCounts[severityKey] = severityCounts.GetValueOrDefault(severityKey) + 1;
|
||||
}
|
||||
|
||||
return new PolicyPackCompilationStatistics
|
||||
{
|
||||
TotalRules = rules.Count,
|
||||
EnabledRules = rules.Count(r => r.Enabled),
|
||||
DisabledRules = rules.Count(r => !r.Enabled),
|
||||
RulesWithRego = rules.Count(r => !string.IsNullOrWhiteSpace(r.Rego)),
|
||||
RulesWithoutRego = rules.Count(r => string.IsNullOrWhiteSpace(r.Rego)),
|
||||
SeverityCounts = severityCounts
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeDigest(IReadOnlyList<PolicyRule> rules)
|
||||
{
|
||||
// Create deterministic representation for hashing
|
||||
var orderedRules = rules
|
||||
.OrderBy(r => r.RuleId, StringComparer.Ordinal)
|
||||
.Select(r => new
|
||||
{
|
||||
r.RuleId,
|
||||
r.Name,
|
||||
r.Severity,
|
||||
r.Rego,
|
||||
r.Enabled
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var json = JsonSerializer.Serialize(orderedRules, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = false
|
||||
});
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private long GetElapsedMs(long startTimestamp)
|
||||
{
|
||||
var elapsed = _timeProvider.GetElapsedTime(startTimestamp, _timeProvider.GetTimestamp());
|
||||
return (long)Math.Ceiling(elapsed.TotalMilliseconds);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using StellaOps.Policy.Registry.Contracts;
|
||||
using StellaOps.Policy.Registry.Storage;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of quick policy simulation service.
|
||||
/// Evaluates policy rules against provided input and returns violations.
|
||||
/// </summary>
|
||||
public sealed partial class PolicySimulationService : IPolicySimulationService
|
||||
{
|
||||
private readonly IPolicyPackStore _packStore;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
// Regex patterns for input reference extraction
|
||||
[GeneratedRegex(@"input\.(\w+(?:\.\w+)*)", RegexOptions.None)]
|
||||
private static partial Regex InputReferenceRegex();
|
||||
|
||||
[GeneratedRegex(@"input\[""([^""]+)""\]", RegexOptions.None)]
|
||||
private static partial Regex InputBracketReferenceRegex();
|
||||
|
||||
public PolicySimulationService(IPolicyPackStore packStore, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_packStore = packStore ?? throw new ArgumentNullException(nameof(packStore));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<PolicySimulationResponse> SimulateAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
SimulationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var start = _timeProvider.GetTimestamp();
|
||||
var executedAt = _timeProvider.GetUtcNow();
|
||||
var simulationId = GenerateSimulationId(tenantId, packId, executedAt);
|
||||
|
||||
var pack = await _packStore.GetByIdAsync(tenantId, packId, cancellationToken);
|
||||
if (pack is null)
|
||||
{
|
||||
return new PolicySimulationResponse
|
||||
{
|
||||
SimulationId = simulationId,
|
||||
Success = false,
|
||||
ExecutedAt = executedAt,
|
||||
DurationMilliseconds = GetElapsedMs(start),
|
||||
Errors = [new SimulationError { Code = "PACK_NOT_FOUND", Message = $"Policy pack {packId} not found" }]
|
||||
};
|
||||
}
|
||||
|
||||
return await SimulateRulesInternalAsync(
|
||||
simulationId,
|
||||
pack.Rules ?? [],
|
||||
request,
|
||||
start,
|
||||
executedAt,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PolicySimulationResponse> SimulateRulesAsync(
|
||||
Guid tenantId,
|
||||
IReadOnlyList<PolicyRule> rules,
|
||||
SimulationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var start = _timeProvider.GetTimestamp();
|
||||
var executedAt = _timeProvider.GetUtcNow();
|
||||
var simulationId = GenerateSimulationId(tenantId, Guid.Empty, executedAt);
|
||||
|
||||
return await SimulateRulesInternalAsync(
|
||||
simulationId,
|
||||
rules,
|
||||
request,
|
||||
start,
|
||||
executedAt,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task<InputValidationResult> ValidateInputAsync(
|
||||
IReadOnlyDictionary<string, object> input,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var errors = new List<InputValidationError>();
|
||||
|
||||
if (input.Count == 0)
|
||||
{
|
||||
errors.Add(new InputValidationError
|
||||
{
|
||||
Path = "$",
|
||||
Message = "Input must contain at least one property"
|
||||
});
|
||||
}
|
||||
|
||||
// Check for common required fields
|
||||
var commonFields = new[] { "subject", "resource", "action", "context" };
|
||||
var missingFields = commonFields.Where(f => !input.ContainsKey(f)).ToList();
|
||||
|
||||
if (missingFields.Count == commonFields.Length)
|
||||
{
|
||||
// Warn if none of the common fields are present
|
||||
errors.Add(new InputValidationError
|
||||
{
|
||||
Path = "$",
|
||||
Message = $"Input should contain at least one of: {string.Join(", ", commonFields)}"
|
||||
});
|
||||
}
|
||||
|
||||
return Task.FromResult(errors.Count > 0
|
||||
? InputValidationResult.Invalid(errors)
|
||||
: InputValidationResult.Valid());
|
||||
}
|
||||
|
||||
private async Task<PolicySimulationResponse> SimulateRulesInternalAsync(
|
||||
string simulationId,
|
||||
IReadOnlyList<PolicyRule> rules,
|
||||
SimulationRequest request,
|
||||
long startTimestamp,
|
||||
DateTimeOffset executedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var violations = new List<SimulatedViolation>();
|
||||
var errors = new List<SimulationError>();
|
||||
var trace = new List<string>();
|
||||
int rulesMatched = 0;
|
||||
|
||||
var enabledRules = rules.Where(r => r.Enabled).ToList();
|
||||
|
||||
foreach (var rule in enabledRules)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var (matched, violation, traceEntry) = EvaluateRule(rule, request.Input, request.Options);
|
||||
|
||||
if (request.Options?.Trace == true && traceEntry is not null)
|
||||
{
|
||||
trace.Add(traceEntry);
|
||||
}
|
||||
|
||||
if (matched)
|
||||
{
|
||||
rulesMatched++;
|
||||
if (violation is not null)
|
||||
{
|
||||
violations.Add(violation);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add(new SimulationError
|
||||
{
|
||||
RuleId = rule.RuleId,
|
||||
Code = "EVALUATION_ERROR",
|
||||
Message = ex.Message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var elapsed = GetElapsedMs(startTimestamp);
|
||||
var severityCounts = violations
|
||||
.GroupBy(v => v.Severity.ToLowerInvariant())
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
var summary = new SimulationSummary
|
||||
{
|
||||
TotalRulesEvaluated = enabledRules.Count,
|
||||
RulesMatched = rulesMatched,
|
||||
ViolationsFound = violations.Count,
|
||||
ViolationsBySeverity = severityCounts
|
||||
};
|
||||
|
||||
var result = new SimulationResult
|
||||
{
|
||||
Result = new Dictionary<string, object>
|
||||
{
|
||||
["allow"] = violations.Count == 0,
|
||||
["violations_count"] = violations.Count
|
||||
},
|
||||
Violations = violations.Count > 0 ? violations : null,
|
||||
Trace = request.Options?.Trace == true && trace.Count > 0 ? trace : null,
|
||||
Explain = request.Options?.Explain == true ? BuildExplainTrace(enabledRules, request.Input) : null
|
||||
};
|
||||
|
||||
return new PolicySimulationResponse
|
||||
{
|
||||
SimulationId = simulationId,
|
||||
Success = errors.Count == 0,
|
||||
ExecutedAt = executedAt,
|
||||
DurationMilliseconds = elapsed,
|
||||
Result = result,
|
||||
Summary = summary,
|
||||
Errors = errors.Count > 0 ? errors : null
|
||||
};
|
||||
}
|
||||
|
||||
private (bool matched, SimulatedViolation? violation, string? trace) EvaluateRule(
|
||||
PolicyRule rule,
|
||||
IReadOnlyDictionary<string, object> input,
|
||||
SimulationOptions? options)
|
||||
{
|
||||
// If no Rego code, use basic rule matching based on severity and name
|
||||
if (string.IsNullOrWhiteSpace(rule.Rego))
|
||||
{
|
||||
// Without Rego, we do pattern-based matching on rule name/description
|
||||
var matched = MatchRuleByName(rule, input);
|
||||
var trace = options?.Trace == true
|
||||
? $"Rule {rule.RuleId}: matched={matched} (no Rego, name-based)"
|
||||
: null;
|
||||
|
||||
if (matched)
|
||||
{
|
||||
var violation = new SimulatedViolation
|
||||
{
|
||||
RuleId = rule.RuleId,
|
||||
Severity = rule.Severity.ToString().ToLowerInvariant(),
|
||||
Message = rule.Description ?? $"Violation of rule {rule.Name}"
|
||||
};
|
||||
return (true, violation, trace);
|
||||
}
|
||||
|
||||
return (false, null, trace);
|
||||
}
|
||||
|
||||
// Evaluate Rego-based rule
|
||||
var regoResult = EvaluateRegoRule(rule, input);
|
||||
var regoTrace = options?.Trace == true
|
||||
? $"Rule {rule.RuleId}: matched={regoResult.matched}, inputs_used={string.Join(",", regoResult.inputsUsed)}"
|
||||
: null;
|
||||
|
||||
if (regoResult.matched)
|
||||
{
|
||||
var violation = new SimulatedViolation
|
||||
{
|
||||
RuleId = rule.RuleId,
|
||||
Severity = rule.Severity.ToString().ToLowerInvariant(),
|
||||
Message = rule.Description ?? $"Violation of rule {rule.Name}",
|
||||
Context = regoResult.context
|
||||
};
|
||||
return (true, violation, regoTrace);
|
||||
}
|
||||
|
||||
return (false, null, regoTrace);
|
||||
}
|
||||
|
||||
private static bool MatchRuleByName(PolicyRule rule, IReadOnlyDictionary<string, object> input)
|
||||
{
|
||||
// Simple heuristic matching for rules without Rego
|
||||
var ruleName = rule.Name.ToLowerInvariant();
|
||||
var ruleDesc = rule.Description?.ToLowerInvariant() ?? "";
|
||||
|
||||
// Check if any input key matches rule keywords
|
||||
foreach (var (key, value) in input)
|
||||
{
|
||||
var keyLower = key.ToLowerInvariant();
|
||||
var valueLower = value?.ToString()?.ToLowerInvariant() ?? "";
|
||||
|
||||
if (ruleName.Contains(keyLower) || ruleDesc.Contains(keyLower))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (ruleName.Contains(valueLower) || ruleDesc.Contains(valueLower))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private (bool matched, HashSet<string> inputsUsed, IReadOnlyDictionary<string, object>? context) EvaluateRegoRule(
|
||||
PolicyRule rule,
|
||||
IReadOnlyDictionary<string, object> input)
|
||||
{
|
||||
// Extract input references from Rego code
|
||||
var inputRefs = ExtractInputReferences(rule.Rego!);
|
||||
var inputsUsed = new HashSet<string>();
|
||||
var context = new Dictionary<string, object>();
|
||||
|
||||
// Simple evaluation: check if referenced inputs exist and have values
|
||||
bool allInputsPresent = true;
|
||||
foreach (var inputRef in inputRefs)
|
||||
{
|
||||
var value = GetNestedValue(input, inputRef);
|
||||
if (value is not null)
|
||||
{
|
||||
inputsUsed.Add(inputRef);
|
||||
context[inputRef] = value;
|
||||
}
|
||||
else
|
||||
{
|
||||
allInputsPresent = false;
|
||||
}
|
||||
}
|
||||
|
||||
// For this simplified simulation:
|
||||
// - Rule matches if all referenced inputs are present
|
||||
// - This simulates the rule being able to evaluate
|
||||
var matched = inputRefs.Count > 0 && allInputsPresent;
|
||||
|
||||
return (matched, inputsUsed, context.Count > 0 ? context : null);
|
||||
}
|
||||
|
||||
private static HashSet<string> ExtractInputReferences(string rego)
|
||||
{
|
||||
var refs = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
// Match input.field.subfield pattern
|
||||
foreach (Match match in InputReferenceRegex().Matches(rego))
|
||||
{
|
||||
refs.Add(match.Groups[1].Value);
|
||||
}
|
||||
|
||||
// Match input["field"] pattern
|
||||
foreach (Match match in InputBracketReferenceRegex().Matches(rego))
|
||||
{
|
||||
refs.Add(match.Groups[1].Value);
|
||||
}
|
||||
|
||||
return refs;
|
||||
}
|
||||
|
||||
private static object? GetNestedValue(IReadOnlyDictionary<string, object> input, string path)
|
||||
{
|
||||
var parts = path.Split('.');
|
||||
object? current = input;
|
||||
|
||||
foreach (var part in parts)
|
||||
{
|
||||
if (current is IReadOnlyDictionary<string, object> dict)
|
||||
{
|
||||
if (!dict.TryGetValue(part, out current))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
else if (current is JsonElement jsonElement)
|
||||
{
|
||||
if (jsonElement.ValueKind == JsonValueKind.Object &&
|
||||
jsonElement.TryGetProperty(part, out var prop))
|
||||
{
|
||||
current = prop;
|
||||
}
|
||||
else
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
private static PolicyExplainTrace BuildExplainTrace(
|
||||
IReadOnlyList<PolicyRule> rules,
|
||||
IReadOnlyDictionary<string, object> input)
|
||||
{
|
||||
var steps = new List<object>();
|
||||
|
||||
steps.Add(new { type = "input_received", keys = input.Keys.ToList() });
|
||||
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
steps.Add(new
|
||||
{
|
||||
type = "rule_evaluation",
|
||||
rule_id = rule.RuleId,
|
||||
rule_name = rule.Name,
|
||||
severity = rule.Severity.ToString(),
|
||||
has_rego = !string.IsNullOrWhiteSpace(rule.Rego)
|
||||
});
|
||||
}
|
||||
|
||||
steps.Add(new { type = "evaluation_complete", rules_count = rules.Count });
|
||||
|
||||
return new PolicyExplainTrace { Steps = steps };
|
||||
}
|
||||
|
||||
private static string GenerateSimulationId(Guid tenantId, Guid packId, DateTimeOffset timestamp)
|
||||
{
|
||||
var content = $"{tenantId}:{packId}:{timestamp.ToUnixTimeMilliseconds()}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
|
||||
return $"sim_{Convert.ToHexString(hash)[..16].ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private long GetElapsedMs(long startTimestamp)
|
||||
{
|
||||
var elapsed = _timeProvider.GetElapsedTime(startTimestamp, _timeProvider.GetTimestamp());
|
||||
return (long)Math.Ceiling(elapsed.TotalMilliseconds);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Policy.Registry.Contracts;
|
||||
using StellaOps.Policy.Registry.Storage;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of promotion service for managing environment bindings.
|
||||
/// </summary>
|
||||
public sealed class PromotionService : IPromotionService
|
||||
{
|
||||
private readonly IPolicyPackStore _packStore;
|
||||
private readonly IPublishPipelineService _publishService;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private readonly ConcurrentDictionary<(Guid TenantId, string BindingId), PromotionBinding> _bindings = new();
|
||||
private readonly ConcurrentDictionary<(Guid TenantId, string Environment), string> _activeBindings = new();
|
||||
private readonly ConcurrentDictionary<(Guid TenantId, string Environment), List<PromotionHistoryEntry>> _history = new();
|
||||
|
||||
public PromotionService(
|
||||
IPolicyPackStore packStore,
|
||||
IPublishPipelineService publishService,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_packStore = packStore ?? throw new ArgumentNullException(nameof(packStore));
|
||||
_publishService = publishService ?? throw new ArgumentNullException(nameof(publishService));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<PromotionBinding> CreateBindingAsync(
|
||||
Guid tenantId,
|
||||
CreatePromotionBindingRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var pack = await _packStore.GetByIdAsync(tenantId, request.PackId, cancellationToken);
|
||||
if (pack is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Policy pack {request.PackId} not found");
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var bindingId = GenerateBindingId(tenantId, request.PackId, request.Environment, now);
|
||||
|
||||
var binding = new PromotionBinding
|
||||
{
|
||||
BindingId = bindingId,
|
||||
TenantId = tenantId,
|
||||
PackId = request.PackId,
|
||||
PackVersion = pack.Version,
|
||||
Environment = request.Environment,
|
||||
Mode = request.Mode,
|
||||
Status = PromotionBindingStatus.Pending,
|
||||
Rules = request.Rules,
|
||||
CreatedAt = now,
|
||||
CreatedBy = request.CreatedBy,
|
||||
Metadata = request.Metadata
|
||||
};
|
||||
|
||||
_bindings[(tenantId, bindingId)] = binding;
|
||||
|
||||
return binding;
|
||||
}
|
||||
|
||||
public async Task<PromotionResult> PromoteAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
PromoteRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Validate promotion
|
||||
var validation = await ValidatePromotionAsync(tenantId, packId, request.TargetEnvironment, cancellationToken);
|
||||
if (!validation.IsValid && !request.Force)
|
||||
{
|
||||
return new PromotionResult
|
||||
{
|
||||
Success = false,
|
||||
Error = string.Join("; ", validation.Errors?.Select(e => e.Message) ?? [])
|
||||
};
|
||||
}
|
||||
|
||||
var pack = await _packStore.GetByIdAsync(tenantId, packId, cancellationToken);
|
||||
if (pack is null)
|
||||
{
|
||||
return new PromotionResult
|
||||
{
|
||||
Success = false,
|
||||
Error = $"Policy pack {packId} not found"
|
||||
};
|
||||
}
|
||||
|
||||
// Check pack is published
|
||||
var publicationStatus = await _publishService.GetPublicationStatusAsync(tenantId, packId, cancellationToken);
|
||||
if (publicationStatus is null || publicationStatus.State != PublishState.Published)
|
||||
{
|
||||
return new PromotionResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Policy pack must be published before promotion"
|
||||
};
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var bindingId = GenerateBindingId(tenantId, packId, request.TargetEnvironment, now);
|
||||
|
||||
// Deactivate current binding if exists
|
||||
string? previousBindingId = null;
|
||||
if (_activeBindings.TryGetValue((tenantId, request.TargetEnvironment), out var currentBindingId))
|
||||
{
|
||||
if (_bindings.TryGetValue((tenantId, currentBindingId), out var currentBinding))
|
||||
{
|
||||
previousBindingId = currentBindingId;
|
||||
var supersededBinding = currentBinding with
|
||||
{
|
||||
Status = PromotionBindingStatus.Superseded,
|
||||
DeactivatedAt = now
|
||||
};
|
||||
_bindings[(tenantId, currentBindingId)] = supersededBinding;
|
||||
|
||||
AddHistoryEntry(tenantId, request.TargetEnvironment, new PromotionHistoryEntry
|
||||
{
|
||||
BindingId = currentBindingId,
|
||||
PackId = currentBinding.PackId,
|
||||
PackVersion = currentBinding.PackVersion,
|
||||
Action = PromotionHistoryAction.Superseded,
|
||||
Timestamp = now,
|
||||
PerformedBy = request.PromotedBy,
|
||||
Comment = $"Superseded by promotion of {packId}"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create new binding
|
||||
var binding = new PromotionBinding
|
||||
{
|
||||
BindingId = bindingId,
|
||||
TenantId = tenantId,
|
||||
PackId = packId,
|
||||
PackVersion = pack.Version,
|
||||
Environment = request.TargetEnvironment,
|
||||
Mode = PromotionBindingMode.Manual,
|
||||
Status = PromotionBindingStatus.Active,
|
||||
CreatedAt = now,
|
||||
ActivatedAt = now,
|
||||
CreatedBy = request.PromotedBy,
|
||||
ActivatedBy = request.PromotedBy
|
||||
};
|
||||
|
||||
_bindings[(tenantId, bindingId)] = binding;
|
||||
_activeBindings[(tenantId, request.TargetEnvironment)] = bindingId;
|
||||
|
||||
AddHistoryEntry(tenantId, request.TargetEnvironment, new PromotionHistoryEntry
|
||||
{
|
||||
BindingId = bindingId,
|
||||
PackId = packId,
|
||||
PackVersion = pack.Version,
|
||||
Action = PromotionHistoryAction.Promoted,
|
||||
Timestamp = now,
|
||||
PerformedBy = request.PromotedBy,
|
||||
Comment = request.Comment,
|
||||
PreviousBindingId = previousBindingId
|
||||
});
|
||||
|
||||
var warnings = validation.Warnings?.Select(w => w.Message).ToList();
|
||||
|
||||
return new PromotionResult
|
||||
{
|
||||
Success = true,
|
||||
Binding = binding,
|
||||
PreviousBindingId = previousBindingId,
|
||||
Warnings = warnings?.Count > 0 ? warnings : null
|
||||
};
|
||||
}
|
||||
|
||||
public Task<PromotionBinding?> GetBindingAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
string environment,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var binding = _bindings.Values
|
||||
.Where(b => b.TenantId == tenantId && b.PackId == packId && b.Environment == environment)
|
||||
.OrderByDescending(b => b.CreatedAt)
|
||||
.FirstOrDefault();
|
||||
|
||||
return Task.FromResult(binding);
|
||||
}
|
||||
|
||||
public Task<PromotionBindingList> ListBindingsAsync(
|
||||
Guid tenantId,
|
||||
string? environment = null,
|
||||
Guid? packId = null,
|
||||
int pageSize = 20,
|
||||
string? pageToken = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _bindings.Values.Where(b => b.TenantId == tenantId);
|
||||
|
||||
if (!string.IsNullOrEmpty(environment))
|
||||
{
|
||||
query = query.Where(b => b.Environment == environment);
|
||||
}
|
||||
|
||||
if (packId.HasValue)
|
||||
{
|
||||
query = query.Where(b => b.PackId == packId.Value);
|
||||
}
|
||||
|
||||
var items = query.OrderByDescending(b => b.CreatedAt).ToList();
|
||||
|
||||
int skip = 0;
|
||||
if (!string.IsNullOrEmpty(pageToken) && int.TryParse(pageToken, out var offset))
|
||||
{
|
||||
skip = offset;
|
||||
}
|
||||
|
||||
var pagedItems = items.Skip(skip).Take(pageSize).ToList();
|
||||
string? nextToken = skip + pagedItems.Count < items.Count
|
||||
? (skip + pagedItems.Count).ToString()
|
||||
: null;
|
||||
|
||||
return Task.FromResult(new PromotionBindingList
|
||||
{
|
||||
Items = pagedItems,
|
||||
NextPageToken = nextToken,
|
||||
TotalCount = items.Count
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<ActiveEnvironmentPolicy?> GetActiveForEnvironmentAsync(
|
||||
Guid tenantId,
|
||||
string environment,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_activeBindings.TryGetValue((tenantId, environment), out var bindingId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!_bindings.TryGetValue((tenantId, bindingId), out var binding))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var publicationStatus = await _publishService.GetPublicationStatusAsync(tenantId, binding.PackId, cancellationToken);
|
||||
|
||||
return new ActiveEnvironmentPolicy
|
||||
{
|
||||
Environment = environment,
|
||||
PackId = binding.PackId,
|
||||
PackVersion = binding.PackVersion,
|
||||
PackDigest = publicationStatus?.Digest ?? "",
|
||||
BindingId = bindingId,
|
||||
ActivatedAt = binding.ActivatedAt ?? binding.CreatedAt,
|
||||
ActivatedBy = binding.ActivatedBy
|
||||
};
|
||||
}
|
||||
|
||||
public Task<RollbackResult> RollbackAsync(
|
||||
Guid tenantId,
|
||||
string environment,
|
||||
RollbackRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_history.TryGetValue((tenantId, environment), out var history) || history.Count < 2)
|
||||
{
|
||||
return Task.FromResult(new RollbackResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "No rollback target available"
|
||||
});
|
||||
}
|
||||
|
||||
// Find target binding
|
||||
PromotionHistoryEntry? targetEntry = null;
|
||||
if (!string.IsNullOrEmpty(request.TargetBindingId))
|
||||
{
|
||||
targetEntry = history.FirstOrDefault(h => h.BindingId == request.TargetBindingId);
|
||||
}
|
||||
else
|
||||
{
|
||||
var stepsBack = request.StepsBack ?? 1;
|
||||
var promotions = history.Where(h => h.Action == PromotionHistoryAction.Promoted).ToList();
|
||||
if (promotions.Count > stepsBack)
|
||||
{
|
||||
targetEntry = promotions[stepsBack];
|
||||
}
|
||||
}
|
||||
|
||||
if (targetEntry is null)
|
||||
{
|
||||
return Task.FromResult(new RollbackResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Target binding not found"
|
||||
});
|
||||
}
|
||||
|
||||
if (!_bindings.TryGetValue((tenantId, targetEntry.BindingId), out var targetBinding))
|
||||
{
|
||||
return Task.FromResult(new RollbackResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Target binding no longer exists"
|
||||
});
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Deactivate current binding
|
||||
string? rolledBackBindingId = null;
|
||||
if (_activeBindings.TryGetValue((tenantId, environment), out var currentBindingId))
|
||||
{
|
||||
if (_bindings.TryGetValue((tenantId, currentBindingId), out var currentBinding))
|
||||
{
|
||||
rolledBackBindingId = currentBindingId;
|
||||
var rolledBackBinding = currentBinding with
|
||||
{
|
||||
Status = PromotionBindingStatus.RolledBack,
|
||||
DeactivatedAt = now
|
||||
};
|
||||
_bindings[(tenantId, currentBindingId)] = rolledBackBinding;
|
||||
|
||||
AddHistoryEntry(tenantId, environment, new PromotionHistoryEntry
|
||||
{
|
||||
BindingId = currentBindingId,
|
||||
PackId = currentBinding.PackId,
|
||||
PackVersion = currentBinding.PackVersion,
|
||||
Action = PromotionHistoryAction.RolledBack,
|
||||
Timestamp = now,
|
||||
PerformedBy = request.RolledBackBy,
|
||||
Comment = request.Reason
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Restore target binding
|
||||
var restoredBinding = targetBinding with
|
||||
{
|
||||
Status = PromotionBindingStatus.Active,
|
||||
ActivatedAt = now,
|
||||
ActivatedBy = request.RolledBackBy,
|
||||
DeactivatedAt = null
|
||||
};
|
||||
|
||||
_bindings[(tenantId, targetBinding.BindingId)] = restoredBinding;
|
||||
_activeBindings[(tenantId, environment)] = targetBinding.BindingId;
|
||||
|
||||
AddHistoryEntry(tenantId, environment, new PromotionHistoryEntry
|
||||
{
|
||||
BindingId = targetBinding.BindingId,
|
||||
PackId = targetBinding.PackId,
|
||||
PackVersion = targetBinding.PackVersion,
|
||||
Action = PromotionHistoryAction.Promoted,
|
||||
Timestamp = now,
|
||||
PerformedBy = request.RolledBackBy,
|
||||
Comment = $"Restored via rollback: {request.Reason}",
|
||||
PreviousBindingId = rolledBackBindingId
|
||||
});
|
||||
|
||||
return Task.FromResult(new RollbackResult
|
||||
{
|
||||
Success = true,
|
||||
RestoredBinding = restoredBinding,
|
||||
RolledBackBindingId = rolledBackBindingId
|
||||
});
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<PromotionHistoryEntry>> GetHistoryAsync(
|
||||
Guid tenantId,
|
||||
string environment,
|
||||
int limit = 50,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_history.TryGetValue((tenantId, environment), out var history))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<PromotionHistoryEntry>>(Array.Empty<PromotionHistoryEntry>());
|
||||
}
|
||||
|
||||
var entries = history.OrderByDescending(h => h.Timestamp).Take(limit).ToList();
|
||||
return Task.FromResult<IReadOnlyList<PromotionHistoryEntry>>(entries);
|
||||
}
|
||||
|
||||
public async Task<PromotionValidationResult> ValidatePromotionAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
string targetEnvironment,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var errors = new List<PromotionValidationError>();
|
||||
var warnings = new List<PromotionValidationWarning>();
|
||||
|
||||
// Check pack exists
|
||||
var pack = await _packStore.GetByIdAsync(tenantId, packId, cancellationToken);
|
||||
if (pack is null)
|
||||
{
|
||||
errors.Add(new PromotionValidationError
|
||||
{
|
||||
Code = "PACK_NOT_FOUND",
|
||||
Message = $"Policy pack {packId} not found"
|
||||
});
|
||||
return new PromotionValidationResult { IsValid = false, Errors = errors };
|
||||
}
|
||||
|
||||
// Check pack is published
|
||||
var publicationStatus = await _publishService.GetPublicationStatusAsync(tenantId, packId, cancellationToken);
|
||||
if (publicationStatus is null)
|
||||
{
|
||||
errors.Add(new PromotionValidationError
|
||||
{
|
||||
Code = "NOT_PUBLISHED",
|
||||
Message = "Policy pack must be published before promotion"
|
||||
});
|
||||
}
|
||||
else if (publicationStatus.State == PublishState.Revoked)
|
||||
{
|
||||
errors.Add(new PromotionValidationError
|
||||
{
|
||||
Code = "REVOKED",
|
||||
Message = "Cannot promote a revoked policy pack"
|
||||
});
|
||||
}
|
||||
|
||||
// Check environment rules
|
||||
if (targetEnvironment.Equals("production", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Production requires additional validation
|
||||
var activeStaging = await GetActiveForEnvironmentAsync(tenantId, "staging", cancellationToken);
|
||||
if (activeStaging is null || activeStaging.PackId != packId)
|
||||
{
|
||||
warnings.Add(new PromotionValidationWarning
|
||||
{
|
||||
Code = "NOT_IN_STAGING",
|
||||
Message = "Policy pack has not been validated in staging environment"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check for existing active binding with same pack
|
||||
var currentActive = await GetActiveForEnvironmentAsync(tenantId, targetEnvironment, cancellationToken);
|
||||
if (currentActive is not null && currentActive.PackId == packId && currentActive.PackVersion == pack.Version)
|
||||
{
|
||||
warnings.Add(new PromotionValidationWarning
|
||||
{
|
||||
Code = "ALREADY_ACTIVE",
|
||||
Message = "Same version is already active in this environment"
|
||||
});
|
||||
}
|
||||
|
||||
return new PromotionValidationResult
|
||||
{
|
||||
IsValid = errors.Count == 0,
|
||||
Errors = errors.Count > 0 ? errors : null,
|
||||
Warnings = warnings.Count > 0 ? warnings : null
|
||||
};
|
||||
}
|
||||
|
||||
private void AddHistoryEntry(Guid tenantId, string environment, PromotionHistoryEntry entry)
|
||||
{
|
||||
_history.AddOrUpdate(
|
||||
(tenantId, environment),
|
||||
_ => [entry],
|
||||
(_, list) =>
|
||||
{
|
||||
list.Insert(0, entry);
|
||||
return list;
|
||||
});
|
||||
}
|
||||
|
||||
private static string GenerateBindingId(Guid tenantId, Guid packId, string environment, DateTimeOffset timestamp)
|
||||
{
|
||||
var content = $"{tenantId}:{packId}:{environment}:{timestamp.ToUnixTimeMilliseconds()}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
|
||||
return $"bind_{Convert.ToHexString(hash)[..16].ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Policy.Registry.Contracts;
|
||||
using StellaOps.Policy.Registry.Storage;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of publish pipeline service.
|
||||
/// Handles policy pack publication with attestation generation.
|
||||
/// </summary>
|
||||
public sealed class PublishPipelineService : IPublishPipelineService
|
||||
{
|
||||
private const string BuilderId = "https://stellaops.io/policy-registry/v1";
|
||||
private const string BuildType = "https://stellaops.io/policy-registry/v1/publish";
|
||||
private const string AttestationPredicateType = "https://slsa.dev/provenance/v1";
|
||||
|
||||
private readonly IPolicyPackStore _packStore;
|
||||
private readonly IPolicyPackCompiler _compiler;
|
||||
private readonly IReviewWorkflowService _reviewService;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private readonly ConcurrentDictionary<(Guid TenantId, Guid PackId), PublicationStatus> _publications = new();
|
||||
private readonly ConcurrentDictionary<(Guid TenantId, Guid PackId), PolicyPackAttestation> _attestations = new();
|
||||
|
||||
public PublishPipelineService(
|
||||
IPolicyPackStore packStore,
|
||||
IPolicyPackCompiler compiler,
|
||||
IReviewWorkflowService reviewService,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_packStore = packStore ?? throw new ArgumentNullException(nameof(packStore));
|
||||
_compiler = compiler ?? throw new ArgumentNullException(nameof(compiler));
|
||||
_reviewService = reviewService ?? throw new ArgumentNullException(nameof(reviewService));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<PublishResult> PublishAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
PublishPackRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Get the policy pack
|
||||
var pack = await _packStore.GetByIdAsync(tenantId, packId, cancellationToken);
|
||||
if (pack is null)
|
||||
{
|
||||
return new PublishResult
|
||||
{
|
||||
Success = false,
|
||||
Error = $"Policy pack {packId} not found"
|
||||
};
|
||||
}
|
||||
|
||||
// Verify pack is in correct state
|
||||
if (pack.Status != PolicyPackStatus.PendingReview)
|
||||
{
|
||||
return new PublishResult
|
||||
{
|
||||
Success = false,
|
||||
Error = $"Policy pack must be in PendingReview status to publish. Current status: {pack.Status}"
|
||||
};
|
||||
}
|
||||
|
||||
// Compile to get digest
|
||||
var compilationResult = await _compiler.CompileAsync(tenantId, packId, cancellationToken);
|
||||
if (!compilationResult.Success)
|
||||
{
|
||||
return new PublishResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Policy pack compilation failed. Cannot publish."
|
||||
};
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var digest = compilationResult.Digest!;
|
||||
|
||||
// Get review information if available
|
||||
var reviews = await _reviewService.ListReviewsAsync(tenantId, ReviewStatus.Approved, packId, 1, null, cancellationToken);
|
||||
var review = reviews.Items.FirstOrDefault();
|
||||
|
||||
// Build attestation
|
||||
var attestation = BuildAttestation(
|
||||
pack,
|
||||
digest,
|
||||
compilationResult,
|
||||
review,
|
||||
request,
|
||||
now);
|
||||
|
||||
// Update pack status to Published
|
||||
var updatedPack = await _packStore.UpdateStatusAsync(tenantId, packId, PolicyPackStatus.Published, request.PublishedBy, cancellationToken);
|
||||
if (updatedPack is null)
|
||||
{
|
||||
return new PublishResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Failed to update policy pack status"
|
||||
};
|
||||
}
|
||||
|
||||
// Create publication status
|
||||
var status = new PublicationStatus
|
||||
{
|
||||
PackId = packId,
|
||||
PackVersion = pack.Version,
|
||||
Digest = digest,
|
||||
State = PublishState.Published,
|
||||
PublishedAt = now,
|
||||
PublishedBy = request.PublishedBy,
|
||||
SignatureKeyId = request.SigningOptions?.KeyId,
|
||||
SignatureAlgorithm = request.SigningOptions?.Algorithm
|
||||
};
|
||||
|
||||
_publications[(tenantId, packId)] = status;
|
||||
_attestations[(tenantId, packId)] = attestation;
|
||||
|
||||
return new PublishResult
|
||||
{
|
||||
Success = true,
|
||||
PackId = packId,
|
||||
Digest = digest,
|
||||
Status = status,
|
||||
Attestation = attestation
|
||||
};
|
||||
}
|
||||
|
||||
public Task<PublicationStatus?> GetPublicationStatusAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_publications.TryGetValue((tenantId, packId), out var status);
|
||||
return Task.FromResult(status);
|
||||
}
|
||||
|
||||
public Task<PolicyPackAttestation?> GetAttestationAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_attestations.TryGetValue((tenantId, packId), out var attestation);
|
||||
return Task.FromResult(attestation);
|
||||
}
|
||||
|
||||
public async Task<AttestationVerificationResult> VerifyAttestationAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var checks = new List<VerificationCheck>();
|
||||
var errors = new List<string>();
|
||||
var warnings = new List<string>();
|
||||
|
||||
// Check publication exists
|
||||
if (!_publications.TryGetValue((tenantId, packId), out var status))
|
||||
{
|
||||
return new AttestationVerificationResult
|
||||
{
|
||||
Valid = false,
|
||||
Errors = ["Policy pack is not published"]
|
||||
};
|
||||
}
|
||||
|
||||
checks.Add(new VerificationCheck
|
||||
{
|
||||
Name = "publication_exists",
|
||||
Passed = true,
|
||||
Details = $"Published at {status.PublishedAt:O}"
|
||||
});
|
||||
|
||||
// Check not revoked
|
||||
if (status.State == PublishState.Revoked)
|
||||
{
|
||||
errors.Add($"Policy pack was revoked at {status.RevokedAt:O}: {status.RevokeReason}");
|
||||
checks.Add(new VerificationCheck
|
||||
{
|
||||
Name = "not_revoked",
|
||||
Passed = false,
|
||||
Details = status.RevokeReason
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
checks.Add(new VerificationCheck
|
||||
{
|
||||
Name = "not_revoked",
|
||||
Passed = true,
|
||||
Details = "Policy pack has not been revoked"
|
||||
});
|
||||
}
|
||||
|
||||
// Check attestation exists
|
||||
if (!_attestations.TryGetValue((tenantId, packId), out var attestation))
|
||||
{
|
||||
errors.Add("Attestation not found");
|
||||
checks.Add(new VerificationCheck
|
||||
{
|
||||
Name = "attestation_exists",
|
||||
Passed = false,
|
||||
Details = "No attestation on record"
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
checks.Add(new VerificationCheck
|
||||
{
|
||||
Name = "attestation_exists",
|
||||
Passed = true,
|
||||
Details = $"Found {attestation.Signatures.Count} signature(s)"
|
||||
});
|
||||
|
||||
// Verify signatures
|
||||
foreach (var sig in attestation.Signatures)
|
||||
{
|
||||
// In a real implementation, this would verify the actual cryptographic signature
|
||||
var sigValid = !string.IsNullOrEmpty(sig.Signature);
|
||||
checks.Add(new VerificationCheck
|
||||
{
|
||||
Name = $"signature_{sig.KeyId}",
|
||||
Passed = sigValid,
|
||||
Details = sigValid ? $"Signature verified for key {sig.KeyId}" : "Invalid signature"
|
||||
});
|
||||
|
||||
if (!sigValid)
|
||||
{
|
||||
errors.Add($"Invalid signature for key {sig.KeyId}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify pack still exists and matches digest
|
||||
var pack = await _packStore.GetByIdAsync(tenantId, packId, cancellationToken);
|
||||
if (pack is null)
|
||||
{
|
||||
errors.Add("Policy pack no longer exists");
|
||||
checks.Add(new VerificationCheck
|
||||
{
|
||||
Name = "pack_exists",
|
||||
Passed = false,
|
||||
Details = "Policy pack has been deleted"
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
checks.Add(new VerificationCheck
|
||||
{
|
||||
Name = "pack_exists",
|
||||
Passed = true,
|
||||
Details = $"Pack version: {pack.Version}"
|
||||
});
|
||||
|
||||
// Verify digest matches
|
||||
var digestMatch = pack.Digest == status.Digest;
|
||||
checks.Add(new VerificationCheck
|
||||
{
|
||||
Name = "digest_match",
|
||||
Passed = digestMatch,
|
||||
Details = digestMatch ? "Digest matches" : $"Digest mismatch: expected {status.Digest}, got {pack.Digest}"
|
||||
});
|
||||
|
||||
if (!digestMatch)
|
||||
{
|
||||
errors.Add("Policy pack has been modified since publication");
|
||||
}
|
||||
}
|
||||
|
||||
return new AttestationVerificationResult
|
||||
{
|
||||
Valid = errors.Count == 0,
|
||||
Checks = checks,
|
||||
Errors = errors.Count > 0 ? errors : null,
|
||||
Warnings = warnings.Count > 0 ? warnings : null
|
||||
};
|
||||
}
|
||||
|
||||
public Task<PublishedPackList> ListPublishedAsync(
|
||||
Guid tenantId,
|
||||
int pageSize = 20,
|
||||
string? pageToken = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = _publications
|
||||
.Where(kv => kv.Key.TenantId == tenantId)
|
||||
.Select(kv => kv.Value)
|
||||
.OrderByDescending(p => p.PublishedAt)
|
||||
.ToList();
|
||||
|
||||
int skip = 0;
|
||||
if (!string.IsNullOrEmpty(pageToken) && int.TryParse(pageToken, out var offset))
|
||||
{
|
||||
skip = offset;
|
||||
}
|
||||
|
||||
var pagedItems = items.Skip(skip).Take(pageSize).ToList();
|
||||
string? nextToken = skip + pagedItems.Count < items.Count
|
||||
? (skip + pagedItems.Count).ToString()
|
||||
: null;
|
||||
|
||||
return Task.FromResult(new PublishedPackList
|
||||
{
|
||||
Items = pagedItems,
|
||||
NextPageToken = nextToken,
|
||||
TotalCount = items.Count
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<RevokeResult> RevokeAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
RevokePackRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_publications.TryGetValue((tenantId, packId), out var status))
|
||||
{
|
||||
return new RevokeResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Policy pack is not published"
|
||||
};
|
||||
}
|
||||
|
||||
if (status.State == PublishState.Revoked)
|
||||
{
|
||||
return new RevokeResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Policy pack is already revoked"
|
||||
};
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var updatedStatus = status with
|
||||
{
|
||||
State = PublishState.Revoked,
|
||||
RevokedAt = now,
|
||||
RevokedBy = request.RevokedBy,
|
||||
RevokeReason = request.Reason
|
||||
};
|
||||
|
||||
_publications[(tenantId, packId)] = updatedStatus;
|
||||
|
||||
// Update pack status to archived
|
||||
await _packStore.UpdateStatusAsync(tenantId, packId, PolicyPackStatus.Archived, request.RevokedBy, cancellationToken);
|
||||
|
||||
return new RevokeResult
|
||||
{
|
||||
Success = true,
|
||||
Status = updatedStatus
|
||||
};
|
||||
}
|
||||
|
||||
private PolicyPackAttestation BuildAttestation(
|
||||
PolicyPackEntity pack,
|
||||
string digest,
|
||||
PolicyPackCompilationResult compilationResult,
|
||||
ReviewRequest? review,
|
||||
PublishPackRequest request,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
var subject = new AttestationSubject
|
||||
{
|
||||
Name = $"policy-pack/{pack.Name}",
|
||||
Digest = new Dictionary<string, string>
|
||||
{
|
||||
["sha256"] = digest.Replace("sha256:", "")
|
||||
}
|
||||
};
|
||||
|
||||
var predicate = new AttestationPredicate
|
||||
{
|
||||
BuildType = BuildType,
|
||||
Builder = new AttestationBuilder
|
||||
{
|
||||
Id = BuilderId,
|
||||
Version = "1.0.0"
|
||||
},
|
||||
BuildStartedOn = pack.CreatedAt,
|
||||
BuildFinishedOn = now,
|
||||
Compilation = new PolicyPackCompilationMetadata
|
||||
{
|
||||
Digest = digest,
|
||||
RuleCount = compilationResult.Statistics?.TotalRules ?? 0,
|
||||
CompiledAt = now,
|
||||
Statistics = compilationResult.Statistics?.SeverityCounts
|
||||
},
|
||||
Review = review is not null ? new PolicyPackReviewMetadata
|
||||
{
|
||||
ReviewId = review.ReviewId,
|
||||
ApprovedAt = review.ResolvedAt ?? now,
|
||||
ApprovedBy = review.ResolvedBy,
|
||||
Reviewers = review.Reviewers
|
||||
} : null,
|
||||
Metadata = request.Metadata?.ToDictionary(kv => kv.Key, kv => (object)kv.Value)
|
||||
};
|
||||
|
||||
var payload = new AttestationPayload
|
||||
{
|
||||
Type = "https://in-toto.io/Statement/v1",
|
||||
PredicateType = request.AttestationOptions?.PredicateType ?? AttestationPredicateType,
|
||||
Subject = subject,
|
||||
Predicate = predicate
|
||||
};
|
||||
|
||||
var payloadJson = JsonSerializer.Serialize(payload, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = false
|
||||
});
|
||||
|
||||
var payloadBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(payloadJson));
|
||||
|
||||
// Generate signature (simulated - in production would use actual signing)
|
||||
var signature = GenerateSignature(payloadBase64, request.SigningOptions);
|
||||
|
||||
return new PolicyPackAttestation
|
||||
{
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
Payload = payloadBase64,
|
||||
Signatures =
|
||||
[
|
||||
new AttestationSignature
|
||||
{
|
||||
KeyId = request.SigningOptions?.KeyId ?? "default",
|
||||
Signature = signature,
|
||||
Timestamp = request.SigningOptions?.IncludeTimestamp == true ? now : null
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
private static string GenerateSignature(string payload, SigningOptions? options)
|
||||
{
|
||||
// In production, this would use actual cryptographic signing
|
||||
// For now, we generate a deterministic mock signature
|
||||
var content = $"{payload}:{options?.KeyId ?? "default"}:{options?.Algorithm ?? SigningAlgorithm.ECDSA_P256_SHA256}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
|
||||
return Convert.ToBase64String(hash);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Policy.Registry.Contracts;
|
||||
using StellaOps.Policy.Registry.Storage;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of review workflow service with in-memory storage.
|
||||
/// </summary>
|
||||
public sealed class ReviewWorkflowService : IReviewWorkflowService
|
||||
{
|
||||
private readonly IPolicyPackStore _packStore;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ConcurrentDictionary<(Guid TenantId, string ReviewId), ReviewRequest> _reviews = new();
|
||||
private readonly ConcurrentDictionary<(Guid TenantId, string ReviewId), List<ReviewAuditEntry>> _auditTrails = new();
|
||||
|
||||
public ReviewWorkflowService(IPolicyPackStore packStore, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_packStore = packStore ?? throw new ArgumentNullException(nameof(packStore));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<ReviewRequest> SubmitForReviewAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
SubmitReviewRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var pack = await _packStore.GetByIdAsync(tenantId, packId, cancellationToken);
|
||||
if (pack is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Policy pack {packId} not found");
|
||||
}
|
||||
|
||||
if (pack.Status != PolicyPackStatus.Draft)
|
||||
{
|
||||
throw new InvalidOperationException($"Only draft policy packs can be submitted for review. Current status: {pack.Status}");
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var reviewId = GenerateReviewId(tenantId, packId, now);
|
||||
|
||||
var review = new ReviewRequest
|
||||
{
|
||||
ReviewId = reviewId,
|
||||
TenantId = tenantId,
|
||||
PackId = packId,
|
||||
PackVersion = pack.Version,
|
||||
Status = ReviewStatus.Pending,
|
||||
Description = request.Description,
|
||||
Reviewers = request.Reviewers,
|
||||
Urgency = request.Urgency,
|
||||
SubmittedBy = pack.CreatedBy,
|
||||
SubmittedAt = now,
|
||||
Metadata = request.Metadata
|
||||
};
|
||||
|
||||
_reviews[(tenantId, reviewId)] = review;
|
||||
|
||||
// Update pack status to pending review
|
||||
await _packStore.UpdateStatusAsync(tenantId, packId, PolicyPackStatus.PendingReview, pack.CreatedBy, cancellationToken);
|
||||
|
||||
// Add audit entry
|
||||
AddAuditEntry(tenantId, reviewId, packId, ReviewAuditAction.Submitted, now, pack.CreatedBy,
|
||||
null, ReviewStatus.Pending, $"Submitted for review: {request.Description ?? "No description"}");
|
||||
|
||||
// Add reviewer assignment audit entries
|
||||
if (request.Reviewers is { Count: > 0 })
|
||||
{
|
||||
foreach (var reviewer in request.Reviewers)
|
||||
{
|
||||
AddAuditEntry(tenantId, reviewId, packId, ReviewAuditAction.AssignedReviewer, now, pack.CreatedBy,
|
||||
null, null, $"Assigned reviewer: {reviewer}",
|
||||
new Dictionary<string, object> { ["reviewer"] = reviewer });
|
||||
}
|
||||
}
|
||||
|
||||
return review;
|
||||
}
|
||||
|
||||
public async Task<ReviewDecision> ApproveAsync(
|
||||
Guid tenantId,
|
||||
string reviewId,
|
||||
ApproveReviewRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_reviews.TryGetValue((tenantId, reviewId), out var review))
|
||||
{
|
||||
throw new InvalidOperationException($"Review {reviewId} not found");
|
||||
}
|
||||
|
||||
if (review.Status is not (ReviewStatus.Pending or ReviewStatus.InReview or ReviewStatus.ChangesRequested))
|
||||
{
|
||||
throw new InvalidOperationException($"Review cannot be approved in status: {review.Status}");
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var previousStatus = review.Status;
|
||||
|
||||
var updatedReview = review with
|
||||
{
|
||||
Status = ReviewStatus.Approved,
|
||||
ResolvedAt = now,
|
||||
ResolvedBy = request.ApprovedBy
|
||||
};
|
||||
|
||||
_reviews[(tenantId, reviewId)] = updatedReview;
|
||||
|
||||
// Add audit entry
|
||||
AddAuditEntry(tenantId, reviewId, review.PackId, ReviewAuditAction.Approved, now, request.ApprovedBy,
|
||||
previousStatus, ReviewStatus.Approved, request.Comment ?? "Approved");
|
||||
|
||||
return new ReviewDecision
|
||||
{
|
||||
ReviewId = reviewId,
|
||||
NewStatus = ReviewStatus.Approved,
|
||||
DecidedAt = now,
|
||||
DecidedBy = request.ApprovedBy,
|
||||
Comment = request.Comment
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ReviewDecision> RejectAsync(
|
||||
Guid tenantId,
|
||||
string reviewId,
|
||||
RejectReviewRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_reviews.TryGetValue((tenantId, reviewId), out var review))
|
||||
{
|
||||
throw new InvalidOperationException($"Review {reviewId} not found");
|
||||
}
|
||||
|
||||
if (review.Status is not (ReviewStatus.Pending or ReviewStatus.InReview or ReviewStatus.ChangesRequested))
|
||||
{
|
||||
throw new InvalidOperationException($"Review cannot be rejected in status: {review.Status}");
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var previousStatus = review.Status;
|
||||
|
||||
var updatedReview = review with
|
||||
{
|
||||
Status = ReviewStatus.Rejected,
|
||||
ResolvedAt = now,
|
||||
ResolvedBy = request.RejectedBy
|
||||
};
|
||||
|
||||
_reviews[(tenantId, reviewId)] = updatedReview;
|
||||
|
||||
// Revert pack to draft
|
||||
await _packStore.UpdateStatusAsync(tenantId, review.PackId, PolicyPackStatus.Draft, request.RejectedBy, cancellationToken);
|
||||
|
||||
// Add audit entry
|
||||
AddAuditEntry(tenantId, reviewId, review.PackId, ReviewAuditAction.Rejected, now, request.RejectedBy,
|
||||
previousStatus, ReviewStatus.Rejected, request.Reason);
|
||||
|
||||
return new ReviewDecision
|
||||
{
|
||||
ReviewId = reviewId,
|
||||
NewStatus = ReviewStatus.Rejected,
|
||||
DecidedAt = now,
|
||||
DecidedBy = request.RejectedBy,
|
||||
Comment = request.Reason
|
||||
};
|
||||
}
|
||||
|
||||
public Task<ReviewDecision> RequestChangesAsync(
|
||||
Guid tenantId,
|
||||
string reviewId,
|
||||
RequestChangesRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_reviews.TryGetValue((tenantId, reviewId), out var review))
|
||||
{
|
||||
throw new InvalidOperationException($"Review {reviewId} not found");
|
||||
}
|
||||
|
||||
if (review.Status is not (ReviewStatus.Pending or ReviewStatus.InReview))
|
||||
{
|
||||
throw new InvalidOperationException($"Changes cannot be requested in status: {review.Status}");
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var previousStatus = review.Status;
|
||||
|
||||
var updatedReview = review with
|
||||
{
|
||||
Status = ReviewStatus.ChangesRequested,
|
||||
PendingComments = request.Comments
|
||||
};
|
||||
|
||||
_reviews[(tenantId, reviewId)] = updatedReview;
|
||||
|
||||
// Add audit entry for status change
|
||||
AddAuditEntry(tenantId, reviewId, review.PackId, ReviewAuditAction.ChangesRequested, now, request.RequestedBy,
|
||||
previousStatus, ReviewStatus.ChangesRequested, $"Requested {request.Comments.Count} change(s)");
|
||||
|
||||
// Add audit entries for each comment
|
||||
foreach (var comment in request.Comments)
|
||||
{
|
||||
AddAuditEntry(tenantId, reviewId, review.PackId, ReviewAuditAction.CommentAdded, now, request.RequestedBy,
|
||||
null, null, comment.Comment,
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
["rule_id"] = comment.RuleId ?? "general",
|
||||
["severity"] = comment.Severity.ToString()
|
||||
});
|
||||
}
|
||||
|
||||
return Task.FromResult(new ReviewDecision
|
||||
{
|
||||
ReviewId = reviewId,
|
||||
NewStatus = ReviewStatus.ChangesRequested,
|
||||
DecidedAt = now,
|
||||
DecidedBy = request.RequestedBy,
|
||||
Comments = request.Comments
|
||||
});
|
||||
}
|
||||
|
||||
public Task<ReviewRequest?> GetReviewAsync(
|
||||
Guid tenantId,
|
||||
string reviewId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_reviews.TryGetValue((tenantId, reviewId), out var review);
|
||||
return Task.FromResult(review);
|
||||
}
|
||||
|
||||
public Task<ReviewRequestList> ListReviewsAsync(
|
||||
Guid tenantId,
|
||||
ReviewStatus? status = null,
|
||||
Guid? packId = null,
|
||||
int pageSize = 20,
|
||||
string? pageToken = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _reviews.Values.Where(r => r.TenantId == tenantId);
|
||||
|
||||
if (status.HasValue)
|
||||
{
|
||||
query = query.Where(r => r.Status == status.Value);
|
||||
}
|
||||
|
||||
if (packId.HasValue)
|
||||
{
|
||||
query = query.Where(r => r.PackId == packId.Value);
|
||||
}
|
||||
|
||||
var items = query.OrderByDescending(r => r.SubmittedAt).ToList();
|
||||
|
||||
int skip = 0;
|
||||
if (!string.IsNullOrEmpty(pageToken) && int.TryParse(pageToken, out var offset))
|
||||
{
|
||||
skip = offset;
|
||||
}
|
||||
|
||||
var pagedItems = items.Skip(skip).Take(pageSize).ToList();
|
||||
string? nextToken = skip + pagedItems.Count < items.Count
|
||||
? (skip + pagedItems.Count).ToString()
|
||||
: null;
|
||||
|
||||
return Task.FromResult(new ReviewRequestList
|
||||
{
|
||||
Items = pagedItems,
|
||||
NextPageToken = nextToken,
|
||||
TotalCount = items.Count
|
||||
});
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ReviewAuditEntry>> GetAuditTrailAsync(
|
||||
Guid tenantId,
|
||||
string reviewId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_auditTrails.TryGetValue((tenantId, reviewId), out var trail))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<ReviewAuditEntry>>(Array.Empty<ReviewAuditEntry>());
|
||||
}
|
||||
|
||||
var entries = trail.OrderByDescending(e => e.Timestamp).ToList();
|
||||
return Task.FromResult<IReadOnlyList<ReviewAuditEntry>>(entries);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ReviewAuditEntry>> GetPackAuditTrailAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entries = _auditTrails
|
||||
.Where(kv => kv.Key.TenantId == tenantId)
|
||||
.SelectMany(kv => kv.Value)
|
||||
.Where(e => e.PackId == packId)
|
||||
.OrderByDescending(e => e.Timestamp)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<ReviewAuditEntry>>(entries);
|
||||
}
|
||||
|
||||
private void AddAuditEntry(
|
||||
Guid tenantId,
|
||||
string reviewId,
|
||||
Guid packId,
|
||||
ReviewAuditAction action,
|
||||
DateTimeOffset timestamp,
|
||||
string? performedBy,
|
||||
ReviewStatus? previousStatus,
|
||||
ReviewStatus? newStatus,
|
||||
string? comment,
|
||||
IReadOnlyDictionary<string, object>? details = null)
|
||||
{
|
||||
var auditId = GenerateAuditId(tenantId, reviewId, timestamp);
|
||||
var entry = new ReviewAuditEntry
|
||||
{
|
||||
AuditId = auditId,
|
||||
ReviewId = reviewId,
|
||||
PackId = packId,
|
||||
Action = action,
|
||||
Timestamp = timestamp,
|
||||
PerformedBy = performedBy,
|
||||
PreviousStatus = previousStatus,
|
||||
NewStatus = newStatus,
|
||||
Comment = comment,
|
||||
Details = details
|
||||
};
|
||||
|
||||
_auditTrails.AddOrUpdate(
|
||||
(tenantId, reviewId),
|
||||
_ => [entry],
|
||||
(_, list) =>
|
||||
{
|
||||
list.Add(entry);
|
||||
return list;
|
||||
});
|
||||
}
|
||||
|
||||
private static string GenerateReviewId(Guid tenantId, Guid packId, DateTimeOffset timestamp)
|
||||
{
|
||||
var content = $"{tenantId}:{packId}:{timestamp.ToUnixTimeMilliseconds()}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
|
||||
return $"rev_{Convert.ToHexString(hash)[..16].ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static string GenerateAuditId(Guid tenantId, string reviewId, DateTimeOffset timestamp)
|
||||
{
|
||||
var content = $"{tenantId}:{reviewId}:{timestamp.ToUnixTimeMilliseconds()}:{Guid.NewGuid()}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
|
||||
return $"aud_{Convert.ToHexString(hash)[..12].ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
232
src/Policy/StellaOps.Policy.Registry/Storage/Entities.cs
Normal file
232
src/Policy/StellaOps.Policy.Registry/Storage/Entities.cs
Normal 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; }
|
||||
}
|
||||
212
src/Policy/StellaOps.Policy.Registry/Storage/IPolicyPackStore.cs
Normal file
212
src/Policy/StellaOps.Policy.Registry/Storage/IPolicyPackStore.cs
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()}";
|
||||
}
|
||||
}
|
||||
@@ -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()}";
|
||||
}
|
||||
}
|
||||
@@ -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 _));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// Activity source for Policy Registry tracing.
|
||||
/// Provides distributed tracing capabilities for all registry operations.
|
||||
/// </summary>
|
||||
public static class PolicyRegistryActivitySource
|
||||
{
|
||||
public const string SourceName = "StellaOps.Policy.Registry";
|
||||
|
||||
public static readonly ActivitySource ActivitySource = new(SourceName, "1.0.0");
|
||||
|
||||
// Pack operations
|
||||
public static Activity? StartCreatePack(string tenantId, string packName)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("policy_registry.pack.create", ActivityKind.Internal);
|
||||
activity?.SetTag("tenant_id", tenantId);
|
||||
activity?.SetTag("pack_name", packName);
|
||||
return activity;
|
||||
}
|
||||
|
||||
public static Activity? StartGetPack(string tenantId, Guid packId)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("policy_registry.pack.get", ActivityKind.Internal);
|
||||
activity?.SetTag("tenant_id", tenantId);
|
||||
activity?.SetTag("pack_id", packId.ToString());
|
||||
return activity;
|
||||
}
|
||||
|
||||
public static Activity? StartUpdatePack(string tenantId, Guid packId)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("policy_registry.pack.update", ActivityKind.Internal);
|
||||
activity?.SetTag("tenant_id", tenantId);
|
||||
activity?.SetTag("pack_id", packId.ToString());
|
||||
return activity;
|
||||
}
|
||||
|
||||
public static Activity? StartDeletePack(string tenantId, Guid packId)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("policy_registry.pack.delete", ActivityKind.Internal);
|
||||
activity?.SetTag("tenant_id", tenantId);
|
||||
activity?.SetTag("pack_id", packId.ToString());
|
||||
return activity;
|
||||
}
|
||||
|
||||
// Compilation operations
|
||||
public static Activity? StartCompile(string tenantId, Guid packId)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("policy_registry.compile", ActivityKind.Internal);
|
||||
activity?.SetTag("tenant_id", tenantId);
|
||||
activity?.SetTag("pack_id", packId.ToString());
|
||||
return activity;
|
||||
}
|
||||
|
||||
public static Activity? StartValidateRule(string tenantId, string ruleId)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("policy_registry.rule.validate", ActivityKind.Internal);
|
||||
activity?.SetTag("tenant_id", tenantId);
|
||||
activity?.SetTag("rule_id", ruleId);
|
||||
return activity;
|
||||
}
|
||||
|
||||
// Simulation operations
|
||||
public static Activity? StartSimulation(string tenantId, Guid packId)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("policy_registry.simulate", ActivityKind.Internal);
|
||||
activity?.SetTag("tenant_id", tenantId);
|
||||
activity?.SetTag("pack_id", packId.ToString());
|
||||
return activity;
|
||||
}
|
||||
|
||||
public static Activity? StartBatchSimulation(string tenantId, Guid packId, int inputCount)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("policy_registry.batch_simulate", ActivityKind.Internal);
|
||||
activity?.SetTag("tenant_id", tenantId);
|
||||
activity?.SetTag("pack_id", packId.ToString());
|
||||
activity?.SetTag("input_count", inputCount);
|
||||
return activity;
|
||||
}
|
||||
|
||||
// Review operations
|
||||
public static Activity? StartSubmitReview(string tenantId, Guid packId)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("policy_registry.review.submit", ActivityKind.Internal);
|
||||
activity?.SetTag("tenant_id", tenantId);
|
||||
activity?.SetTag("pack_id", packId.ToString());
|
||||
return activity;
|
||||
}
|
||||
|
||||
public static Activity? StartApproveReview(string tenantId, string reviewId)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("policy_registry.review.approve", ActivityKind.Internal);
|
||||
activity?.SetTag("tenant_id", tenantId);
|
||||
activity?.SetTag("review_id", reviewId);
|
||||
return activity;
|
||||
}
|
||||
|
||||
public static Activity? StartRejectReview(string tenantId, string reviewId)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("policy_registry.review.reject", ActivityKind.Internal);
|
||||
activity?.SetTag("tenant_id", tenantId);
|
||||
activity?.SetTag("review_id", reviewId);
|
||||
return activity;
|
||||
}
|
||||
|
||||
// Publish operations
|
||||
public static Activity? StartPublish(string tenantId, Guid packId)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("policy_registry.publish", ActivityKind.Internal);
|
||||
activity?.SetTag("tenant_id", tenantId);
|
||||
activity?.SetTag("pack_id", packId.ToString());
|
||||
return activity;
|
||||
}
|
||||
|
||||
public static Activity? StartRevoke(string tenantId, Guid packId)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("policy_registry.revoke", ActivityKind.Internal);
|
||||
activity?.SetTag("tenant_id", tenantId);
|
||||
activity?.SetTag("pack_id", packId.ToString());
|
||||
return activity;
|
||||
}
|
||||
|
||||
public static Activity? StartVerifyAttestation(string tenantId, Guid packId)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("policy_registry.attestation.verify", ActivityKind.Internal);
|
||||
activity?.SetTag("tenant_id", tenantId);
|
||||
activity?.SetTag("pack_id", packId.ToString());
|
||||
return activity;
|
||||
}
|
||||
|
||||
// Promotion operations
|
||||
public static Activity? StartPromotion(string tenantId, Guid packId, string targetEnvironment)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("policy_registry.promote", ActivityKind.Internal);
|
||||
activity?.SetTag("tenant_id", tenantId);
|
||||
activity?.SetTag("pack_id", packId.ToString());
|
||||
activity?.SetTag("target_environment", targetEnvironment);
|
||||
return activity;
|
||||
}
|
||||
|
||||
public static Activity? StartRollback(string tenantId, string environment)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("policy_registry.rollback", ActivityKind.Internal);
|
||||
activity?.SetTag("tenant_id", tenantId);
|
||||
activity?.SetTag("environment", environment);
|
||||
return activity;
|
||||
}
|
||||
|
||||
public static Activity? StartValidatePromotion(string tenantId, Guid packId, string targetEnvironment)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("policy_registry.promotion.validate", ActivityKind.Internal);
|
||||
activity?.SetTag("tenant_id", tenantId);
|
||||
activity?.SetTag("pack_id", packId.ToString());
|
||||
activity?.SetTag("target_environment", targetEnvironment);
|
||||
return activity;
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
public static void SetError(this Activity? activity, Exception ex)
|
||||
{
|
||||
if (activity is null) return;
|
||||
|
||||
activity.SetStatus(ActivityStatusCode.Error, ex.Message);
|
||||
activity.SetTag("error.type", ex.GetType().FullName);
|
||||
activity.SetTag("error.message", ex.Message);
|
||||
}
|
||||
|
||||
public static void SetSuccess(this Activity? activity)
|
||||
{
|
||||
activity?.SetStatus(ActivityStatusCode.Ok);
|
||||
}
|
||||
|
||||
public static void SetResult(this Activity? activity, string key, object? value)
|
||||
{
|
||||
if (activity is null || value is null) return;
|
||||
activity.SetTag($"result.{key}", value.ToString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// Structured logging event IDs for Policy Registry operations.
|
||||
/// Provides consistent event identification for log analysis and alerting.
|
||||
/// </summary>
|
||||
public static class PolicyRegistryLogEvents
|
||||
{
|
||||
// Pack operations (1000-1099)
|
||||
public static readonly EventId PackCreated = new(1000, "PackCreated");
|
||||
public static readonly EventId PackUpdated = new(1001, "PackUpdated");
|
||||
public static readonly EventId PackDeleted = new(1002, "PackDeleted");
|
||||
public static readonly EventId PackStatusChanged = new(1003, "PackStatusChanged");
|
||||
public static readonly EventId PackNotFound = new(1004, "PackNotFound");
|
||||
public static readonly EventId PackValidationFailed = new(1005, "PackValidationFailed");
|
||||
|
||||
// Compilation operations (1100-1199)
|
||||
public static readonly EventId CompilationStarted = new(1100, "CompilationStarted");
|
||||
public static readonly EventId CompilationSucceeded = new(1101, "CompilationSucceeded");
|
||||
public static readonly EventId CompilationFailed = new(1102, "CompilationFailed");
|
||||
public static readonly EventId RuleValidationStarted = new(1110, "RuleValidationStarted");
|
||||
public static readonly EventId RuleValidationSucceeded = new(1111, "RuleValidationSucceeded");
|
||||
public static readonly EventId RuleValidationFailed = new(1112, "RuleValidationFailed");
|
||||
public static readonly EventId DigestComputed = new(1120, "DigestComputed");
|
||||
|
||||
// Simulation operations (1200-1299)
|
||||
public static readonly EventId SimulationStarted = new(1200, "SimulationStarted");
|
||||
public static readonly EventId SimulationCompleted = new(1201, "SimulationCompleted");
|
||||
public static readonly EventId SimulationFailed = new(1202, "SimulationFailed");
|
||||
public static readonly EventId ViolationDetected = new(1210, "ViolationDetected");
|
||||
public static readonly EventId BatchSimulationSubmitted = new(1220, "BatchSimulationSubmitted");
|
||||
public static readonly EventId BatchSimulationStarted = new(1221, "BatchSimulationStarted");
|
||||
public static readonly EventId BatchSimulationCompleted = new(1222, "BatchSimulationCompleted");
|
||||
public static readonly EventId BatchSimulationFailed = new(1223, "BatchSimulationFailed");
|
||||
public static readonly EventId BatchSimulationCancelled = new(1224, "BatchSimulationCancelled");
|
||||
public static readonly EventId BatchSimulationProgress = new(1225, "BatchSimulationProgress");
|
||||
|
||||
// Review operations (1300-1399)
|
||||
public static readonly EventId ReviewSubmitted = new(1300, "ReviewSubmitted");
|
||||
public static readonly EventId ReviewApproved = new(1301, "ReviewApproved");
|
||||
public static readonly EventId ReviewRejected = new(1302, "ReviewRejected");
|
||||
public static readonly EventId ReviewChangesRequested = new(1303, "ReviewChangesRequested");
|
||||
public static readonly EventId ReviewCancelled = new(1304, "ReviewCancelled");
|
||||
public static readonly EventId ReviewerAssigned = new(1310, "ReviewerAssigned");
|
||||
public static readonly EventId ReviewerRemoved = new(1311, "ReviewerRemoved");
|
||||
public static readonly EventId ReviewCommentAdded = new(1320, "ReviewCommentAdded");
|
||||
|
||||
// Publish operations (1400-1499)
|
||||
public static readonly EventId PublishStarted = new(1400, "PublishStarted");
|
||||
public static readonly EventId PublishSucceeded = new(1401, "PublishSucceeded");
|
||||
public static readonly EventId PublishFailed = new(1402, "PublishFailed");
|
||||
public static readonly EventId AttestationGenerated = new(1410, "AttestationGenerated");
|
||||
public static readonly EventId AttestationVerified = new(1411, "AttestationVerified");
|
||||
public static readonly EventId AttestationVerificationFailed = new(1412, "AttestationVerificationFailed");
|
||||
public static readonly EventId SignatureGenerated = new(1420, "SignatureGenerated");
|
||||
public static readonly EventId PackRevoked = new(1430, "PackRevoked");
|
||||
|
||||
// Promotion operations (1500-1599)
|
||||
public static readonly EventId PromotionStarted = new(1500, "PromotionStarted");
|
||||
public static readonly EventId PromotionSucceeded = new(1501, "PromotionSucceeded");
|
||||
public static readonly EventId PromotionFailed = new(1502, "PromotionFailed");
|
||||
public static readonly EventId PromotionValidationStarted = new(1510, "PromotionValidationStarted");
|
||||
public static readonly EventId PromotionValidationPassed = new(1511, "PromotionValidationPassed");
|
||||
public static readonly EventId PromotionValidationFailed = new(1512, "PromotionValidationFailed");
|
||||
public static readonly EventId BindingCreated = new(1520, "BindingCreated");
|
||||
public static readonly EventId BindingActivated = new(1521, "BindingActivated");
|
||||
public static readonly EventId BindingSuperseded = new(1522, "BindingSuperseded");
|
||||
public static readonly EventId RollbackStarted = new(1530, "RollbackStarted");
|
||||
public static readonly EventId RollbackSucceeded = new(1531, "RollbackSucceeded");
|
||||
public static readonly EventId RollbackFailed = new(1532, "RollbackFailed");
|
||||
|
||||
// Store operations (1600-1699)
|
||||
public static readonly EventId StoreReadStarted = new(1600, "StoreReadStarted");
|
||||
public static readonly EventId StoreReadCompleted = new(1601, "StoreReadCompleted");
|
||||
public static readonly EventId StoreWriteStarted = new(1610, "StoreWriteStarted");
|
||||
public static readonly EventId StoreWriteCompleted = new(1611, "StoreWriteCompleted");
|
||||
public static readonly EventId StoreDeleteStarted = new(1620, "StoreDeleteStarted");
|
||||
public static readonly EventId StoreDeleteCompleted = new(1621, "StoreDeleteCompleted");
|
||||
|
||||
// Verification policy operations (1700-1799)
|
||||
public static readonly EventId VerificationPolicyCreated = new(1700, "VerificationPolicyCreated");
|
||||
public static readonly EventId VerificationPolicyUpdated = new(1701, "VerificationPolicyUpdated");
|
||||
public static readonly EventId VerificationPolicyDeleted = new(1702, "VerificationPolicyDeleted");
|
||||
|
||||
// Snapshot operations (1800-1899)
|
||||
public static readonly EventId SnapshotCreated = new(1800, "SnapshotCreated");
|
||||
public static readonly EventId SnapshotDeleted = new(1801, "SnapshotDeleted");
|
||||
public static readonly EventId SnapshotVerified = new(1802, "SnapshotVerified");
|
||||
|
||||
// Override operations (1900-1999)
|
||||
public static readonly EventId OverrideCreated = new(1900, "OverrideCreated");
|
||||
public static readonly EventId OverrideApproved = new(1901, "OverrideApproved");
|
||||
public static readonly EventId OverrideDisabled = new(1902, "OverrideDisabled");
|
||||
public static readonly EventId OverrideExpired = new(1903, "OverrideExpired");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Log message templates for Policy Registry operations.
|
||||
/// </summary>
|
||||
public static class PolicyRegistryLogMessages
|
||||
{
|
||||
// Pack messages
|
||||
public const string PackCreated = "Created policy pack {PackId} '{PackName}' v{Version} for tenant {TenantId}";
|
||||
public const string PackUpdated = "Updated policy pack {PackId} for tenant {TenantId}";
|
||||
public const string PackDeleted = "Deleted policy pack {PackId} for tenant {TenantId}";
|
||||
public const string PackStatusChanged = "Policy pack {PackId} status changed from {OldStatus} to {NewStatus}";
|
||||
public const string PackNotFound = "Policy pack {PackId} not found for tenant {TenantId}";
|
||||
|
||||
// Compilation messages
|
||||
public const string CompilationStarted = "Starting compilation for pack {PackId}";
|
||||
public const string CompilationSucceeded = "Compilation succeeded for pack {PackId}: {RuleCount} rules, digest {Digest}";
|
||||
public const string CompilationFailed = "Compilation failed for pack {PackId}: {ErrorCount} errors";
|
||||
public const string DigestComputed = "Computed digest {Digest} for pack {PackId}";
|
||||
|
||||
// Simulation messages
|
||||
public const string SimulationStarted = "Starting simulation for pack {PackId}";
|
||||
public const string SimulationCompleted = "Simulation completed for pack {PackId}: {ViolationCount} violations in {DurationMs}ms";
|
||||
public const string ViolationDetected = "Violation detected: rule {RuleId}, severity {Severity}";
|
||||
public const string BatchSimulationSubmitted = "Batch simulation {JobId} submitted with {InputCount} inputs";
|
||||
public const string BatchSimulationCompleted = "Batch simulation {JobId} completed: {Succeeded} succeeded, {Failed} failed";
|
||||
|
||||
// Review messages
|
||||
public const string ReviewSubmitted = "Review {ReviewId} submitted for pack {PackId}";
|
||||
public const string ReviewApproved = "Review {ReviewId} approved by {ApprovedBy}";
|
||||
public const string ReviewRejected = "Review {ReviewId} rejected: {Reason}";
|
||||
public const string ReviewChangesRequested = "Review {ReviewId}: {CommentCount} changes requested";
|
||||
|
||||
// Publish messages
|
||||
public const string PublishStarted = "Starting publish for pack {PackId}";
|
||||
public const string PublishSucceeded = "Pack {PackId} published with digest {Digest}";
|
||||
public const string PublishFailed = "Failed to publish pack {PackId}: {Error}";
|
||||
public const string AttestationGenerated = "Generated attestation for pack {PackId} with {SignatureCount} signatures";
|
||||
public const string PackRevoked = "Pack {PackId} revoked: {Reason}";
|
||||
|
||||
// Promotion messages
|
||||
public const string PromotionStarted = "Starting promotion of pack {PackId} to {Environment}";
|
||||
public const string PromotionSucceeded = "Pack {PackId} promoted to {Environment}";
|
||||
public const string PromotionFailed = "Failed to promote pack {PackId} to {Environment}: {Error}";
|
||||
public const string RollbackStarted = "Starting rollback in {Environment}";
|
||||
public const string RollbackSucceeded = "Rollback succeeded in {Environment}, restored binding {BindingId}";
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// Metrics instrumentation for Policy Registry.
|
||||
/// Implements REGISTRY-API-27-009: Metrics/logs/traces + dashboards.
|
||||
/// </summary>
|
||||
public sealed class PolicyRegistryMetrics : IDisposable
|
||||
{
|
||||
public const string MeterName = "StellaOps.Policy.Registry";
|
||||
|
||||
private readonly Meter _meter;
|
||||
|
||||
// Counters
|
||||
private readonly Counter<long> _packsCreated;
|
||||
private readonly Counter<long> _packsPublished;
|
||||
private readonly Counter<long> _packsRevoked;
|
||||
private readonly Counter<long> _compilations;
|
||||
private readonly Counter<long> _compilationErrors;
|
||||
private readonly Counter<long> _simulations;
|
||||
private readonly Counter<long> _batchSimulations;
|
||||
private readonly Counter<long> _reviewsSubmitted;
|
||||
private readonly Counter<long> _reviewsApproved;
|
||||
private readonly Counter<long> _reviewsRejected;
|
||||
private readonly Counter<long> _promotions;
|
||||
private readonly Counter<long> _rollbacks;
|
||||
private readonly Counter<long> _violations;
|
||||
|
||||
// Histograms
|
||||
private readonly Histogram<double> _compilationDuration;
|
||||
private readonly Histogram<double> _simulationDuration;
|
||||
private readonly Histogram<double> _batchSimulationDuration;
|
||||
private readonly Histogram<long> _rulesPerPack;
|
||||
private readonly Histogram<long> _violationsPerSimulation;
|
||||
private readonly Histogram<long> _inputsPerBatch;
|
||||
|
||||
// Gauges (via ObservableGauge)
|
||||
private long _activePacks;
|
||||
private long _pendingReviews;
|
||||
private long _runningBatchJobs;
|
||||
|
||||
public PolicyRegistryMetrics(IMeterFactory? meterFactory = null)
|
||||
{
|
||||
_meter = meterFactory?.Create(MeterName) ?? new Meter(MeterName, "1.0.0");
|
||||
|
||||
// Counters
|
||||
_packsCreated = _meter.CreateCounter<long>(
|
||||
"policy_registry.packs.created",
|
||||
unit: "{pack}",
|
||||
description: "Total number of policy packs created");
|
||||
|
||||
_packsPublished = _meter.CreateCounter<long>(
|
||||
"policy_registry.packs.published",
|
||||
unit: "{pack}",
|
||||
description: "Total number of policy packs published");
|
||||
|
||||
_packsRevoked = _meter.CreateCounter<long>(
|
||||
"policy_registry.packs.revoked",
|
||||
unit: "{pack}",
|
||||
description: "Total number of policy packs revoked");
|
||||
|
||||
_compilations = _meter.CreateCounter<long>(
|
||||
"policy_registry.compilations.total",
|
||||
unit: "{compilation}",
|
||||
description: "Total number of policy pack compilations");
|
||||
|
||||
_compilationErrors = _meter.CreateCounter<long>(
|
||||
"policy_registry.compilations.errors",
|
||||
unit: "{error}",
|
||||
description: "Total number of compilation errors");
|
||||
|
||||
_simulations = _meter.CreateCounter<long>(
|
||||
"policy_registry.simulations.total",
|
||||
unit: "{simulation}",
|
||||
description: "Total number of policy simulations");
|
||||
|
||||
_batchSimulations = _meter.CreateCounter<long>(
|
||||
"policy_registry.batch_simulations.total",
|
||||
unit: "{batch}",
|
||||
description: "Total number of batch simulations");
|
||||
|
||||
_reviewsSubmitted = _meter.CreateCounter<long>(
|
||||
"policy_registry.reviews.submitted",
|
||||
unit: "{review}",
|
||||
description: "Total number of reviews submitted");
|
||||
|
||||
_reviewsApproved = _meter.CreateCounter<long>(
|
||||
"policy_registry.reviews.approved",
|
||||
unit: "{review}",
|
||||
description: "Total number of reviews approved");
|
||||
|
||||
_reviewsRejected = _meter.CreateCounter<long>(
|
||||
"policy_registry.reviews.rejected",
|
||||
unit: "{review}",
|
||||
description: "Total number of reviews rejected");
|
||||
|
||||
_promotions = _meter.CreateCounter<long>(
|
||||
"policy_registry.promotions.total",
|
||||
unit: "{promotion}",
|
||||
description: "Total number of environment promotions");
|
||||
|
||||
_rollbacks = _meter.CreateCounter<long>(
|
||||
"policy_registry.rollbacks.total",
|
||||
unit: "{rollback}",
|
||||
description: "Total number of environment rollbacks");
|
||||
|
||||
_violations = _meter.CreateCounter<long>(
|
||||
"policy_registry.violations.total",
|
||||
unit: "{violation}",
|
||||
description: "Total number of policy violations detected");
|
||||
|
||||
// Histograms
|
||||
_compilationDuration = _meter.CreateHistogram<double>(
|
||||
"policy_registry.compilation.duration",
|
||||
unit: "ms",
|
||||
description: "Duration of policy pack compilations");
|
||||
|
||||
_simulationDuration = _meter.CreateHistogram<double>(
|
||||
"policy_registry.simulation.duration",
|
||||
unit: "ms",
|
||||
description: "Duration of policy simulations");
|
||||
|
||||
_batchSimulationDuration = _meter.CreateHistogram<double>(
|
||||
"policy_registry.batch_simulation.duration",
|
||||
unit: "ms",
|
||||
description: "Duration of batch simulations");
|
||||
|
||||
_rulesPerPack = _meter.CreateHistogram<long>(
|
||||
"policy_registry.pack.rules",
|
||||
unit: "{rule}",
|
||||
description: "Number of rules per policy pack");
|
||||
|
||||
_violationsPerSimulation = _meter.CreateHistogram<long>(
|
||||
"policy_registry.simulation.violations",
|
||||
unit: "{violation}",
|
||||
description: "Number of violations per simulation");
|
||||
|
||||
_inputsPerBatch = _meter.CreateHistogram<long>(
|
||||
"policy_registry.batch_simulation.inputs",
|
||||
unit: "{input}",
|
||||
description: "Number of inputs per batch simulation");
|
||||
|
||||
// Observable gauges
|
||||
_meter.CreateObservableGauge(
|
||||
"policy_registry.packs.active",
|
||||
() => _activePacks,
|
||||
unit: "{pack}",
|
||||
description: "Number of currently active policy packs");
|
||||
|
||||
_meter.CreateObservableGauge(
|
||||
"policy_registry.reviews.pending",
|
||||
() => _pendingReviews,
|
||||
unit: "{review}",
|
||||
description: "Number of pending reviews");
|
||||
|
||||
_meter.CreateObservableGauge(
|
||||
"policy_registry.batch_jobs.running",
|
||||
() => _runningBatchJobs,
|
||||
unit: "{job}",
|
||||
description: "Number of running batch simulation jobs");
|
||||
}
|
||||
|
||||
// Record methods
|
||||
public void RecordPackCreated(string tenantId, string packName)
|
||||
{
|
||||
_packsCreated.Add(1, new KeyValuePair<string, object?>("tenant_id", tenantId),
|
||||
new KeyValuePair<string, object?>("pack_name", packName));
|
||||
Interlocked.Increment(ref _activePacks);
|
||||
}
|
||||
|
||||
public void RecordPackPublished(string tenantId, string environment)
|
||||
{
|
||||
_packsPublished.Add(1, new KeyValuePair<string, object?>("tenant_id", tenantId),
|
||||
new KeyValuePair<string, object?>("environment", environment));
|
||||
}
|
||||
|
||||
public void RecordPackRevoked(string tenantId, string reason)
|
||||
{
|
||||
_packsRevoked.Add(1, new KeyValuePair<string, object?>("tenant_id", tenantId),
|
||||
new KeyValuePair<string, object?>("reason", reason));
|
||||
Interlocked.Decrement(ref _activePacks);
|
||||
}
|
||||
|
||||
public void RecordCompilation(string tenantId, bool success, long durationMs, int ruleCount)
|
||||
{
|
||||
var status = success ? "success" : "failure";
|
||||
_compilations.Add(1, new KeyValuePair<string, object?>("tenant_id", tenantId),
|
||||
new KeyValuePair<string, object?>("status", status));
|
||||
|
||||
if (!success)
|
||||
{
|
||||
_compilationErrors.Add(1, new KeyValuePair<string, object?>("tenant_id", tenantId));
|
||||
}
|
||||
|
||||
_compilationDuration.Record(durationMs, new KeyValuePair<string, object?>("tenant_id", tenantId),
|
||||
new KeyValuePair<string, object?>("status", status));
|
||||
|
||||
_rulesPerPack.Record(ruleCount, new KeyValuePair<string, object?>("tenant_id", tenantId));
|
||||
}
|
||||
|
||||
public void RecordSimulation(string tenantId, bool success, long durationMs, int violationCount)
|
||||
{
|
||||
var status = success ? "success" : "failure";
|
||||
_simulations.Add(1, new KeyValuePair<string, object?>("tenant_id", tenantId),
|
||||
new KeyValuePair<string, object?>("status", status));
|
||||
|
||||
_simulationDuration.Record(durationMs, new KeyValuePair<string, object?>("tenant_id", tenantId),
|
||||
new KeyValuePair<string, object?>("status", status));
|
||||
|
||||
_violationsPerSimulation.Record(violationCount, new KeyValuePair<string, object?>("tenant_id", tenantId));
|
||||
|
||||
if (violationCount > 0)
|
||||
{
|
||||
_violations.Add(violationCount, new KeyValuePair<string, object?>("tenant_id", tenantId));
|
||||
}
|
||||
}
|
||||
|
||||
public void RecordBatchSimulation(string tenantId, int inputCount, int succeeded, int failed, long durationMs)
|
||||
{
|
||||
_batchSimulations.Add(1, new KeyValuePair<string, object?>("tenant_id", tenantId));
|
||||
_batchSimulationDuration.Record(durationMs, new KeyValuePair<string, object?>("tenant_id", tenantId));
|
||||
_inputsPerBatch.Record(inputCount, new KeyValuePair<string, object?>("tenant_id", tenantId));
|
||||
}
|
||||
|
||||
public void RecordReviewSubmitted(string tenantId, string urgency)
|
||||
{
|
||||
_reviewsSubmitted.Add(1, new KeyValuePair<string, object?>("tenant_id", tenantId),
|
||||
new KeyValuePair<string, object?>("urgency", urgency));
|
||||
Interlocked.Increment(ref _pendingReviews);
|
||||
}
|
||||
|
||||
public void RecordReviewApproved(string tenantId)
|
||||
{
|
||||
_reviewsApproved.Add(1, new KeyValuePair<string, object?>("tenant_id", tenantId));
|
||||
Interlocked.Decrement(ref _pendingReviews);
|
||||
}
|
||||
|
||||
public void RecordReviewRejected(string tenantId)
|
||||
{
|
||||
_reviewsRejected.Add(1, new KeyValuePair<string, object?>("tenant_id", tenantId));
|
||||
Interlocked.Decrement(ref _pendingReviews);
|
||||
}
|
||||
|
||||
public void RecordPromotion(string tenantId, string environment)
|
||||
{
|
||||
_promotions.Add(1, new KeyValuePair<string, object?>("tenant_id", tenantId),
|
||||
new KeyValuePair<string, object?>("environment", environment));
|
||||
}
|
||||
|
||||
public void RecordRollback(string tenantId, string environment)
|
||||
{
|
||||
_rollbacks.Add(1, new KeyValuePair<string, object?>("tenant_id", tenantId),
|
||||
new KeyValuePair<string, object?>("environment", environment));
|
||||
}
|
||||
|
||||
public void IncrementRunningBatchJobs() => Interlocked.Increment(ref _runningBatchJobs);
|
||||
public void DecrementRunningBatchJobs() => Interlocked.Decrement(ref _runningBatchJobs);
|
||||
|
||||
public void Dispose() => _meter.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
using StellaOps.Policy.Registry.Contracts;
|
||||
using StellaOps.Policy.Registry.Services;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Testing;
|
||||
|
||||
/// <summary>
|
||||
/// Test fixtures and data generators for Policy Registry testing.
|
||||
/// </summary>
|
||||
public static class PolicyRegistryTestFixtures
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates basic policy rules for testing.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<PolicyRule> CreateBasicRules()
|
||||
{
|
||||
return
|
||||
[
|
||||
new PolicyRule
|
||||
{
|
||||
RuleId = "test-rule-001",
|
||||
Name = "Deny Critical CVEs",
|
||||
Description = "Blocks any image with critical CVEs",
|
||||
Severity = Severity.Critical,
|
||||
Rego = @"
|
||||
package stellaops.policy.test
|
||||
|
||||
default deny = false
|
||||
|
||||
deny {
|
||||
input.vulnerabilities[_].severity == ""critical""
|
||||
}
|
||||
",
|
||||
Enabled = true
|
||||
},
|
||||
new PolicyRule
|
||||
{
|
||||
RuleId = "test-rule-002",
|
||||
Name = "Require SBOM",
|
||||
Description = "Requires valid SBOM for all images",
|
||||
Severity = Severity.High,
|
||||
Rego = @"
|
||||
package stellaops.policy.test
|
||||
|
||||
default require_sbom = false
|
||||
|
||||
require_sbom {
|
||||
input.sbom != null
|
||||
count(input.sbom.packages) > 0
|
||||
}
|
||||
",
|
||||
Enabled = true
|
||||
},
|
||||
new PolicyRule
|
||||
{
|
||||
RuleId = "test-rule-003",
|
||||
Name = "Warn on Medium CVEs",
|
||||
Description = "Warns when medium severity CVEs are present",
|
||||
Severity = Severity.Medium,
|
||||
Rego = @"
|
||||
package stellaops.policy.test
|
||||
|
||||
warn[msg] {
|
||||
vuln := input.vulnerabilities[_]
|
||||
vuln.severity == ""medium""
|
||||
msg := sprintf(""Medium CVE found: %s"", [vuln.id])
|
||||
}
|
||||
",
|
||||
Enabled = true
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates rules with Rego syntax errors for testing compilation failures.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<PolicyRule> CreateInvalidRegoRules()
|
||||
{
|
||||
return
|
||||
[
|
||||
new PolicyRule
|
||||
{
|
||||
RuleId = "invalid-rule-001",
|
||||
Name = "Invalid Syntax",
|
||||
Description = "Rule with syntax errors",
|
||||
Severity = Severity.High,
|
||||
Rego = @"
|
||||
package stellaops.policy.test
|
||||
|
||||
deny {
|
||||
input.something == ""value
|
||||
} // missing closing quote
|
||||
",
|
||||
Enabled = true
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates rules without Rego code for testing name-based matching.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<PolicyRule> CreateRulesWithoutRego()
|
||||
{
|
||||
return
|
||||
[
|
||||
new PolicyRule
|
||||
{
|
||||
RuleId = "no-rego-001",
|
||||
Name = "Vulnerability Check",
|
||||
Description = "Checks for vulnerabilities",
|
||||
Severity = Severity.High,
|
||||
Enabled = true
|
||||
},
|
||||
new PolicyRule
|
||||
{
|
||||
RuleId = "no-rego-002",
|
||||
Name = "License Compliance",
|
||||
Description = "Verifies license compliance",
|
||||
Severity = Severity.Medium,
|
||||
Enabled = true
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates test simulation input.
|
||||
/// </summary>
|
||||
public static IReadOnlyDictionary<string, object> CreateTestSimulationInput()
|
||||
{
|
||||
return new Dictionary<string, object>
|
||||
{
|
||||
["subject"] = new Dictionary<string, object>
|
||||
{
|
||||
["type"] = "container_image",
|
||||
["name"] = "myregistry.io/myapp",
|
||||
["digest"] = "sha256:abc123"
|
||||
},
|
||||
["vulnerabilities"] = new[]
|
||||
{
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
["id"] = "CVE-2024-1234",
|
||||
["severity"] = "critical",
|
||||
["package"] = "openssl",
|
||||
["version"] = "1.1.1"
|
||||
},
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
["id"] = "CVE-2024-5678",
|
||||
["severity"] = "medium",
|
||||
["package"] = "curl",
|
||||
["version"] = "7.88.0"
|
||||
}
|
||||
},
|
||||
["sbom"] = new Dictionary<string, object>
|
||||
{
|
||||
["format"] = "spdx",
|
||||
["packages"] = new[]
|
||||
{
|
||||
new Dictionary<string, object> { ["name"] = "openssl", ["version"] = "1.1.1" },
|
||||
new Dictionary<string, object> { ["name"] = "curl", ["version"] = "7.88.0" }
|
||||
}
|
||||
},
|
||||
["context"] = new Dictionary<string, object>
|
||||
{
|
||||
["environment"] = "production",
|
||||
["namespace"] = "default"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates batch simulation inputs.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<BatchSimulationInput> CreateBatchSimulationInputs(int count = 5)
|
||||
{
|
||||
var inputs = new List<BatchSimulationInput>();
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
inputs.Add(new BatchSimulationInput
|
||||
{
|
||||
InputId = $"input-{i:D3}",
|
||||
Input = CreateTestSimulationInput(),
|
||||
Tags = new Dictionary<string, string>
|
||||
{
|
||||
["test_batch"] = "true",
|
||||
["index"] = i.ToString()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return inputs;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a verification policy request.
|
||||
/// </summary>
|
||||
public static CreateVerificationPolicyRequest CreateVerificationPolicyRequest(
|
||||
string? policyId = null)
|
||||
{
|
||||
return new CreateVerificationPolicyRequest
|
||||
{
|
||||
PolicyId = policyId ?? $"test-policy-{Guid.NewGuid():N}",
|
||||
Version = "1.0.0",
|
||||
Description = "Test verification policy",
|
||||
TenantScope = "*",
|
||||
PredicateTypes = ["https://slsa.dev/provenance/v1", "https://spdx.dev/Document"],
|
||||
SignerRequirements = new SignerRequirements
|
||||
{
|
||||
MinimumSignatures = 1,
|
||||
TrustedKeyFingerprints = ["SHA256:test-fingerprint-1", "SHA256:test-fingerprint-2"],
|
||||
RequireRekor = false
|
||||
},
|
||||
ValidityWindow = new ValidityWindow
|
||||
{
|
||||
MaxAttestationAge = 86400 // 24 hours
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a snapshot request.
|
||||
/// </summary>
|
||||
public static CreateSnapshotRequest CreateSnapshotRequest(params Guid[] packIds)
|
||||
{
|
||||
return new CreateSnapshotRequest
|
||||
{
|
||||
Description = "Test snapshot",
|
||||
PackIds = packIds.Length > 0 ? packIds.ToList() : [Guid.NewGuid()],
|
||||
Metadata = new Dictionary<string, object>
|
||||
{
|
||||
["created_for_test"] = true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a violation request.
|
||||
/// </summary>
|
||||
public static CreateViolationRequest CreateViolationRequest(
|
||||
string? ruleId = null,
|
||||
Severity severity = Severity.High)
|
||||
{
|
||||
return new CreateViolationRequest
|
||||
{
|
||||
RuleId = ruleId ?? "test-rule-001",
|
||||
Severity = severity,
|
||||
Message = $"Test violation for rule {ruleId ?? "test-rule-001"}",
|
||||
Purl = "pkg:npm/lodash@4.17.20",
|
||||
CveId = "CVE-2024-1234",
|
||||
Context = new Dictionary<string, object>
|
||||
{
|
||||
["environment"] = "test",
|
||||
["detected_at"] = DateTimeOffset.UtcNow.ToString("O")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an override request.
|
||||
/// </summary>
|
||||
public static CreateOverrideRequest CreateOverrideRequest(
|
||||
string? ruleId = null)
|
||||
{
|
||||
return new CreateOverrideRequest
|
||||
{
|
||||
RuleId = ruleId ?? "test-rule-001",
|
||||
Reason = "Test override for false positive",
|
||||
Scope = new OverrideScope
|
||||
{
|
||||
Purl = "pkg:npm/lodash@4.17.20",
|
||||
Environment = "development"
|
||||
},
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30)
|
||||
};
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user