Skip to content
Slicekit

Backend guides

Auditing

Record who did what by emitting audit events, and where the trail is stored and surfaced.

View .md
On this page

What auditing gives you

Slicekit emits a single, uniform AuditEvent envelope for every security-relevant action. Each event is sequenced into a SHA-256 hash chain, rendered as a structured Serilog log line, and exported over OTLP to Loki. From there it is queryable in two places: the in-app admin audit log, and Grafana. There is no audit table in Postgres (only the chain cursor lives there) and no app-side purge job. Retention is enforced by Loki’s compactor. See Observability for the full pipeline.

The plumbing lives in api/src/Slicekit.Core/Auditing/. The two types you touch as a feature author are IAuditService and AuditEvent.

Three categories, two ways in

Audit events fall into categories defined by AuditCategory.cs:

CategorySourceExamples
SecuritySecurityAuditEventHandler (subscribes to domain events) plus direct emits from handlersUser.LoggedIn, User.PasswordChanged, ApiKey.Created
DataChangeAuditingSaveChangesInterceptor (EF change tracking)User.Modified, User.PermissionAssigned
RequestAuditRequestMiddleware (every HTTP request)Http.GET, Http.POST with status, route, latency

Most of what you need is captured for free. Every authenticated HTTP request is audited by the middleware, every tracked entity change is captured by the EF interceptor, and most success paths are already covered because the aggregate raises a domain event that SecurityAuditEventHandler translates into an AuditEvent. See CQRS and events for how those events are raised and published.

You only emit by hand when an action has no natural domain event, or when you want richer metadata than the interceptor captures.

Emitting from a handler

Inject IAuditService and call EmitAsync. The interface is one method:

public interface IAuditService
{
    Task EmitAsync(AuditEvent evt, CancellationToken ct = default);
}

Here is the real emit in RevokeUserSessions/Handler.cs:

public sealed class RevokeUserSessionsCommandHandler(
    AppDbContext db,
    HybridCache cache,
    IOptions<AuthSettings> authOptions,
    IAuditService audit)
{
    public async Task<Result<int>> HandleAsync(RevokeUserSessionsCommand command, CancellationToken ct = default)
    {
        // ... revoke the tokens ...

        await audit.EmitAsync(new AuditEvent(
            AuditCategory.Security,
            "Admin.UserSessionsRevoked",
            AuditOutcome.Success,
            new AuditActor(UserId: command.ActorId),
            new AuditResource("User", command.UserId.ToString()),
            Metadata: new Dictionary<string, object?> { ["count"] = ids.Count }), ct);

        return ids.Count;
    }
}

EmitAsync enriches the actor and publishes to the durable audit-events Wolverine queue, then returns immediately. Hash chaining and log emission run off the request thread on the queue consumer (AuditEventHandler), so emitting never adds latency to the response.

Prefer raising a domain event and handling it in SecurityAuditEventHandler when the action already produces one: that keeps the audit out of your business logic. Emit directly when there is no event to lean on.

The fields you fill in

AuditEvent is an immutable record:

public sealed record AuditEvent(
    AuditCategory Category,
    string Action,
    AuditOutcome Outcome,
    AuditActor Actor,
    AuditResource? Resource = null,
    string? Reason = null,
    IReadOnlyDictionary<string, object?>? Metadata = null);
  • Category groups the event (Security, DataChange, Request, DataAccess).
  • Action is a dotted string verb like User.PasswordChanged or Admin.UserSessionsRevoked.
  • Outcome is Success or Failure. Use Failure for denials and failed attempts so the security dashboards and alerts can count them.
  • Actor is who did it. Pass new AuditActor(UserId: ...) and the rest is filled in for you (see below), or use AuditActor.System for background work and AuditActor.Anonymous for unauthenticated calls.
  • Resource is the thing acted on: new AuditResource("User", id.ToString()).
  • Reason is free text, surfaced on failures (e.g. a lockout reason).
  • Metadata is an optional bag of structured extras. Sensitive keys are redacted before the line is written (see below).

Sequence, PrevHash, and Hash are set by AuditEventHandler, not by you. Leave them at their defaults.

What the actor captures (and pseudonymizes)

AuditService.EnrichActor fills the actor from the current request before publishing, so you do not assemble PII yourself:

  • UserId (a Guid pseudonym) from the JWT subject, unless you passed one explicitly.
  • IpHash, an HMAC-SHA256 of the remote IP salted with Auditing:IpHashSalt, truncated to 16 hex chars. Plaintext IP never reaches Loki. Rotate the salt periodically for forward secrecy.
  • UserAgentFamily, a coarse string like Chrome/Windows, never the raw UA.
  • TraceId from the active activity, so an audit line links back to its trace.
  • OnBehalfOfUserId, read from the RFC 8693 act claim during admin impersonation: UserId is then the user being acted as, and OnBehalfOfUserId is the admin really doing it. See Authentication.

Plaintext email, IP, and user-agent are never written. This keeps the trail GDPR-safe by default.

The log line, and redaction

AuditEventHandler links the chain, persists the cursor, and writes a fixed-shape line:

audit <Category>/<Action> <Outcome> actor=<id@iphash> resource=<Type/Id> reason=<text-or-null> seq=<n> metadata=<json-or-empty>

metadata= is JSON serialized from AuditEvent.Metadata. The handler re-applies the redaction rules from Auditing:SensitivePropertyNames over the metadata, recursively, as a last line of defense: any key matching the sensitive patterns (or a property marked [Sensitive]) is replaced with the redaction marker before it can reach Loki, even if an upstream emitter forgot to redact.

Where it is surfaced

In-app admin audit log. Two admin endpoints proxy Loki so day-to-day lookups do not require Grafana access:

  • GET /api/v1/admin/audit-events: application-wide, with from/to/category/action/ outcome/search filters. Gated by Allow.AdminGetAuditEvents.
  • GET /api/v1/admin/users/{userId}/audit-events: same filters, scoped to one user. Gated by Allow.AdminGetUserAuditEvents.

Both parse the Loki log lines back into structured fields via AuditLineParser, and return a grafanaUrl so the SPA can deep-link any single event into Grafana Explore. When Loki is not configured the endpoints return lokiEnabled: false and an empty list, and the SPA shows a “Loki not configured” notice instead of an error. See Adding a permission for how the Allow.* gates are wired.

Grafana. The provisioned Slicekit - Audit & Security dashboard charts failed logins, denials, top actors, and the full audit event log, with a cross-link from each line to its trace. See Observability.

Opting an endpoint out

Request auditing is on by default for authenticated calls. To skip a specific endpoint, attach SkipAuditAttribute:

routes.MapGet("/health", HandleAsync)
    .WithMetadata(new SkipAuditAttribute());

Broader knobs live under Auditing in appsettings.json: Enabled (global on/off), IncludeAnonymousRequests, TrackedEntities/ExcludedEntities for the EF interceptor, SensitivePropertyNames for redaction, and RequestPathExclusions for the middleware. See the Settings pattern.

Tamper evidence

Every event carries Sequence, PrevHash, and Hash (SHA-256 over canonical JSON including the previous hash). The audit-events queue is sequential so the chain links one event at a time. A modified or deleted event shows up as a chain mismatch on the next verification. This is detection-grade, not prevention-grade: anyone who can write to the Loki volume can still delete lines. For write-once regulatory storage, add an S3 Object Lock exporter to the OTel collector, or sign each event with an HMAC key in a separate store.

Checklist

  • Inject IAuditService only when no domain event already covers the action; otherwise raise the event and let SecurityAuditEventHandler emit.
  • Pick the right Category, a dotted Action verb, and the correct Outcome (use Failure for denials so alerts can count them).
  • Pass new AuditActor(UserId: actorId) and let enrichment fill IP hash, UA family, and trace id.
  • Put structured extras in Metadata; never put secrets there unredacted (the patterns in Auditing:SensitivePropertyNames are your safety net, not a license).
  • Leave Sequence/PrevHash/Hash at their defaults.
  • If you added a new audited admin action, gate its read endpoint with an Allow.* permission and confirm it shows up in the admin audit log and the Grafana audit dashboard.
  • Attach SkipAuditAttribute to noisy or health-check endpoints you do not want in the request trail.