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:
AuditActionAttribute– marks an endpoint for automatic audit event emissionAuditActionFilter– ASP.NET Core endpoint filter that creates and sendsUnifiedAuditEventpayloadsHttpAuditEventEmitter– posts events to the Timeline service’sPOST /api/v1/audit/ingestendpointAddAuditEmission()– 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:
AuditActionFilter(scoped endpoint filter)HttpAuditEventEmitterasIAuditEventEmitter(singleton)AuditEmissionOptionsbound from configuration
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
| Parameter | Required | Description |
|---|---|---|
module | Yes | Module name. Prefer the AuditModules constants (AuditModules.Scanner, AuditModules.Policy, …) over magic strings. See the catalog-drift warning below. |
action | Yes | Action name. Prefer the AuditActions constants (e.g., "create", "update", "delete", "promote", "approve"). |
ResourceType | No | Optional resource type override. If omitted, inferred from the URL path segment after the version prefix. |
CaptureBody | No | Whether to capture the request body into details.requestBody. Defaults to true for POST/PUT/PATCH, false otherwise. |
SensitiveFields | No | Extra 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 tonull= “no filter”, and the query answered with every module’s rows. That is the bug this rule now prevents.)
UnifiedAuditModuleFilterTests.EveryAuditModulesConstant_IsQueryablefails 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
}
}
| Key | Default | Notes |
|---|---|---|
TimelineBaseUrl | http://timeline.stella-ops.local | Also settable via STELLAOPS_TIMELINE_URL. |
Enabled | true | false ⇒ events are dropped silently. |
TimeoutSeconds | 3 | HTTP timeout for the ingest POST. |
MaxBodySizeBytes | 65536 | An over-limit body is replaced by {"_truncated":true,"_originalSizeBytes":N} — not clipped. |
RedactedFieldPatterns | (unset) | ⚠️ When set, replaces the default redaction patterns entirely (see below). |
IdentityEnvelopeSigningKey | falls back to Router:IdentityEnvelopeSigningKey, then STELLAOPS_IDENTITY_ENVELOPE_SIGNING_KEY | Signs the ingest hop’s service identity envelope. Already present in every compose service — you should not need to set this. |
ServiceIdentity | entry assembly name | Envelope Subject: which service produced the event. |
IdentityEnvelopeLifetimeSeconds | 120 | Envelope lifetime (verifier allows 5 min skew on top). |
Environment variable overrides use the standard __ separator: AuditEmission__Enabled=false, AuditEmission__TimeoutSeconds=5, …
⚠️
RedactedFieldPatternsreplaces, it does not extend. The defaults (password,secret,token,apikey,connectionstring,credential,privatekey,signingkey—AuditPiiRedactor.cs:19-29) are dropped the moment you set this key. To add a pattern for one endpoint, useSensitiveFieldson the attribute instead — that one is additive.
How the filter works
- The endpoint executes normally and returns its result to the caller.
- After execution, the filter checks for
AuditActionAttributemetadata. No attribute ⇒ pass-through. - If present, it builds an
AuditEventPayloadcontaining:- Module and Action from the attribute (lower-cased)
- Actor from
HttpContext.Userclaims (sub,name,email; type isuser/service/system). If the Router verified an on-behalf-of operator, the operator becomes the author and the service account is kept asdetails.onBehalfOf.delegate. - TenantId from the
stellaops:tenantclaim (never a header, query, or body value) - Resource from route parameters (first matching
id,resourceId, …, else the first GUID route value, else anidfield 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 theX-StellaOps-Correlation-Idheader, else the legacyX-Correlation-Idheader, elseHttpContext.TraceIdentifier. Header values are honoured only if they parse as a GUID or a ULID — anything else silently falls through to the trace id.
- The event is posted asynchronously (fire-and-forget) to
POST /api/v1/audit/ingest. - 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
IAuditBeforeStateProvider— capture the pre-mutation state intodetails.beforeState(2 s budget).IAuditResourceEnricher— turn a bare resource id into a human-readableresource.name(2 s budget).
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
- Auth: the caller must be authenticated and carry
audit:ingest(ortimeline:write).AddAuditEmissionhandles this for you: the emitter signs a short-lived service identity envelope with the shared HMAC key your service already holds (Router:IdentityEnvelopeSigningKey/STELLAOPS_IDENTITY_ENVELOPE_SIGNING_KEY) and attaches it asX-StellaOps-Identity-Envelope+-Signature. No per-service Authority client, no bearer token, and no new secret is required. If the key is absent, the POST goes out unauthenticated, Timeline answers401, the event is lost, and the emitter logs a one-time Warning naming the key — check there first if audit rows stop appearing. - Body: JSON matching the
AuditEventPayloadschema (camelCase), wire-compatible with Timeline’sUnifiedAuditIngestRequest. - Responses:
202 Acceptedwith{ "eventId": "...", "status": "accepted" };400 module_and_action_requiredif either is missing;401 audit_ingest_unauthenticatedfor a caller with no verified identity (network trust alone is not enough — see below);403 audit_ingest_scope_requiredif the identity lacks an accepted scope;503if persistence fails. - Gateway route: covered by the existing
^/api/v1/audit(.*)route (src/Router/StellaOps.Gateway.WebService/appsettings.json:146, mirrored indevops/compose/router-gateway-local.json).
Why ingest refuses in-network callers (SPRINT_20260712_008 / W3-C).
Authority__ResourceServer__BypassNetworksforce-satisfies any scope requirement for an in-CIDR caller that sends noAuthorizationheader — without authenticating the principal — and timeline-web trusts172.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
- Fire-and-forget emission: Audit events are sent asynchronously after the endpoint responds. This ensures zero latency impact on the audited endpoint — at the cost of at-most-once delivery.
- No compile-time dependency on Timeline: The
AuditEventPayloadDTOs in the emission library are wire-compatible withUnifiedAuditEventbut live in a separate namespace, avoiding circular dependencies. - Authenticated by the platform’s existing service identity, not a new credential (W3-C): the emitter signs a
GatewayIdentityEnvelopewith the shared HMAC key every service already holds to verify the gateway’s envelope, so audit ingest stopped depending on network trust without provisioning 40 Authority clients or distributing a new secret. The trade-off is stated plainly in architecture §10: the key is symmetric and shared, so audit authorship is trusted at the stack boundary, not per service. - Durable, hash-chained ingest store: ingested events go straight to PostgreSQL (
PostgresUnifiedAuditEventStore). The schema auto-migrates on Timeline startup. (Superseded history: an in-memory ring buffer,IngestAuditEventStore, served the alpha. It is now dead code — registered nowhere. Ignore any doc that still describes it.) - Timeline is the read topology’s centre (DEPRECATE-002): Timeline no longer polls per-service audit endpoints —
HttpUnifiedAuditEventProvideris neutered and returns an empty set. The surviving per-service endpoints (Authority, Policy, Notify, ReleaseOrchestrator) proxy to Timeline viaITimelineAuditQueryClientand stamp Sunset/Deprecation/Link headers.CompositeUnifiedAuditEventProviderremains registered but is now effectively a pass-through to Postgres.
File locations
| File | Path |
|---|---|
| Shared library | src/__Libraries/StellaOps.Audit.Emission/ |
| Attribute | src/__Libraries/StellaOps.Audit.Emission/AuditActionAttribute.cs |
| Filter | src/__Libraries/StellaOps.Audit.Emission/AuditActionFilter.cs |
| Emitter | src/__Libraries/StellaOps.Audit.Emission/HttpAuditEventEmitter.cs |
| PII redactor | src/__Libraries/StellaOps.Audit.Emission/AuditPiiRedactor.cs |
| DI extension | src/__Libraries/StellaOps.Audit.Emission/AuditEmissionServiceExtensions.cs |
| Read-side proxy client | src/__Libraries/StellaOps.Audit.Emission/HttpTimelineAuditQueryClient.cs |
| Ingest endpoint | src/Timeline/StellaOps.Timeline.WebService/Endpoints/UnifiedAuditEndpoints.cs |
| Ingest identity guard | src/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 DDL | src/Timeline/__Libraries/StellaOps.Timeline.Core/Migrations/001_v1_timeline_core_baseline.sql |
| Composite provider | src/Timeline/StellaOps.Timeline.WebService/Audit/CompositeUnifiedAuditEventProvider.cs |
