Skip to main content

Audit Trail

The audit layer writes a durable record for every secret operation: who accessed which key, when it happened, and whether it succeeded. It is separate from logging and metrics:

ConcernPurposeRetention
LoggingOperator diagnosticsShort, sampled
MetricsAggregates and alertsTime series
Audit trailPer-access compliance recordLong, durable

Audit records never contain a secret value. Failed operations record the exception type, not the exception message, because backend messages can echo the submitted payload.

Usage​

await using var provider = new SecretProviderBuilder()
.WithVault(options =>
{
options.Url = "https://vault:8200";
options.Token = token;
})
.WithAudit(options =>
{
options.Sinks.Add(new FileSecretAuditSink("/var/log/secrets-audit.jsonl"));
options.Scope = SecretAuditScope.All;
options.OnSinkFailure = AuditFailureMode.Ignore;
options.OnSinkError = ex => logger.LogError(ex, "audit sink failed");
})
.Build();

WithAuditSink is the shorthand when only a sink is needed:

var provider = new SecretProviderBuilder()
.WithFileSystem(options => options.BasePath = "/etc/secrets")
.WithAuditSink(new FileSecretAuditSink("/var/log/secrets-audit.jsonl"))
.Build();

The builder fills in the registered provider name automatically. Records contain:

PropertyDescription
OperationGetSecretAsync, PutSecretAsync, DeleteSecretAsync, GetSecretVersionsAsync, SecretExistsAsync, or DisposeAsync
ProviderRegistered provider name, for example vault or postgresql
KeyTarget secret key, when the operation targets one
VersionResolved version, when the operation returns one
OutcomeSuccess, Failure, Cancelled, or Denied
TimestampOperation start time, captured by the library
DurationHow long the operation took
ActorId / ActorDisplayNameIdentity from the ambient actor accessor
CorrelationIdFrom Activity baggage, when the host set correlation.id or correlation-id
TraceIdCurrent Activity trace id
FailureTypeException type name when the operation failed

Scope​

Scope selects which operations are audited. The default is All.

.WithAudit(options =>
{
options.Scope = SecretAuditScope.Reads | SecretAuditScope.Deletes;
})
ValueOperations
ReadsGetSecretAsync
WritesPutSecretAsync
DeletesDeleteSecretAsync
EnumerationGetSecretVersionsAsync, SecretExistsAsync
AllEverything above, plus DisposeAsync

SecretExistsAsync and GetSecretVersionsAsync are treated as enumeration because they reveal secret names even when they do not reveal values.

Actor propagation​

The library cannot infer the caller. Push an actor per request or message scope:

public sealed class SecretActorMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context, ISecretActorAccessor accessor)
{
using var _ = accessor.Push(new SecretActor(
Id: context.User.FindFirst("sub")?.Value ?? "anonymous",
DisplayName: context.User.Identity?.Name));

await next(context);
}
}

The default accessor is AmbientSecretActorAccessor.Instance, which flows the actor through AsyncLocal<T>, so it follows async continuations. AddSecretsManager registers that instance as ISecretActorAccessor in DI. Set AuditLayerOptions.ActorAccessor to use a different implementation.

Failure isolation​

By default, a broken sink never changes the outcome of a secret operation:

  • Every sink call is wrapped.
  • Sink errors go to OnSinkError; they do not propagate.
  • Audit writes use CancellationToken.None, so a record is still written when the caller cancels.
  • On a failed operation the original exception is always rethrown, even with OnSinkFailure = AuditFailureMode.Throw.

Set OnSinkFailure = AuditFailureMode.Throw only when the audit trail is a hard requirement, and document the resulting outage risk. It is not the default. Every registered sink still receives the record before the failure is rethrown.

warning

Audit records are written after the provider operation completes. For PutSecretAsync and DeleteSecretAsync with Throw, a sink failure is reported to the caller even though the mutation has already committed. An outer retry layer may then repeat that mutation. Reads are fail-closed because the value is not returned.

Layer ordering​

The audit layer records the call it wraps. Placing it outside WithRetry records one event per logical call; placing it inside records one event per retry attempt. Choose the position deliberately and keep it stable across services.

File sink​

FileSecretAuditSink writes one JSON object per line (JSON Lines), append-only.

var sink = new FileSecretAuditSink("/var/log/secrets-audit.jsonl", new FileSecretAuditSinkOptions
{
FlushEachWrite = true,
MaxFileSizeBytes = 100 * 1024 * 1024
});
OptionTypeRequiredDefaultDescription
FlushEachWriteboolNotrueForce each record to durable storage. An unflushed audit record is not an audit record
MaxFileSizeByteslong?NonullRotate to <name>.<UTC timestamp><extension> when the file reaches this size, for example audit.20261021T1200000000000.jsonl. Rotated files are never overwritten or deleted

The parent directory is created when it does not exist. On Unix, a newly created sink file is opened with 0600 permissions. Rotation keeps the extension, so *.jsonl globs still find rotated files. Retention is the host's responsibility: rotated files are never deleted.

PostgreSQL sink​

The SecretsManager.PostgreSql package ships PostgreSqlSecretAuditSink:

Program.cs
var provider = new SecretProviderBuilder()
.WithPostgreSql(options =>
{
options.ConnectionString = "Host=localhost;Database=secrets;Username=app;Password=secret";
})
.WithPostgreSqlAuditSink(options =>
{
options.ConnectionString = "Host=localhost;Database=secrets;Username=app;Password=secret";
options.Schema = "public";
options.TableName = "sm_secret_audit_log";
})
.Build();
OptionTypeRequiredDefaultDescription
ConnectionStringstringYes""PostgreSQL connection string
SchemastringNo"public"Schema for the audit table
TableNamestringNo"sm_secret_audit_log"Audit table name
AutoCreateSchemaboolNofalseCreate the table and timestamp index on first write. Requires CREATE on the schema

Schema and TableName must be letters, digits, or underscores and start with a letter or underscore. TableName is capped at 48 characters so the generated <table>_timestamp_idx index name fits PostgreSQL's 63-character identifier limit.

With AutoCreateSchema = false, create the table from a migration role:

CREATE TABLE public.sm_secret_audit_log (
id BIGSERIAL PRIMARY KEY,
"timestamp" TIMESTAMPTZ NOT NULL,
"operation" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"key" TEXT,
"version" TEXT,
"outcome" TEXT NOT NULL,
"duration_ms" DOUBLE PRECISION NOT NULL,
"actor_id" TEXT,
"actor_display_name" TEXT,
"correlation_id" TEXT,
"trace_id" TEXT,
"failure_type" TEXT
);

CREATE INDEX sm_secret_audit_log_timestamp_idx
ON public.sm_secret_audit_log ("timestamp");

GRANT SELECT, INSERT ON public.sm_secret_audit_log TO secrets_app;
GRANT USAGE ON SEQUENCE public.sm_secret_audit_log_id_seq TO secrets_app;

The sink stores Outcome as text (Success, Failure, Cancelled, Denied) and Duration as milliseconds. It uses the same Npgsql connection string conventions as the PostgreSQL provider; add SSL Mode=VerifyFull when the database is not a trusted local socket.

Configuration​

Sinks are objects, so they cannot come from appsettings.json. The policy can:

{
"SecretsManager": {
"Audit": {
"Enabled": true,
"Scope": "All",
"OnSinkFailure": "Ignore"
}
}
}
var provider = new SecretProviderBuilder()
.ConfigurePostgreSql(configuration)
.ConfigureAudit(configuration)
.WithPostgreSqlAuditSink(options =>
{
options.ConnectionString = configuration.GetConnectionString("Audit")!;
})
.Build();

ConfigureAudit adds the layer with the policy from configuration. A sink must still be registered with WithAuditSink or WithPostgreSqlAuditSink. Build fails when the layer is enabled and has no sinks.

Custom sinks​

Implement ISecretAuditSink. Implementations must be thread-safe:

public sealed class MyAuditSink : ISecretAuditSink
{
public ValueTask WriteAsync(
SecretAuditEvent auditEvent,
CancellationToken cancellationToken = default)
{
// Forward to your SIEM or database.
return ValueTask.CompletedTask;
}
}

The interface is deliberately small so database and SIEM connectors stay consumer-provided unless a package ships one, as SecretsManager.PostgreSql does.