- Wire StellaOps.Audit.Emission DI in: Authority, Policy, Release-Orchestrator, EvidenceLocker, Notify, Scanner, Scheduler, Integrations, Platform - Add AuditEmission__TimelineBaseUrl to compose defaults - Endpoint filter annotation deferred to follow-up pass Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
420 lines
23 KiB
C#
420 lines
23 KiB
C#
// Licensed under BUSL-1.1. Copyright (C) 2026 StellaOps Contributors.
|
|
// Postgres-backed repositories for VulnExplorer adapters (VXLM-005 gap fix).
|
|
// Replaces ConcurrentDictionary in-memory stores with durable persistence.
|
|
|
|
using Microsoft.Extensions.Logging;
|
|
using Npgsql;
|
|
using NpgsqlTypes;
|
|
using StellaOps.Findings.Ledger.Infrastructure.Postgres;
|
|
using StellaOps.Findings.Ledger.WebService.Contracts.VulnExplorer;
|
|
using System.Text.Json;
|
|
|
|
namespace StellaOps.Findings.Ledger.WebService.Services;
|
|
|
|
// ============================================================================
|
|
// Interfaces
|
|
// ============================================================================
|
|
|
|
public interface IVexDecisionRepository
|
|
{
|
|
Task<VexDecisionDto> CreateAsync(string tenantId, VexDecisionDto decision, CancellationToken ct = default);
|
|
Task<VexDecisionDto?> UpdateAsync(string tenantId, Guid id, UpdateVexDecisionRequest request, DateTimeOffset updatedAt, CancellationToken ct = default);
|
|
Task<VexDecisionDto?> GetAsync(string tenantId, Guid id, CancellationToken ct = default);
|
|
Task<IReadOnlyList<VexDecisionDto>> QueryAsync(string tenantId, string? vulnerabilityId, string? subjectName, VexStatus? status, int skip, int take, CancellationToken ct = default);
|
|
Task<int> CountAsync(string tenantId, CancellationToken ct = default);
|
|
}
|
|
|
|
public interface IFixVerificationRepository
|
|
{
|
|
Task<FixVerificationRecord> CreateAsync(string tenantId, FixVerificationRecord record, CancellationToken ct = default);
|
|
Task<FixVerificationRecord?> UpdateAsync(string tenantId, string cveId, string verdict, FixVerificationTransition transition, CancellationToken ct = default);
|
|
}
|
|
|
|
public interface IAuditBundleRepository
|
|
{
|
|
Task<AuditBundleResponse> CreateAsync(string tenantId, AuditBundleResponse bundle, IReadOnlyList<Guid> decisionIds, CancellationToken ct = default);
|
|
Task<string> NextBundleIdAsync(string tenantId, CancellationToken ct = default);
|
|
}
|
|
|
|
// ============================================================================
|
|
// JSON serialization options (shared)
|
|
// ============================================================================
|
|
|
|
internal static class VulnExplorerJsonOptions
|
|
{
|
|
internal static readonly JsonSerializerOptions Default = new()
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
WriteIndented = false,
|
|
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
|
|
};
|
|
}
|
|
|
|
// ============================================================================
|
|
// PostgresVexDecisionRepository
|
|
// ============================================================================
|
|
|
|
public sealed class PostgresVexDecisionRepository : IVexDecisionRepository
|
|
{
|
|
private readonly LedgerDataSource _dataSource;
|
|
private readonly ILogger<PostgresVexDecisionRepository> _logger;
|
|
|
|
public PostgresVexDecisionRepository(LedgerDataSource dataSource, ILogger<PostgresVexDecisionRepository> logger)
|
|
{
|
|
_dataSource = dataSource;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<VexDecisionDto> CreateAsync(string tenantId, VexDecisionDto decision, CancellationToken ct = default)
|
|
{
|
|
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "vex-write", ct).ConfigureAwait(false);
|
|
|
|
const string sql = """
|
|
INSERT INTO findings.vex_decisions
|
|
(id, tenant_id, vulnerability_id, subject, status, justification_type,
|
|
justification_text, evidence_refs, scope, valid_for, attestation_ref,
|
|
signed_override, supersedes_decision_id, created_by, created_at, updated_at)
|
|
VALUES
|
|
(@id, @tenant_id, @vulnerability_id, @subject::jsonb, @status, @justification_type,
|
|
@justification_text, @evidence_refs::jsonb, @scope::jsonb, @valid_for::jsonb,
|
|
@attestation_ref::jsonb, @signed_override::jsonb, @supersedes_decision_id,
|
|
@created_by::jsonb, @created_at, @updated_at)
|
|
""";
|
|
|
|
await using var cmd = new NpgsqlCommand(sql, connection);
|
|
cmd.Parameters.AddWithValue("id", decision.Id);
|
|
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
|
cmd.Parameters.AddWithValue("vulnerability_id", decision.VulnerabilityId);
|
|
cmd.Parameters.Add(new NpgsqlParameter("subject", NpgsqlDbType.Jsonb) { Value = JsonSerializer.Serialize(decision.Subject, VulnExplorerJsonOptions.Default) });
|
|
cmd.Parameters.AddWithValue("status", decision.Status.ToString());
|
|
cmd.Parameters.AddWithValue("justification_type", decision.JustificationType.ToString());
|
|
cmd.Parameters.AddWithValue("justification_text", (object?)decision.JustificationText ?? DBNull.Value);
|
|
cmd.Parameters.Add(new NpgsqlParameter("evidence_refs", NpgsqlDbType.Jsonb) { Value = decision.EvidenceRefs is not null ? JsonSerializer.Serialize(decision.EvidenceRefs, VulnExplorerJsonOptions.Default) : DBNull.Value });
|
|
cmd.Parameters.Add(new NpgsqlParameter("scope", NpgsqlDbType.Jsonb) { Value = decision.Scope is not null ? JsonSerializer.Serialize(decision.Scope, VulnExplorerJsonOptions.Default) : DBNull.Value });
|
|
cmd.Parameters.Add(new NpgsqlParameter("valid_for", NpgsqlDbType.Jsonb) { Value = decision.ValidFor is not null ? JsonSerializer.Serialize(decision.ValidFor, VulnExplorerJsonOptions.Default) : DBNull.Value });
|
|
cmd.Parameters.Add(new NpgsqlParameter("attestation_ref", NpgsqlDbType.Jsonb) { Value = decision.AttestationRef is not null ? JsonSerializer.Serialize(decision.AttestationRef, VulnExplorerJsonOptions.Default) : DBNull.Value });
|
|
cmd.Parameters.Add(new NpgsqlParameter("signed_override", NpgsqlDbType.Jsonb) { Value = decision.SignedOverride is not null ? JsonSerializer.Serialize(decision.SignedOverride, VulnExplorerJsonOptions.Default) : DBNull.Value });
|
|
cmd.Parameters.AddWithValue("supersedes_decision_id", (object?)decision.SupersedesDecisionId ?? DBNull.Value);
|
|
cmd.Parameters.Add(new NpgsqlParameter("created_by", NpgsqlDbType.Jsonb) { Value = JsonSerializer.Serialize(decision.CreatedBy, VulnExplorerJsonOptions.Default) });
|
|
cmd.Parameters.AddWithValue("created_at", decision.CreatedAt);
|
|
cmd.Parameters.AddWithValue("updated_at", (object?)decision.UpdatedAt ?? DBNull.Value);
|
|
|
|
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
|
_logger.LogDebug("Created VEX decision {DecisionId} for tenant {TenantId}", decision.Id, tenantId);
|
|
return decision;
|
|
}
|
|
|
|
public async Task<VexDecisionDto?> UpdateAsync(string tenantId, Guid id, UpdateVexDecisionRequest request, DateTimeOffset updatedAt, CancellationToken ct = default)
|
|
{
|
|
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "vex-write", ct).ConfigureAwait(false);
|
|
|
|
// First fetch the existing record
|
|
var existing = await GetInternalAsync(connection, id, ct).ConfigureAwait(false);
|
|
if (existing is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var updated = existing with
|
|
{
|
|
Status = request.Status ?? existing.Status,
|
|
JustificationType = request.JustificationType ?? existing.JustificationType,
|
|
JustificationText = request.JustificationText ?? existing.JustificationText,
|
|
EvidenceRefs = request.EvidenceRefs ?? existing.EvidenceRefs,
|
|
Scope = request.Scope ?? existing.Scope,
|
|
ValidFor = request.ValidFor ?? existing.ValidFor,
|
|
SupersedesDecisionId = request.SupersedesDecisionId ?? existing.SupersedesDecisionId,
|
|
UpdatedAt = updatedAt
|
|
};
|
|
|
|
const string sql = """
|
|
UPDATE findings.vex_decisions SET
|
|
status = @status,
|
|
justification_type = @justification_type,
|
|
justification_text = @justification_text,
|
|
evidence_refs = @evidence_refs::jsonb,
|
|
scope = @scope::jsonb,
|
|
valid_for = @valid_for::jsonb,
|
|
supersedes_decision_id = @supersedes_decision_id,
|
|
updated_at = @updated_at
|
|
WHERE id = @id
|
|
""";
|
|
|
|
await using var cmd = new NpgsqlCommand(sql, connection);
|
|
cmd.Parameters.AddWithValue("id", id);
|
|
cmd.Parameters.AddWithValue("status", updated.Status.ToString());
|
|
cmd.Parameters.AddWithValue("justification_type", updated.JustificationType.ToString());
|
|
cmd.Parameters.AddWithValue("justification_text", (object?)updated.JustificationText ?? DBNull.Value);
|
|
cmd.Parameters.Add(new NpgsqlParameter("evidence_refs", NpgsqlDbType.Jsonb) { Value = updated.EvidenceRefs is not null ? JsonSerializer.Serialize(updated.EvidenceRefs, VulnExplorerJsonOptions.Default) : DBNull.Value });
|
|
cmd.Parameters.Add(new NpgsqlParameter("scope", NpgsqlDbType.Jsonb) { Value = updated.Scope is not null ? JsonSerializer.Serialize(updated.Scope, VulnExplorerJsonOptions.Default) : DBNull.Value });
|
|
cmd.Parameters.Add(new NpgsqlParameter("valid_for", NpgsqlDbType.Jsonb) { Value = updated.ValidFor is not null ? JsonSerializer.Serialize(updated.ValidFor, VulnExplorerJsonOptions.Default) : DBNull.Value });
|
|
cmd.Parameters.AddWithValue("supersedes_decision_id", (object?)updated.SupersedesDecisionId ?? DBNull.Value);
|
|
cmd.Parameters.AddWithValue("updated_at", updatedAt);
|
|
|
|
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
|
_logger.LogDebug("Updated VEX decision {DecisionId} for tenant {TenantId}", id, tenantId);
|
|
return updated;
|
|
}
|
|
|
|
public async Task<VexDecisionDto?> GetAsync(string tenantId, Guid id, CancellationToken ct = default)
|
|
{
|
|
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "vex-read", ct).ConfigureAwait(false);
|
|
return await GetInternalAsync(connection, id, ct).ConfigureAwait(false);
|
|
}
|
|
|
|
public async Task<IReadOnlyList<VexDecisionDto>> QueryAsync(
|
|
string tenantId, string? vulnerabilityId, string? subjectName, VexStatus? status,
|
|
int skip, int take, CancellationToken ct = default)
|
|
{
|
|
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "vex-read", ct).ConfigureAwait(false);
|
|
|
|
var sql = "SELECT id, vulnerability_id, subject, status, justification_type, justification_text, evidence_refs, scope, valid_for, attestation_ref, signed_override, supersedes_decision_id, created_by, created_at, updated_at FROM findings.vex_decisions WHERE 1=1";
|
|
var parameters = new List<NpgsqlParameter>();
|
|
|
|
if (vulnerabilityId is not null)
|
|
{
|
|
sql += " AND vulnerability_id = @vulnerability_id";
|
|
parameters.Add(new NpgsqlParameter("vulnerability_id", vulnerabilityId));
|
|
}
|
|
|
|
if (subjectName is not null)
|
|
{
|
|
sql += " AND subject->>'name' ILIKE @subject_name";
|
|
parameters.Add(new NpgsqlParameter("subject_name", $"%{subjectName}%"));
|
|
}
|
|
|
|
if (status is not null)
|
|
{
|
|
sql += " AND status = @status";
|
|
parameters.Add(new NpgsqlParameter("status", status.Value.ToString()));
|
|
}
|
|
|
|
sql += " ORDER BY created_at DESC, id ASC LIMIT @take OFFSET @skip";
|
|
parameters.Add(new NpgsqlParameter("take", take));
|
|
parameters.Add(new NpgsqlParameter("skip", skip));
|
|
|
|
await using var cmd = new NpgsqlCommand(sql, connection);
|
|
cmd.Parameters.AddRange(parameters.ToArray());
|
|
|
|
var results = new List<VexDecisionDto>();
|
|
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
|
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
|
{
|
|
results.Add(MapDecision(reader));
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
public async Task<int> CountAsync(string tenantId, CancellationToken ct = default)
|
|
{
|
|
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "vex-read", ct).ConfigureAwait(false);
|
|
|
|
const string sql = "SELECT COUNT(*) FROM findings.vex_decisions";
|
|
await using var cmd = new NpgsqlCommand(sql, connection);
|
|
var result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
|
|
return Convert.ToInt32(result);
|
|
}
|
|
|
|
private static async Task<VexDecisionDto?> GetInternalAsync(NpgsqlConnection connection, Guid id, CancellationToken ct)
|
|
{
|
|
const string sql = """
|
|
SELECT id, vulnerability_id, subject, status, justification_type, justification_text,
|
|
evidence_refs, scope, valid_for, attestation_ref, signed_override,
|
|
supersedes_decision_id, created_by, created_at, updated_at
|
|
FROM findings.vex_decisions
|
|
WHERE id = @id
|
|
""";
|
|
|
|
await using var cmd = new NpgsqlCommand(sql, connection);
|
|
cmd.Parameters.AddWithValue("id", id);
|
|
|
|
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
|
return await reader.ReadAsync(ct).ConfigureAwait(false) ? MapDecision(reader) : null;
|
|
}
|
|
|
|
private static VexDecisionDto MapDecision(NpgsqlDataReader reader)
|
|
{
|
|
var statusStr = reader.GetString(reader.GetOrdinal("status"));
|
|
var justTypeStr = reader.GetString(reader.GetOrdinal("justification_type"));
|
|
|
|
return new VexDecisionDto(
|
|
Id: reader.GetGuid(reader.GetOrdinal("id")),
|
|
VulnerabilityId: reader.GetString(reader.GetOrdinal("vulnerability_id")),
|
|
Subject: JsonSerializer.Deserialize<SubjectRefDto>(reader.GetString(reader.GetOrdinal("subject")), VulnExplorerJsonOptions.Default)!,
|
|
Status: Enum.TryParse<VexStatus>(statusStr, ignoreCase: true, out var s) ? s : VexStatus.NotAffected,
|
|
JustificationType: Enum.TryParse<VexJustificationType>(justTypeStr, ignoreCase: true, out var j) ? j : VexJustificationType.Other,
|
|
JustificationText: reader.IsDBNull(reader.GetOrdinal("justification_text")) ? null : reader.GetString(reader.GetOrdinal("justification_text")),
|
|
EvidenceRefs: reader.IsDBNull(reader.GetOrdinal("evidence_refs")) ? null : JsonSerializer.Deserialize<IReadOnlyList<EvidenceRefDto>>(reader.GetString(reader.GetOrdinal("evidence_refs")), VulnExplorerJsonOptions.Default),
|
|
Scope: reader.IsDBNull(reader.GetOrdinal("scope")) ? null : JsonSerializer.Deserialize<VexScopeDto>(reader.GetString(reader.GetOrdinal("scope")), VulnExplorerJsonOptions.Default),
|
|
ValidFor: reader.IsDBNull(reader.GetOrdinal("valid_for")) ? null : JsonSerializer.Deserialize<ValidForDto>(reader.GetString(reader.GetOrdinal("valid_for")), VulnExplorerJsonOptions.Default),
|
|
AttestationRef: reader.IsDBNull(reader.GetOrdinal("attestation_ref")) ? null : JsonSerializer.Deserialize<AttestationRefDto>(reader.GetString(reader.GetOrdinal("attestation_ref")), VulnExplorerJsonOptions.Default),
|
|
SignedOverride: reader.IsDBNull(reader.GetOrdinal("signed_override")) ? null : JsonSerializer.Deserialize<VexOverrideAttestationDto>(reader.GetString(reader.GetOrdinal("signed_override")), VulnExplorerJsonOptions.Default),
|
|
SupersedesDecisionId: reader.IsDBNull(reader.GetOrdinal("supersedes_decision_id")) ? null : reader.GetGuid(reader.GetOrdinal("supersedes_decision_id")),
|
|
CreatedBy: JsonSerializer.Deserialize<ActorRefDto>(reader.GetString(reader.GetOrdinal("created_by")), VulnExplorerJsonOptions.Default)!,
|
|
CreatedAt: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
|
|
UpdatedAt: reader.IsDBNull(reader.GetOrdinal("updated_at")) ? null : reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("updated_at"))
|
|
);
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// PostgresFixVerificationRepository
|
|
// ============================================================================
|
|
|
|
public sealed class PostgresFixVerificationRepository : IFixVerificationRepository
|
|
{
|
|
private readonly LedgerDataSource _dataSource;
|
|
private readonly ILogger<PostgresFixVerificationRepository> _logger;
|
|
|
|
public PostgresFixVerificationRepository(LedgerDataSource dataSource, ILogger<PostgresFixVerificationRepository> logger)
|
|
{
|
|
_dataSource = dataSource;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<FixVerificationRecord> CreateAsync(string tenantId, FixVerificationRecord record, CancellationToken ct = default)
|
|
{
|
|
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "fix-write", ct).ConfigureAwait(false);
|
|
|
|
const string sql = """
|
|
INSERT INTO findings.fix_verifications
|
|
(cve_id, tenant_id, component_purl, artifact_digest, verdict, transitions, created_at, updated_at)
|
|
VALUES
|
|
(@cve_id, @tenant_id, @component_purl, @artifact_digest, @verdict, @transitions::jsonb, @created_at, @updated_at)
|
|
""";
|
|
|
|
await using var cmd = new NpgsqlCommand(sql, connection);
|
|
cmd.Parameters.AddWithValue("cve_id", record.CveId);
|
|
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
|
cmd.Parameters.AddWithValue("component_purl", record.ComponentPurl);
|
|
cmd.Parameters.AddWithValue("artifact_digest", (object?)record.ArtifactDigest ?? DBNull.Value);
|
|
cmd.Parameters.AddWithValue("verdict", record.Verdict);
|
|
cmd.Parameters.Add(new NpgsqlParameter("transitions", NpgsqlDbType.Jsonb) { Value = JsonSerializer.Serialize(record.Transitions, VulnExplorerJsonOptions.Default) });
|
|
cmd.Parameters.AddWithValue("created_at", record.CreatedAt);
|
|
cmd.Parameters.AddWithValue("updated_at", record.UpdatedAt);
|
|
|
|
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
|
_logger.LogDebug("Created fix verification for CVE {CveId} in tenant {TenantId}", record.CveId, tenantId);
|
|
return record;
|
|
}
|
|
|
|
public async Task<FixVerificationRecord?> UpdateAsync(string tenantId, string cveId, string verdict, FixVerificationTransition transition, CancellationToken ct = default)
|
|
{
|
|
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "fix-write", ct).ConfigureAwait(false);
|
|
|
|
// First fetch existing
|
|
var existing = await GetInternalAsync(connection, cveId, ct).ConfigureAwait(false);
|
|
if (existing is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var transitions = existing.Transitions.ToList();
|
|
transitions.Add(transition);
|
|
|
|
var updated = existing with
|
|
{
|
|
Verdict = verdict,
|
|
Transitions = transitions.ToArray(),
|
|
UpdatedAt = transition.ChangedAt
|
|
};
|
|
|
|
const string sql = """
|
|
UPDATE findings.fix_verifications SET
|
|
verdict = @verdict,
|
|
transitions = @transitions::jsonb,
|
|
updated_at = @updated_at
|
|
WHERE cve_id = @cve_id
|
|
""";
|
|
|
|
await using var cmd = new NpgsqlCommand(sql, connection);
|
|
cmd.Parameters.AddWithValue("cve_id", cveId);
|
|
cmd.Parameters.AddWithValue("verdict", verdict);
|
|
cmd.Parameters.Add(new NpgsqlParameter("transitions", NpgsqlDbType.Jsonb) { Value = JsonSerializer.Serialize(transitions, VulnExplorerJsonOptions.Default) });
|
|
cmd.Parameters.AddWithValue("updated_at", transition.ChangedAt);
|
|
|
|
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
|
_logger.LogDebug("Updated fix verification {CveId} to verdict {Verdict} in tenant {TenantId}", cveId, verdict, tenantId);
|
|
return updated;
|
|
}
|
|
|
|
private static async Task<FixVerificationRecord?> GetInternalAsync(NpgsqlConnection connection, string cveId, CancellationToken ct)
|
|
{
|
|
const string sql = """
|
|
SELECT cve_id, component_purl, artifact_digest, verdict, transitions, created_at, updated_at
|
|
FROM findings.fix_verifications
|
|
WHERE cve_id = @cve_id
|
|
""";
|
|
|
|
await using var cmd = new NpgsqlCommand(sql, connection);
|
|
cmd.Parameters.AddWithValue("cve_id", cveId);
|
|
|
|
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
|
if (!await reader.ReadAsync(ct).ConfigureAwait(false))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return new FixVerificationRecord(
|
|
CveId: reader.GetString(reader.GetOrdinal("cve_id")),
|
|
ComponentPurl: reader.GetString(reader.GetOrdinal("component_purl")),
|
|
ArtifactDigest: reader.IsDBNull(reader.GetOrdinal("artifact_digest")) ? null : reader.GetString(reader.GetOrdinal("artifact_digest")),
|
|
Verdict: reader.GetString(reader.GetOrdinal("verdict")),
|
|
Transitions: JsonSerializer.Deserialize<IReadOnlyList<FixVerificationTransition>>(reader.GetString(reader.GetOrdinal("transitions")), VulnExplorerJsonOptions.Default) ?? [],
|
|
CreatedAt: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
|
|
UpdatedAt: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("updated_at"))
|
|
);
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// PostgresAuditBundleRepository
|
|
// ============================================================================
|
|
|
|
public sealed class PostgresAuditBundleRepository : IAuditBundleRepository
|
|
{
|
|
private readonly LedgerDataSource _dataSource;
|
|
private readonly ILogger<PostgresAuditBundleRepository> _logger;
|
|
|
|
public PostgresAuditBundleRepository(LedgerDataSource dataSource, ILogger<PostgresAuditBundleRepository> logger)
|
|
{
|
|
_dataSource = dataSource;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<AuditBundleResponse> CreateAsync(string tenantId, AuditBundleResponse bundle, IReadOnlyList<Guid> decisionIds, CancellationToken ct = default)
|
|
{
|
|
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "audit-write", ct).ConfigureAwait(false);
|
|
|
|
const string sql = """
|
|
INSERT INTO findings.audit_bundles
|
|
(bundle_id, tenant_id, decision_ids, decisions, evidence_refs, created_at)
|
|
VALUES
|
|
(@bundle_id, @tenant_id, @decision_ids::jsonb, @decisions::jsonb, @evidence_refs::jsonb, @created_at)
|
|
""";
|
|
|
|
await using var cmd = new NpgsqlCommand(sql, connection);
|
|
cmd.Parameters.AddWithValue("bundle_id", bundle.BundleId);
|
|
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
|
cmd.Parameters.Add(new NpgsqlParameter("decision_ids", NpgsqlDbType.Jsonb) { Value = JsonSerializer.Serialize(decisionIds, VulnExplorerJsonOptions.Default) });
|
|
cmd.Parameters.Add(new NpgsqlParameter("decisions", NpgsqlDbType.Jsonb) { Value = JsonSerializer.Serialize(bundle.Decisions, VulnExplorerJsonOptions.Default) });
|
|
cmd.Parameters.Add(new NpgsqlParameter("evidence_refs", NpgsqlDbType.Jsonb) { Value = JsonSerializer.Serialize(bundle.EvidenceRefs, VulnExplorerJsonOptions.Default) });
|
|
cmd.Parameters.AddWithValue("created_at", bundle.CreatedAt);
|
|
|
|
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
|
_logger.LogDebug("Created audit bundle {BundleId} for tenant {TenantId}", bundle.BundleId, tenantId);
|
|
return bundle;
|
|
}
|
|
|
|
public async Task<string> NextBundleIdAsync(string tenantId, CancellationToken ct = default)
|
|
{
|
|
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "audit-write", ct).ConfigureAwait(false);
|
|
|
|
const string sql = "SELECT nextval('findings.audit_bundle_seq')";
|
|
await using var cmd = new NpgsqlCommand(sql, connection);
|
|
var result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
|
|
return $"bundle-{Convert.ToInt64(result):D6}";
|
|
}
|
|
}
|