Refactor code structure for improved readability and maintainability

This commit is contained in:
StellaOps Bot
2025-12-06 10:23:40 +02:00
parent 6beb9d7c4e
commit 37304cf819
78 changed files with 5471 additions and 104 deletions

View File

@@ -1,4 +1,5 @@
using StellaOps.Findings.Ledger.Domain;
using StellaOps.Findings.Ledger.Services;
namespace StellaOps.Findings.Ledger.Infrastructure;
@@ -34,4 +35,35 @@ public interface IFindingProjectionRepository
string tenantId,
DateTimeOffset since,
CancellationToken cancellationToken);
/// <summary>
/// Queries scored findings with filtering and pagination.
/// </summary>
Task<(IReadOnlyList<FindingProjection> Projections, int TotalCount)> QueryScoredAsync(
ScoredFindingsQuery query,
CancellationToken cancellationToken);
/// <summary>
/// Gets the severity distribution for a tenant.
/// </summary>
Task<SeverityDistribution> GetSeverityDistributionAsync(
string tenantId,
string? policyVersion,
CancellationToken cancellationToken);
/// <summary>
/// Gets the score distribution for a tenant.
/// </summary>
Task<ScoreDistribution> GetScoreDistributionAsync(
string tenantId,
string? policyVersion,
CancellationToken cancellationToken);
/// <summary>
/// Gets aggregate risk statistics for a tenant.
/// </summary>
Task<(int Total, int Scored, decimal AvgScore, decimal MaxScore)> GetRiskAggregatesAsync(
string tenantId,
string? policyVersion,
CancellationToken cancellationToken);
}

View File

@@ -38,6 +38,40 @@ public sealed class LedgerDataSource : IAsyncDisposable
public Task<NpgsqlConnection> OpenConnectionAsync(string tenantId, string role, CancellationToken cancellationToken)
=> OpenConnectionInternalAsync(tenantId, role, cancellationToken);
/// <summary>
/// Opens a system connection without tenant context. For migrations and admin operations only.
/// RLS policies will block queries on tenant-scoped tables unless using BYPASSRLS role.
/// </summary>
public async Task<NpgsqlConnection> OpenSystemConnectionAsync(CancellationToken cancellationToken)
{
var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
try
{
await using var command = new NpgsqlCommand("SET TIME ZONE 'UTC';", connection);
command.CommandTimeout = _options.CommandTimeoutSeconds;
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
LedgerMetrics.ConnectionOpened("system");
connection.StateChange += (_, args) =>
{
if (args.CurrentState == ConnectionState.Closed)
{
LedgerMetrics.ConnectionClosed("system");
}
};
_logger.LogDebug("Opened system connection without tenant context (for migrations/admin)");
}
catch
{
await connection.DisposeAsync().ConfigureAwait(false);
throw;
}
return connection;
}
private async Task<NpgsqlConnection> OpenConnectionInternalAsync(string tenantId, string role, CancellationToken cancellationToken)
{
var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);

View File

@@ -4,6 +4,7 @@ using Npgsql;
using NpgsqlTypes;
using StellaOps.Findings.Ledger.Domain;
using StellaOps.Findings.Ledger.Hashing;
using StellaOps.Findings.Ledger.Services;
namespace StellaOps.Findings.Ledger.Infrastructure.Postgres;
@@ -395,4 +396,264 @@ public sealed class PostgresFindingProjectionRepository : IFindingProjectionRepo
return new FindingStatsResult(0, 0, 0, 0, 0, 0);
}
public async Task<(IReadOnlyList<FindingProjection> Projections, int TotalCount)> QueryScoredAsync(
ScoredFindingsQuery query,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(query);
ArgumentException.ThrowIfNullOrWhiteSpace(query.TenantId);
await using var connection = await _dataSource.OpenConnectionAsync(query.TenantId, "projector", cancellationToken).ConfigureAwait(false);
// Build dynamic query
var whereConditions = new List<string> { "tenant_id = @tenant_id" };
var parameters = new List<NpgsqlParameter>
{
new NpgsqlParameter<string>("tenant_id", query.TenantId) { NpgsqlDbType = NpgsqlDbType.Text }
};
if (!string.IsNullOrWhiteSpace(query.PolicyVersion))
{
whereConditions.Add("policy_version = @policy_version");
parameters.Add(new NpgsqlParameter<string>("policy_version", query.PolicyVersion) { NpgsqlDbType = NpgsqlDbType.Text });
}
if (query.MinScore.HasValue)
{
whereConditions.Add("risk_score >= @min_score");
parameters.Add(new NpgsqlParameter<decimal>("min_score", query.MinScore.Value) { NpgsqlDbType = NpgsqlDbType.Numeric });
}
if (query.MaxScore.HasValue)
{
whereConditions.Add("risk_score <= @max_score");
parameters.Add(new NpgsqlParameter<decimal>("max_score", query.MaxScore.Value) { NpgsqlDbType = NpgsqlDbType.Numeric });
}
if (query.Severities is { Count: > 0 })
{
whereConditions.Add("risk_severity = ANY(@severities)");
parameters.Add(new NpgsqlParameter("severities", query.Severities.ToArray()) { NpgsqlDbType = NpgsqlDbType.Array | NpgsqlDbType.Text });
}
if (query.Statuses is { Count: > 0 })
{
whereConditions.Add("status = ANY(@statuses)");
parameters.Add(new NpgsqlParameter("statuses", query.Statuses.ToArray()) { NpgsqlDbType = NpgsqlDbType.Array | NpgsqlDbType.Text });
}
var whereClause = string.Join(" AND ", whereConditions);
var orderColumn = query.SortBy switch
{
ScoredFindingsSortField.RiskScore => "risk_score",
ScoredFindingsSortField.RiskSeverity => "risk_severity",
ScoredFindingsSortField.UpdatedAt => "updated_at",
ScoredFindingsSortField.FindingId => "finding_id",
_ => "risk_score"
};
var orderDirection = query.Descending ? "DESC NULLS LAST" : "ASC NULLS FIRST";
// Count query
var countSql = $"SELECT COUNT(*) FROM findings_projection WHERE {whereClause}";
await using var countCommand = new NpgsqlCommand(countSql, connection);
countCommand.CommandTimeout = _dataSource.CommandTimeoutSeconds;
foreach (var p in parameters) countCommand.Parameters.Add(p.Clone());
var totalCount = Convert.ToInt32(await countCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false));
// Data query
var dataSql = $@"
SELECT
tenant_id, finding_id, policy_version, status, severity, risk_score, risk_severity,
risk_profile_version, risk_explanation_id, risk_event_sequence, labels, current_event_id,
explain_ref, policy_rationale, updated_at, cycle_hash
FROM findings_projection
WHERE {whereClause}
ORDER BY {orderColumn} {orderDirection}
LIMIT @limit";
parameters.Add(new NpgsqlParameter<int>("limit", query.Limit) { NpgsqlDbType = NpgsqlDbType.Integer });
await using var dataCommand = new NpgsqlCommand(dataSql, connection);
dataCommand.CommandTimeout = _dataSource.CommandTimeoutSeconds;
foreach (var p in parameters) dataCommand.Parameters.Add(p.Clone());
var results = new List<FindingProjection>();
await using var reader = await dataCommand.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapProjection(reader));
}
return (results, totalCount);
}
public async Task<SeverityDistribution> GetSeverityDistributionAsync(
string tenantId,
string? policyVersion,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var sql = @"
SELECT
COALESCE(SUM(CASE WHEN risk_severity = 'critical' THEN 1 ELSE 0 END), 0) as critical,
COALESCE(SUM(CASE WHEN risk_severity = 'high' THEN 1 ELSE 0 END), 0) as high,
COALESCE(SUM(CASE WHEN risk_severity = 'medium' THEN 1 ELSE 0 END), 0) as medium,
COALESCE(SUM(CASE WHEN risk_severity = 'low' THEN 1 ELSE 0 END), 0) as low,
COALESCE(SUM(CASE WHEN risk_severity = 'informational' THEN 1 ELSE 0 END), 0) as informational,
COALESCE(SUM(CASE WHEN risk_severity IS NULL THEN 1 ELSE 0 END), 0) as unscored
FROM findings_projection
WHERE tenant_id = @tenant_id";
if (!string.IsNullOrWhiteSpace(policyVersion))
{
sql += " AND policy_version = @policy_version";
}
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "projector", cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("tenant_id", tenantId);
if (!string.IsNullOrWhiteSpace(policyVersion))
{
command.Parameters.AddWithValue("policy_version", policyVersion);
}
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return new SeverityDistribution
{
Critical = reader.GetInt32(0),
High = reader.GetInt32(1),
Medium = reader.GetInt32(2),
Low = reader.GetInt32(3),
Informational = reader.GetInt32(4),
Unscored = reader.GetInt32(5)
};
}
return new SeverityDistribution();
}
public async Task<ScoreDistribution> GetScoreDistributionAsync(
string tenantId,
string? policyVersion,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var sql = @"
SELECT
COALESCE(SUM(CASE WHEN risk_score >= 0 AND risk_score < 0.2 THEN 1 ELSE 0 END), 0) as score_0_20,
COALESCE(SUM(CASE WHEN risk_score >= 0.2 AND risk_score < 0.4 THEN 1 ELSE 0 END), 0) as score_20_40,
COALESCE(SUM(CASE WHEN risk_score >= 0.4 AND risk_score < 0.6 THEN 1 ELSE 0 END), 0) as score_40_60,
COALESCE(SUM(CASE WHEN risk_score >= 0.6 AND risk_score < 0.8 THEN 1 ELSE 0 END), 0) as score_60_80,
COALESCE(SUM(CASE WHEN risk_score >= 0.8 THEN 1 ELSE 0 END), 0) as score_80_100
FROM findings_projection
WHERE tenant_id = @tenant_id AND risk_score IS NOT NULL";
if (!string.IsNullOrWhiteSpace(policyVersion))
{
sql += " AND policy_version = @policy_version";
}
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "projector", cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("tenant_id", tenantId);
if (!string.IsNullOrWhiteSpace(policyVersion))
{
command.Parameters.AddWithValue("policy_version", policyVersion);
}
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return new ScoreDistribution
{
Score0To20 = reader.GetInt32(0),
Score20To40 = reader.GetInt32(1),
Score40To60 = reader.GetInt32(2),
Score60To80 = reader.GetInt32(3),
Score80To100 = reader.GetInt32(4)
};
}
return new ScoreDistribution();
}
public async Task<(int Total, int Scored, decimal AvgScore, decimal MaxScore)> GetRiskAggregatesAsync(
string tenantId,
string? policyVersion,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var sql = @"
SELECT
COUNT(*) as total,
COUNT(risk_score) as scored,
COALESCE(AVG(risk_score), 0) as avg_score,
COALESCE(MAX(risk_score), 0) as max_score
FROM findings_projection
WHERE tenant_id = @tenant_id";
if (!string.IsNullOrWhiteSpace(policyVersion))
{
sql += " AND policy_version = @policy_version";
}
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "projector", cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("tenant_id", tenantId);
if (!string.IsNullOrWhiteSpace(policyVersion))
{
command.Parameters.AddWithValue("policy_version", policyVersion);
}
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return (
reader.GetInt32(0),
reader.GetInt32(1),
reader.GetDecimal(2),
reader.GetDecimal(3));
}
return (0, 0, 0m, 0m);
}
private static FindingProjection MapProjection(NpgsqlDataReader reader)
{
var labelsJson = reader.GetString(10);
var labels = System.Text.Json.Nodes.JsonNode.Parse(labelsJson) as System.Text.Json.Nodes.JsonObject ?? new System.Text.Json.Nodes.JsonObject();
var rationaleJson = reader.GetString(13);
var rationale = System.Text.Json.Nodes.JsonNode.Parse(rationaleJson) as System.Text.Json.Nodes.JsonArray ?? new System.Text.Json.Nodes.JsonArray();
return new FindingProjection(
TenantId: reader.GetString(0),
FindingId: reader.GetString(1),
PolicyVersion: reader.GetString(2),
Status: reader.GetString(3),
Severity: reader.IsDBNull(4) ? null : reader.GetDecimal(4),
RiskScore: reader.IsDBNull(5) ? null : reader.GetDecimal(5),
RiskSeverity: reader.IsDBNull(6) ? null : reader.GetString(6),
RiskProfileVersion: reader.IsDBNull(7) ? null : reader.GetString(7),
RiskExplanationId: reader.IsDBNull(8) ? null : reader.GetGuid(8),
RiskEventSequence: reader.IsDBNull(9) ? null : reader.GetInt64(9),
Labels: labels,
CurrentEventId: reader.GetGuid(11),
ExplainRef: reader.IsDBNull(12) ? null : reader.GetString(12),
PolicyRationale: rationale,
UpdatedAt: reader.GetDateTime(14),
CycleHash: reader.GetString(15));
}
}

View File

@@ -0,0 +1,168 @@
using Microsoft.Extensions.Logging;
using Npgsql;
namespace StellaOps.Findings.Ledger.Infrastructure.Postgres;
/// <summary>
/// Service for validating Row-Level Security configuration on Findings Ledger tables.
/// Used for compliance checks and deployment verification.
/// </summary>
public sealed class RlsValidationService
{
private readonly LedgerDataSource _dataSource;
private readonly ILogger<RlsValidationService> _logger;
private static readonly string[] RlsProtectedTables =
[
"ledger_events",
"ledger_merkle_roots",
"findings_projection",
"finding_history",
"triage_actions",
"ledger_attestations",
"orchestrator_exports",
"airgap_imports"
];
public RlsValidationService(
LedgerDataSource dataSource,
ILogger<RlsValidationService> logger)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Validates that all required tables have RLS enabled and policies configured.
/// </summary>
public async Task<RlsValidationResult> ValidateAsync(CancellationToken cancellationToken = default)
{
var result = new RlsValidationResult();
try
{
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
// Check RLS enabled on all tables
var rlsStatus = await CheckRlsEnabledAsync(connection, cancellationToken).ConfigureAwait(false);
result.TablesWithRlsEnabled = rlsStatus;
// Check policies exist
var policyStatus = await CheckPoliciesExistAsync(connection, cancellationToken).ConfigureAwait(false);
result.TablesWithPolicies = policyStatus;
// Check tenant function exists
result.TenantFunctionExists = await CheckTenantFunctionExistsAsync(connection, cancellationToken).ConfigureAwait(false);
// Determine overall status
result.IsCompliant = result.TablesWithRlsEnabled.Count == RlsProtectedTables.Length
&& result.TablesWithPolicies.Count == RlsProtectedTables.Length
&& result.TenantFunctionExists;
if (!result.IsCompliant)
{
var missingRls = RlsProtectedTables.Except(result.TablesWithRlsEnabled).ToList();
var missingPolicies = RlsProtectedTables.Except(result.TablesWithPolicies).ToList();
result.Issues.AddRange(missingRls.Select(t => $"Table '{t}' does not have RLS enabled"));
result.Issues.AddRange(missingPolicies.Select(t => $"Table '{t}' does not have tenant isolation policy"));
if (!result.TenantFunctionExists)
{
result.Issues.Add("Function 'findings_ledger_app.require_current_tenant()' does not exist");
}
_logger.LogWarning("RLS validation failed: {IssueCount} issues found", result.Issues.Count);
}
else
{
_logger.LogInformation("RLS validation passed: All {TableCount} tables are properly protected", RlsProtectedTables.Length);
}
}
catch (Exception ex)
{
result.IsCompliant = false;
result.Issues.Add($"Validation failed with error: {ex.Message}");
_logger.LogError(ex, "RLS validation failed with exception");
}
return result;
}
private async Task<List<string>> CheckRlsEnabledAsync(NpgsqlConnection connection, CancellationToken cancellationToken)
{
const string sql = """
SELECT tablename::TEXT
FROM pg_tables
WHERE schemaname = 'public'
AND tablename = ANY(@tables)
AND tablename IN (
SELECT relname::TEXT
FROM pg_class
WHERE relrowsecurity = true
)
""";
await using var cmd = new NpgsqlCommand(sql, connection);
cmd.Parameters.AddWithValue("tables", RlsProtectedTables);
var tables = new List<string>();
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
tables.Add(reader.GetString(0));
}
return tables;
}
private async Task<List<string>> CheckPoliciesExistAsync(NpgsqlConnection connection, CancellationToken cancellationToken)
{
const string sql = """
SELECT DISTINCT tablename::TEXT
FROM pg_policies
WHERE schemaname = 'public'
AND tablename = ANY(@tables)
AND policyname LIKE '%_tenant_isolation'
""";
await using var cmd = new NpgsqlCommand(sql, connection);
cmd.Parameters.AddWithValue("tables", RlsProtectedTables);
var tables = new List<string>();
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
tables.Add(reader.GetString(0));
}
return tables;
}
private async Task<bool> CheckTenantFunctionExistsAsync(NpgsqlConnection connection, CancellationToken cancellationToken)
{
const string sql = """
SELECT COUNT(*)
FROM pg_proc p
JOIN pg_namespace n ON p.pronamespace = n.oid
WHERE p.proname = 'require_current_tenant'
AND n.nspname = 'findings_ledger_app'
""";
await using var cmd = new NpgsqlCommand(sql, connection);
var count = await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return Convert.ToInt64(count) > 0;
}
}
/// <summary>
/// Result of RLS validation.
/// </summary>
public sealed class RlsValidationResult
{
public bool IsCompliant { get; set; }
public List<string> TablesWithRlsEnabled { get; set; } = [];
public List<string> TablesWithPolicies { get; set; } = [];
public bool TenantFunctionExists { get; set; }
public List<string> Issues { get; set; } = [];
}

View File

@@ -264,6 +264,184 @@ internal static class LedgerMetrics
StalenessValidationFailures.Add(1, tags);
}
private static readonly Counter<long> ScoredFindingsExports = Meter.CreateCounter<long>(
"ledger_scored_findings_exports_total",
description: "Count of scored findings export operations.");
private static readonly Histogram<double> ScoredFindingsExportDuration = Meter.CreateHistogram<double>(
"ledger_scored_findings_export_duration_seconds",
unit: "s",
description: "Duration of scored findings export operations.");
public static void RecordScoredFindingsExport(string? tenantId, int recordCount, double durationSeconds)
{
var tags = new KeyValuePair<string, object?>[]
{
new("tenant", tenantId ?? "unknown"),
new("record_count", recordCount)
};
ScoredFindingsExports.Add(1, tags);
ScoredFindingsExportDuration.Record(durationSeconds, tags);
}
// LEDGER-RISK-69-001: Scoring metrics/dashboards
private static readonly Histogram<double> ScoringLatencySeconds = Meter.CreateHistogram<double>(
"ledger_scoring_latency_seconds",
unit: "s",
description: "Latency of risk scoring operations per finding.");
private static readonly Counter<long> ScoringOperationsTotal = Meter.CreateCounter<long>(
"ledger_scoring_operations_total",
description: "Total number of scoring operations by result.");
private static readonly Counter<long> ScoringProviderGaps = Meter.CreateCounter<long>(
"ledger_scoring_provider_gaps_total",
description: "Count of findings where scoring provider was unavailable or returned no data.");
private static readonly ConcurrentDictionary<string, SeveritySnapshot> SeverityByTenantPolicy = new(StringComparer.Ordinal);
private static readonly ConcurrentDictionary<string, double> ScoreFreshnessByTenant = new(StringComparer.Ordinal);
private static readonly ObservableGauge<long> SeverityCriticalGauge =
Meter.CreateObservableGauge("ledger_severity_distribution_critical", ObserveSeverityCritical,
description: "Current count of critical severity findings by tenant and policy.");
private static readonly ObservableGauge<long> SeverityHighGauge =
Meter.CreateObservableGauge("ledger_severity_distribution_high", ObserveSeverityHigh,
description: "Current count of high severity findings by tenant and policy.");
private static readonly ObservableGauge<long> SeverityMediumGauge =
Meter.CreateObservableGauge("ledger_severity_distribution_medium", ObserveSeverityMedium,
description: "Current count of medium severity findings by tenant and policy.");
private static readonly ObservableGauge<long> SeverityLowGauge =
Meter.CreateObservableGauge("ledger_severity_distribution_low", ObserveSeverityLow,
description: "Current count of low severity findings by tenant and policy.");
private static readonly ObservableGauge<long> SeverityUnknownGauge =
Meter.CreateObservableGauge("ledger_severity_distribution_unknown", ObserveSeverityUnknown,
description: "Current count of unknown/unscored findings by tenant and policy.");
private static readonly ObservableGauge<double> ScoreFreshnessGauge =
Meter.CreateObservableGauge("ledger_score_freshness_seconds", ObserveScoreFreshness, unit: "s",
description: "Time since last scoring operation completed by tenant.");
public static void RecordScoringLatency(TimeSpan duration, string? tenantId, string? policyVersion, string result)
{
var tags = new KeyValuePair<string, object?>[]
{
new("tenant", tenantId ?? string.Empty),
new("policy_version", policyVersion ?? string.Empty),
new("result", result)
};
ScoringLatencySeconds.Record(duration.TotalSeconds, tags);
ScoringOperationsTotal.Add(1, tags);
}
public static void RecordScoringProviderGap(string? tenantId, string? provider, string reason)
{
var tags = new KeyValuePair<string, object?>[]
{
new("tenant", tenantId ?? string.Empty),
new("provider", provider ?? "unknown"),
new("reason", reason)
};
ScoringProviderGaps.Add(1, tags);
}
public static void UpdateSeverityDistribution(
string tenantId,
string? policyVersion,
int critical,
int high,
int medium,
int low,
int unknown)
{
var key = BuildTenantPolicyKey(tenantId, policyVersion);
SeverityByTenantPolicy[key] = new SeveritySnapshot(tenantId, policyVersion ?? "default", critical, high, medium, low, unknown);
}
public static void UpdateScoreFreshness(string tenantId, double secondsSinceLastScoring)
{
var key = NormalizeTenant(tenantId);
ScoreFreshnessByTenant[key] = secondsSinceLastScoring < 0 ? 0 : secondsSinceLastScoring;
}
private static string BuildTenantPolicyKey(string? tenantId, string? policyVersion)
{
var t = string.IsNullOrWhiteSpace(tenantId) ? string.Empty : tenantId;
var p = string.IsNullOrWhiteSpace(policyVersion) ? "default" : policyVersion;
return $"{t}|{p}";
}
private sealed record SeveritySnapshot(
string TenantId,
string PolicyVersion,
int Critical,
int High,
int Medium,
int Low,
int Unknown);
private static IEnumerable<Measurement<long>> ObserveSeverityCritical()
{
foreach (var kvp in SeverityByTenantPolicy)
{
yield return new Measurement<long>(kvp.Value.Critical,
new KeyValuePair<string, object?>("tenant", kvp.Value.TenantId),
new KeyValuePair<string, object?>("policy_version", kvp.Value.PolicyVersion));
}
}
private static IEnumerable<Measurement<long>> ObserveSeverityHigh()
{
foreach (var kvp in SeverityByTenantPolicy)
{
yield return new Measurement<long>(kvp.Value.High,
new KeyValuePair<string, object?>("tenant", kvp.Value.TenantId),
new KeyValuePair<string, object?>("policy_version", kvp.Value.PolicyVersion));
}
}
private static IEnumerable<Measurement<long>> ObserveSeverityMedium()
{
foreach (var kvp in SeverityByTenantPolicy)
{
yield return new Measurement<long>(kvp.Value.Medium,
new KeyValuePair<string, object?>("tenant", kvp.Value.TenantId),
new KeyValuePair<string, object?>("policy_version", kvp.Value.PolicyVersion));
}
}
private static IEnumerable<Measurement<long>> ObserveSeverityLow()
{
foreach (var kvp in SeverityByTenantPolicy)
{
yield return new Measurement<long>(kvp.Value.Low,
new KeyValuePair<string, object?>("tenant", kvp.Value.TenantId),
new KeyValuePair<string, object?>("policy_version", kvp.Value.PolicyVersion));
}
}
private static IEnumerable<Measurement<long>> ObserveSeverityUnknown()
{
foreach (var kvp in SeverityByTenantPolicy)
{
yield return new Measurement<long>(kvp.Value.Unknown,
new KeyValuePair<string, object?>("tenant", kvp.Value.TenantId),
new KeyValuePair<string, object?>("policy_version", kvp.Value.PolicyVersion));
}
}
private static IEnumerable<Measurement<double>> ObserveScoreFreshness()
{
foreach (var kvp in ScoreFreshnessByTenant)
{
yield return new Measurement<double>(kvp.Value, new KeyValuePair<string, object?>("tenant", kvp.Key));
}
}
private static IEnumerable<Measurement<double>> ObserveProjectionLag()
{
foreach (var kvp in ProjectionLagByTenant)

View File

@@ -0,0 +1,57 @@
namespace StellaOps.Findings.Ledger.Services;
/// <summary>
/// Service for querying scored findings with filtering, pagination, and explainability.
/// </summary>
public interface IScoredFindingsQueryService
{
/// <summary>
/// Queries scored findings with filters and pagination.
/// </summary>
Task<ScoredFindingsQueryResult> QueryAsync(
ScoredFindingsQuery query,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets a single scored finding by ID.
/// </summary>
Task<ScoredFinding?> GetByIdAsync(
string tenantId,
string findingId,
string? policyVersion = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the score explanation for a finding.
/// </summary>
Task<ScoredFindingExplanation?> GetExplanationAsync(
string tenantId,
string findingId,
Guid? explanationId = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets a risk summary for a tenant.
/// </summary>
Task<RiskSummary> GetSummaryAsync(
string tenantId,
string? policyVersion = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the severity distribution for a tenant.
/// </summary>
Task<SeverityDistribution> GetSeverityDistributionAsync(
string tenantId,
string? policyVersion = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets top findings by risk score.
/// </summary>
Task<IReadOnlyList<ScoredFinding>> GetTopRisksAsync(
string tenantId,
int count = 10,
string? policyVersion = null,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,232 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging;
using StellaOps.Findings.Ledger.Observability;
namespace StellaOps.Findings.Ledger.Services;
/// <summary>
/// Service for exporting scored findings to various formats.
/// </summary>
public sealed class ScoredFindingsExportService : IScoredFindingsExportService
{
private readonly IScoredFindingsQueryService _queryService;
private readonly TimeProvider _timeProvider;
private readonly ILogger<ScoredFindingsExportService> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
WriteIndented = false
};
public ScoredFindingsExportService(
IScoredFindingsQueryService queryService,
TimeProvider timeProvider,
ILogger<ScoredFindingsExportService> logger)
{
_queryService = queryService ?? throw new ArgumentNullException(nameof(queryService));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<ExportResult> ExportAsync(
ScoredFindingsExportRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(request.TenantId);
var startTime = _timeProvider.GetUtcNow();
var query = new ScoredFindingsQuery
{
TenantId = request.TenantId,
PolicyVersion = request.PolicyVersion,
MinScore = request.MinScore,
MaxScore = request.MaxScore,
Severities = request.Severities,
Statuses = request.Statuses,
Limit = request.MaxRecords ?? 10000,
SortBy = ScoredFindingsSortField.RiskScore,
Descending = true
};
var result = await _queryService.QueryAsync(query, cancellationToken).ConfigureAwait(false);
var exportData = request.Format switch
{
ExportFormat.Json => ExportToJson(result.Findings, request),
ExportFormat.Ndjson => ExportToNdjson(result.Findings, request),
ExportFormat.Csv => ExportToCsv(result.Findings, request),
_ => throw new ArgumentOutOfRangeException(nameof(request.Format))
};
var endTime = _timeProvider.GetUtcNow();
var duration = endTime - startTime;
LedgerMetrics.RecordScoredFindingsExport(request.TenantId, result.Findings.Count, duration.TotalSeconds);
_logger.LogInformation(
"Exported {Count} scored findings for tenant {TenantId} in {Duration:F2}s",
result.Findings.Count, request.TenantId, duration.TotalSeconds);
return new ExportResult
{
TenantId = request.TenantId,
Format = request.Format,
RecordCount = result.Findings.Count,
Data = exportData,
ContentType = GetContentType(request.Format),
GeneratedAt = endTime,
DurationMs = (long)duration.TotalMilliseconds
};
}
public async Task<Stream> ExportToStreamAsync(
ScoredFindingsExportRequest request,
CancellationToken cancellationToken = default)
{
var result = await ExportAsync(request, cancellationToken).ConfigureAwait(false);
return new MemoryStream(result.Data);
}
private static byte[] ExportToJson(IReadOnlyList<ScoredFinding> findings, ScoredFindingsExportRequest request)
{
var envelope = new JsonObject
{
["version"] = "1.0",
["tenant_id"] = request.TenantId,
["generated_at"] = DateTimeOffset.UtcNow.ToString("O"),
["record_count"] = findings.Count,
["findings"] = new JsonArray(findings.Select(MapToJsonNode).ToArray())
};
return JsonSerializer.SerializeToUtf8Bytes(envelope, JsonOptions);
}
private static byte[] ExportToNdjson(IReadOnlyList<ScoredFinding> findings, ScoredFindingsExportRequest request)
{
var sb = new StringBuilder();
foreach (var finding in findings)
{
sb.AppendLine(JsonSerializer.Serialize(MapToExportRecord(finding), JsonOptions));
}
return Encoding.UTF8.GetBytes(sb.ToString());
}
private static byte[] ExportToCsv(IReadOnlyList<ScoredFinding> findings, ScoredFindingsExportRequest request)
{
var sb = new StringBuilder();
sb.AppendLine("tenant_id,finding_id,policy_version,status,risk_score,risk_severity,risk_profile_version,updated_at");
foreach (var finding in findings)
{
sb.AppendLine(string.Join(",",
EscapeCsv(finding.TenantId),
EscapeCsv(finding.FindingId),
EscapeCsv(finding.PolicyVersion),
EscapeCsv(finding.Status),
finding.RiskScore?.ToString("F4") ?? "",
EscapeCsv(finding.RiskSeverity ?? ""),
EscapeCsv(finding.RiskProfileVersion ?? ""),
finding.UpdatedAt.ToString("O")));
}
return Encoding.UTF8.GetBytes(sb.ToString());
}
private static JsonNode MapToJsonNode(ScoredFinding finding)
{
return JsonSerializer.SerializeToNode(MapToExportRecord(finding), JsonOptions)!;
}
private static object MapToExportRecord(ScoredFinding finding)
{
return new
{
finding.TenantId,
finding.FindingId,
finding.PolicyVersion,
finding.Status,
finding.RiskScore,
finding.RiskSeverity,
finding.RiskProfileVersion,
finding.RiskExplanationId,
finding.ExplainRef,
finding.UpdatedAt
};
}
private static string EscapeCsv(string value)
{
if (string.IsNullOrEmpty(value)) return "";
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
{
return $"\"{value.Replace("\"", "\"\"")}\"";
}
return value;
}
private static string GetContentType(ExportFormat format) => format switch
{
ExportFormat.Json => "application/json",
ExportFormat.Ndjson => "application/x-ndjson",
ExportFormat.Csv => "text/csv",
_ => "application/octet-stream"
};
}
/// <summary>
/// Service interface for exporting scored findings.
/// </summary>
public interface IScoredFindingsExportService
{
Task<ExportResult> ExportAsync(
ScoredFindingsExportRequest request,
CancellationToken cancellationToken = default);
Task<Stream> ExportToStreamAsync(
ScoredFindingsExportRequest request,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Request for exporting scored findings.
/// </summary>
public sealed record ScoredFindingsExportRequest
{
public required string TenantId { get; init; }
public string? PolicyVersion { get; init; }
public decimal? MinScore { get; init; }
public decimal? MaxScore { get; init; }
public IReadOnlyList<string>? Severities { get; init; }
public IReadOnlyList<string>? Statuses { get; init; }
public int? MaxRecords { get; init; }
public ExportFormat Format { get; init; } = ExportFormat.Json;
public bool IncludeExplanations { get; init; }
}
/// <summary>
/// Export formats.
/// </summary>
public enum ExportFormat
{
Json,
Ndjson,
Csv
}
/// <summary>
/// Result of an export operation.
/// </summary>
public sealed record ExportResult
{
public required string TenantId { get; init; }
public required ExportFormat Format { get; init; }
public required int RecordCount { get; init; }
public required byte[] Data { get; init; }
public required string ContentType { get; init; }
public required DateTimeOffset GeneratedAt { get; init; }
public long DurationMs { get; init; }
}

View File

@@ -0,0 +1,118 @@
namespace StellaOps.Findings.Ledger.Services;
/// <summary>
/// Query parameters for scored findings.
/// </summary>
public sealed record ScoredFindingsQuery
{
public required string TenantId { get; init; }
public string? PolicyVersion { get; init; }
public decimal? MinScore { get; init; }
public decimal? MaxScore { get; init; }
public IReadOnlyList<string>? Severities { get; init; }
public IReadOnlyList<string>? Statuses { get; init; }
public string? ProfileId { get; init; }
public DateTimeOffset? ScoredAfter { get; init; }
public DateTimeOffset? ScoredBefore { get; init; }
public string? Cursor { get; init; }
public int Limit { get; init; } = 50;
public ScoredFindingsSortField SortBy { get; init; } = ScoredFindingsSortField.RiskScore;
public bool Descending { get; init; } = true;
}
/// <summary>
/// Sort fields for scored findings queries.
/// </summary>
public enum ScoredFindingsSortField
{
RiskScore,
RiskSeverity,
UpdatedAt,
FindingId
}
/// <summary>
/// Result of a scored findings query.
/// </summary>
public sealed record ScoredFindingsQueryResult
{
public required IReadOnlyList<ScoredFinding> Findings { get; init; }
public string? NextCursor { get; init; }
public bool HasMore { get; init; }
public int TotalCount { get; init; }
}
/// <summary>
/// A finding with risk score information.
/// </summary>
public sealed record ScoredFinding
{
public required string TenantId { get; init; }
public required string FindingId { get; init; }
public required string PolicyVersion { get; init; }
public required string Status { get; init; }
public decimal? RiskScore { get; init; }
public string? RiskSeverity { get; init; }
public string? RiskProfileVersion { get; init; }
public Guid? RiskExplanationId { get; init; }
public string? ExplainRef { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
}
/// <summary>
/// Detailed score explanation for a finding.
/// </summary>
public sealed record ScoredFindingExplanation
{
public required string FindingId { get; init; }
public required string ProfileId { get; init; }
public required string ProfileVersion { get; init; }
public decimal RawScore { get; init; }
public decimal NormalizedScore { get; init; }
public required string Severity { get; init; }
public required IReadOnlyDictionary<string, decimal> SignalValues { get; init; }
public required IReadOnlyDictionary<string, decimal> SignalContributions { get; init; }
public string? OverrideApplied { get; init; }
public string? OverrideReason { get; init; }
public DateTimeOffset ScoredAt { get; init; }
}
/// <summary>
/// Severity distribution summary.
/// </summary>
public sealed record SeverityDistribution
{
public int Critical { get; init; }
public int High { get; init; }
public int Medium { get; init; }
public int Low { get; init; }
public int Informational { get; init; }
public int Unscored { get; init; }
}
/// <summary>
/// Score distribution buckets.
/// </summary>
public sealed record ScoreDistribution
{
public int Score0To20 { get; init; }
public int Score20To40 { get; init; }
public int Score40To60 { get; init; }
public int Score60To80 { get; init; }
public int Score80To100 { get; init; }
}
/// <summary>
/// Risk summary for a tenant.
/// </summary>
public sealed record RiskSummary
{
public required string TenantId { get; init; }
public int TotalFindings { get; init; }
public int ScoredFindings { get; init; }
public decimal AverageScore { get; init; }
public decimal MaxScore { get; init; }
public required SeverityDistribution SeverityDistribution { get; init; }
public required ScoreDistribution ScoreDistribution { get; init; }
public DateTimeOffset CalculatedAt { get; init; }
}

View File

@@ -0,0 +1,194 @@
using Microsoft.Extensions.Logging;
using StellaOps.Findings.Ledger.Domain;
using StellaOps.Findings.Ledger.Infrastructure;
namespace StellaOps.Findings.Ledger.Services;
/// <summary>
/// Service for querying scored findings with filtering, pagination, and explainability.
/// </summary>
public sealed class ScoredFindingsQueryService : IScoredFindingsQueryService
{
private readonly IFindingProjectionRepository _repository;
private readonly IRiskExplanationStore _explanationStore;
private readonly TimeProvider _timeProvider;
private readonly ILogger<ScoredFindingsQueryService> _logger;
public ScoredFindingsQueryService(
IFindingProjectionRepository repository,
IRiskExplanationStore explanationStore,
TimeProvider timeProvider,
ILogger<ScoredFindingsQueryService> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_explanationStore = explanationStore ?? throw new ArgumentNullException(nameof(explanationStore));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<ScoredFindingsQueryResult> QueryAsync(
ScoredFindingsQuery query,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
ArgumentException.ThrowIfNullOrWhiteSpace(query.TenantId);
var (projections, totalCount) = await _repository.QueryScoredAsync(query, cancellationToken)
.ConfigureAwait(false);
var findings = projections
.Select(MapToScoredFinding)
.ToList();
var hasMore = findings.Count == query.Limit && totalCount > query.Limit;
var nextCursor = hasMore && findings.Count > 0
? EncodeCursor(findings[^1])
: null;
return new ScoredFindingsQueryResult
{
Findings = findings,
NextCursor = nextCursor,
HasMore = hasMore,
TotalCount = totalCount
};
}
public async Task<ScoredFinding?> GetByIdAsync(
string tenantId,
string findingId,
string? policyVersion = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
var projection = await _repository.GetAsync(
tenantId,
findingId,
policyVersion ?? "default",
cancellationToken).ConfigureAwait(false);
return projection is null ? null : MapToScoredFinding(projection);
}
public async Task<ScoredFindingExplanation?> GetExplanationAsync(
string tenantId,
string findingId,
Guid? explanationId = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
var explanation = await _explanationStore.GetAsync(
tenantId,
findingId,
explanationId,
cancellationToken).ConfigureAwait(false);
return explanation;
}
public async Task<RiskSummary> GetSummaryAsync(
string tenantId,
string? policyVersion = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var severityDist = await _repository.GetSeverityDistributionAsync(tenantId, policyVersion, cancellationToken)
.ConfigureAwait(false);
var scoreDist = await _repository.GetScoreDistributionAsync(tenantId, policyVersion, cancellationToken)
.ConfigureAwait(false);
var (total, scored, avgScore, maxScore) = await _repository.GetRiskAggregatesAsync(tenantId, policyVersion, cancellationToken)
.ConfigureAwait(false);
return new RiskSummary
{
TenantId = tenantId,
TotalFindings = total,
ScoredFindings = scored,
AverageScore = avgScore,
MaxScore = maxScore,
SeverityDistribution = severityDist,
ScoreDistribution = scoreDist,
CalculatedAt = _timeProvider.GetUtcNow()
};
}
public async Task<SeverityDistribution> GetSeverityDistributionAsync(
string tenantId,
string? policyVersion = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
return await _repository.GetSeverityDistributionAsync(tenantId, policyVersion, cancellationToken)
.ConfigureAwait(false);
}
public async Task<IReadOnlyList<ScoredFinding>> GetTopRisksAsync(
string tenantId,
int count = 10,
string? policyVersion = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var query = new ScoredFindingsQuery
{
TenantId = tenantId,
PolicyVersion = policyVersion,
Limit = count,
SortBy = ScoredFindingsSortField.RiskScore,
Descending = true
};
var result = await QueryAsync(query, cancellationToken).ConfigureAwait(false);
return result.Findings;
}
private static ScoredFinding MapToScoredFinding(FindingProjection projection)
{
return new ScoredFinding
{
TenantId = projection.TenantId,
FindingId = projection.FindingId,
PolicyVersion = projection.PolicyVersion,
Status = projection.Status,
RiskScore = projection.RiskScore,
RiskSeverity = projection.RiskSeverity,
RiskProfileVersion = projection.RiskProfileVersion,
RiskExplanationId = projection.RiskExplanationId,
ExplainRef = projection.ExplainRef,
UpdatedAt = projection.UpdatedAt
};
}
private static string EncodeCursor(ScoredFinding finding)
{
// Simple cursor encoding: findingId|score|updatedAt
var cursor = $"{finding.FindingId}|{finding.RiskScore}|{finding.UpdatedAt:O}";
return Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(cursor));
}
}
/// <summary>
/// Store for risk score explanations.
/// </summary>
public interface IRiskExplanationStore
{
Task<ScoredFindingExplanation?> GetAsync(
string tenantId,
string findingId,
Guid? explanationId,
CancellationToken cancellationToken);
Task StoreAsync(
string tenantId,
ScoredFindingExplanation explanation,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,178 @@
using Microsoft.Extensions.Logging;
using StellaOps.Findings.Ledger.Infrastructure;
using StellaOps.Findings.Ledger.Observability;
namespace StellaOps.Findings.Ledger.Services;
/// <summary>
/// Service for emitting and updating risk scoring metrics.
/// Supports dashboards for scoring latency, severity distribution, result freshness, and provider gaps.
/// </summary>
public sealed class ScoringMetricsService : IScoringMetricsService
{
private readonly IFindingProjectionRepository _repository;
private readonly TimeProvider _timeProvider;
private readonly ILogger<ScoringMetricsService> _logger;
public ScoringMetricsService(
IFindingProjectionRepository repository,
TimeProvider timeProvider,
ILogger<ScoringMetricsService> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task RefreshSeverityDistributionAsync(
string tenantId,
string? policyVersion = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var distribution = await _repository.GetSeverityDistributionAsync(tenantId, policyVersion, cancellationToken)
.ConfigureAwait(false);
LedgerMetrics.UpdateSeverityDistribution(
tenantId,
policyVersion,
distribution.Critical,
distribution.High,
distribution.Medium,
distribution.Low,
distribution.Unscored);
_logger.LogDebug(
"Updated severity distribution for tenant {TenantId}: Critical={Critical}, High={High}, Medium={Medium}, Low={Low}, Unscored={Unscored}",
tenantId, distribution.Critical, distribution.High, distribution.Medium, distribution.Low, distribution.Unscored);
}
public void RecordScoringOperation(
string tenantId,
string? policyVersion,
TimeSpan duration,
ScoringResult result)
{
LedgerMetrics.RecordScoringLatency(duration, tenantId, policyVersion, result.ToString().ToLowerInvariant());
LedgerMetrics.UpdateScoreFreshness(tenantId, 0);
_logger.LogDebug(
"Recorded scoring operation for tenant {TenantId}: Duration={Duration:F3}s, Result={Result}",
tenantId, duration.TotalSeconds, result);
}
public void RecordProviderGap(
string tenantId,
string? provider,
string reason)
{
LedgerMetrics.RecordScoringProviderGap(tenantId, provider, reason);
_logger.LogWarning(
"Provider gap recorded for tenant {TenantId}: Provider={Provider}, Reason={Reason}",
tenantId, provider ?? "unknown", reason);
}
public void UpdateScoreFreshness(string tenantId, DateTimeOffset lastScoringTime)
{
var now = _timeProvider.GetUtcNow();
var freshness = (now - lastScoringTime).TotalSeconds;
LedgerMetrics.UpdateScoreFreshness(tenantId, freshness);
}
public async Task<ScoringMetricsSummary> GetSummaryAsync(
string tenantId,
string? policyVersion = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var severityDist = await _repository.GetSeverityDistributionAsync(tenantId, policyVersion, cancellationToken)
.ConfigureAwait(false);
var scoreDist = await _repository.GetScoreDistributionAsync(tenantId, policyVersion, cancellationToken)
.ConfigureAwait(false);
var (total, scored, avgScore, maxScore) = await _repository.GetRiskAggregatesAsync(tenantId, policyVersion, cancellationToken)
.ConfigureAwait(false);
var coveragePercent = total > 0 ? (decimal)scored / total * 100 : 0;
return new ScoringMetricsSummary
{
TenantId = tenantId,
PolicyVersion = policyVersion ?? "default",
TotalFindings = total,
ScoredFindings = scored,
UnscoredFindings = total - scored,
CoveragePercent = coveragePercent,
AverageScore = avgScore,
MaxScore = maxScore,
SeverityDistribution = severityDist,
ScoreDistribution = scoreDist,
CalculatedAt = _timeProvider.GetUtcNow()
};
}
}
/// <summary>
/// Interface for scoring metrics service.
/// </summary>
public interface IScoringMetricsService
{
Task RefreshSeverityDistributionAsync(
string tenantId,
string? policyVersion = null,
CancellationToken cancellationToken = default);
void RecordScoringOperation(
string tenantId,
string? policyVersion,
TimeSpan duration,
ScoringResult result);
void RecordProviderGap(
string tenantId,
string? provider,
string reason);
void UpdateScoreFreshness(string tenantId, DateTimeOffset lastScoringTime);
Task<ScoringMetricsSummary> GetSummaryAsync(
string tenantId,
string? policyVersion = null,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of a scoring operation.
/// </summary>
public enum ScoringResult
{
Success,
PartialSuccess,
ProviderUnavailable,
PolicyMissing,
ValidationFailed,
Timeout,
Error
}
/// <summary>
/// Summary of scoring metrics for a tenant.
/// </summary>
public sealed record ScoringMetricsSummary
{
public required string TenantId { get; init; }
public required string PolicyVersion { get; init; }
public int TotalFindings { get; init; }
public int ScoredFindings { get; init; }
public int UnscoredFindings { get; init; }
public decimal CoveragePercent { get; init; }
public decimal AverageScore { get; init; }
public decimal MaxScore { get; init; }
public required SeverityDistribution SeverityDistribution { get; init; }
public required ScoreDistribution ScoreDistribution { get; init; }
public DateTimeOffset CalculatedAt { get; init; }
}

View File

@@ -0,0 +1,160 @@
-- 007_enable_rls.sql
-- Enable Row-Level Security for Findings Ledger tenant isolation (LEDGER-TEN-48-001-DEV)
-- Based on Evidence Locker pattern per CONTRACT-FINDINGS-LEDGER-RLS-011
BEGIN;
-- ============================================
-- 1. Create app schema and tenant function
-- ============================================
CREATE SCHEMA IF NOT EXISTS findings_ledger_app;
CREATE OR REPLACE FUNCTION findings_ledger_app.require_current_tenant()
RETURNS TEXT
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
tenant_text TEXT;
BEGIN
tenant_text := current_setting('app.current_tenant', true);
IF tenant_text IS NULL OR length(trim(tenant_text)) = 0 THEN
RAISE EXCEPTION 'app.current_tenant is not set for the current session'
USING ERRCODE = 'P0001';
END IF;
RETURN tenant_text;
END;
$$;
COMMENT ON FUNCTION findings_ledger_app.require_current_tenant() IS
'Returns the current tenant ID from session variable, raises exception if not set';
-- ============================================
-- 2. Enable RLS on ledger_events
-- ============================================
ALTER TABLE ledger_events ENABLE ROW LEVEL SECURITY;
ALTER TABLE ledger_events FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS ledger_events_tenant_isolation ON ledger_events;
CREATE POLICY ledger_events_tenant_isolation
ON ledger_events
FOR ALL
USING (tenant_id = findings_ledger_app.require_current_tenant())
WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant());
-- ============================================
-- 3. Enable RLS on ledger_merkle_roots
-- ============================================
ALTER TABLE ledger_merkle_roots ENABLE ROW LEVEL SECURITY;
ALTER TABLE ledger_merkle_roots FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS ledger_merkle_roots_tenant_isolation ON ledger_merkle_roots;
CREATE POLICY ledger_merkle_roots_tenant_isolation
ON ledger_merkle_roots
FOR ALL
USING (tenant_id = findings_ledger_app.require_current_tenant())
WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant());
-- ============================================
-- 4. Enable RLS on findings_projection
-- ============================================
ALTER TABLE findings_projection ENABLE ROW LEVEL SECURITY;
ALTER TABLE findings_projection FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS findings_projection_tenant_isolation ON findings_projection;
CREATE POLICY findings_projection_tenant_isolation
ON findings_projection
FOR ALL
USING (tenant_id = findings_ledger_app.require_current_tenant())
WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant());
-- ============================================
-- 5. Enable RLS on finding_history
-- ============================================
ALTER TABLE finding_history ENABLE ROW LEVEL SECURITY;
ALTER TABLE finding_history FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS finding_history_tenant_isolation ON finding_history;
CREATE POLICY finding_history_tenant_isolation
ON finding_history
FOR ALL
USING (tenant_id = findings_ledger_app.require_current_tenant())
WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant());
-- ============================================
-- 6. Enable RLS on triage_actions
-- ============================================
ALTER TABLE triage_actions ENABLE ROW LEVEL SECURITY;
ALTER TABLE triage_actions FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS triage_actions_tenant_isolation ON triage_actions;
CREATE POLICY triage_actions_tenant_isolation
ON triage_actions
FOR ALL
USING (tenant_id = findings_ledger_app.require_current_tenant())
WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant());
-- ============================================
-- 7. Enable RLS on ledger_attestations
-- ============================================
ALTER TABLE ledger_attestations ENABLE ROW LEVEL SECURITY;
ALTER TABLE ledger_attestations FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS ledger_attestations_tenant_isolation ON ledger_attestations;
CREATE POLICY ledger_attestations_tenant_isolation
ON ledger_attestations
FOR ALL
USING (tenant_id = findings_ledger_app.require_current_tenant())
WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant());
-- ============================================
-- 8. Enable RLS on orchestrator_exports
-- ============================================
ALTER TABLE orchestrator_exports ENABLE ROW LEVEL SECURITY;
ALTER TABLE orchestrator_exports FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS orchestrator_exports_tenant_isolation ON orchestrator_exports;
CREATE POLICY orchestrator_exports_tenant_isolation
ON orchestrator_exports
FOR ALL
USING (tenant_id = findings_ledger_app.require_current_tenant())
WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant());
-- ============================================
-- 9. Enable RLS on airgap_imports
-- ============================================
ALTER TABLE airgap_imports ENABLE ROW LEVEL SECURITY;
ALTER TABLE airgap_imports FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS airgap_imports_tenant_isolation ON airgap_imports;
CREATE POLICY airgap_imports_tenant_isolation
ON airgap_imports
FOR ALL
USING (tenant_id = findings_ledger_app.require_current_tenant())
WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant());
-- ============================================
-- 10. Create admin bypass role
-- ============================================
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'findings_ledger_admin') THEN
CREATE ROLE findings_ledger_admin NOLOGIN BYPASSRLS;
END IF;
END;
$$;
COMMENT ON ROLE findings_ledger_admin IS
'Admin role that bypasses RLS for migrations and cross-tenant operations';
COMMIT;

View File

@@ -0,0 +1,42 @@
-- 007_enable_rls_rollback.sql
-- Rollback: Disable Row-Level Security for Findings Ledger (LEDGER-TEN-48-001-DEV)
BEGIN;
-- ============================================
-- 1. Disable RLS on all tables
-- ============================================
ALTER TABLE ledger_events DISABLE ROW LEVEL SECURITY;
ALTER TABLE ledger_merkle_roots DISABLE ROW LEVEL SECURITY;
ALTER TABLE findings_projection DISABLE ROW LEVEL SECURITY;
ALTER TABLE finding_history DISABLE ROW LEVEL SECURITY;
ALTER TABLE triage_actions DISABLE ROW LEVEL SECURITY;
ALTER TABLE ledger_attestations DISABLE ROW LEVEL SECURITY;
ALTER TABLE orchestrator_exports DISABLE ROW LEVEL SECURITY;
ALTER TABLE airgap_imports DISABLE ROW LEVEL SECURITY;
-- ============================================
-- 2. Drop all tenant isolation policies
-- ============================================
DROP POLICY IF EXISTS ledger_events_tenant_isolation ON ledger_events;
DROP POLICY IF EXISTS ledger_merkle_roots_tenant_isolation ON ledger_merkle_roots;
DROP POLICY IF EXISTS findings_projection_tenant_isolation ON findings_projection;
DROP POLICY IF EXISTS finding_history_tenant_isolation ON finding_history;
DROP POLICY IF EXISTS triage_actions_tenant_isolation ON triage_actions;
DROP POLICY IF EXISTS ledger_attestations_tenant_isolation ON ledger_attestations;
DROP POLICY IF EXISTS orchestrator_exports_tenant_isolation ON orchestrator_exports;
DROP POLICY IF EXISTS airgap_imports_tenant_isolation ON airgap_imports;
-- ============================================
-- 3. Drop tenant validation function and schema
-- ============================================
DROP FUNCTION IF EXISTS findings_ledger_app.require_current_tenant();
DROP SCHEMA IF EXISTS findings_ledger_app;
-- Note: Admin role is NOT dropped to avoid breaking other grants
-- DROP ROLE IF EXISTS findings_ledger_admin;
COMMIT;