Backend guides
Auditing
Record who did what by emitting audit events, and where the trail is stored and surfaced.
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:
| Category | Source | Examples |
|---|---|---|
Security | SecurityAuditEventHandler (subscribes to domain events) plus direct emits from handlers | User.LoggedIn, User.PasswordChanged, ApiKey.Created |
DataChange | AuditingSaveChangesInterceptor (EF change tracking) | User.Modified, User.PermissionAssigned |
Request | AuditRequestMiddleware (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);
Categorygroups the event (Security,DataChange,Request,DataAccess).Actionis a dotted string verb likeUser.PasswordChangedorAdmin.UserSessionsRevoked.OutcomeisSuccessorFailure. UseFailurefor denials and failed attempts so the security dashboards and alerts can count them.Actoris who did it. Passnew AuditActor(UserId: ...)and the rest is filled in for you (see below), or useAuditActor.Systemfor background work andAuditActor.Anonymousfor unauthenticated calls.Resourceis the thing acted on:new AuditResource("User", id.ToString()).Reasonis free text, surfaced on failures (e.g. a lockout reason).Metadatais 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 withAuditing:IpHashSalt, truncated to 16 hex chars. Plaintext IP never reaches Loki. Rotate the salt periodically for forward secrecy.UserAgentFamily, a coarse string likeChrome/Windows, never the raw UA.TraceIdfrom the active activity, so an audit line links back to its trace.OnBehalfOfUserId, read from the RFC 8693actclaim during admin impersonation:UserIdis then the user being acted as, andOnBehalfOfUserIdis 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, withfrom/to/category/action/outcome/searchfilters. Gated byAllow.AdminGetAuditEvents.GET /api/v1/admin/users/{userId}/audit-events: same filters, scoped to one user. Gated byAllow.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
IAuditServiceonly when no domain event already covers the action; otherwise raise the event and letSecurityAuditEventHandleremit. - Pick the right
Category, a dottedActionverb, and the correctOutcome(useFailurefor 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 inAuditing:SensitivePropertyNamesare your safety net, not a license). - Leave
Sequence/PrevHash/Hashat 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
SkipAuditAttributeto noisy or health-check endpoints you do not want in the request trail.