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
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:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user