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

@@ -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; }
}