# Auditing

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

## 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](/docs/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](/docs/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:

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

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

```csharp
public sealed class RevokeUserSessionsCommandHandler(
    AppDbContext db,
    HybridCache cache,
    IOptions 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:

```csharp
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](/docs/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 /  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](/docs/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](/docs/observability).

## Opting an endpoint out

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

```csharp
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](/docs/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.
