Skip to main content

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:

FieldFromAnswers
ActionName / ActionSourceroute action / controllerwhat was done (which endpoint)
ActionPath · ActionQuery · ActionParametersrequest path, query, bodywith what inputs
EntraUserIdoid claim (or legacy object-id)who did it
EntraDisplayName · EntraEmail · CompanyNamename / email / companyname claimswho, 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.

Gotcha: an audited command must run in an authenticated HTTP context

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.