Type to search pages and rules.

Browse pages

Build an Audit Trail

An audit trail proves who attempted what against which data and what each attempt produced, so a product can answer for every sensitive action. This guide builds that proof on the LiteBus pipeline that runs each state change in the .NET backend: the entry shape, the pipeline handlers, the protected store, and the read path. Each step names the audit rule it satisfies, and the guide adds no rule of its own.

Markdown

Prerequisites#

Start with:

  • The audit extension selected in the consumer's .litespec/project.json.
  • The audit adoption decision that litespec/ext/audit#record-the-audit-adoption-decision requires.
  • The audit field on each command record, set to its reason and target or left at its default of audited with reason none.
  • A LiteBus command pipeline that commits once for each command.
  • A database account reserved for audit writes, separate from the account that writes business data.
  • The LiteBus release pinned for the LiteBus.* packages in litespec.manifest.json.
  • The pipeline reference in the docs/ folder of the LiteBus v7 branch, which defines the handler interfaces this guide implements.

Read the audit extension before this guide: it states the obligations, and this guide shows one compliant way to meet them.

Procedure#

Define the Record Shape#

Model the audit record once, using the fields the extension requires in litespec/ext/audit#record-the-required-audit-fields. Each row below names the provision that requires it.

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-refusal-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 carries no person name, no address, no message body, and no state snapshot. That exclusion lets the product meet litespec/ext/audit#exclude-personal-data-beyond-actor-identity and litespec/ext/audit#remove-the-identity-mapping-on-erasure.

Declare the Selection on Each Command#

Attach the values fixed per use case (action identity, target kind, category) to the command type with an attribute, as litespec/ext/audit#declare-audit-selection-beside-the-command requires. Declare the audit target and reason beside the message: the attribute names the message fields that carry the target identity and the reason value, so the pipeline records both on every outcome, including a denial. The command record's audit field selects which use cases are audited and why; async work audits at request time.

C#
[Audited("catalog.add-product", TargetKind = "product", Category = AuditCategory.Configuration, Target = nameof(AddProductCommand.ProductId), Reason = nameof(AddProductCommand.Reason))]
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 that scans every ICommand implementation and fails the build when a type carries neither attribute. That test implements litespec/ext/audit#reject-an-undeclared-command.

Carry Request Context to the Pipeline#

Capture the request 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, and expose the three 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 handler interface that LiteBus runs before every command.

Read the declaration from the command type. Resolve the actor, the tenant, the clock, and the request context, and put the assembled scope into IExecutionContext.Items. The ambient execution context carries those items through every later pipeline stage.

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 command handler contribute only the values it alone determines, as litespec/ext/audit#restrict-handler-contribution-to-determined-values requires. A generated identity and a reason value composed from loaded state are the usual two.

C#
var product = Product.Create(ProductId.New(), ...);
productRepository.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 audit record in the same session as the business change, as litespec/ext/audit#commit-a-success-record-with-its-business-change requires.

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 { }

When both handlers keep the default priority, the order depends on registration order, 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 outside the failing transaction, so the rollback does not remove it, as litespec/ext/audit#write-an-unsuccessful-record-outside-the-failed-transaction requires. 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, as litespec/ext/audit#publish-unsuccessful-write-failures requires. Count a missing scope on the same counter before returning. The separate connection can lose a record when the process dies mid-write; the counter is what distinguishes that rare loss from a defect.

Protect the Store#

Create a database role that holds INSERT and SELECT on the audit table and holds neither UPDATE nor DELETE, and write through that role. This satisfies 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. The pair satisfies 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, and gate it on a permission. This satisfies litespec/ext/audit#give-the-trail-a-read-path.

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

Check#

Work through the provisions in the audit extension; each one this guide implements ends with the evidence to check.

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 scenario on its own use case page, proved by the test that asserts it.