331 lines
11 KiB
C#
331 lines
11 KiB
C#
using System.Text.Json;
|
|
using Microsoft.Extensions.Logging;
|
|
using Npgsql;
|
|
using NpgsqlTypes;
|
|
using StellaOps.Evidence.Core;
|
|
using StellaOps.Infrastructure.Postgres.Repositories;
|
|
|
|
namespace StellaOps.Evidence.Persistence.Postgres;
|
|
|
|
/// <summary>
|
|
/// PostgreSQL implementation of <see cref="IEvidenceStore"/>.
|
|
/// Stores evidence records with content-addressed IDs and tenant isolation via RLS.
|
|
/// </summary>
|
|
public sealed class PostgresEvidenceStore : RepositoryBase<EvidenceDataSource>, IEvidenceStore
|
|
{
|
|
private readonly string _tenantId;
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
|
};
|
|
|
|
/// <summary>
|
|
/// Creates a new PostgreSQL evidence store for the specified tenant.
|
|
/// </summary>
|
|
/// <param name="dataSource">Evidence data source.</param>
|
|
/// <param name="tenantId">Tenant identifier for RLS.</param>
|
|
/// <param name="logger">Logger instance.</param>
|
|
public PostgresEvidenceStore(
|
|
EvidenceDataSource dataSource,
|
|
string tenantId,
|
|
ILogger<PostgresEvidenceStore> logger)
|
|
: base(dataSource, logger)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
|
_tenantId = tenantId;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<string> StoreAsync(IEvidence evidence, CancellationToken ct = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(evidence);
|
|
|
|
const string sql = """
|
|
INSERT INTO evidence.records (
|
|
evidence_id, subject_node_id, evidence_type, payload,
|
|
payload_schema_ver, external_cid, provenance, signatures, tenant_id
|
|
) VALUES (
|
|
@evidenceId, @subjectNodeId, @evidenceType, @payload,
|
|
@payloadSchemaVer, @externalCid, @provenance, @signatures, @tenantId
|
|
)
|
|
ON CONFLICT (evidence_id) DO NOTHING
|
|
RETURNING evidence_id
|
|
""";
|
|
|
|
await using var connection = await DataSource.OpenConnectionAsync(_tenantId, "writer", ct)
|
|
.ConfigureAwait(false);
|
|
await using var command = CreateCommand(sql, connection);
|
|
|
|
AddEvidenceParameters(command, evidence);
|
|
|
|
var result = await command.ExecuteScalarAsync(ct).ConfigureAwait(false);
|
|
|
|
// If result is null, row already existed (idempotent)
|
|
return evidence.EvidenceId;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<int> StoreBatchAsync(IEnumerable<IEvidence> evidenceRecords, CancellationToken ct = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(evidenceRecords);
|
|
|
|
var records = evidenceRecords.ToList();
|
|
if (records.Count == 0)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
await using var connection = await DataSource.OpenConnectionAsync(_tenantId, "writer", ct)
|
|
.ConfigureAwait(false);
|
|
await using var transaction = await connection.BeginTransactionAsync(ct).ConfigureAwait(false);
|
|
|
|
var storedCount = 0;
|
|
|
|
foreach (var evidence in records)
|
|
{
|
|
const string sql = """
|
|
INSERT INTO evidence.records (
|
|
evidence_id, subject_node_id, evidence_type, payload,
|
|
payload_schema_ver, external_cid, provenance, signatures, tenant_id
|
|
) VALUES (
|
|
@evidenceId, @subjectNodeId, @evidenceType, @payload,
|
|
@payloadSchemaVer, @externalCid, @provenance, @signatures, @tenantId
|
|
)
|
|
ON CONFLICT (evidence_id) DO NOTHING
|
|
""";
|
|
|
|
await using var command = new NpgsqlCommand(sql, connection, transaction)
|
|
{
|
|
CommandTimeout = CommandTimeoutSeconds
|
|
};
|
|
|
|
AddEvidenceParameters(command, evidence);
|
|
|
|
var affected = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
|
if (affected > 0)
|
|
{
|
|
storedCount++;
|
|
}
|
|
}
|
|
|
|
await transaction.CommitAsync(ct).ConfigureAwait(false);
|
|
return storedCount;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IEvidence?> GetByIdAsync(string evidenceId, CancellationToken ct = default)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(evidenceId);
|
|
|
|
const string sql = """
|
|
SELECT evidence_id, subject_node_id, evidence_type, payload,
|
|
payload_schema_ver, external_cid, provenance, signatures
|
|
FROM evidence.records
|
|
WHERE evidence_id = @evidenceId
|
|
AND tenant_id = @tenantId
|
|
""";
|
|
|
|
return await QuerySingleOrDefaultAsync<IEvidence>(
|
|
_tenantId,
|
|
sql,
|
|
cmd =>
|
|
{
|
|
AddParameter(cmd, "@evidenceId", evidenceId);
|
|
AddParameter(cmd, "@tenantId", Guid.Parse(_tenantId));
|
|
},
|
|
MapEvidence,
|
|
ct).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<IEvidence>> GetBySubjectAsync(
|
|
string subjectNodeId,
|
|
EvidenceType? typeFilter = null,
|
|
CancellationToken ct = default)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(subjectNodeId);
|
|
|
|
var sql = """
|
|
SELECT evidence_id, subject_node_id, evidence_type, payload,
|
|
payload_schema_ver, external_cid, provenance, signatures
|
|
FROM evidence.records
|
|
WHERE subject_node_id = @subjectNodeId
|
|
AND tenant_id = @tenantId
|
|
""";
|
|
|
|
if (typeFilter.HasValue)
|
|
{
|
|
sql += " AND evidence_type = @evidenceType";
|
|
}
|
|
|
|
sql += " ORDER BY created_at DESC";
|
|
|
|
return await QueryAsync<IEvidence>(
|
|
_tenantId,
|
|
sql,
|
|
cmd =>
|
|
{
|
|
AddParameter(cmd, "@subjectNodeId", subjectNodeId);
|
|
AddParameter(cmd, "@tenantId", Guid.Parse(_tenantId));
|
|
if (typeFilter.HasValue)
|
|
{
|
|
AddParameter(cmd, "@evidenceType", (short)typeFilter.Value);
|
|
}
|
|
},
|
|
MapEvidence,
|
|
ct).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<IEvidence>> GetByTypeAsync(
|
|
EvidenceType evidenceType,
|
|
int limit = 100,
|
|
CancellationToken ct = default)
|
|
{
|
|
const string sql = """
|
|
SELECT evidence_id, subject_node_id, evidence_type, payload,
|
|
payload_schema_ver, external_cid, provenance, signatures
|
|
FROM evidence.records
|
|
WHERE evidence_type = @evidenceType
|
|
AND tenant_id = @tenantId
|
|
ORDER BY created_at DESC
|
|
LIMIT @limit
|
|
""";
|
|
|
|
return await QueryAsync<IEvidence>(
|
|
_tenantId,
|
|
sql,
|
|
cmd =>
|
|
{
|
|
AddParameter(cmd, "@evidenceType", (short)evidenceType);
|
|
AddParameter(cmd, "@tenantId", Guid.Parse(_tenantId));
|
|
AddParameter(cmd, "@limit", limit);
|
|
},
|
|
MapEvidence,
|
|
ct).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<bool> ExistsAsync(string subjectNodeId, EvidenceType type, CancellationToken ct = default)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(subjectNodeId);
|
|
|
|
const string sql = """
|
|
SELECT EXISTS(
|
|
SELECT 1 FROM evidence.records
|
|
WHERE subject_node_id = @subjectNodeId
|
|
AND evidence_type = @evidenceType
|
|
AND tenant_id = @tenantId
|
|
)
|
|
""";
|
|
|
|
var result = await ExecuteScalarAsync<bool>(
|
|
_tenantId,
|
|
sql,
|
|
cmd =>
|
|
{
|
|
AddParameter(cmd, "@subjectNodeId", subjectNodeId);
|
|
AddParameter(cmd, "@evidenceType", (short)type);
|
|
AddParameter(cmd, "@tenantId", Guid.Parse(_tenantId));
|
|
},
|
|
ct).ConfigureAwait(false);
|
|
|
|
return result;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<bool> DeleteAsync(string evidenceId, CancellationToken ct = default)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(evidenceId);
|
|
|
|
const string sql = """
|
|
DELETE FROM evidence.records
|
|
WHERE evidence_id = @evidenceId
|
|
AND tenant_id = @tenantId
|
|
""";
|
|
|
|
var affected = await ExecuteAsync(
|
|
_tenantId,
|
|
sql,
|
|
cmd =>
|
|
{
|
|
AddParameter(cmd, "@evidenceId", evidenceId);
|
|
AddParameter(cmd, "@tenantId", Guid.Parse(_tenantId));
|
|
},
|
|
ct).ConfigureAwait(false);
|
|
|
|
return affected > 0;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<int> CountBySubjectAsync(string subjectNodeId, CancellationToken ct = default)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(subjectNodeId);
|
|
|
|
const string sql = """
|
|
SELECT COUNT(*)
|
|
FROM evidence.records
|
|
WHERE subject_node_id = @subjectNodeId
|
|
AND tenant_id = @tenantId
|
|
""";
|
|
|
|
var result = await ExecuteScalarAsync<long>(
|
|
_tenantId,
|
|
sql,
|
|
cmd =>
|
|
{
|
|
AddParameter(cmd, "@subjectNodeId", subjectNodeId);
|
|
AddParameter(cmd, "@tenantId", Guid.Parse(_tenantId));
|
|
},
|
|
ct).ConfigureAwait(false);
|
|
|
|
return (int)result;
|
|
}
|
|
|
|
private void AddEvidenceParameters(NpgsqlCommand command, IEvidence evidence)
|
|
{
|
|
AddParameter(command, "@evidenceId", evidence.EvidenceId);
|
|
AddParameter(command, "@subjectNodeId", evidence.SubjectNodeId);
|
|
AddParameter(command, "@evidenceType", (short)evidence.EvidenceType);
|
|
command.Parameters.Add(new NpgsqlParameter<byte[]>("@payload", NpgsqlDbType.Bytea)
|
|
{
|
|
TypedValue = evidence.Payload.ToArray()
|
|
});
|
|
AddParameter(command, "@payloadSchemaVer", evidence.PayloadSchemaVersion);
|
|
AddParameter(command, "@externalCid", evidence.ExternalPayloadCid);
|
|
AddJsonbParameter(command, "@provenance", JsonSerializer.Serialize(evidence.Provenance, JsonOptions));
|
|
AddJsonbParameter(command, "@signatures", JsonSerializer.Serialize(evidence.Signatures, JsonOptions));
|
|
AddParameter(command, "@tenantId", Guid.Parse(_tenantId));
|
|
}
|
|
|
|
private static IEvidence MapEvidence(NpgsqlDataReader reader)
|
|
{
|
|
var evidenceId = reader.GetString(0);
|
|
var subjectNodeId = reader.GetString(1);
|
|
var evidenceType = (EvidenceType)reader.GetInt16(2);
|
|
var payload = reader.GetFieldValue<byte[]>(3);
|
|
var payloadSchemaVer = reader.GetString(4);
|
|
var externalCid = GetNullableString(reader, 5);
|
|
var provenanceJson = reader.GetString(6);
|
|
var signaturesJson = reader.GetString(7);
|
|
|
|
var provenance = JsonSerializer.Deserialize<EvidenceProvenance>(provenanceJson, JsonOptions)
|
|
?? throw new InvalidOperationException($"Failed to deserialize provenance for evidence {evidenceId}");
|
|
|
|
var signatures = JsonSerializer.Deserialize<List<EvidenceSignature>>(signaturesJson, JsonOptions)
|
|
?? [];
|
|
|
|
return new EvidenceRecord
|
|
{
|
|
EvidenceId = evidenceId,
|
|
SubjectNodeId = subjectNodeId,
|
|
EvidenceType = evidenceType,
|
|
Payload = payload,
|
|
PayloadSchemaVersion = payloadSchemaVer,
|
|
ExternalPayloadCid = externalCid,
|
|
Provenance = provenance,
|
|
Signatures = signatures
|
|
};
|
|
}
|
|
}
|