up
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Findings.Ledger.Options;
|
||||
using StellaOps.Findings.Ledger.Services.Incident;
|
||||
using StellaOps.Findings.Ledger.Tests.Observability;
|
||||
using StellaOps.Telemetry.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Tests.Incident;
|
||||
|
||||
public class LedgerIncidentCoordinatorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Activation_updates_state_and_notifies()
|
||||
{
|
||||
var options = Options.Create(new LedgerIncidentOptions { RetentionExtensionDays = 45, LagTraceThresholdSeconds = 0.0 });
|
||||
var logger = new TestLogger<LedgerIncidentCoordinator>();
|
||||
var notifier = new TestNotifier();
|
||||
var incidentService = new StubIncidentModeService();
|
||||
|
||||
using var coordinator = new LedgerIncidentCoordinator(options, logger, notifier, TimeProvider.System, incidentService);
|
||||
|
||||
await incidentService.ActivateAsync("actor-a", reason: "test");
|
||||
|
||||
coordinator.IsActive.Should().BeTrue();
|
||||
coordinator.Current.RetentionExtensionDays.Should().Be(45);
|
||||
notifier.Published.Should().ContainSingle();
|
||||
logger.Entries.Should().ContainSingle(e => e.EventId.Id == 6901);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordProjectionLag_emits_when_active_and_above_threshold()
|
||||
{
|
||||
var options = Options.Create(new LedgerIncidentOptions { LagTraceThresholdSeconds = 0.1, RetentionExtensionDays = 5 });
|
||||
var logger = new TestLogger<LedgerIncidentCoordinator>();
|
||||
var notifier = new TestNotifier();
|
||||
var incidentService = new StubIncidentModeService();
|
||||
|
||||
using var coordinator = new LedgerIncidentCoordinator(options, logger, notifier, TimeProvider.System, incidentService);
|
||||
await incidentService.ActivateAsync("actor-a");
|
||||
|
||||
coordinator.RecordProjectionLag(new ProjectionLagSample(
|
||||
"tenant-a",
|
||||
Guid.NewGuid(),
|
||||
10,
|
||||
"finding.created",
|
||||
"v1",
|
||||
5.0,
|
||||
DateTimeOffset.UtcNow.AddSeconds(-5),
|
||||
DateTimeOffset.UtcNow));
|
||||
|
||||
logger.Entries.Should().Contain(e => e.EventId.Id == 6902);
|
||||
coordinator.GetDiagnosticsSnapshot().LagSamples.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
private sealed class TestNotifier : ILedgerIncidentNotifier
|
||||
{
|
||||
private readonly List<LedgerIncidentSnapshot> _published = new();
|
||||
public IReadOnlyList<LedgerIncidentSnapshot> Published => _published;
|
||||
|
||||
public Task PublishIncidentModeChangedAsync(LedgerIncidentSnapshot snapshot, CancellationToken cancellationToken)
|
||||
{
|
||||
_published.Add(snapshot);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubIncidentModeService : IIncidentModeService
|
||||
{
|
||||
private IncidentModeState? _state;
|
||||
public bool IsActive => _state is { Enabled: true } && !_state.IsExpired;
|
||||
public IncidentModeState? CurrentState => _state;
|
||||
|
||||
public event EventHandler<IncidentModeActivatedEventArgs>? Activated;
|
||||
public event EventHandler<IncidentModeDeactivatedEventArgs>? Deactivated;
|
||||
|
||||
public Task<IncidentModeActivationResult> ActivateAsync(string actor, string? tenantId = null, TimeSpan? ttlOverride = null, string? reason = null, CancellationToken ct = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
_state = new IncidentModeState
|
||||
{
|
||||
Enabled = true,
|
||||
ActivatedAt = now,
|
||||
ExpiresAt = now.AddMinutes(30),
|
||||
Actor = actor,
|
||||
TenantId = tenantId,
|
||||
Source = IncidentModeSource.Api,
|
||||
Reason = reason,
|
||||
ActivationId = Guid.NewGuid().ToString("N")[..12]
|
||||
};
|
||||
|
||||
Activated?.Invoke(this, new IncidentModeActivatedEventArgs { State = _state, WasReactivation = false });
|
||||
return Task.FromResult(IncidentModeActivationResult.Succeeded(_state));
|
||||
}
|
||||
|
||||
public Task<IncidentModeDeactivationResult> DeactivateAsync(string actor, string? reason = null, CancellationToken ct = default)
|
||||
{
|
||||
var previous = _state;
|
||||
_state = null;
|
||||
if (previous is not null)
|
||||
{
|
||||
Deactivated?.Invoke(this, new IncidentModeDeactivatedEventArgs
|
||||
{
|
||||
State = previous,
|
||||
Reason = IncidentModeDeactivationReason.Manual,
|
||||
DeactivatedBy = actor
|
||||
});
|
||||
}
|
||||
|
||||
return Task.FromResult(IncidentModeDeactivationResult.Succeeded(previous is not null, IncidentModeDeactivationReason.Manual));
|
||||
}
|
||||
|
||||
public Task<DateTimeOffset?> ExtendTtlAsync(TimeSpan extension, string actor, CancellationToken ct = default) =>
|
||||
Task.FromResult<DateTimeOffset?>(_state?.ExpiresAt?.Add(extension));
|
||||
|
||||
public IReadOnlyDictionary<string, string> GetIncidentTags() => new Dictionary<string, string>();
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using System.Text.Json.Nodes;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.Observability;
|
||||
using StellaOps.Findings.Ledger.Services.Incident;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Tests.Observability;
|
||||
@@ -45,6 +46,49 @@ public class LedgerTimelineTests
|
||||
state["Status"].Should().Be("affected");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmitIncidentModeChanged_writes_structured_log()
|
||||
{
|
||||
var logger = new TestLogger<LedgerTimelineTests>();
|
||||
var snapshot = new LedgerIncidentSnapshot(
|
||||
IsActive: true,
|
||||
ActivationId: "act-123",
|
||||
Actor: "actor-1",
|
||||
Reason: "reason",
|
||||
TenantId: "tenant-a",
|
||||
ChangedAt: DateTimeOffset.UtcNow,
|
||||
ExpiresAt: DateTimeOffset.UtcNow.AddMinutes(10),
|
||||
RetentionExtensionDays: 30);
|
||||
|
||||
LedgerTimeline.EmitIncidentModeChanged(logger, snapshot, wasReactivation: false);
|
||||
|
||||
var entry = logger.Entries.Single(e => e.EventId.Id == 6901);
|
||||
var state = AsDictionary(entry.State);
|
||||
state["RetentionExtensionDays"].Should().Be(30);
|
||||
state["ActivationId"].Should().Be("act-123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmitIncidentLagTrace_writes_structured_log()
|
||||
{
|
||||
var logger = new TestLogger<LedgerTimelineTests>();
|
||||
var sample = new ProjectionLagSample(
|
||||
"tenant-a",
|
||||
Guid.NewGuid(),
|
||||
10,
|
||||
"finding.created",
|
||||
"v1",
|
||||
12.5,
|
||||
DateTimeOffset.UtcNow.AddSeconds(-12),
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
LedgerTimeline.EmitIncidentLagTrace(logger, sample);
|
||||
|
||||
var entry = logger.Entries.Single(e => e.EventId.Id == 6902);
|
||||
var state = AsDictionary(entry.State);
|
||||
state["LagSeconds"].Should().Be(12.5);
|
||||
}
|
||||
|
||||
private static LedgerEventRecord CreateRecord()
|
||||
{
|
||||
var payload = new JsonObject { ["status"] = "affected" };
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using System;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.Infrastructure;
|
||||
using StellaOps.Findings.Ledger.Services;
|
||||
using StellaOps.Findings.Ledger.Services.Incident;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Tests.Services;
|
||||
|
||||
public class LedgerEventWriteServiceIncidentTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task AppendAsync_sequence_mismatch_records_conflict_snapshot()
|
||||
{
|
||||
var repo = new Mock<ILedgerEventRepository>();
|
||||
repo.Setup(r => r.GetByEventIdAsync(It.IsAny<string>(), It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((LedgerEventRecord?)null);
|
||||
|
||||
var chainId = Guid.NewGuid();
|
||||
var chainHead = new LedgerEventRecord(
|
||||
"tenant-a",
|
||||
chainId,
|
||||
1,
|
||||
Guid.NewGuid(),
|
||||
LedgerEventConstants.EventFindingCreated,
|
||||
"v1",
|
||||
"finding-1",
|
||||
"artifact-1",
|
||||
null,
|
||||
"actor-1",
|
||||
"operator",
|
||||
DateTimeOffset.UtcNow,
|
||||
DateTimeOffset.UtcNow,
|
||||
new JsonObject(),
|
||||
"hash-prev",
|
||||
LedgerEventConstants.EmptyHash,
|
||||
"leaf-hash",
|
||||
"{}");
|
||||
|
||||
repo.Setup(r => r.GetChainHeadAsync("tenant-a", chainId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(chainHead);
|
||||
|
||||
var scheduler = new Mock<IMerkleAnchorScheduler>();
|
||||
var diagnostics = new Mock<ILedgerIncidentDiagnostics>();
|
||||
|
||||
var service = new LedgerEventWriteService(
|
||||
repo.Object,
|
||||
scheduler.Object,
|
||||
NullLogger<LedgerEventWriteService>.Instance,
|
||||
diagnostics.Object);
|
||||
|
||||
var draft = new LedgerEventDraft(
|
||||
"tenant-a",
|
||||
chainId,
|
||||
3,
|
||||
Guid.NewGuid(),
|
||||
LedgerEventConstants.EventFindingCreated,
|
||||
"v1",
|
||||
"finding-1",
|
||||
"artifact-1",
|
||||
null,
|
||||
"actor-1",
|
||||
"operator",
|
||||
DateTimeOffset.UtcNow,
|
||||
DateTimeOffset.UtcNow,
|
||||
new JsonObject(),
|
||||
new JsonObject(),
|
||||
null);
|
||||
|
||||
var result = await service.AppendAsync(draft, CancellationToken.None);
|
||||
|
||||
result.Status.Should().Be(LedgerWriteStatus.Conflict);
|
||||
diagnostics.Verify(d => d.RecordConflict(It.Is<ConflictSnapshot>(s => s.Reason == "sequence_mismatch")), Times.Once);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.Infrastructure.Snapshot;
|
||||
using StellaOps.Findings.Ledger.Services;
|
||||
using StellaOps.Findings.Ledger.Services.Incident;
|
||||
using Xunit;
|
||||
|
||||
public class SnapshotServiceTests
|
||||
@@ -58,6 +59,33 @@ public class SnapshotServiceTests
|
||||
Assert.True(result.Snapshot.ExpiresAt > DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSnapshotAsync_WhenIncidentActive_ExtendsRetention()
|
||||
{
|
||||
var incident = new StaticIncidentDiagnostics(new LedgerIncidentSnapshot(
|
||||
IsActive: true,
|
||||
ActivationId: "act-1",
|
||||
Actor: "actor",
|
||||
Reason: "reason",
|
||||
TenantId: "tenant-incident",
|
||||
ChangedAt: DateTimeOffset.UtcNow,
|
||||
ExpiresAt: DateTimeOffset.UtcNow.AddHours(1),
|
||||
RetentionExtensionDays: 7));
|
||||
|
||||
var service = new SnapshotService(
|
||||
_snapshotRepository,
|
||||
_timeTravelRepository,
|
||||
NullLogger<SnapshotService>.Instance,
|
||||
incident);
|
||||
|
||||
var result = await service.CreateSnapshotAsync(
|
||||
new CreateSnapshotInput("tenant-incident", Label: "incident-snapshot", ExpiresIn: TimeSpan.FromDays(1)));
|
||||
|
||||
Assert.NotNull(result.Snapshot);
|
||||
Assert.True(result.Snapshot!.ExpiresAt >= DateTimeOffset.UtcNow.AddDays(1));
|
||||
Assert.Equal("enabled", result.Snapshot.Metadata?["incident.mode"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSnapshotAsync_ReturnsExistingSnapshot()
|
||||
{
|
||||
@@ -371,3 +399,34 @@ internal class InMemoryTimeTravelRepository : ITimeTravelRepository
|
||||
TimeSpan.FromMinutes(5)));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class StaticIncidentDiagnostics : ILedgerIncidentDiagnostics
|
||||
{
|
||||
public StaticIncidentDiagnostics(LedgerIncidentSnapshot current)
|
||||
{
|
||||
Current = current;
|
||||
}
|
||||
|
||||
public bool IsActive => Current.IsActive;
|
||||
|
||||
public LedgerIncidentSnapshot Current { get; }
|
||||
|
||||
public IncidentDiagnosticsSnapshot GetDiagnosticsSnapshot() => new(
|
||||
Current,
|
||||
Array.Empty<ProjectionLagSample>(),
|
||||
Array.Empty<ConflictSnapshot>(),
|
||||
Array.Empty<ReplayTraceSample>(),
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
public void RecordConflict(ConflictSnapshot snapshot)
|
||||
{
|
||||
}
|
||||
|
||||
public void RecordProjectionLag(ProjectionLagSample sample)
|
||||
{
|
||||
}
|
||||
|
||||
public void RecordReplayTrace(ReplayTraceSample sample)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,10 @@ using StellaOps.Findings.Ledger.WebService.Services;
|
||||
using StellaOps.Telemetry.Core;
|
||||
using StellaOps.Findings.Ledger.Services.Security;
|
||||
using StellaOps.Findings.Ledger.Observability;
|
||||
using StellaOps.Findings.Ledger.OpenApi;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Findings.Ledger.Services.Incident;
|
||||
|
||||
const string LedgerWritePolicy = "ledger.events.write";
|
||||
const string LedgerExportPolicy = "ledger.export.read";
|
||||
@@ -62,6 +66,11 @@ builder.Services.AddOptions<LedgerServiceOptions>()
|
||||
.PostConfigure(options => options.Validate())
|
||||
.ValidateOnStart();
|
||||
|
||||
builder.Services.AddOptions<LedgerIncidentOptions>()
|
||||
.Bind(builder.Configuration.GetSection(LedgerIncidentOptions.SectionName))
|
||||
.PostConfigure(options => options.Validate())
|
||||
.ValidateOnStart();
|
||||
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
@@ -80,6 +89,8 @@ builder.Services.AddStellaOpsTelemetry(
|
||||
tracerBuilder.AddHttpClientInstrumentation();
|
||||
});
|
||||
|
||||
builder.Services.AddIncidentMode(builder.Configuration);
|
||||
|
||||
builder.Services.AddStellaOpsResourceServerAuthentication(
|
||||
builder.Configuration,
|
||||
configurationSection: null,
|
||||
@@ -130,6 +141,10 @@ builder.Services.AddAuthorization(options =>
|
||||
});
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<ILedgerIncidentNotifier, LoggingLedgerIncidentNotifier>();
|
||||
builder.Services.AddSingleton<LedgerIncidentCoordinator>();
|
||||
builder.Services.AddSingleton<ILedgerIncidentDiagnostics>(sp => sp.GetRequiredService<LedgerIncidentCoordinator>());
|
||||
builder.Services.AddSingleton<ILedgerIncidentState>(sp => sp.GetRequiredService<LedgerIncidentCoordinator>());
|
||||
builder.Services.AddSingleton<LedgerAnchorQueue>();
|
||||
builder.Services.AddSingleton<LedgerDataSource>();
|
||||
builder.Services.AddSingleton<IMerkleAnchorRepository, PostgresMerkleAnchorRepository>();
|
||||
@@ -232,6 +247,8 @@ app.MapGet("/ledger/export/findings", async Task<Results<FileStreamHttpResult, J
|
||||
ExportQueryService exportQueryService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
DeprecationHeaders.Apply(httpContext.Response, "ledger.export.findings");
|
||||
|
||||
if (!httpContext.Request.Headers.TryGetValue("X-Stella-Tenant", out var tenantValues) || string.IsNullOrWhiteSpace(tenantValues))
|
||||
{
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "missing_tenant", detail: "X-Stella-Tenant header is required.");
|
||||
@@ -841,20 +858,40 @@ app.MapPut("/v1/ledger/attestation-pointers/{pointerId}/verification", async Tas
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
app.MapGet("/.well-known/openapi", () =>
|
||||
app.MapGet("/.well-known/openapi", async (HttpContext context) =>
|
||||
{
|
||||
var contentRoot = AppContext.BaseDirectory;
|
||||
var candidate = Path.GetFullPath(Path.Combine(contentRoot, "../../docs/modules/findings-ledger/openapi/findings-ledger.v1.yaml"));
|
||||
if (!File.Exists(candidate))
|
||||
var specPath = OpenApiMetadataFactory.GetSpecPath(contentRoot);
|
||||
|
||||
if (!File.Exists(specPath))
|
||||
{
|
||||
return Results.Problem(statusCode: StatusCodes.Status500InternalServerError, title: "openapi_missing", detail: "OpenAPI document not found on server.");
|
||||
}
|
||||
|
||||
var yaml = File.ReadAllText(candidate);
|
||||
return Results.Text(yaml, "application/yaml");
|
||||
var specBytes = await File.ReadAllBytesAsync(specPath, context.RequestAborted).ConfigureAwait(false);
|
||||
var etag = OpenApiMetadataFactory.ComputeEtag(specBytes);
|
||||
|
||||
if (context.Request.Headers.IfNoneMatch.Any(match => string.Equals(match, etag, StringComparison.Ordinal)))
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status304NotModified);
|
||||
}
|
||||
|
||||
context.Response.Headers.ETag = etag;
|
||||
context.Response.Headers.CacheControl = "public, max-age=300, must-revalidate";
|
||||
context.Response.Headers.Append("X-Api-Version", OpenApiMetadataFactory.ApiVersion);
|
||||
context.Response.Headers.Append("X-Build-Version", OpenApiMetadataFactory.GetBuildVersion());
|
||||
|
||||
var lastModified = OpenApiMetadataFactory.GetLastModified(specPath);
|
||||
if (lastModified.HasValue)
|
||||
{
|
||||
context.Response.Headers.LastModified = lastModified.Value.ToString("R");
|
||||
}
|
||||
|
||||
return Results.Text(Encoding.UTF8.GetString(specBytes), "application/yaml");
|
||||
})
|
||||
.WithName("LedgerOpenApiDocument")
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status304NotModified)
|
||||
.ProducesProblem(StatusCodes.Status500InternalServerError);
|
||||
|
||||
// Snapshot Endpoints (LEDGER-PACKS-42-001-DEV)
|
||||
|
||||
25
src/Findings/StellaOps.Findings.Ledger/DeprecationHeaders.cs
Normal file
25
src/Findings/StellaOps.Findings.Ledger/DeprecationHeaders.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace StellaOps.Findings.Ledger;
|
||||
|
||||
/// <summary>
|
||||
/// Applies standardized deprecation/notification headers to retiring endpoints.
|
||||
/// </summary>
|
||||
public static class DeprecationHeaders
|
||||
{
|
||||
private const string DeprecationLink =
|
||||
"</.well-known/openapi>; rel=\"deprecation\"; type=\"application/yaml\"";
|
||||
|
||||
public const string SunsetDate = "2026-03-31T00:00:00Z";
|
||||
|
||||
public static void Apply(HttpResponse response, string endpointId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(response);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(endpointId);
|
||||
|
||||
response.Headers["Deprecation"] = "true";
|
||||
response.Headers["Sunset"] = SunsetDate;
|
||||
response.Headers["X-Deprecated-Endpoint"] = endpointId;
|
||||
response.Headers.Append("Link", DeprecationLink);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.Findings.Ledger.Infrastructure.Attestation;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Domain;
|
||||
|
||||
@@ -18,7 +19,12 @@ public sealed record FindingProjection(
|
||||
string? ExplainRef,
|
||||
JsonArray PolicyRationale,
|
||||
DateTimeOffset UpdatedAt,
|
||||
string CycleHash);
|
||||
string CycleHash,
|
||||
int AttestationCount = 0,
|
||||
int VerifiedAttestationCount = 0,
|
||||
int FailedAttestationCount = 0,
|
||||
int UnverifiedAttestationCount = 0,
|
||||
OverallVerificationStatus AttestationStatus = OverallVerificationStatus.NoAttestations);
|
||||
|
||||
public sealed record FindingHistoryEntry(
|
||||
string TenantId,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace StellaOps.Findings.Ledger.Infrastructure.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// Computes overall attestation status from summary counts.
|
||||
/// </summary>
|
||||
public static class AttestationStatusCalculator
|
||||
{
|
||||
public static OverallVerificationStatus Compute(int attestationCount, int verifiedCount)
|
||||
{
|
||||
if (attestationCount <= 0)
|
||||
{
|
||||
return OverallVerificationStatus.NoAttestations;
|
||||
}
|
||||
|
||||
if (verifiedCount == attestationCount)
|
||||
{
|
||||
return OverallVerificationStatus.AllVerified;
|
||||
}
|
||||
|
||||
if (verifiedCount > 0)
|
||||
{
|
||||
return OverallVerificationStatus.PartiallyVerified;
|
||||
}
|
||||
|
||||
return OverallVerificationStatus.NoneVerified;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.Infrastructure.Attestation;
|
||||
using StellaOps.Findings.Ledger.Hashing;
|
||||
using StellaOps.Findings.Ledger.Services;
|
||||
|
||||
@@ -11,23 +13,43 @@ namespace StellaOps.Findings.Ledger.Infrastructure.Postgres;
|
||||
public sealed class PostgresFindingProjectionRepository : IFindingProjectionRepository
|
||||
{
|
||||
private const string GetProjectionSql = """
|
||||
SELECT 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 tenant_id = @tenant_id
|
||||
AND finding_id = @finding_id
|
||||
AND policy_version = @policy_version
|
||||
WITH attestation_summary AS (
|
||||
SELECT
|
||||
COUNT(*) AS attestation_count,
|
||||
COUNT(*) FILTER (WHERE verification_result IS NOT NULL
|
||||
AND (verification_result->>'verified')::boolean = true) AS verified_count,
|
||||
COUNT(*) FILTER (WHERE verification_result IS NOT NULL
|
||||
AND (verification_result->>'verified')::boolean = false) AS failed_count,
|
||||
COUNT(*) FILTER (WHERE verification_result IS NULL) AS unverified_count
|
||||
FROM ledger_attestation_pointers ap
|
||||
WHERE ap.tenant_id = @tenant_id
|
||||
AND ap.finding_id = @finding_id
|
||||
)
|
||||
SELECT fp.tenant_id,
|
||||
fp.finding_id,
|
||||
fp.policy_version,
|
||||
fp.status,
|
||||
fp.severity,
|
||||
fp.risk_score,
|
||||
fp.risk_severity,
|
||||
fp.risk_profile_version,
|
||||
fp.risk_explanation_id,
|
||||
fp.risk_event_sequence,
|
||||
fp.labels,
|
||||
fp.current_event_id,
|
||||
fp.explain_ref,
|
||||
fp.policy_rationale,
|
||||
fp.updated_at,
|
||||
fp.cycle_hash,
|
||||
COALESCE(a.attestation_count, 0) AS attestation_count,
|
||||
COALESCE(a.verified_count, 0) AS verified_count,
|
||||
COALESCE(a.failed_count, 0) AS failed_count,
|
||||
COALESCE(a.unverified_count, 0) AS unverified_count
|
||||
FROM findings_projection fp
|
||||
LEFT JOIN attestation_summary a ON TRUE
|
||||
WHERE fp.tenant_id = @tenant_id
|
||||
AND fp.finding_id = @finding_id
|
||||
AND fp.policy_version = @policy_version
|
||||
""";
|
||||
|
||||
private const string UpsertProjectionSql = """
|
||||
@@ -203,47 +225,7 @@ public sealed class PostgresFindingProjectionRepository : IFindingProjectionRepo
|
||||
return null;
|
||||
}
|
||||
|
||||
var status = reader.GetString(0);
|
||||
var severity = reader.IsDBNull(1) ? (decimal?)null : reader.GetDecimal(1);
|
||||
var riskScore = reader.IsDBNull(2) ? (decimal?)null : reader.GetDecimal(2);
|
||||
var riskSeverity = reader.IsDBNull(3) ? null : reader.GetString(3);
|
||||
var riskProfileVersion = reader.IsDBNull(4) ? null : reader.GetString(4);
|
||||
var riskExplanationId = reader.IsDBNull(5) ? (Guid?)null : reader.GetGuid(5);
|
||||
var riskEventSequence = reader.IsDBNull(6) ? (long?)null : reader.GetInt64(6);
|
||||
var labelsJson = reader.GetFieldValue<string>(7);
|
||||
var labels = JsonNode.Parse(labelsJson)?.AsObject() ?? new JsonObject();
|
||||
var currentEventId = reader.GetGuid(8);
|
||||
var explainRef = reader.IsDBNull(9) ? null : reader.GetString(9);
|
||||
var rationaleJson = reader.IsDBNull(10) ? string.Empty : reader.GetFieldValue<string>(10);
|
||||
JsonArray rationale;
|
||||
if (string.IsNullOrWhiteSpace(rationaleJson))
|
||||
{
|
||||
rationale = new JsonArray();
|
||||
}
|
||||
else
|
||||
{
|
||||
rationale = JsonNode.Parse(rationaleJson) as JsonArray ?? new JsonArray();
|
||||
}
|
||||
var updatedAt = reader.GetFieldValue<DateTimeOffset>(11);
|
||||
var cycleHash = reader.GetString(12);
|
||||
|
||||
return new FindingProjection(
|
||||
tenantId,
|
||||
findingId,
|
||||
policyVersion,
|
||||
status,
|
||||
severity,
|
||||
riskScore,
|
||||
riskSeverity,
|
||||
riskProfileVersion,
|
||||
riskExplanationId,
|
||||
riskEventSequence,
|
||||
labels,
|
||||
currentEventId,
|
||||
explainRef,
|
||||
rationale,
|
||||
updatedAt,
|
||||
cycleHash);
|
||||
return MapProjection(reader);
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(FindingProjection projection, CancellationToken cancellationToken)
|
||||
@@ -407,7 +389,7 @@ public sealed class PostgresFindingProjectionRepository : IFindingProjectionRepo
|
||||
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 whereConditions = new List<string> { "fp.tenant_id = @tenant_id" };
|
||||
var parameters = new List<NpgsqlParameter>
|
||||
{
|
||||
new NpgsqlParameter<string>("tenant_id", query.TenantId) { NpgsqlDbType = NpgsqlDbType.Text }
|
||||
@@ -415,34 +397,86 @@ public sealed class PostgresFindingProjectionRepository : IFindingProjectionRepo
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.PolicyVersion))
|
||||
{
|
||||
whereConditions.Add("policy_version = @policy_version");
|
||||
whereConditions.Add("fp.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");
|
||||
whereConditions.Add("fp.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");
|
||||
whereConditions.Add("fp.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)");
|
||||
whereConditions.Add("fp.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)");
|
||||
whereConditions.Add("fp.status = ANY(@statuses)");
|
||||
parameters.Add(new NpgsqlParameter("statuses", query.Statuses.ToArray()) { NpgsqlDbType = NpgsqlDbType.Array | NpgsqlDbType.Text });
|
||||
}
|
||||
|
||||
if (query.AttestationTypes is { Count: > 0 })
|
||||
{
|
||||
parameters.Add(new NpgsqlParameter("attestation_types", query.AttestationTypes.Select(t => t.ToString()).ToArray())
|
||||
{
|
||||
NpgsqlDbType = NpgsqlDbType.Array | NpgsqlDbType.Text
|
||||
});
|
||||
}
|
||||
|
||||
var attestationWhere = new List<string>();
|
||||
|
||||
if (query.AttestationVerification.HasValue &&
|
||||
query.AttestationVerification.Value != AttestationVerificationFilter.Any)
|
||||
{
|
||||
var filter = query.AttestationVerification.Value switch
|
||||
{
|
||||
AttestationVerificationFilter.Verified => "verified_count > 0",
|
||||
AttestationVerificationFilter.Unverified => "unverified_count > 0",
|
||||
AttestationVerificationFilter.Failed => "failed_count > 0",
|
||||
_ => string.Empty
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter))
|
||||
{
|
||||
attestationWhere.Add(filter);
|
||||
}
|
||||
}
|
||||
|
||||
if (query.AttestationStatus.HasValue)
|
||||
{
|
||||
var statusFilter = query.AttestationStatus.Value switch
|
||||
{
|
||||
OverallVerificationStatus.AllVerified =>
|
||||
"attestation_count > 0 AND verified_count = attestation_count",
|
||||
OverallVerificationStatus.PartiallyVerified =>
|
||||
"attestation_count > 0 AND verified_count > 0 AND verified_count < attestation_count",
|
||||
OverallVerificationStatus.NoneVerified =>
|
||||
"attestation_count > 0 AND verified_count = 0",
|
||||
OverallVerificationStatus.NoAttestations =>
|
||||
"attestation_count = 0",
|
||||
_ => string.Empty
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(statusFilter))
|
||||
{
|
||||
attestationWhere.Add(statusFilter);
|
||||
}
|
||||
}
|
||||
|
||||
var attestationWhereClause = attestationWhere.Count > 0
|
||||
? "WHERE " + string.Join(" AND ", attestationWhere)
|
||||
: string.Empty;
|
||||
|
||||
var whereClause = string.Join(" AND ", whereConditions);
|
||||
var orderColumn = query.SortBy switch
|
||||
{
|
||||
@@ -454,8 +488,46 @@ public sealed class PostgresFindingProjectionRepository : IFindingProjectionRepo
|
||||
};
|
||||
var orderDirection = query.Descending ? "DESC NULLS LAST" : "ASC NULLS FIRST";
|
||||
|
||||
var attestationSummarySql = new StringBuilder(@"
|
||||
SELECT tenant_id,
|
||||
finding_id,
|
||||
COUNT(*) AS attestation_count,
|
||||
COUNT(*) FILTER (WHERE verification_result IS NOT NULL
|
||||
AND (verification_result->>'verified')::boolean = true) AS verified_count,
|
||||
COUNT(*) FILTER (WHERE verification_result IS NOT NULL
|
||||
AND (verification_result->>'verified')::boolean = false) AS failed_count,
|
||||
COUNT(*) FILTER (WHERE verification_result IS NULL) AS unverified_count
|
||||
FROM ledger_attestation_pointers
|
||||
WHERE tenant_id = @tenant_id");
|
||||
|
||||
if (query.AttestationTypes is { Count: > 0 })
|
||||
{
|
||||
attestationSummarySql.Append(" AND attestation_type = ANY(@attestation_types)");
|
||||
}
|
||||
|
||||
attestationSummarySql.Append(" GROUP BY tenant_id, finding_id");
|
||||
|
||||
var cte = $@"
|
||||
WITH attestation_summary AS (
|
||||
{attestationSummarySql}
|
||||
),
|
||||
filtered_projection AS (
|
||||
SELECT
|
||||
fp.tenant_id, fp.finding_id, fp.policy_version, fp.status, fp.severity, fp.risk_score, fp.risk_severity,
|
||||
fp.risk_profile_version, fp.risk_explanation_id, fp.risk_event_sequence, fp.labels, fp.current_event_id,
|
||||
fp.explain_ref, fp.policy_rationale, fp.updated_at, fp.cycle_hash,
|
||||
COALESCE(a.attestation_count, 0) AS attestation_count,
|
||||
COALESCE(a.verified_count, 0) AS verified_count,
|
||||
COALESCE(a.failed_count, 0) AS failed_count,
|
||||
COALESCE(a.unverified_count, 0) AS unverified_count
|
||||
FROM findings_projection fp
|
||||
LEFT JOIN attestation_summary a
|
||||
ON a.tenant_id = fp.tenant_id AND a.finding_id = fp.finding_id
|
||||
WHERE {whereClause}
|
||||
)";
|
||||
|
||||
// Count query
|
||||
var countSql = $"SELECT COUNT(*) FROM findings_projection WHERE {whereClause}";
|
||||
var countSql = $"{cte} SELECT COUNT(*) FROM filtered_projection {attestationWhereClause};";
|
||||
await using var countCommand = new NpgsqlCommand(countSql, connection);
|
||||
countCommand.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
foreach (var p in parameters) countCommand.Parameters.Add(p.Clone());
|
||||
@@ -463,12 +535,14 @@ public sealed class PostgresFindingProjectionRepository : IFindingProjectionRepo
|
||||
|
||||
// Data query
|
||||
var dataSql = $@"
|
||||
{cte}
|
||||
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}
|
||||
explain_ref, policy_rationale, updated_at, cycle_hash,
|
||||
attestation_count, verified_count, failed_count, unverified_count
|
||||
FROM filtered_projection
|
||||
{attestationWhereClause}
|
||||
ORDER BY {orderColumn} {orderDirection}
|
||||
LIMIT @limit";
|
||||
|
||||
@@ -638,6 +712,12 @@ public sealed class PostgresFindingProjectionRepository : IFindingProjectionRepo
|
||||
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();
|
||||
|
||||
var attestationCount = reader.GetInt32(16);
|
||||
var verifiedCount = reader.GetInt32(17);
|
||||
var failedCount = reader.GetInt32(18);
|
||||
var unverifiedCount = reader.GetInt32(19);
|
||||
var attestationStatus = AttestationStatusCalculator.Compute(attestationCount, verifiedCount);
|
||||
|
||||
return new FindingProjection(
|
||||
TenantId: reader.GetString(0),
|
||||
FindingId: reader.GetString(1),
|
||||
@@ -654,6 +734,11 @@ public sealed class PostgresFindingProjectionRepository : IFindingProjectionRepo
|
||||
ExplainRef: reader.IsDBNull(12) ? null : reader.GetString(12),
|
||||
PolicyRationale: rationale,
|
||||
UpdatedAt: reader.GetDateTime(14),
|
||||
CycleHash: reader.GetString(15));
|
||||
CycleHash: reader.GetString(15),
|
||||
AttestationCount: attestationCount,
|
||||
VerifiedAttestationCount: verifiedCount,
|
||||
FailedAttestationCount: failedCount,
|
||||
UnverifiedAttestationCount: unverifiedCount,
|
||||
AttestationStatus: attestationStatus);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ using StellaOps.Findings.Ledger.Infrastructure.Policy;
|
||||
using StellaOps.Findings.Ledger.Options;
|
||||
using StellaOps.Findings.Ledger.Observability;
|
||||
using StellaOps.Findings.Ledger.Services;
|
||||
using StellaOps.Findings.Ledger.Services.Incident;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Infrastructure.Projection;
|
||||
|
||||
@@ -19,6 +20,7 @@ public sealed class LedgerProjectionWorker : BackgroundService
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly LedgerServiceOptions.ProjectionOptions _options;
|
||||
private readonly ILogger<LedgerProjectionWorker> _logger;
|
||||
private readonly ILedgerIncidentDiagnostics? _incidentDiagnostics;
|
||||
|
||||
public LedgerProjectionWorker(
|
||||
ILedgerEventStream eventStream,
|
||||
@@ -26,7 +28,8 @@ public sealed class LedgerProjectionWorker : BackgroundService
|
||||
IPolicyEvaluationService policyEvaluationService,
|
||||
IOptions<LedgerServiceOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<LedgerProjectionWorker> logger)
|
||||
ILogger<LedgerProjectionWorker> logger,
|
||||
ILedgerIncidentDiagnostics? incidentDiagnostics = null)
|
||||
{
|
||||
_eventStream = eventStream ?? throw new ArgumentNullException(nameof(eventStream));
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
@@ -34,6 +37,7 @@ public sealed class LedgerProjectionWorker : BackgroundService
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value.Projection;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_incidentDiagnostics = incidentDiagnostics;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
@@ -138,6 +142,15 @@ public sealed class LedgerProjectionWorker : BackgroundService
|
||||
record.PolicyVersion,
|
||||
evaluationStatus ?? string.Empty);
|
||||
LedgerTimeline.EmitProjectionUpdated(_logger, record, evaluationStatus, evidenceBundleRef: null);
|
||||
_incidentDiagnostics?.RecordProjectionLag(new ProjectionLagSample(
|
||||
TenantId: record.TenantId,
|
||||
ChainId: record.ChainId,
|
||||
SequenceNumber: record.SequenceNumber,
|
||||
EventType: record.EventType,
|
||||
PolicyVersion: record.PolicyVersion,
|
||||
LagSeconds: lagSeconds,
|
||||
RecordedAt: record.RecordedAt,
|
||||
ObservedAt: now));
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.Infrastructure.Exports;
|
||||
using StellaOps.Findings.Ledger.Services.Incident;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Observability;
|
||||
|
||||
@@ -23,6 +24,10 @@ internal static class LedgerTimeline
|
||||
private static readonly EventId TimeTravelQueryEvent = new(6803, "ledger.timetravel.query");
|
||||
private static readonly EventId ReplayCompletedEvent = new(6804, "ledger.replay.completed");
|
||||
private static readonly EventId DiffComputedEvent = new(6805, "ledger.diff.computed");
|
||||
private static readonly EventId IncidentModeChangedEvent = new(6901, "ledger.incident.mode");
|
||||
private static readonly EventId IncidentLagTraceEvent = new(6902, "ledger.incident.lag_trace");
|
||||
private static readonly EventId IncidentConflictSnapshotEvent = new(6903, "ledger.incident.conflict_snapshot");
|
||||
private static readonly EventId IncidentReplayTraceEvent = new(6904, "ledger.incident.replay_trace");
|
||||
|
||||
public static void EmitLedgerAppended(ILogger logger, LedgerEventRecord record, string? evidenceBundleRef = null)
|
||||
{
|
||||
@@ -280,4 +285,87 @@ internal static class LedgerTimeline
|
||||
modified,
|
||||
removed);
|
||||
}
|
||||
|
||||
public static void EmitIncidentModeChanged(ILogger logger, LedgerIncidentSnapshot snapshot, bool wasReactivation)
|
||||
{
|
||||
if (logger is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
IncidentModeChangedEvent,
|
||||
"timeline ledger.incident.mode state={State} activation_id={ActivationId} actor={Actor} reason={Reason} expires_at={ExpiresAt} retention_extension_days={RetentionExtensionDays} reactivation={Reactivation}",
|
||||
snapshot.IsActive ? "enabled" : "disabled",
|
||||
snapshot.ActivationId ?? string.Empty,
|
||||
snapshot.Actor ?? string.Empty,
|
||||
snapshot.Reason ?? string.Empty,
|
||||
snapshot.ExpiresAt?.ToString("O") ?? string.Empty,
|
||||
snapshot.RetentionExtensionDays,
|
||||
wasReactivation);
|
||||
}
|
||||
|
||||
public static void EmitIncidentLagTrace(ILogger logger, ProjectionLagSample sample)
|
||||
{
|
||||
if (logger is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogWarning(
|
||||
IncidentLagTraceEvent,
|
||||
"timeline ledger.incident.lag_trace tenant={Tenant} chain={ChainId} seq={Sequence} event_type={EventType} policy={PolicyVersion} lag_seconds={LagSeconds:0.000} recorded_at={RecordedAt} observed_at={ObservedAt}",
|
||||
sample.TenantId,
|
||||
sample.ChainId,
|
||||
sample.SequenceNumber,
|
||||
sample.EventType,
|
||||
sample.PolicyVersion,
|
||||
sample.LagSeconds,
|
||||
sample.RecordedAt.ToString("O"),
|
||||
sample.ObservedAt.ToString("O"));
|
||||
}
|
||||
|
||||
public static void EmitIncidentConflictSnapshot(ILogger logger, ConflictSnapshot snapshot)
|
||||
{
|
||||
if (logger is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogWarning(
|
||||
IncidentConflictSnapshotEvent,
|
||||
"timeline ledger.incident.conflict_snapshot tenant={Tenant} chain={ChainId} seq={Sequence} event_id={EventId} event_type={EventType} policy={PolicyVersion} reason={Reason} expected_seq={ExpectedSequence} actor={Actor} actor_type={ActorType} observed_at={ObservedAt}",
|
||||
snapshot.TenantId,
|
||||
snapshot.ChainId,
|
||||
snapshot.SequenceNumber,
|
||||
snapshot.EventId,
|
||||
snapshot.EventType,
|
||||
snapshot.PolicyVersion,
|
||||
snapshot.Reason,
|
||||
snapshot.ExpectedSequence,
|
||||
snapshot.ActorId ?? string.Empty,
|
||||
snapshot.ActorType ?? string.Empty,
|
||||
snapshot.ObservedAt.ToString("O"));
|
||||
}
|
||||
|
||||
public static void EmitIncidentReplayTrace(ILogger logger, ReplayTraceSample sample)
|
||||
{
|
||||
if (logger is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
IncidentReplayTraceEvent,
|
||||
"timeline ledger.incident.replay_trace tenant={Tenant} from_seq={FromSequence} to_seq={ToSequence} events={Events} duration_ms={DurationMs} has_more={HasMore} chain_filters={ChainFilters} event_type_filters={EventTypeFilters} observed_at={ObservedAt}",
|
||||
sample.TenantId,
|
||||
sample.FromSequence,
|
||||
sample.ToSequence,
|
||||
sample.EventsCount,
|
||||
sample.DurationMs,
|
||||
sample.HasMore,
|
||||
sample.ChainFilterCount,
|
||||
sample.EventTypeFilterCount,
|
||||
sample.ObservedAt.ToString("O"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// Provides versioned metadata for the Findings Ledger OpenAPI discovery endpoint.
|
||||
/// </summary>
|
||||
public static class OpenApiMetadataFactory
|
||||
{
|
||||
public const string ApiVersion = "1.0.0-beta1";
|
||||
|
||||
public static string GetBuildVersion()
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
var informational = assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
|
||||
return string.IsNullOrWhiteSpace(informational)
|
||||
? assembly.GetName().Version?.ToString() ?? "0.0.0"
|
||||
: informational;
|
||||
}
|
||||
|
||||
public static string GetSpecPath(string contentRoot)
|
||||
{
|
||||
var current = Path.GetFullPath(contentRoot);
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
var candidate = Path.Combine(current, "docs", "modules", "findings-ledger", "openapi", "findings-ledger.v1.yaml");
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
current = Path.GetFullPath(Path.Combine(current, ".."));
|
||||
}
|
||||
|
||||
// Fallback to previous behavior if traversal fails
|
||||
return Path.GetFullPath(Path.Combine(contentRoot, "../../docs/modules/findings-ledger/openapi/findings-ledger.v1.yaml"));
|
||||
}
|
||||
|
||||
public static DateTimeOffset? GetLastModified(string specPath)
|
||||
{
|
||||
return File.Exists(specPath)
|
||||
? File.GetLastWriteTimeUtc(specPath)
|
||||
: null;
|
||||
}
|
||||
|
||||
public static string ComputeEtag(byte[] content)
|
||||
{
|
||||
var hash = SHA256.HashData(content);
|
||||
var shortHash = Convert.ToHexString(hash)[..16].ToLowerInvariant();
|
||||
return $"W/\"{shortHash}\"";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Configures incident-mode behaviour for the Findings Ledger.
|
||||
/// </summary>
|
||||
public sealed class LedgerIncidentOptions
|
||||
{
|
||||
public const string SectionName = "findings:ledger:incident";
|
||||
|
||||
/// <summary>
|
||||
/// Enables ledger-side incident instrumentation.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Number of days to extend retention windows while incident mode is active.
|
||||
/// </summary>
|
||||
public int RetentionExtensionDays { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum projection lag (seconds) that will be recorded during incident mode.
|
||||
/// </summary>
|
||||
public double LagTraceThresholdSeconds { get; set; } = 15;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of projection lag samples to retain.
|
||||
/// </summary>
|
||||
public int LagTraceBufferSize { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of conflict snapshots to retain.
|
||||
/// </summary>
|
||||
public int ConflictSnapshotBufferSize { get; set; } = 50;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of replay traces to retain.
|
||||
/// </summary>
|
||||
public int ReplayTraceBufferSize { get; set; } = 50;
|
||||
|
||||
/// <summary>
|
||||
/// Enables capture of projection lag traces when incident mode is active.
|
||||
/// </summary>
|
||||
public bool CaptureLagTraces { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Enables capture of conflict snapshots when incident mode is active.
|
||||
/// </summary>
|
||||
public bool CaptureConflictSnapshots { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Enables capture of replay request traces when incident mode is active.
|
||||
/// </summary>
|
||||
public bool CaptureReplayTraces { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to emit structured timeline/log entries for incident actions.
|
||||
/// </summary>
|
||||
public bool EmitTimelineEvents { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to emit notifier events (logging by default) for incident actions.
|
||||
/// </summary>
|
||||
public bool EmitNotifications { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Clears buffered diagnostics on each activation to avoid mixing epochs.
|
||||
/// </summary>
|
||||
public bool ResetDiagnosticsOnActivation { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Validates option values.
|
||||
/// </summary>
|
||||
public void Validate()
|
||||
{
|
||||
if (RetentionExtensionDays < 0 || RetentionExtensionDays > 3650)
|
||||
{
|
||||
throw new InvalidOperationException("RetentionExtensionDays must be between 0 and 3650.");
|
||||
}
|
||||
|
||||
if (LagTraceThresholdSeconds < 0)
|
||||
{
|
||||
throw new InvalidOperationException("LagTraceThresholdSeconds must be non-negative.");
|
||||
}
|
||||
|
||||
if (LagTraceBufferSize <= 0 || ConflictSnapshotBufferSize <= 0 || ReplayTraceBufferSize <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Incident diagnostic buffer sizes must be positive.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Findings.Ledger.Observability;
|
||||
using StellaOps.Findings.Ledger.Options;
|
||||
using StellaOps.Telemetry.Core;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Services.Incident;
|
||||
|
||||
public interface ILedgerIncidentDiagnostics : ILedgerIncidentState
|
||||
{
|
||||
void RecordProjectionLag(ProjectionLagSample sample);
|
||||
|
||||
void RecordConflict(ConflictSnapshot snapshot);
|
||||
|
||||
void RecordReplayTrace(ReplayTraceSample sample);
|
||||
|
||||
IncidentDiagnosticsSnapshot GetDiagnosticsSnapshot();
|
||||
}
|
||||
|
||||
public interface ILedgerIncidentState
|
||||
{
|
||||
bool IsActive { get; }
|
||||
|
||||
LedgerIncidentSnapshot Current { get; }
|
||||
}
|
||||
|
||||
public interface ILedgerIncidentNotifier
|
||||
{
|
||||
Task PublishIncidentModeChangedAsync(LedgerIncidentSnapshot snapshot, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class LoggingLedgerIncidentNotifier : ILedgerIncidentNotifier
|
||||
{
|
||||
private readonly ILogger<LoggingLedgerIncidentNotifier> _logger;
|
||||
|
||||
public LoggingLedgerIncidentNotifier(ILogger<LoggingLedgerIncidentNotifier> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public Task PublishIncidentModeChangedAsync(LedgerIncidentSnapshot snapshot, CancellationToken cancellationToken)
|
||||
{
|
||||
var state = snapshot.IsActive ? "enabled" : "disabled";
|
||||
_logger.LogWarning(
|
||||
"NOTIFICATION: Ledger incident mode {State} (activation_id={ActivationId}, retention_extension_days={ExtensionDays})",
|
||||
state,
|
||||
snapshot.ActivationId ?? string.Empty,
|
||||
snapshot.RetentionExtensionDays);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record LedgerIncidentSnapshot(
|
||||
bool IsActive,
|
||||
string? ActivationId,
|
||||
string? Actor,
|
||||
string? Reason,
|
||||
string? TenantId,
|
||||
DateTimeOffset ChangedAt,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
int RetentionExtensionDays);
|
||||
|
||||
public sealed record ProjectionLagSample(
|
||||
string TenantId,
|
||||
Guid ChainId,
|
||||
long SequenceNumber,
|
||||
string EventType,
|
||||
string PolicyVersion,
|
||||
double LagSeconds,
|
||||
DateTimeOffset RecordedAt,
|
||||
DateTimeOffset ObservedAt);
|
||||
|
||||
public sealed record ConflictSnapshot(
|
||||
string TenantId,
|
||||
Guid ChainId,
|
||||
long SequenceNumber,
|
||||
Guid EventId,
|
||||
string EventType,
|
||||
string PolicyVersion,
|
||||
string Reason,
|
||||
DateTimeOffset RecordedAt,
|
||||
DateTimeOffset ObservedAt,
|
||||
string? ActorId,
|
||||
string? ActorType,
|
||||
long ExpectedSequence,
|
||||
string? ProvidedPreviousHash,
|
||||
string? ExpectedPreviousHash);
|
||||
|
||||
public sealed record ReplayTraceSample(
|
||||
string TenantId,
|
||||
long FromSequence,
|
||||
long ToSequence,
|
||||
long EventsCount,
|
||||
bool HasMore,
|
||||
double DurationMs,
|
||||
DateTimeOffset ObservedAt,
|
||||
int ChainFilterCount,
|
||||
int EventTypeFilterCount);
|
||||
|
||||
public sealed record IncidentDiagnosticsSnapshot(
|
||||
LedgerIncidentSnapshot Incident,
|
||||
IReadOnlyList<ProjectionLagSample> LagSamples,
|
||||
IReadOnlyList<ConflictSnapshot> ConflictSnapshots,
|
||||
IReadOnlyList<ReplayTraceSample> ReplayTraces,
|
||||
DateTimeOffset CapturedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Coordinates ledger-specific incident mode behaviour (diagnostics, retention hints, timeline/notification events).
|
||||
/// </summary>
|
||||
public sealed class LedgerIncidentCoordinator : ILedgerIncidentDiagnostics, IDisposable
|
||||
{
|
||||
private const int ReplayTraceLogThresholdMs = 250;
|
||||
|
||||
private readonly LedgerIncidentOptions _options;
|
||||
private readonly ILogger<LedgerIncidentCoordinator> _logger;
|
||||
private readonly ILedgerIncidentNotifier _notifier;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IIncidentModeService? _incidentModeService;
|
||||
|
||||
private readonly ConcurrentQueue<ProjectionLagSample> _lagSamples = new();
|
||||
private readonly ConcurrentQueue<ConflictSnapshot> _conflictSnapshots = new();
|
||||
private readonly ConcurrentQueue<ReplayTraceSample> _replayTraces = new();
|
||||
private readonly ConcurrentDictionary<string, DateTimeOffset> _lastLagLogByChain = new(StringComparer.Ordinal);
|
||||
|
||||
private readonly object _stateLock = new();
|
||||
private LedgerIncidentSnapshot _current;
|
||||
private bool _disposed;
|
||||
|
||||
public LedgerIncidentCoordinator(
|
||||
IOptions<LedgerIncidentOptions> options,
|
||||
ILogger<LedgerIncidentCoordinator> logger,
|
||||
ILedgerIncidentNotifier notifier,
|
||||
TimeProvider? timeProvider = null,
|
||||
IIncidentModeService? incidentModeService = null)
|
||||
{
|
||||
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value;
|
||||
_options.Validate();
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_notifier = notifier ?? throw new ArgumentNullException(nameof(notifier));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_incidentModeService = incidentModeService;
|
||||
|
||||
_current = new LedgerIncidentSnapshot(
|
||||
IsActive: false,
|
||||
ActivationId: null,
|
||||
Actor: null,
|
||||
Reason: null,
|
||||
TenantId: null,
|
||||
ChangedAt: _timeProvider.GetUtcNow(),
|
||||
ExpiresAt: null,
|
||||
RetentionExtensionDays: 0);
|
||||
|
||||
if (_incidentModeService is not null)
|
||||
{
|
||||
_incidentModeService.Activated += OnActivated;
|
||||
_incidentModeService.Deactivated += OnDeactivated;
|
||||
|
||||
if (_incidentModeService.CurrentState is { } state && !_incidentModeService.CurrentState.IsExpired)
|
||||
{
|
||||
ApplyIncidentState(state, wasReactivation: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsActive => _current.IsActive;
|
||||
|
||||
public LedgerIncidentSnapshot Current => _current;
|
||||
|
||||
public void RecordProjectionLag(ProjectionLagSample sample)
|
||||
{
|
||||
if (!_options.Enabled || !IsActive || !_options.CaptureLagTraces)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EnqueueWithLimit(_lagSamples, sample, _options.LagTraceBufferSize);
|
||||
|
||||
if (_options.EmitTimelineEvents && sample.LagSeconds >= _options.LagTraceThresholdSeconds)
|
||||
{
|
||||
var now = sample.ObservedAt;
|
||||
var key = $"{sample.TenantId}:{sample.ChainId}";
|
||||
if (!_lastLagLogByChain.TryGetValue(key, out var lastLogged) ||
|
||||
now - lastLogged >= TimeSpan.FromMinutes(1))
|
||||
{
|
||||
_lastLagLogByChain[key] = now;
|
||||
LedgerTimeline.EmitIncidentLagTrace(_logger, sample);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void RecordConflict(ConflictSnapshot snapshot)
|
||||
{
|
||||
if (!_options.Enabled || !IsActive || !_options.CaptureConflictSnapshots)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EnqueueWithLimit(_conflictSnapshots, snapshot, _options.ConflictSnapshotBufferSize);
|
||||
if (_options.EmitTimelineEvents)
|
||||
{
|
||||
LedgerTimeline.EmitIncidentConflictSnapshot(_logger, snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
public void RecordReplayTrace(ReplayTraceSample sample)
|
||||
{
|
||||
if (!_options.Enabled || !IsActive || !_options.CaptureReplayTraces)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EnqueueWithLimit(_replayTraces, sample, _options.ReplayTraceBufferSize);
|
||||
|
||||
if (_options.EmitTimelineEvents &&
|
||||
(sample.DurationMs >= ReplayTraceLogThresholdMs || sample.HasMore))
|
||||
{
|
||||
LedgerTimeline.EmitIncidentReplayTrace(_logger, sample);
|
||||
}
|
||||
}
|
||||
|
||||
public IncidentDiagnosticsSnapshot GetDiagnosticsSnapshot()
|
||||
{
|
||||
return new IncidentDiagnosticsSnapshot(
|
||||
_current,
|
||||
_lagSamples.ToArray(),
|
||||
_conflictSnapshots.ToArray(),
|
||||
_replayTraces.ToArray(),
|
||||
_timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
private void OnActivated(object? sender, IncidentModeActivatedEventArgs e)
|
||||
{
|
||||
ApplyIncidentState(e.State, e.WasReactivation);
|
||||
}
|
||||
|
||||
private void OnDeactivated(object? sender, IncidentModeDeactivatedEventArgs e)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
_current = new LedgerIncidentSnapshot(
|
||||
IsActive: false,
|
||||
ActivationId: e.State.ActivationId,
|
||||
Actor: e.DeactivatedBy,
|
||||
Reason: e.Reason.ToString(),
|
||||
TenantId: e.State.TenantId,
|
||||
ChangedAt: _timeProvider.GetUtcNow(),
|
||||
ExpiresAt: e.State.ExpiresAt,
|
||||
RetentionExtensionDays: 0);
|
||||
}
|
||||
|
||||
if (_options.EmitTimelineEvents)
|
||||
{
|
||||
LedgerTimeline.EmitIncidentModeChanged(_logger, _current, wasReactivation: false);
|
||||
}
|
||||
|
||||
if (_options.EmitNotifications)
|
||||
{
|
||||
_ = SafeNotifyAsync(_current);
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyIncidentState(IncidentModeState state, bool wasReactivation)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
_current = new LedgerIncidentSnapshot(
|
||||
IsActive: true,
|
||||
ActivationId: state.ActivationId,
|
||||
Actor: state.Actor,
|
||||
Reason: state.Reason,
|
||||
TenantId: state.TenantId,
|
||||
ChangedAt: _timeProvider.GetUtcNow(),
|
||||
ExpiresAt: state.ExpiresAt,
|
||||
RetentionExtensionDays: _options.RetentionExtensionDays);
|
||||
|
||||
if (_options.ResetDiagnosticsOnActivation)
|
||||
{
|
||||
ClearDiagnostics();
|
||||
}
|
||||
}
|
||||
|
||||
if (_options.EmitTimelineEvents)
|
||||
{
|
||||
LedgerTimeline.EmitIncidentModeChanged(_logger, _current, wasReactivation);
|
||||
}
|
||||
|
||||
if (_options.EmitNotifications)
|
||||
{
|
||||
_ = SafeNotifyAsync(_current);
|
||||
}
|
||||
}
|
||||
|
||||
private Task SafeNotifyAsync(LedgerIncidentSnapshot snapshot)
|
||||
{
|
||||
try
|
||||
{
|
||||
return _notifier.PublishIncidentModeChangedAsync(snapshot, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to publish incident mode notification.");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearDiagnostics()
|
||||
{
|
||||
while (_lagSamples.TryDequeue(out _))
|
||||
{
|
||||
}
|
||||
|
||||
while (_conflictSnapshots.TryDequeue(out _))
|
||||
{
|
||||
}
|
||||
|
||||
while (_replayTraces.TryDequeue(out _))
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnqueueWithLimit<T>(ConcurrentQueue<T> queue, T item, int limit)
|
||||
{
|
||||
queue.Enqueue(item);
|
||||
while (queue.Count > limit && queue.TryDequeue(out _))
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_incidentModeService is not null)
|
||||
{
|
||||
_incidentModeService.Activated -= OnActivated;
|
||||
_incidentModeService.Deactivated -= OnDeactivated;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.Hashing;
|
||||
using StellaOps.Findings.Ledger.Infrastructure;
|
||||
using StellaOps.Findings.Ledger.Observability;
|
||||
using StellaOps.Findings.Ledger.Services.Incident;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Services;
|
||||
|
||||
@@ -18,15 +19,18 @@ public sealed class LedgerEventWriteService : ILedgerEventWriteService
|
||||
private readonly ILedgerEventRepository _repository;
|
||||
private readonly IMerkleAnchorScheduler _merkleAnchorScheduler;
|
||||
private readonly ILogger<LedgerEventWriteService> _logger;
|
||||
private readonly ILedgerIncidentDiagnostics? _incidentDiagnostics;
|
||||
|
||||
public LedgerEventWriteService(
|
||||
ILedgerEventRepository repository,
|
||||
IMerkleAnchorScheduler merkleAnchorScheduler,
|
||||
ILogger<LedgerEventWriteService> logger)
|
||||
ILogger<LedgerEventWriteService> logger,
|
||||
ILedgerIncidentDiagnostics? incidentDiagnostics = null)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_merkleAnchorScheduler = merkleAnchorScheduler ?? throw new ArgumentNullException(nameof(merkleAnchorScheduler));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_incidentDiagnostics = incidentDiagnostics;
|
||||
}
|
||||
|
||||
public async Task<LedgerWriteResult> AppendAsync(LedgerEventDraft draft, CancellationToken cancellationToken)
|
||||
@@ -57,6 +61,7 @@ public sealed class LedgerEventWriteService : ILedgerEventWriteService
|
||||
if (!string.Equals(existing.CanonicalJson, canonicalJson, StringComparison.Ordinal))
|
||||
{
|
||||
LedgerTelemetry.MarkError(activity, "event_id_conflict");
|
||||
RecordConflictSnapshot(draft, expectedSequence: existing.SequenceNumber + 1, reason: "event_id_conflict", expectedPreviousHash: existing.EventHash);
|
||||
return LedgerWriteResult.Conflict(
|
||||
"event_id_conflict",
|
||||
$"Event '{draft.EventId}' already exists with a different payload.");
|
||||
@@ -71,6 +76,7 @@ public sealed class LedgerEventWriteService : ILedgerEventWriteService
|
||||
if (draft.SequenceNumber != expectedSequence)
|
||||
{
|
||||
LedgerTelemetry.MarkError(activity, "sequence_mismatch");
|
||||
RecordConflictSnapshot(draft, expectedSequence, reason: "sequence_mismatch", expectedPreviousHash: chainHead?.EventHash);
|
||||
return LedgerWriteResult.Conflict(
|
||||
"sequence_mismatch",
|
||||
$"Sequence number '{draft.SequenceNumber}' does not match expected '{expectedSequence}'.");
|
||||
@@ -80,6 +86,7 @@ public sealed class LedgerEventWriteService : ILedgerEventWriteService
|
||||
if (draft.ProvidedPreviousHash is not null && !string.Equals(draft.ProvidedPreviousHash, previousHash, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
LedgerTelemetry.MarkError(activity, "previous_hash_mismatch");
|
||||
RecordConflictSnapshot(draft, expectedSequence, reason: "previous_hash_mismatch", providedPreviousHash: draft.ProvidedPreviousHash, expectedPreviousHash: previousHash);
|
||||
return LedgerWriteResult.Conflict(
|
||||
"previous_hash_mismatch",
|
||||
$"Provided previous hash '{draft.ProvidedPreviousHash}' does not match chain head hash '{previousHash}'.");
|
||||
@@ -143,11 +150,13 @@ public sealed class LedgerEventWriteService : ILedgerEventWriteService
|
||||
var persisted = await _repository.GetByEventIdAsync(draft.TenantId, draft.EventId, cancellationToken).ConfigureAwait(false);
|
||||
if (persisted is null)
|
||||
{
|
||||
RecordConflictSnapshot(draft, expectedSequence, reason: "append_failed", expectedPreviousHash: previousHash);
|
||||
return LedgerWriteResult.Conflict("append_failed", "Ledger append failed due to concurrent write.");
|
||||
}
|
||||
|
||||
if (!string.Equals(persisted.CanonicalJson, record.CanonicalJson, StringComparison.Ordinal))
|
||||
{
|
||||
RecordConflictSnapshot(draft, expectedSequence, reason: "event_id_conflict", expectedPreviousHash: persisted.EventHash);
|
||||
return LedgerWriteResult.Conflict("event_id_conflict", "Ledger append raced with conflicting payload.");
|
||||
}
|
||||
|
||||
@@ -157,6 +166,37 @@ public sealed class LedgerEventWriteService : ILedgerEventWriteService
|
||||
return LedgerWriteResult.Success(record);
|
||||
}
|
||||
|
||||
private void RecordConflictSnapshot(
|
||||
LedgerEventDraft draft,
|
||||
long expectedSequence,
|
||||
string reason,
|
||||
string? providedPreviousHash = null,
|
||||
string? expectedPreviousHash = null)
|
||||
{
|
||||
if (_incidentDiagnostics is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var snapshot = new ConflictSnapshot(
|
||||
TenantId: draft.TenantId,
|
||||
ChainId: draft.ChainId,
|
||||
SequenceNumber: draft.SequenceNumber,
|
||||
EventId: draft.EventId,
|
||||
EventType: draft.EventType,
|
||||
PolicyVersion: draft.PolicyVersion ?? string.Empty,
|
||||
Reason: reason,
|
||||
RecordedAt: draft.RecordedAt,
|
||||
ObservedAt: DateTimeOffset.UtcNow,
|
||||
ActorId: draft.ActorId,
|
||||
ActorType: draft.ActorType,
|
||||
ExpectedSequence: expectedSequence,
|
||||
ProvidedPreviousHash: providedPreviousHash,
|
||||
ExpectedPreviousHash: expectedPreviousHash);
|
||||
|
||||
_incidentDiagnostics.RecordConflict(snapshot);
|
||||
}
|
||||
|
||||
private static string DetermineSource(LedgerEventDraft draft)
|
||||
{
|
||||
if (draft.SourceRunId.HasValue)
|
||||
|
||||
@@ -154,7 +154,12 @@ public sealed class ScoredFindingsExportService : IScoredFindingsExportService
|
||||
finding.RiskProfileVersion,
|
||||
finding.RiskExplanationId,
|
||||
finding.ExplainRef,
|
||||
finding.UpdatedAt
|
||||
finding.UpdatedAt,
|
||||
finding.AttestationStatus,
|
||||
finding.AttestationCount,
|
||||
finding.VerifiedAttestationCount,
|
||||
finding.FailedAttestationCount,
|
||||
finding.UnverifiedAttestationCount
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using StellaOps.Findings.Ledger.Infrastructure.Attestation;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Services;
|
||||
|
||||
/// <summary>
|
||||
@@ -18,6 +20,9 @@ public sealed record ScoredFindingsQuery
|
||||
public int Limit { get; init; } = 50;
|
||||
public ScoredFindingsSortField SortBy { get; init; } = ScoredFindingsSortField.RiskScore;
|
||||
public bool Descending { get; init; } = true;
|
||||
public IReadOnlyList<AttestationType>? AttestationTypes { get; init; }
|
||||
public AttestationVerificationFilter? AttestationVerification { get; init; }
|
||||
public OverallVerificationStatus? AttestationStatus { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -57,6 +62,11 @@ public sealed record ScoredFinding
|
||||
public Guid? RiskExplanationId { get; init; }
|
||||
public string? ExplainRef { get; init; }
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
public int AttestationCount { get; init; }
|
||||
public int VerifiedAttestationCount { get; init; }
|
||||
public int FailedAttestationCount { get; init; }
|
||||
public int UnverifiedAttestationCount { get; init; }
|
||||
public OverallVerificationStatus AttestationStatus { get; init; } = OverallVerificationStatus.NoAttestations;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -164,7 +164,12 @@ public sealed class ScoredFindingsQueryService : IScoredFindingsQueryService
|
||||
RiskProfileVersion = projection.RiskProfileVersion,
|
||||
RiskExplanationId = projection.RiskExplanationId,
|
||||
ExplainRef = projection.ExplainRef,
|
||||
UpdatedAt = projection.UpdatedAt
|
||||
UpdatedAt = projection.UpdatedAt,
|
||||
AttestationCount = projection.AttestationCount,
|
||||
VerifiedAttestationCount = projection.VerifiedAttestationCount,
|
||||
FailedAttestationCount = projection.FailedAttestationCount,
|
||||
UnverifiedAttestationCount = projection.UnverifiedAttestationCount,
|
||||
AttestationStatus = projection.AttestationStatus
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
namespace StellaOps.Findings.Ledger.Services;
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
@@ -7,6 +8,7 @@ using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.Infrastructure.Snapshot;
|
||||
using StellaOps.Findings.Ledger.Observability;
|
||||
using StellaOps.Findings.Ledger.Services.Incident;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing ledger snapshots and time-travel queries.
|
||||
@@ -17,15 +19,18 @@ public sealed class SnapshotService
|
||||
private readonly ITimeTravelRepository _timeTravelRepository;
|
||||
private readonly ILogger<SnapshotService> _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly ILedgerIncidentDiagnostics? _incidentDiagnostics;
|
||||
|
||||
public SnapshotService(
|
||||
ISnapshotRepository snapshotRepository,
|
||||
ITimeTravelRepository timeTravelRepository,
|
||||
ILogger<SnapshotService> logger)
|
||||
ILogger<SnapshotService> logger,
|
||||
ILedgerIncidentDiagnostics? incidentDiagnostics = null)
|
||||
{
|
||||
_snapshotRepository = snapshotRepository;
|
||||
_timeTravelRepository = timeTravelRepository;
|
||||
_logger = logger;
|
||||
_incidentDiagnostics = incidentDiagnostics;
|
||||
_jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
@@ -42,32 +47,33 @@ public sealed class SnapshotService
|
||||
{
|
||||
try
|
||||
{
|
||||
var effectiveInput = ApplyIncidentRetention(input);
|
||||
_logger.LogInformation(
|
||||
"Creating snapshot for tenant {TenantId} at sequence {Sequence} / timestamp {Timestamp}",
|
||||
input.TenantId,
|
||||
input.AtSequence,
|
||||
input.AtTimestamp);
|
||||
effectiveInput.TenantId,
|
||||
effectiveInput.AtSequence,
|
||||
effectiveInput.AtTimestamp);
|
||||
|
||||
// Get current ledger state
|
||||
var currentPoint = await _timeTravelRepository.GetCurrentPointAsync(input.TenantId, ct);
|
||||
var currentPoint = await _timeTravelRepository.GetCurrentPointAsync(effectiveInput.TenantId, ct);
|
||||
|
||||
// Create the snapshot record
|
||||
var snapshot = await _snapshotRepository.CreateAsync(
|
||||
input.TenantId,
|
||||
input,
|
||||
effectiveInput.TenantId,
|
||||
effectiveInput,
|
||||
currentPoint.SequenceNumber,
|
||||
currentPoint.Timestamp,
|
||||
ct);
|
||||
|
||||
// Compute statistics asynchronously
|
||||
var statistics = await ComputeStatisticsAsync(
|
||||
input.TenantId,
|
||||
effectiveInput.TenantId,
|
||||
snapshot.SequenceNumber,
|
||||
input.IncludeEntityTypes,
|
||||
effectiveInput.IncludeEntityTypes,
|
||||
ct);
|
||||
|
||||
await _snapshotRepository.UpdateStatisticsAsync(
|
||||
input.TenantId,
|
||||
effectiveInput.TenantId,
|
||||
snapshot.SnapshotId,
|
||||
statistics,
|
||||
ct);
|
||||
@@ -79,12 +85,12 @@ public sealed class SnapshotService
|
||||
if (input.Sign)
|
||||
{
|
||||
merkleRoot = await ComputeMerkleRootAsync(
|
||||
input.TenantId,
|
||||
effectiveInput.TenantId,
|
||||
snapshot.SequenceNumber,
|
||||
ct);
|
||||
|
||||
await _snapshotRepository.SetMerkleRootAsync(
|
||||
input.TenantId,
|
||||
effectiveInput.TenantId,
|
||||
snapshot.SnapshotId,
|
||||
merkleRoot,
|
||||
dsseDigest,
|
||||
@@ -93,20 +99,20 @@ public sealed class SnapshotService
|
||||
|
||||
// Mark as available
|
||||
await _snapshotRepository.UpdateStatusAsync(
|
||||
input.TenantId,
|
||||
effectiveInput.TenantId,
|
||||
snapshot.SnapshotId,
|
||||
SnapshotStatus.Available,
|
||||
ct);
|
||||
|
||||
// Retrieve updated snapshot
|
||||
var finalSnapshot = await _snapshotRepository.GetByIdAsync(
|
||||
input.TenantId,
|
||||
effectiveInput.TenantId,
|
||||
snapshot.SnapshotId,
|
||||
ct);
|
||||
|
||||
LedgerTimeline.EmitSnapshotCreated(
|
||||
_logger,
|
||||
input.TenantId,
|
||||
effectiveInput.TenantId,
|
||||
snapshot.SnapshotId,
|
||||
snapshot.SequenceNumber,
|
||||
statistics.FindingsCount);
|
||||
@@ -196,7 +202,20 @@ public sealed class SnapshotService
|
||||
ReplayRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return await _timeTravelRepository.ReplayEventsAsync(request, ct);
|
||||
var result = await _timeTravelRepository.ReplayEventsAsync(request, ct);
|
||||
|
||||
_incidentDiagnostics?.RecordReplayTrace(new ReplayTraceSample(
|
||||
TenantId: request.TenantId,
|
||||
FromSequence: result.Metadata.FromSequence,
|
||||
ToSequence: result.Metadata.ToSequence,
|
||||
EventsCount: result.Metadata.EventsCount,
|
||||
HasMore: result.Metadata.HasMore,
|
||||
DurationMs: result.Metadata.ReplayDurationMs,
|
||||
ObservedAt: DateTimeOffset.UtcNow,
|
||||
ChainFilterCount: request.ChainIds?.Count ?? 0,
|
||||
EventTypeFilterCount: request.EventTypes?.Count ?? 0));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -249,6 +268,15 @@ public sealed class SnapshotService
|
||||
public async Task<int> ExpireOldSnapshotsAsync(CancellationToken ct = default)
|
||||
{
|
||||
var cutoff = DateTimeOffset.UtcNow;
|
||||
if (_incidentDiagnostics?.IsActive == true && _incidentDiagnostics.Current.RetentionExtensionDays > 0)
|
||||
{
|
||||
cutoff = cutoff.AddDays(-_incidentDiagnostics.Current.RetentionExtensionDays);
|
||||
_logger.LogInformation(
|
||||
"Incident mode active; extending snapshot expiry cutoff by {ExtensionDays} days (activation {ActivationId}).",
|
||||
_incidentDiagnostics.Current.RetentionExtensionDays,
|
||||
_incidentDiagnostics.Current.ActivationId ?? string.Empty);
|
||||
}
|
||||
|
||||
var count = await _snapshotRepository.ExpireSnapshotsAsync(cutoff, ct);
|
||||
|
||||
if (count > 0)
|
||||
@@ -367,4 +395,44 @@ public sealed class SnapshotService
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return Convert.ToHexStringLower(bytes);
|
||||
}
|
||||
|
||||
private CreateSnapshotInput ApplyIncidentRetention(CreateSnapshotInput input)
|
||||
{
|
||||
if (_incidentDiagnostics is null || !_incidentDiagnostics.IsActive)
|
||||
{
|
||||
return input;
|
||||
}
|
||||
|
||||
var incident = _incidentDiagnostics.Current;
|
||||
if (incident.RetentionExtensionDays <= 0)
|
||||
{
|
||||
return input;
|
||||
}
|
||||
|
||||
TimeSpan? expiresIn = input.ExpiresIn;
|
||||
if (expiresIn.HasValue)
|
||||
{
|
||||
expiresIn = expiresIn.Value.Add(TimeSpan.FromDays(incident.RetentionExtensionDays));
|
||||
}
|
||||
|
||||
var metadata = input.Metadata is null
|
||||
? new Dictionary<string, object>()
|
||||
: new Dictionary<string, object>(input.Metadata);
|
||||
|
||||
metadata["incident.mode"] = "enabled";
|
||||
metadata["incident.activationId"] = incident.ActivationId ?? string.Empty;
|
||||
metadata["incident.retentionExtensionDays"] = incident.RetentionExtensionDays;
|
||||
metadata["incident.changedAt"] = incident.ChangedAt.ToString("O");
|
||||
if (incident.ExpiresAt is not null)
|
||||
{
|
||||
metadata["incident.expiresAt"] = incident.ExpiresAt.Value.ToString("O");
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Incident mode active; extending snapshot retention by {ExtensionDays} days (activation {ActivationId}).",
|
||||
incident.RetentionExtensionDays,
|
||||
incident.ActivationId ?? string.Empty);
|
||||
|
||||
return input with { ExpiresIn = expiresIn, Metadata = metadata };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="..\..\Telemetry\StellaOps.Telemetry.Core\StellaOps.Telemetry.Core\StellaOps.Telemetry.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Findings Ledger · Sprint 0120-0000-0001
|
||||
# Findings Ledger · Sprint 0120-0000-0001
|
||||
|
||||
| Task ID | Status | Notes | Updated (UTC) |
|
||||
| --- | --- | --- | --- |
|
||||
@@ -8,9 +8,18 @@
|
||||
|
||||
Status changes must be mirrored in `docs/implplan/SPRINT_0120_0001_0001_policy_reasoning.md`.
|
||||
|
||||
# Findings Ledger · Sprint 0121-0001-0001
|
||||
# Findings Ledger · Sprint 0121-0001-0001
|
||||
|
||||
| Task ID | Status | Notes | Updated (UTC) |
|
||||
| --- | --- | --- | --- |
|
||||
| LEDGER-OBS-54-001 | DONE | Implemented `/v1/ledger/attestations` with deterministic paging, filter hash guard, and schema/OpenAPI updates. | 2025-11-22 |
|
||||
| LEDGER-GAPS-121-009 | DONE | FL1–FL10 remediation: schema catalog + export canonicals, Merkle/external anchor policy, tenant isolation/redaction manifest, offline verifier + checksum guard, golden fixtures, backpressure metrics. | 2025-12-02 |
|
||||
| LEDGER-GAPS-121-009 | DONE | FL1–FL10 remediation: schema catalog + export canonicals, Merkle/external anchor policy, tenant isolation/redaction manifest, offline verifier + checksum guard, golden fixtures, backpressure metrics. | 2025-12-02 |
|
||||
# Findings Ledger Aú Sprint 0121-0001-0002
|
||||
|
||||
| Task ID | Status | Notes | Updated (UTC) |
|
||||
| --- | --- | --- | --- |
|
||||
| LEDGER-ATTEST-73-002 | DONE | Verification-result and attestation-status filters wired into findings projection queries and exports; tests added. | 2025-12-08 |
|
||||
| LEDGER-OAS-61-002 | DONE | `/.well-known/openapi` serves spec with version/build headers, ETag, cache hints. | 2025-12-08 |
|
||||
| LEDGER-OAS-62-001 | DONE | SDK-facing OpenAPI assertions for pagination, evidence links, provenance added. | 2025-12-08 |
|
||||
| LEDGER-OAS-63-001 | DONE | Deprecation headers and notifications applied to legacy findings export endpoint. | 2025-12-08 |
|
||||
| LEDGER-OBS-55-001 | DONE | Incident-mode diagnostics (lag/conflict/replay traces), retention extension for snapshots, timeline/notifier hooks. | 2025-12-08 |
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Findings.Ledger.Infrastructure.Attestation;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Tests;
|
||||
|
||||
public class AttestationStatusCalculatorTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0, 0, OverallVerificationStatus.NoAttestations)]
|
||||
[InlineData(3, 3, OverallVerificationStatus.AllVerified)]
|
||||
[InlineData(4, 1, OverallVerificationStatus.PartiallyVerified)]
|
||||
[InlineData(2, 0, OverallVerificationStatus.NoneVerified)]
|
||||
public void Compute_ReturnsExpectedStatus(int attestationCount, int verifiedCount, OverallVerificationStatus expected)
|
||||
{
|
||||
AttestationStatusCalculator.Compute(attestationCount, verifiedCount)
|
||||
.Should().Be(expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using StellaOps.Findings.Ledger;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Tests;
|
||||
|
||||
public class DeprecationHeadersTests
|
||||
{
|
||||
[Fact]
|
||||
public void Apply_SetsStandardDeprecationHeaders()
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
DeprecationHeaders.Apply(context.Response, "ledger.export.findings");
|
||||
|
||||
context.Response.Headers["Deprecation"].ToString().Should().Be("true");
|
||||
context.Response.Headers["Sunset"].ToString().Should().Be(DeprecationHeaders.SunsetDate);
|
||||
context.Response.Headers["X-Deprecated-Endpoint"].ToString().Should().Be("ledger.export.findings");
|
||||
context.Response.Headers["Link"].ToString().Should().Contain("/.well-known/openapi");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Findings.Ledger.OpenApi;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Tests;
|
||||
|
||||
public class OpenApiMetadataFactoryTests
|
||||
{
|
||||
[Fact]
|
||||
public void ComputeEtag_IsDeterministicAndWeak()
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes("spec-content");
|
||||
|
||||
var etag1 = OpenApiMetadataFactory.ComputeEtag(bytes);
|
||||
var etag2 = OpenApiMetadataFactory.ComputeEtag(bytes);
|
||||
|
||||
etag1.Should().StartWith("W/\"");
|
||||
etag1.Should().Be(etag2);
|
||||
etag1.Length.Should().BeGreaterThan(6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSpecPath_ResolvesExistingSpec()
|
||||
{
|
||||
var path = OpenApiMetadataFactory.GetSpecPath(AppContext.BaseDirectory);
|
||||
File.Exists(path).Should().BeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Findings.Ledger.OpenApi;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Tests;
|
||||
|
||||
public class OpenApiSdkSurfaceTests
|
||||
{
|
||||
private readonly string _specContent;
|
||||
|
||||
public OpenApiSdkSurfaceTests()
|
||||
{
|
||||
var path = OpenApiMetadataFactory.GetSpecPath(AppContext.BaseDirectory);
|
||||
_specContent = File.ReadAllText(path, Encoding.UTF8);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindingsEndpoints_ExposePaginationAndFilters()
|
||||
{
|
||||
_specContent.Should().Contain("/findings");
|
||||
_specContent.Should().Contain("page_token");
|
||||
_specContent.Should().MatchRegex("nextPageToken|next_page_token");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvidenceSchemas_ExposeEvidenceLinks()
|
||||
{
|
||||
_specContent.Should().Contain("evidenceBundleRef");
|
||||
_specContent.Should().Contain("ExportProvenance");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AttestationPointers_ExposeProvenanceMetadata()
|
||||
{
|
||||
_specContent.Should().Contain("/v1/ledger/attestations");
|
||||
_specContent.Should().Contain("attestation");
|
||||
_specContent.Should().Contain("provenance");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.Infrastructure;
|
||||
using StellaOps.Findings.Ledger.Infrastructure.Attestation;
|
||||
using StellaOps.Findings.Ledger.Services;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Tests;
|
||||
|
||||
public class ScoredFindingsQueryServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task QueryAsync_MapsAttestationMetadata()
|
||||
{
|
||||
var projection = new FindingProjection(
|
||||
TenantId: "tenant-a",
|
||||
FindingId: "finding-123",
|
||||
PolicyVersion: "v1",
|
||||
Status: "affected",
|
||||
Severity: 7.5m,
|
||||
RiskScore: 0.9m,
|
||||
RiskSeverity: "critical",
|
||||
RiskProfileVersion: "p1",
|
||||
RiskExplanationId: Guid.NewGuid(),
|
||||
RiskEventSequence: 42,
|
||||
Labels: new(),
|
||||
CurrentEventId: Guid.NewGuid(),
|
||||
ExplainRef: "explain-1",
|
||||
PolicyRationale: new(),
|
||||
UpdatedAt: DateTimeOffset.UtcNow,
|
||||
CycleHash: "abc123",
|
||||
AttestationCount: 3,
|
||||
VerifiedAttestationCount: 2,
|
||||
FailedAttestationCount: 1,
|
||||
UnverifiedAttestationCount: 0,
|
||||
AttestationStatus: OverallVerificationStatus.PartiallyVerified);
|
||||
|
||||
var repo = new FakeFindingProjectionRepository(projection);
|
||||
var service = new ScoredFindingsQueryService(
|
||||
repo,
|
||||
new NullRiskExplanationStore(),
|
||||
TimeProvider.System,
|
||||
NullLogger<ScoredFindingsQueryService>.Instance);
|
||||
|
||||
var result = await service.QueryAsync(new ScoredFindingsQuery
|
||||
{
|
||||
TenantId = "tenant-a",
|
||||
Limit = 10
|
||||
});
|
||||
|
||||
result.TotalCount.Should().Be(1);
|
||||
result.Findings.Should().HaveCount(1);
|
||||
|
||||
var finding = result.Findings.Single();
|
||||
finding.AttestationCount.Should().Be(3);
|
||||
finding.VerifiedAttestationCount.Should().Be(2);
|
||||
finding.FailedAttestationCount.Should().Be(1);
|
||||
finding.UnverifiedAttestationCount.Should().Be(0);
|
||||
finding.AttestationStatus.Should().Be(OverallVerificationStatus.PartiallyVerified);
|
||||
}
|
||||
|
||||
private sealed class FakeFindingProjectionRepository : IFindingProjectionRepository
|
||||
{
|
||||
private readonly FindingProjection _projection;
|
||||
|
||||
public FakeFindingProjectionRepository(FindingProjection projection)
|
||||
{
|
||||
_projection = projection;
|
||||
}
|
||||
|
||||
public Task<ProjectionCheckpoint> GetCheckpointAsync(CancellationToken cancellationToken) =>
|
||||
Task.FromResult(ProjectionCheckpoint.Initial(TimeProvider.System));
|
||||
|
||||
public Task<(IReadOnlyList<FindingProjection> Projections, int TotalCount)> QueryScoredAsync(
|
||||
ScoredFindingsQuery query,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult((new List<FindingProjection> { _projection } as IReadOnlyList<FindingProjection>, 1));
|
||||
|
||||
public Task<FindingStatsResult> GetFindingStatsSinceAsync(string tenantId, DateTimeOffset since, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(new FindingStatsResult(0, 0, 0, 0, 0, 0));
|
||||
|
||||
public Task<(int Total, int Scored, decimal AvgScore, decimal MaxScore)> GetRiskAggregatesAsync(string tenantId, string? policyVersion, CancellationToken cancellationToken) =>
|
||||
Task.FromResult((0, 0, 0m, 0m));
|
||||
|
||||
public Task<ScoreDistribution> GetScoreDistributionAsync(string tenantId, string? policyVersion, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(new ScoreDistribution());
|
||||
|
||||
public Task<SeverityDistribution> GetSeverityDistributionAsync(string tenantId, string? policyVersion, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(new SeverityDistribution());
|
||||
|
||||
public Task SaveCheckpointAsync(ProjectionCheckpoint checkpoint, CancellationToken cancellationToken) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
public Task InsertHistoryAsync(FindingHistoryEntry entry, CancellationToken cancellationToken) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
public Task InsertActionAsync(TriageActionEntry entry, CancellationToken cancellationToken) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
public Task<FindingProjection?> GetAsync(string tenantId, string findingId, string policyVersion, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<FindingProjection?>(_projection);
|
||||
|
||||
public Task UpsertAsync(FindingProjection projection, CancellationToken cancellationToken) =>
|
||||
Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class NullRiskExplanationStore : IRiskExplanationStore
|
||||
{
|
||||
public Task<ScoredFindingExplanation?> GetAsync(string tenantId, string findingId, Guid? explanationId, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<ScoredFindingExplanation?>(null);
|
||||
|
||||
public Task StoreAsync(string tenantId, ScoredFindingExplanation explanation, CancellationToken cancellationToken) =>
|
||||
Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user