Audit Event Emission Guide

This guide explains how to add automatic audit event emission to any StellaOps service using the shared StellaOps.Audit.Emission library.

This is the how-to. For the contract, the trust boundary, and the guarantees (and the non-guarantees — emission is best-effort, with no retry or spool), read architecture.md.

Overview

The audit emission infrastructure provides:

  1. AuditActionAttribute– marks an endpoint for automatic audit event emission
  2. AuditActionFilterASP.NET Core endpoint filter that creates and sends UnifiedAuditEvent payloads
  3. HttpAuditEventEmitter– posts events to the Timeline service’s POST /api/v1/audit/ingest endpoint
  4. AddAuditEmission()– single-line DI registration

Events flow: Service endpoint -> AuditActionFilter -> HttpAuditEventEmitter -> Timeline /api/v1/audit/ingest-> PostgresUnifiedAuditEventStore -> timeline.unified_audit_events (durable, per-tenant SHA-256 hash chain).

Step 1: Add project reference

In your service’s .csproj, add a reference to the shared library:

<ProjectReference Include="..\..\__Libraries\StellaOps.Audit.Emission\StellaOps.Audit.Emission.csproj" />

Adjust the relative path as needed for your service’s location in the repo.

Step 2: Register in DI (Program.cs)

Add a single line to your service’s Program.cs:

using StellaOps.Audit.Emission;

// After other service registrations:
builder.Services.AddAuditEmission(builder.Configuration);

This registers:

Step 3: Tag endpoints

Add the AuditActionFilter and AuditActionAttribute metadata to any endpoint you want audited:

using StellaOps.Audit.Emission;

app.MapPost("/api/v1/releases", CreateRelease)
    .AddEndpointFilter<AuditActionFilter>()
    .WithMetadata(new AuditActionAttribute(AuditModules.Release, AuditActions.Release.CreateRelease));

app.MapPut("/api/v1/releases/{id}", UpdateRelease)
    .AddEndpointFilter<AuditActionFilter>()
    .WithMetadata(new AuditActionAttribute(AuditModules.Release, AuditActions.Release.UpdateRelease));

app.MapDelete("/api/v1/releases/{id}", DeleteRelease)
    .AddEndpointFilter<AuditActionFilter>()
    .WithMetadata(new AuditActionAttribute(AuditModules.Release, AuditActions.Release.DeleteRelease));

Prefer the AuditModules / AuditActions constants over raw strings — see the catalog-drift warning below for why a stray literal costs you the query filter.

Attribute parameters

ParameterRequiredDescription
moduleYesModule name. Prefer the AuditModules constants (AuditModules.Scanner, AuditModules.Policy, …) over magic strings. See the catalog-drift warning below.
actionYesAction name. Prefer the AuditActions constants (e.g., "create", "update", "delete", "promote", "approve").
ResourceTypeNoOptional resource type override. If omitted, inferred from the URL path segment after the version prefix.
CaptureBodyNoWhether to capture the request body into details.requestBody. Defaults to true for POST/PUT/PATCH, false otherwise.
SensitiveFieldsNoExtra field-name patterns to redact from the captured body, in addition to AuditPiiRedactor.DefaultRedactedPatterns.

⚠️ A new module must go in BOTH lists. There are two:

  • Emission side: AuditModules (src/__Libraries/StellaOps.Audit.Emission/AuditModules.cs).
  • Query side: UnifiedAuditCatalog.Modules (src/Timeline/StellaOps.Timeline.WebService/Audit/UnifiedAuditContracts.cs) — what ?modules= is validated against.

Ingest accepts any module string, so your rows always persist — but since W3-C an off-catalog ?modules= value is a 400, so a module you forget to catalogue is unqueryable. (It used to be worse: the unknown value was silently dropped, the filter collapsed to null = “no filter”, and the query answered with every module’s rows. That is the bug this rule now prevents.)

UnifiedAuditModuleFilterTests.EveryAuditModulesConstant_IsQueryable fails the build if you add a constant without the catalog entry — but a raw string literal escapes that guard, which is the other reason to use the constants. Detail: architecture §12.1.

You may also use the shorthand from AuditedRouteGroupExtensions instead of the attribute + filter pair:

app.MapPost("/api/v1/capsules", CreateCapsule)
    .Audited(AuditModules.Evidence, AuditActions.Evidence.CreateCapsule, "capsule");

// or, group-wide (endpoints without an AuditActionAttribute pass through silently):
var group = app.MapGroup("/api/v1/policies").WithAuditFilter();

Step 4: Configuration (optional)

The emitter reads configuration from the AuditEmission section or environment variables (AuditEmissionServiceExtensions.cs:43-69):

{
  "AuditEmission": {
    "TimelineBaseUrl": "http://timeline.stella-ops.local",
    "Enabled": true,
    "TimeoutSeconds": 3,
    "MaxBodySizeBytes": 65536
  }
}
KeyDefaultNotes
TimelineBaseUrlhttp://timeline.stella-ops.localAlso settable via STELLAOPS_TIMELINE_URL.
Enabledtruefalse ⇒ events are dropped silently.
TimeoutSeconds3HTTP timeout for the ingest POST.
MaxBodySizeBytes65536An over-limit body is replaced by {"_truncated":true,"_originalSizeBytes":N} — not clipped.
RedactedFieldPatterns(unset)⚠️ When set, replaces the default redaction patterns entirely (see below).
IdentityEnvelopeSigningKeyfalls back to Router:IdentityEnvelopeSigningKey, then STELLAOPS_IDENTITY_ENVELOPE_SIGNING_KEYSigns the ingest hop’s service identity envelope. Already present in every compose service — you should not need to set this.
ServiceIdentityentry assembly nameEnvelope Subject: which service produced the event.
IdentityEnvelopeLifetimeSeconds120Envelope lifetime (verifier allows 5 min skew on top).

Environment variable overrides use the standard __ separator: AuditEmission__Enabled=false, AuditEmission__TimeoutSeconds=5, …

⚠️ RedactedFieldPatterns replaces, it does not extend. The defaults (password, secret, token, apikey, connectionstring, credential, privatekey, signingkeyAuditPiiRedactor.cs:19-29) are dropped the moment you set this key. To add a pattern for one endpoint, use SensitiveFields on the attribute instead — that one is additive.

How the filter works

  1. The endpoint executes normally and returns its result to the caller.
  2. After execution, the filter checks for AuditActionAttribute metadata. No attribute ⇒ pass-through.
  3. If present, it builds an AuditEventPayload containing:
    • Module and Action from the attribute (lower-cased)
    • Actor from HttpContext.User claims (sub, name, email; type is user / service / system). If the Router verified an on-behalf-of operator, the operator becomes the author and the service account is kept as details.onBehalfOf.delegate.
    • TenantId from the stellaops:tenant claim (never a header, query, or body value)
    • Resource from route parameters (first matching id, resourceId, …, else the first GUID route value, else an id field in the response body; "unknown" if nothing resolves)
    • Severity inferred from HTTP response status code (2xx=info, 4xx=warning, 5xx=error)
    • Description auto-generated: "{Action} {module} resource {resourceId}"
    • CorrelationId from HttpContext.Items["correlation_id"], else the X-StellaOps-Correlation-Id header, else the legacy X-Correlation-Id header, else HttpContext.TraceIdentifier. Header values are honoured only if they parse as a GUID or a ULID — anything else silently falls through to the trace id.
  4. The event is posted asynchronously (fire-and-forget) to POST /api/v1/audit/ingest.
  5. Failures are logged but never propagated – audit emission must not affect the endpoint response.

Emission is best-effort: no retry, no spool, no dead-letter. If Timeline is down, the event is logged at Warning and lost. A missing audit row is not proof the action did not happen. See architecture §9.

Optional per-module hooks

Register one implementation per module; the filter matches them by Module.

⚠️ Neither hook’s output is PII-redacted. Redaction is applied to the captured request body only. If your provider returns a secret-bearing field, it ships to the audit store verbatim.

Timeline ingest endpoint

The Timeline service exposes:

POST /api/v1/audit/ingest

Why ingest refuses in-network callers (SPRINT_20260712_008 / W3-C). Authority__ResourceServer__BypassNetworks force-satisfies any scope requirement for an in-CIDR caller that sends no Authorization header — without authenticating the principal — and timeline-web trusts 172.16.0.0/12, the whole compose network. Audit rows, tenant included, were therefore forgeable by anything that could reach timeline-web. Ingest now re-derives the caller from the principal (UnifiedAuditIngestGuard), which the bypass cannot fabricate, so being on the network is no longer a credential.

Ingested events are persisted by PostgresUnifiedAuditEventStoreinto timeline.unified_audit_events under SERIALIZABLE isolation, with a per-tenant SHA-256 hash chain (content_hash / previous_entry_hash / sequence_number), a data_classification stamp driving retention, and GDPR right-to-erasure that keeps the chain verifiable. Events arriving with no tenant land under "default".

Architecture decisions

File locations

FilePath
Shared librarysrc/__Libraries/StellaOps.Audit.Emission/
Attributesrc/__Libraries/StellaOps.Audit.Emission/AuditActionAttribute.cs
Filtersrc/__Libraries/StellaOps.Audit.Emission/AuditActionFilter.cs
Emittersrc/__Libraries/StellaOps.Audit.Emission/HttpAuditEventEmitter.cs
PII redactorsrc/__Libraries/StellaOps.Audit.Emission/AuditPiiRedactor.cs
DI extensionsrc/__Libraries/StellaOps.Audit.Emission/AuditEmissionServiceExtensions.cs
Read-side proxy clientsrc/__Libraries/StellaOps.Audit.Emission/HttpTimelineAuditQueryClient.cs
Ingest endpointsrc/Timeline/StellaOps.Timeline.WebService/Endpoints/UnifiedAuditEndpoints.cs
Ingest identity guardsrc/Timeline/StellaOps.Timeline.WebService/Security/UnifiedAuditIngestGuard.cs
Ingest scope (audit:ingest)src/Authority/.../StellaOps.Auth.Abstractions/StellaOpsScopes.cs + seed S040_audit_ingest_scope.sql
Audit store (system of record)src/Timeline/StellaOps.Timeline.WebService/Audit/PostgresUnifiedAuditEventStore.cs
Schema DDLsrc/Timeline/__Libraries/StellaOps.Timeline.Core/Migrations/001_v1_timeline_core_baseline.sql
Composite providersrc/Timeline/StellaOps.Timeline.WebService/Audit/CompositeUnifiedAuditEventProvider.cs