Auditing
In a control plane where a single click can redeploy a camera, reset a user's password, or change who can see a fleet, "who did that, and when?" can't be an afterthought bolted on per endpoint. It has to be structural — impossible to forget, uniform across every action. The Admin API achieves that with one pipeline behavior, and the payoff is that not a single command handler contains any audit code.
The one idea to hold on to
Auditing is a MediatR pipeline behavior: any command marked IAuditCommand is automatically
recorded, with the who/what/when captured from the HTTP request and claims. A handler does its
work and returns; the behavior wraps it and writes the audit event afterward — the handler never
knows it happened.
public sealed class AuditBehavior<TRequest, TResponse>(IAuditService auditService, IHttpContextAccessor http)
: IPipelineBehavior<TRequest, TResponse> where TRequest : IAuditCommand
{
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
{
var response = await next(); // run the handler first
// …build the audit event from the request + claims…
await _auditService.CreateSingleAudit(audit);
return response;
}
}
The generic constraint where TRequest : IAuditCommand is the whole opt-in mechanism: mark a
command with the IAuditCommand interface and it's audited; leave it unmarked and it isn't.
That's deliberate — state-changing commands are audited; reads are not. You don't want an
audit row for every dashboard refresh.
What gets captured
The behavior reconstructs a full picture of the action from two sources it can always trust in an authenticated request — the route values and the caller's Entra claims:
| Field | From | Answers |
|---|---|---|
ActionName / ActionSource | route action / controller | what was done (which endpoint) |
ActionPath · ActionQuery · ActionParameters | request path, query, body | with what inputs |
EntraUserId | oid claim (or legacy object-id) | who did it |
EntraDisplayName · EntraEmail · CompanyName | name / email / companyname claims | who, in human terms |
The event is written through the shared IAuditService.CreateSingleAudit, which persists it to
the SQL AuditEvent table. Because the identity comes from verified token claims, not from
anything the client sends in the body, the "who" can't be spoofed by the request.
The behavior throws if a required route value or identity claim is missing — ActionName,
controller, EntraUserId, EntraDisplayName, and EntraEmail are all mandatory. That's a
feature: an IAuditCommand that somehow executed without an authenticated caller would produce
an unattributable audit row, so it's rejected instead. The flip side is that dispatching an
audited command from a non-HTTP path (a background job, a test) without a faked context will
fail in the behavior — audited commands are, by design, request-scoped.
Reading the trail
Audits are surfaced where they're most useful — per device. The AuditLog controller's
get-audit-log/{deviceId} backs the device page's Audit Log screen (see
Managing a Device), so an operator investigating a camera sees exactly
which admin actions touched it, by whom, and with what inputs — the movement history's
administrative counterpart.
Wrapping up
Auditing is a single MediatR pipeline behavior gated on the IAuditCommand marker: mark a
state-changing command and it's recorded automatically, with the actor taken from verified Entra
claims and the action from the request itself — no per-handler code, no way to forget. Events land
in SQL, can be grouped into transactions, and surface per-device in the portal. Reads stay
unaudited on purpose.
Auditing answers "what changed." The next lesson answers "what's happening" — the monitoring and analytics screens: Monitoring & Analytics.