Type to search pages and rules.

Browse pages

Build an Audit Trail

Build the audit trail that the audit extension requires, on the LiteBus command pipeline of the .NET stack.

Markdown

Purpose#

Build the audit trail that the audit extension requires, on the LiteBus command pipeline of the .NET stack.

This guide carries no provision. Each step cites the provisions it satisfies.

Prerequisites#

Start with:

  • The audit extension selected in litespec.project.json.
  • The adoption decision required by litespec/ext/audit#record-the-audit-adoption-decision.
  • A command pipeline that commits once for each Command.
  • A database account that the application does not use for business writes.
  • The LiteBus release that litespec.manifest.json pins for the LiteBus.* packages.
  • The pipeline reference in the docs/ directory of the LiteBus v7 branch.

Read the extension page before this guide. The extension states the obligations. This guide states one compliant way to meet them.

Procedure#

Define the record shape#

Model the record once, as the concrete form of litespec/ext/audit#record-the-required-audit-fields.

FieldTypeHoldsProvision
auditIdUUID v7Record identity, ordered by write timelitespec/ext/audit#record-the-required-audit-fields
occurredAtTimestamp with zoneBusiness time of the attemptlitespec/ext/audit#record-the-required-audit-fields
recordedAtTimestamp with zoneTime the evidence was writtenlitespec/ext/audit#record-the-required-audit-fields
actionUse case identityThe invoked use caselitespec/ext/audit#state-the-action-as-use-case-identity
categoryClosed setRetention and review classificationlitespec/ext/audit#use-a-fixed-category-vocabulary
outcomeClosed setsucceeded, denied, or failedlitespec/ext/audit#record-every-attempt-outcome
failureCodeStable codeThe refusal or failure reasonlitespec/ext/audit#record-the-failure-code-on-an-unsuccessful-attempt
actorKindClosed setPerson, device, or named processlitespec/ext/audit#use-a-closed-actor-set
actorIdPseudonymous identifierThe acting accountlitespec/ext/audit#record-the-required-audit-fields
actorDeviceIdPseudonymous identifierThe acting devicelitespec/ext/audit#use-a-closed-actor-set
actorProcessNameThe acting scheduled processlitespec/ext/audit#use-a-closed-actor-set
underGrantBooleanThe actor used a support grantlitespec/ext/audit#record-delegated-administrative-access
tenantIdIdentifierThe tenant that owns the datalitespec/ext/audit#scope-every-record-to-its-tenant
targetKindNameThe kind of thing acted onlitespec/ext/audit#record-the-required-audit-fields
targetIdIdentifierIts identitylitespec/ext/audit#record-the-required-audit-fields
reasonBounded textWhy, where the action requires onelitespec/ext/audit#record-the-required-audit-fields
sourceIpAddressRequest originlitespec/ext/audit#record-the-request-origin
userAgentBounded textThe calling clientlitespec/ext/audit#record-the-request-origin
requestIdIdentifierThe request that produced the attemptlitespec/ext/audit#record-the-request-origin
traceIdIdentifierJoin key to the diagnostic loglitespec/ext/audit#record-the-operation-trace-identity
schemaVersionIntegerThe shape this record was written inlitespec/ext/audit#version-the-audit-record-shape
previousHashHashThe preceding record hashlitespec/ext/audit#provide-tamper-evidence
entryHashHashHash over this canonical recordlitespec/ext/audit#provide-tamper-evidence

The shape holds no name, no address, no message body, and no state snapshot. That exclusion is what litespec/ext/audit#exclude-personal-data-beyond-actor-identity and litespec/ext/audit#remove-the-identity-mapping-on-erasure depend on.

Declare the selection on each Command#

Attach the constant half of the record to the Command type, following litespec/ext/audit#declare-audit-selection-beside-the-command.

C#
[Audited("catalog.add-product", TargetKind = "product", Category = AuditCategory.Configuration)]
public sealed record AddProductCommand(...) : ICommand<AddProductCommandResult>;

[NotAudited("A public catalog read discloses no tenant-owned data.")]
public sealed record BrowseStorefrontQuery(...) : IQuery<BrowseStorefrontQueryResult>;

Write an architecture test over every ICommand implementation. Fail the build when a type carries neither attribute, which is litespec/ext/audit#reject-an-undeclared-command.

Carry request context to the pipeline#

Capture the origin at the HTTP boundary and place it where the pipeline reads it. The endpoint already resolves the actor from verified claims under litespec/quality/security#derive-the-actor-from-claims.

Register middleware that reads the connection address, the user agent, and the request identifier. Expose them through a scoped accessor that the audit producer resolves. This satisfies litespec/ext/audit#record-the-request-origin and litespec/ext/audit#record-the-operation-trace-identity.

Open the audit scope in a pre-handler#

Implement ICommandPreHandler, the global pre-handler that runs for every Command.

Read the declaration from the Command type. Resolve the actor, the tenant, the clock, and the request context. Put the assembled scope into IExecutionContext.Items, which flows through the whole pipeline on the ambient execution context.

C#
internal sealed class OpenAuditScopePreHandler(IAuditScopeFactory factory) : ICommandPreHandler
{
    public Task PreHandleAsync(ICommand message, CancellationToken cancellationToken = default)
    {
        var scope = factory.CreateFor(message);
        if (scope is not null)
        {
            AmbientExecutionContext.Current.Items[AuditScope.Key] = scope;
        }

        return Task.CompletedTask;
    }
}

Enrich from the handler#

Let the handler contribute the values it alone determines, which is litespec/ext/audit#restrict-handler-contribution-to-determined-values. A generated identity and a composed reason are the usual two.

C#
var product = Product.Create(ProductId.New(), ...);
products.Store(product);
audit.Target(product.Id);

The handler sets no actor, action, timestamp, or outcome. The pipeline owns those.

Close the scope as succeeded#

Implement ICommandPostHandler. LiteBus runs a post-handler only when the main handler returns, so reaching this stage is itself the success signal.

Stage the record in the same session as the business change, which is litespec/ext/audit#commit-a-success-record-with-its-business-change.

Order matters. The post-handler that commits the session runs in the same stage, so give the commit an explicit later priority. HandlerPriorityAttribute orders handlers by ascending value, and a handler with no attribute takes priority 0.

C#
[HandlerPriority(10)]
internal sealed class WriteAuditRecordPostHandler : ICommandPostHandler { }

[HandlerPriority(20)]
internal sealed class CommitChangesPostHandler : ICommandPostHandler { }

Leaving both at the default value makes the order depend on registration, which litespec/ext/audit#stage-the-audit-write-before-the-business-commit rejects.

Close the scope as denied or failed#

Implement ICommandErrorHandler, which runs when any stage throws. Map the authorization refusal to denied and every other failure to failed.

Write this record on a connection that the failing transaction does not roll back, following litespec/ext/audit#write-an-unsuccessful-record-outside-the-failed-transaction. Open a second session, write, and commit it there.

C#
internal sealed class WriteFailedAuditRecordErrorHandler(IAuditFailureWriter writer) : ICommandErrorHandler
{
    public async Task HandleErrorAsync(ICommand message, Exception exception, CancellationToken cancellationToken = default)
    {
        var scope = AmbientExecutionContext.Current.Items[AuditScope.Key] as AuditScope;
        if (scope is null)
        {
            writer.CountMissingScope();
            return;
        }

        var outcome = exception is UseCaseForbiddenException ? AuditOutcome.Denied : AuditOutcome.Failed;
        await writer.WriteOutOfBandAsync(scope.Complete(outcome, exception), cancellationToken);
    }
}

Publish the write failure count of this path, which is litespec/ext/audit#publish-unsuccessful-write-failures. Count a missing scope on the same counter before returning. The separate connection accepts a rare loss at process death, and only the counter separates that from a defect.

Protect the store#

Create a role that holds INSERT and SELECT on the audit table and holds neither UPDATE nor DELETE. Write through that role, satisfying litespec/ext/audit#enforce-append-only-through-storage-privilege.

Compute entryHash over the canonical record content and the preceding previousHash. Run a recurring job that walks the chain and raises an alert on a break, satisfying litespec/ext/audit#provide-tamper-evidence and litespec/ext/audit#verify-the-integrity-chain-on-a-schedule.

Provide the read path#

Add a query use case that filters by tenant, actor, action, target, and outcome. Gate it on a permission, satisfying litespec/ext/audit#give-the-trail-a-read-path.

Declare that query audited. Reading the evidence is a sensitive action under litespec/ext/audit#audit-reads-of-the-trail.

Verification#

Run the extension verification table in the audit extension. The table names one evidence row for each provision this guide implements.

Confirm four behaviors by test before review:

  • A refused authorization leaves one denied record after the transaction rolls back.
  • A committed Command leaves exactly one succeeded record in the same transaction.
  • An UPDATE against the audit table returns a privilege error.
  • Each behavior above is a Case on its own use case page, proved by the test that asserts it.