Add post-quantum cryptography support with PqSoftCryptoProvider
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
wine-csp-build / Build Wine CSP Image (push) Has been cancelled

- Implemented PqSoftCryptoProvider for software-only post-quantum algorithms (Dilithium3, Falcon512) using BouncyCastle.
- Added PqSoftProviderOptions and PqSoftKeyOptions for configuration.
- Created unit tests for Dilithium3 and Falcon512 signing and verification.
- Introduced EcdsaPolicyCryptoProvider for compliance profiles (FIPS/eIDAS) with explicit allow-lists.
- Added KcmvpHashOnlyProvider for KCMVP baseline compliance.
- Updated project files and dependencies for new libraries and testing frameworks.
This commit is contained in:
StellaOps Bot
2025-12-07 15:04:19 +02:00
parent 862bb6ed80
commit 98e6b76584
119 changed files with 11436 additions and 1732 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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