Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user