< Summary

Information
Class: AsiBackbone.EntityFrameworkCore.Audit.EfCoreAuditLedgerStore
Assembly: AsiBackbone.EntityFrameworkCore
File(s): /home/runner/work/AsiBackbone/AsiBackbone/src/AsiBackbone.EntityFrameworkCore/Audit/EfCoreAuditLedgerStore.cs
Line coverage
99%
Covered lines: 242
Uncovered lines: 1
Coverable lines: 243
Total lines: 454
Line coverage: 99.5%
Branch coverage
90%
Covered branches: 18
Total branches: 20
Branch coverage: 90%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)100%11100%
AppendAsync()83.33%6696.43%
FindByRecordIdAsync()100%22100%
FindByCorrelationIdAsync()100%11100%
FindByTraceIdAsync()100%11100%
FindByActorIdAsync()100%11100%
FindByRecordedUtcRangeAsync()100%22100%
LedgerRecords()100%11100%
ToEntity(...)100%11100%
ToReasonCodeEntities(...)100%11100%
ToMetadataEntities(...)100%11100%
ToRecords(...)100%11100%
ToRecord(...)100%11100%
DeserializeReasonCodes(...)75%44100%
DeserializeMetadata(...)100%66100%
.ctor(...)100%11100%

File(s)

/home/runner/work/AsiBackbone/AsiBackbone/src/AsiBackbone.EntityFrameworkCore/Audit/EfCoreAuditLedgerStore.cs

#LineLine coverage
 1using System.Collections.ObjectModel;
 2using System.Text.Json;
 3using AsiBackbone.Core.Actors;
 4using AsiBackbone.Core.Audit;
 5using AsiBackbone.Core.Results;
 6using AsiBackbone.EntityFrameworkCore.Persistence;
 7using Microsoft.EntityFrameworkCore;
 8using Microsoft.Extensions.Logging;
 9
 10namespace AsiBackbone.EntityFrameworkCore.Audit;
 11
 12/// <summary>
 13/// Entity Framework Core-backed audit ledger store that persists records through a host-owned <see cref="DbContext" />.
 14/// </summary>
 15/// <remarks>
 16/// This store is append-oriented and intentionally relies on the host application to expose the ASI Backbone entities f
 17/// its own <see cref="DbContext" /> and migrations. It does not create a package-owned context or select a database pro
 18/// </remarks>
 19public sealed class EfCoreAuditLedgerStore : IGovernanceAuditLedgerStore
 20{
 21    private const string AppendFailedReasonCode = "asi_backbone.audit_ledger.append_failed";
 22    private const string AppendFailedReasonMessage =
 23        "The audit ledger record could not be persisted by the configured EF Core store.";
 24
 125    private static readonly Action<ILogger, string, Exception?> LogAuditLedgerAppendFailed =
 126        LoggerMessage.Define<string>(
 127            LogLevel.Error,
 128            new EventId(1001, nameof(LogAuditLedgerAppendFailed)),
 129            "EF Core audit ledger append failed for record {AuditLedgerRecordId}.");
 30
 131    private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
 32
 33    private readonly DbContext dbContext;
 34    private readonly ILogger<EfCoreAuditLedgerStore>? logger;
 35
 36    /// <summary>
 37    /// Initializes a new instance of the <see cref="EfCoreAuditLedgerStore" /> class.
 38    /// </summary>
 39    /// <param name="dbContext">The host-owned database context.</param>
 40    /// <param name="logger">The optional host-owned logger used for internal persistence diagnostics.</param>
 1041    public EfCoreAuditLedgerStore(
 1042        DbContext dbContext,
 1043        ILogger<EfCoreAuditLedgerStore>? logger = null)
 44    {
 1045        ArgumentNullException.ThrowIfNull(dbContext);
 46
 1047        this.dbContext = dbContext;
 1048        this.logger = logger;
 1049    }
 50
 51    /// <inheritdoc />
 52    public async ValueTask<OperationResult<AuditLedgerRecord>> AppendAsync(
 53        AuditLedgerRecord record,
 54        CancellationToken cancellationToken = default)
 55    {
 856        ArgumentNullException.ThrowIfNull(record);
 857        cancellationToken.ThrowIfCancellationRequested();
 58
 859        AuditLedgerRecordEntity entity = ToEntity(record);
 60
 861        _ = await dbContext
 862            .Set<AuditLedgerRecordEntity>()
 863            .AddAsync(entity, cancellationToken)
 864            .ConfigureAwait(false);
 65
 2666        foreach (AuditLedgerReasonCodeEntity reasonCode in ToReasonCodeEntities(entity.Id, record.ReasonCodes))
 67        {
 568            _ = await dbContext
 569                .Set<AuditLedgerReasonCodeEntity>()
 570                .AddAsync(reasonCode, cancellationToken)
 571                .ConfigureAwait(false);
 72        }
 73
 2474        foreach (AuditLedgerMetadataEntity metadata in ToMetadataEntities(entity.Id, record.Metadata))
 75        {
 476            _ = await dbContext
 477                .Set<AuditLedgerMetadataEntity>()
 478                .AddAsync(metadata, cancellationToken)
 479                .ConfigureAwait(false);
 80        }
 81
 82        try
 83        {
 884            _ = await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
 685        }
 286        catch (DbUpdateException ex)
 87        {
 288            dbContext.ChangeTracker.Clear();
 89
 290            if (logger is not null)
 91            {
 092                LogAuditLedgerAppendFailed(logger, record.RecordId, ex);
 93            }
 94
 295            return OperationResult.Failure<AuditLedgerRecord>(
 296                AppendFailedReasonCode,
 297                AppendFailedReasonMessage);
 98        }
 99
 6100        return OperationResult.Success(record);
 8101    }
 102
 103    /// <inheritdoc />
 104    public async ValueTask<AuditLedgerRecord?> FindByRecordIdAsync(
 105        string recordId,
 106        CancellationToken cancellationToken = default)
 107    {
 4108        ArgumentException.ThrowIfNullOrWhiteSpace(recordId);
 109
 4110        string normalizedRecordId = recordId.Trim();
 111
 4112        AuditLedgerRecordEntity? entity = await LedgerRecords()
 4113            .Where(record => record.RecordId == normalizedRecordId)
 4114            .SingleOrDefaultAsync(cancellationToken)
 4115            .ConfigureAwait(false);
 116
 4117        return entity is null ? null : ToRecord(entity);
 4118    }
 119
 120    /// <inheritdoc />
 121    public async ValueTask<IReadOnlyList<AuditLedgerRecord>> FindByCorrelationIdAsync(
 122        string correlationId,
 123        CancellationToken cancellationToken = default)
 124    {
 1125        ArgumentException.ThrowIfNullOrWhiteSpace(correlationId);
 126
 1127        string normalizedCorrelationId = correlationId.Trim();
 128
 1129        List<AuditLedgerRecordEntity> entities = await LedgerRecords()
 1130            .Where(record => record.CorrelationId == normalizedCorrelationId)
 1131            .OrderBy(record => record.RecordedUtc)
 1132            .ThenBy(record => record.RecordId)
 1133            .ToListAsync(cancellationToken)
 1134            .ConfigureAwait(false);
 135
 1136        return ToRecords(entities);
 1137    }
 138
 139    /// <inheritdoc />
 140    public async ValueTask<IReadOnlyList<AuditLedgerRecord>> FindByTraceIdAsync(
 141        string traceId,
 142        CancellationToken cancellationToken = default)
 143    {
 1144        ArgumentException.ThrowIfNullOrWhiteSpace(traceId);
 145
 1146        string normalizedTraceId = traceId.Trim();
 147
 1148        List<AuditLedgerRecordEntity> entities = await LedgerRecords()
 1149            .Where(record => record.TraceId == normalizedTraceId)
 1150            .OrderBy(record => record.RecordedUtc)
 1151            .ThenBy(record => record.RecordId)
 1152            .ToListAsync(cancellationToken)
 1153            .ConfigureAwait(false);
 154
 1155        return ToRecords(entities);
 1156    }
 157
 158    /// <inheritdoc />
 159    public async ValueTask<IReadOnlyList<AuditLedgerRecord>> FindByActorIdAsync(
 160        string actorId,
 161        CancellationToken cancellationToken = default)
 162    {
 1163        ArgumentException.ThrowIfNullOrWhiteSpace(actorId);
 164
 1165        string normalizedActorId = actorId.Trim();
 166
 1167        List<AuditLedgerRecordEntity> entities = await LedgerRecords()
 1168            .Where(record => record.ActorId == normalizedActorId)
 1169            .OrderBy(record => record.RecordedUtc)
 1170            .ThenBy(record => record.RecordId)
 1171            .ToListAsync(cancellationToken)
 1172            .ConfigureAwait(false);
 173
 1174        return ToRecords(entities);
 1175    }
 176
 177    /// <inheritdoc />
 178    public async ValueTask<IReadOnlyList<AuditLedgerRecord>> FindByRecordedUtcRangeAsync(
 179        DateTimeOffset recordedFromUtc,
 180        DateTimeOffset recordedToUtc,
 181        CancellationToken cancellationToken = default)
 182    {
 2183        DateTimeOffset normalizedFromUtc = recordedFromUtc.ToUniversalTime();
 2184        DateTimeOffset normalizedToUtc = recordedToUtc.ToUniversalTime();
 185
 2186        if (normalizedFromUtc > normalizedToUtc)
 187        {
 1188            throw new ArgumentException(
 1189                "The recorded UTC range start must be less than or equal to the range end.",
 1190                nameof(recordedFromUtc));
 191        }
 192
 1193        List<AuditLedgerRecordEntity> entities = await LedgerRecords()
 1194            .Where(record => record.RecordedUtc >= normalizedFromUtc && record.RecordedUtc <= normalizedToUtc)
 1195            .OrderBy(record => record.RecordedUtc)
 1196            .ThenBy(record => record.RecordId)
 1197            .ToListAsync(cancellationToken)
 1198            .ConfigureAwait(false);
 199
 1200        return ToRecords(entities);
 1201    }
 202
 203    private IQueryable<AuditLedgerRecordEntity> LedgerRecords()
 204    {
 8205        return dbContext.Set<AuditLedgerRecordEntity>().AsNoTracking();
 206    }
 207
 208    private static AuditLedgerRecordEntity ToEntity(AuditLedgerRecord record)
 209    {
 8210        return new AuditLedgerRecordEntity
 8211        {
 8212            RecordId = record.RecordId,
 8213            SchemaVersion = record.SchemaVersion,
 8214            EventId = record.EventId,
 8215            DecisionReceiptId = record.DecisionReceiptId,
 8216            OccurredUtc = record.OccurredUtc,
 8217            RecordedUtc = record.RecordedUtc,
 8218            ActorId = record.ActorId,
 8219            ActorType = record.ActorType,
 8220            ActorDisplayName = record.ActorDisplayName,
 8221            OperationName = record.OperationName,
 8222            Outcome = record.Outcome,
 8223            ReasonCodesJson = JsonSerializer.Serialize(record.ReasonCodes, JsonOptions),
 8224            CorrelationId = record.CorrelationId,
 8225            TraceId = record.TraceId,
 8226            SpanId = record.SpanId,
 8227            ParentSpanId = record.ParentSpanId,
 8228            DecisionLatencyMs = record.DecisionLatencyMs,
 8229            ConstraintSetHash = record.ConstraintSetHash,
 8230            ConstraintCount = record.ConstraintCount,
 8231            RiskScore = record.RiskScore,
 8232            PolicyScope = record.PolicyScope,
 8233            TenantHash = record.TenantHash,
 8234            OrganizationHash = record.OrganizationHash,
 8235            EmitterStatus = record.EmitterStatus,
 8236            EmitterProvider = record.EmitterProvider,
 8237            OutboxSequence = record.OutboxSequence,
 8238            GatewayExecutionId = record.GatewayExecutionId,
 8239            DecisionStage = record.DecisionStage,
 8240            PolicyVersion = record.PolicyVersion,
 8241            PolicyHash = record.PolicyHash,
 8242            HandshakeId = record.HandshakeId,
 8243            AcknowledgmentId = record.AcknowledgmentId,
 8244            CapabilityTokenId = record.CapabilityTokenId,
 8245            PreviousRecordHash = record.PreviousRecordHash,
 8246            RecordHash = record.RecordHash,
 8247            SigningHash = record.SigningHash,
 8248            SignatureKeyId = record.SignatureKeyId,
 8249            SignatureKeyVersion = record.SignatureKeyVersion,
 8250            SignatureAlgorithm = record.SignatureAlgorithm,
 8251            SignatureValue = record.SignatureValue,
 8252            SignatureProvider = record.SignatureProvider,
 8253            SignedUtc = record.SignedUtc,
 8254            MetadataJson = JsonSerializer.Serialize(record.Metadata, JsonOptions)
 8255        };
 256    }
 257
 258    private static AuditLedgerReasonCodeEntity[] ToReasonCodeEntities(
 259        Guid auditLedgerRecordId,
 260        IReadOnlyList<string> reasonCodes)
 261    {
 8262        return [.. reasonCodes
 8263            .Select((reasonCode, index) => new AuditLedgerReasonCodeEntity
 8264            {
 8265                AuditLedgerRecordId = auditLedgerRecordId,
 8266                Sequence = index,
 8267                ReasonCode = reasonCode
 8268            })];
 269    }
 270
 271    private static AuditLedgerMetadataEntity[] ToMetadataEntities(
 272        Guid auditLedgerRecordId,
 273        IReadOnlyDictionary<string, string> metadata)
 274    {
 8275        return [.. metadata
 8276            .Select(item => new AuditLedgerMetadataEntity
 8277            {
 8278                AuditLedgerRecordId = auditLedgerRecordId,
 8279                MetadataKey = item.Key,
 8280                MetadataValue = item.Value
 8281            })];
 282    }
 283
 284    private static AuditLedgerRecord[] ToRecords(IEnumerable<AuditLedgerRecordEntity> entities)
 285    {
 4286        return [.. entities.Select(ToRecord)];
 287    }
 288
 289    private static AuditLedgerRecord ToRecord(AuditLedgerRecordEntity entity)
 290    {
 10291        string[] reasonCodes = DeserializeReasonCodes(entity.ReasonCodesJson);
 10292        ReadOnlyDictionary<string, string> metadata = DeserializeMetadata(entity.MetadataJson);
 293
 10294        var residue = new EntityDecisionReceipt(
 10295            entity.EventId,
 10296            entity.DecisionReceiptId,
 10297            entity.SchemaVersion,
 10298            entity.OccurredUtc,
 10299            entity.ActorId,
 10300            entity.ActorType,
 10301            entity.ActorDisplayName,
 10302            entity.OperationName,
 10303            entity.Outcome,
 10304            Array.AsReadOnly(reasonCodes),
 10305            entity.CorrelationId,
 10306            entity.TraceId,
 10307            entity.SpanId,
 10308            entity.ParentSpanId,
 10309            entity.DecisionLatencyMs,
 10310            entity.ConstraintSetHash,
 10311            entity.ConstraintCount,
 10312            entity.RiskScore,
 10313            entity.PolicyScope,
 10314            entity.TenantHash,
 10315            entity.OrganizationHash,
 10316            entity.EmitterStatus,
 10317            entity.EmitterProvider,
 10318            entity.OutboxSequence,
 10319            entity.GatewayExecutionId,
 10320            entity.DecisionStage,
 10321            entity.PolicyVersion,
 10322            entity.PolicyHash,
 10323            metadata);
 324
 10325        return AuditLedgerRecord.FromDecisionReceipt(
 10326            residue,
 10327            entity.RecordId,
 10328            entity.RecordedUtc,
 10329            entity.HandshakeId,
 10330            entity.AcknowledgmentId,
 10331            entity.CapabilityTokenId,
 10332            entity.PreviousRecordHash,
 10333            entity.RecordHash,
 10334            entity.SignatureKeyId,
 10335            entity.SignatureAlgorithm,
 10336            entity.SignatureValue,
 10337            signingHash: entity.SigningHash,
 10338            signatureKeyVersion: entity.SignatureKeyVersion,
 10339            signatureProvider: entity.SignatureProvider,
 10340            signedUtc: entity.SignedUtc,
 10341            schemaVersion: entity.SchemaVersion);
 342    }
 343
 344    private static string[] DeserializeReasonCodes(string? json)
 345    {
 10346        return string.IsNullOrWhiteSpace(json)
 10347            ? []
 10348            : JsonSerializer.Deserialize<string[]>(json, JsonOptions) ?? [];
 349    }
 350
 351    private static ReadOnlyDictionary<string, string> DeserializeMetadata(string? json)
 352    {
 10353        if (string.IsNullOrWhiteSpace(json))
 354        {
 1355            return new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(StringComparer.Ordinal));
 356        }
 357
 9358        Dictionary<string, string>? metadata = JsonSerializer.Deserialize<Dictionary<string, string>>(json, JsonOptions)
 359
 9360        return metadata is null || metadata.Count == 0
 9361            ? new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(StringComparer.Ordinal))
 9362            : new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(metadata, StringComparer.Ordinal));
 363    }
 364
 10365    private sealed class EntityDecisionReceipt(
 10366        string eventId,
 10367        string? decisionReceiptId,
 10368        string schemaVersion,
 10369        DateTimeOffset occurredUtc,
 10370        string actorId,
 10371        GovernanceActorType actorType,
 10372        string? actorDisplayName,
 10373        string operationName,
 10374        string outcome,
 10375        IReadOnlyList<string> reasonCodes,
 10376        string? correlationId,
 10377        string? traceId,
 10378        string? spanId,
 10379        string? parentSpanId,
 10380        long? decisionLatencyMs,
 10381        string? constraintSetHash,
 10382        int? constraintCount,
 10383        double? riskScore,
 10384        string? policyScope,
 10385        string? tenantHash,
 10386        string? organizationHash,
 10387        string? emitterStatus,
 10388        string? emitterProvider,
 10389        long? outboxSequence,
 10390        string? gatewayExecutionId,
 10391        string? decisionStage,
 10392        string? policyVersion,
 10393        string? policyHash,
 10394        IReadOnlyDictionary<string, string> metadata) : IDecisionReceipt
 395    {
 396        public string EventId { get; } = eventId;
 397
 398        public string? DecisionReceiptId { get; } = decisionReceiptId;
 399
 400        public string SchemaVersion { get; } = schemaVersion;
 401
 402        public DateTimeOffset OccurredUtc { get; } = occurredUtc;
 403
 404        public string ActorId { get; } = actorId;
 405
 406        public GovernanceActorType ActorType { get; } = actorType;
 407
 408        public string? ActorDisplayName { get; } = actorDisplayName;
 409
 410        public string OperationName { get; } = operationName;
 411
 412        public string Outcome { get; } = outcome;
 413
 414        public IReadOnlyList<string> ReasonCodes { get; } = reasonCodes;
 415
 416        public string? CorrelationId { get; } = correlationId;
 417
 418        public string? TraceId { get; } = traceId;
 419
 420        public string? SpanId { get; } = spanId;
 421
 422        public string? ParentSpanId { get; } = parentSpanId;
 423
 424        public long? DecisionLatencyMs { get; } = decisionLatencyMs;
 425
 426        public string? ConstraintSetHash { get; } = constraintSetHash;
 427
 428        public int? ConstraintCount { get; } = constraintCount;
 429
 430        public double? RiskScore { get; } = riskScore;
 431
 432        public string? PolicyScope { get; } = policyScope;
 433
 434        public string? TenantHash { get; } = tenantHash;
 435
 436        public string? OrganizationHash { get; } = organizationHash;
 437
 438        public string? EmitterStatus { get; } = emitterStatus;
 439
 440        public string? EmitterProvider { get; } = emitterProvider;
 441
 442        public long? OutboxSequence { get; } = outboxSequence;
 443
 444        public string? GatewayExecutionId { get; } = gatewayExecutionId;
 445
 446        public string? DecisionStage { get; } = decisionStage;
 447
 448        public string? PolicyVersion { get; } = policyVersion;
 449
 450        public string? PolicyHash { get; } = policyHash;
 451
 452        public IReadOnlyDictionary<string, string> Metadata { get; } = metadata;
 453    }
 454}