diff --git a/src/__Libraries/StellaOps.Audit.Emission/AuditActionAttribute.cs b/src/__Libraries/StellaOps.Audit.Emission/AuditActionAttribute.cs
index be3e10f8c..701f141f2 100644
--- a/src/__Libraries/StellaOps.Audit.Emission/AuditActionAttribute.cs
+++ b/src/__Libraries/StellaOps.Audit.Emission/AuditActionAttribute.cs
@@ -37,6 +37,20 @@ public sealed class AuditActionAttribute : Attribute
///
public string? ResourceType { get; set; }
+ ///
+ /// Whether the request body should be captured in the audit event's Details["requestBody"] .
+ /// Default is true for POST/PUT/PATCH and false for all other methods.
+ /// Set explicitly to override the default behavior.
+ ///
+ public bool? CaptureBody { get; set; }
+
+ ///
+ /// Additional field names to redact from the request body, beyond the defaults
+ /// in .
+ /// Matching is case-insensitive and strips separators (underscores, hyphens).
+ ///
+ public string[]? SensitiveFields { get; set; }
+
///
/// Creates a new audit action attribute.
///
@@ -49,4 +63,19 @@ public sealed class AuditActionAttribute : Attribute
Module = module;
Action = action;
}
+
+ ///
+ /// Resolves whether the body should be captured for the given HTTP method.
+ /// If is explicitly set, that value wins.
+ /// Otherwise, defaults to true for POST/PUT/PATCH and false for everything else.
+ ///
+ internal bool ShouldCaptureBody(string httpMethod)
+ {
+ if (CaptureBody.HasValue)
+ {
+ return CaptureBody.Value;
+ }
+
+ return httpMethod is "POST" or "PUT" or "PATCH";
+ }
}
diff --git a/src/__Libraries/StellaOps.Audit.Emission/AuditActionFilter.cs b/src/__Libraries/StellaOps.Audit.Emission/AuditActionFilter.cs
index c139c1e77..c447fbdf3 100644
--- a/src/__Libraries/StellaOps.Audit.Emission/AuditActionFilter.cs
+++ b/src/__Libraries/StellaOps.Audit.Emission/AuditActionFilter.cs
@@ -1,9 +1,13 @@
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
+using System.Buffers;
using System.Security.Claims;
using System.Text.Json;
+using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
namespace StellaOps.Audit.Emission;
@@ -14,6 +18,15 @@ namespace StellaOps.Audit.Emission;
/// The filter reads metadata from the endpoint.
/// If the attribute is not present, the filter is a no-op passthrough.
///
+///
+/// Enhanced capabilities:
+///
+/// - Request body capture (JSON, up to configurable max size, PII-redacted)
+/// - Response resource ID extraction for create operations
+/// - Before-state capture via
+/// - Resource name enrichment via
+///
+///
///
///
/// Usage in minimal API registration:
@@ -27,41 +40,166 @@ public sealed class AuditActionFilter : IEndpointFilter
{
private readonly IAuditEventEmitter _emitter;
private readonly ILogger _logger;
+ private readonly IOptions _options;
- public AuditActionFilter(IAuditEventEmitter emitter, ILogger logger)
+ public AuditActionFilter(
+ IAuditEventEmitter emitter,
+ ILogger logger,
+ IOptions options)
{
_emitter = emitter;
_logger = logger;
+ _options = options;
}
public async ValueTask InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
- // Execute the endpoint first
- var result = await next(context).ConfigureAwait(false);
-
- // Check for the audit attribute
+ // Check for the audit attribute early so we can decide whether to capture pre-state
var auditAttr = context.HttpContext.GetEndpoint()?.Metadata.GetMetadata();
if (auditAttr is null)
{
- return result;
+ return await next(context).ConfigureAwait(false);
}
+ var httpContext = context.HttpContext;
+ var options = _options.Value;
+
+ // --- Pre-execution captures ---
+
+ // 1. Capture request body (if applicable)
+ JsonNode? capturedBody = null;
+ if (auditAttr.ShouldCaptureBody(httpContext.Request.Method))
+ {
+ capturedBody = await CaptureRequestBodyAsync(httpContext, auditAttr, options).ConfigureAwait(false);
+ }
+
+ // 2. Capture before-state (if a provider is registered)
+ Dictionary? beforeState = null;
+ var resourceId = ResolveResourceId(httpContext);
+ var resourceType = auditAttr.ResourceType ?? InferResourceType(httpContext.Request.Path.Value);
+
+ if (!string.IsNullOrWhiteSpace(resourceId))
+ {
+ beforeState = await CaptureBeforeStateAsync(httpContext, auditAttr, resourceType, resourceId).ConfigureAwait(false);
+ }
+
+ // --- Execute the endpoint ---
+ var result = await next(context).ConfigureAwait(false);
+
+ // --- Post-execution: emit audit event ---
// Fire-and-forget: emit the audit event asynchronously without blocking the response
- _ = EmitAuditEventSafeAsync(context.HttpContext, auditAttr, result);
+ _ = EmitAuditEventSafeAsync(httpContext, auditAttr, result, capturedBody, beforeState, options);
return result;
}
+ private async Task CaptureRequestBodyAsync(
+ HttpContext httpContext,
+ AuditActionAttribute auditAttr,
+ AuditEmissionOptions options)
+ {
+ try
+ {
+ var request = httpContext.Request;
+
+ // Enable buffering so the body can be read multiple times
+ // (once here, once by the actual endpoint handler)
+ request.EnableBuffering();
+
+ var contentType = request.ContentType;
+ if (string.IsNullOrWhiteSpace(contentType) ||
+ !contentType.Contains("json", StringComparison.OrdinalIgnoreCase))
+ {
+ return null;
+ }
+
+ if (request.ContentLength is > 0 and var length && length > options.MaxBodySizeBytes)
+ {
+ var truncated = new JsonObject
+ {
+ ["_truncated"] = true,
+ ["_originalSizeBytes"] = length
+ };
+ return truncated;
+ }
+
+ // Read the body
+ request.Body.Position = 0;
+ var maxSize = options.MaxBodySizeBytes;
+ var buffer = ArrayPool.Shared.Rent(maxSize);
+ try
+ {
+ var bytesRead = await request.Body.ReadAsync(buffer.AsMemory(0, maxSize)).ConfigureAwait(false);
+ request.Body.Position = 0; // Reset for the endpoint handler
+
+ if (bytesRead == 0)
+ {
+ return null;
+ }
+
+ var configuredPatterns = options.RedactedFieldPatterns is { Count: > 0 }
+ ? (IReadOnlyList)options.RedactedFieldPatterns
+ : null;
+
+ return AuditPiiRedactor.Redact(
+ buffer.AsSpan(0, bytesRead),
+ additionalPatterns: auditAttr.SensitiveFields,
+ configuredPatterns: configuredPatterns);
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(buffer);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Failed to capture request body for audit event");
+ return null;
+ }
+ }
+
+ private async Task?> CaptureBeforeStateAsync(
+ HttpContext httpContext,
+ AuditActionAttribute auditAttr,
+ string resourceType,
+ string resourceId)
+ {
+ try
+ {
+ var providers = httpContext.RequestServices.GetService>();
+ var provider = providers?.FirstOrDefault(p =>
+ string.Equals(p.Module, auditAttr.Module, StringComparison.OrdinalIgnoreCase));
+
+ if (provider is null)
+ {
+ return null;
+ }
+
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
+ return await provider.GetBeforeStateAsync(resourceType, resourceId, cts.Token).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Failed to capture before-state for audit event ({Module}/{ResourceType}/{ResourceId})",
+ auditAttr.Module, resourceType, resourceId);
+ return null;
+ }
+ }
+
private async Task EmitAuditEventSafeAsync(
HttpContext httpContext,
AuditActionAttribute auditAttr,
- object? result)
+ object? result,
+ JsonNode? capturedBody,
+ Dictionary? beforeState,
+ AuditEmissionOptions options)
{
try
{
- var auditEvent = BuildAuditEvent(httpContext, auditAttr, result);
+ var auditEvent = await BuildAuditEventAsync(
+ httpContext, auditAttr, result, capturedBody, beforeState, options, _logger).ConfigureAwait(false);
await _emitter.EmitAsync(auditEvent, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex)
@@ -76,10 +214,14 @@ public sealed class AuditActionFilter : IEndpointFilter
}
}
- internal static AuditEventPayload BuildAuditEvent(
+ internal static async ValueTask BuildAuditEventAsync(
HttpContext httpContext,
AuditActionAttribute auditAttr,
- object? result)
+ object? result,
+ JsonNode? capturedBody,
+ Dictionary? beforeState,
+ AuditEmissionOptions options,
+ ILogger logger)
{
var now = DateTimeOffset.UtcNow;
var user = httpContext.User;
@@ -111,6 +253,48 @@ public sealed class AuditActionFilter : IEndpointFilter
? corrValues.FirstOrDefault()
: httpContext.TraceIdentifier;
+ // Build the resource payload, optionally enriched
+ var resourcePayload = new AuditResourcePayload
+ {
+ Type = resourceType,
+ Id = resourceId ?? "unknown"
+ };
+ resourcePayload = await TryEnrichResourceAsync(httpContext, auditAttr, resourcePayload, logger).ConfigureAwait(false);
+
+ // Build details dictionary
+ var details = new Dictionary
+ {
+ ["httpMethod"] = request.Method,
+ ["requestPath"] = request.Path.Value,
+ ["statusCode"] = response.StatusCode
+ };
+
+ // Add request body (redacted)
+ if (capturedBody is not null)
+ {
+ details["requestBody"] = capturedBody;
+ }
+
+ // Add before-state
+ if (beforeState is not null)
+ {
+ details["beforeState"] = beforeState;
+ }
+
+ // Extract response resource ID for create operations
+ var responseResourceId = ExtractResponseResourceId(result);
+ if (responseResourceId is not null)
+ {
+ details["responseResourceId"] = responseResourceId;
+
+ // If we didn't have a resource ID from the route (common for POST/create),
+ // update the resource payload with the newly created ID
+ if (resourcePayload.Id == "unknown")
+ {
+ resourcePayload = resourcePayload with { Id = responseResourceId };
+ }
+ }
+
return new AuditEventPayload
{
Id = $"audit-{Guid.NewGuid():N}",
@@ -127,24 +311,117 @@ public sealed class AuditActionFilter : IEndpointFilter
IpAddress = httpContext.Connection.RemoteIpAddress?.ToString(),
UserAgent = request.Headers.UserAgent.FirstOrDefault()
},
- Resource = new AuditResourcePayload
- {
- Type = resourceType,
- Id = resourceId ?? "unknown"
- },
+ Resource = resourcePayload,
Description = description,
- Details = new Dictionary
- {
- ["httpMethod"] = request.Method,
- ["requestPath"] = request.Path.Value,
- ["statusCode"] = response.StatusCode
- },
+ Details = details,
CorrelationId = correlationId,
TenantId = tenantId,
Tags = [auditAttr.Module.ToLowerInvariant(), auditAttr.Action.ToLowerInvariant()]
};
}
+ ///
+ /// Backward-compatible static builder for tests that do not require body/enrichment.
+ ///
+ internal static AuditEventPayload BuildAuditEvent(
+ HttpContext httpContext,
+ AuditActionAttribute auditAttr,
+ object? result)
+ {
+ return BuildAuditEventAsync(
+ httpContext,
+ auditAttr,
+ result,
+ capturedBody: null,
+ beforeState: null,
+ new AuditEmissionOptions(),
+ Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance).AsTask().GetAwaiter().GetResult();
+ }
+
+ private static async ValueTask TryEnrichResourceAsync(
+ HttpContext httpContext,
+ AuditActionAttribute auditAttr,
+ AuditResourcePayload resource,
+ ILogger logger)
+ {
+ try
+ {
+ if (resource.Id == "unknown")
+ {
+ return resource;
+ }
+
+ var enrichers = httpContext.RequestServices.GetService>();
+ var enricher = enrichers?.FirstOrDefault(e =>
+ string.Equals(e.Module, auditAttr.Module, StringComparison.OrdinalIgnoreCase));
+
+ if (enricher is null)
+ {
+ return resource;
+ }
+
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
+ return await enricher.EnrichAsync(resource.Type, resource.Id, cts.Token).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ logger.LogDebug(ex, "Audit resource enrichment failed for {Module}/{Type}/{Id}",
+ auditAttr.Module, resource.Type, resource.Id);
+ return resource;
+ }
+ }
+
+ ///
+ /// Attempts to extract an "id" field from the endpoint result, useful for create operations
+ /// where the new resource ID is returned in the response body.
+ ///
+ internal static string? ExtractResponseResourceId(object? result)
+ {
+ if (result is null)
+ {
+ return null;
+ }
+
+ try
+ {
+ // Handle IResult wrappers (Results.Ok, Results.Created, etc.)
+ // by serializing to JSON and checking for an "id" property.
+ var json = JsonSerializer.Serialize(result, new JsonSerializerOptions
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase
+ });
+
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+
+ if (root.ValueKind != JsonValueKind.Object)
+ {
+ return null;
+ }
+
+ // Check for common ID field names
+ string[] idFields = ["id", "Id", "ID", "resourceId", "ResourceId"];
+ foreach (var field in idFields)
+ {
+ if (root.TryGetProperty(field, out var idProp))
+ {
+ return idProp.ValueKind switch
+ {
+ JsonValueKind.String => idProp.GetString(),
+ JsonValueKind.Number => idProp.GetRawText(),
+ _ => null
+ };
+ }
+ }
+ }
+ catch
+ {
+ // Best-effort; never fail for response inspection
+ }
+
+ return null;
+ }
+
private static string? ResolveClaimValue(ClaimsPrincipal user, params string[] claimTypes)
{
foreach (var claimType in claimTypes)
@@ -159,7 +436,7 @@ public sealed class AuditActionFilter : IEndpointFilter
return null;
}
- private static string? ResolveResourceId(HttpContext httpContext)
+ internal static string? ResolveResourceId(HttpContext httpContext)
{
var routeValues = httpContext.Request.RouteValues;
diff --git a/src/__Libraries/StellaOps.Audit.Emission/AuditActions.cs b/src/__Libraries/StellaOps.Audit.Emission/AuditActions.cs
new file mode 100644
index 000000000..5a17269ae
--- /dev/null
+++ b/src/__Libraries/StellaOps.Audit.Emission/AuditActions.cs
@@ -0,0 +1,356 @@
+// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
+
+namespace StellaOps.Audit.Emission;
+
+///
+/// Well-known action name constants for audit event annotations, organized by module.
+/// Use these instead of magic strings when calling
+/// or constructing
+/// instances.
+///
+public static class AuditActions
+{
+ /// Actions for the Authority (OAuth/OIDC) module.
+ public static class Authority
+ {
+ public const string Bootstrap = "bootstrap";
+ public const string Seed = "seed";
+ public const string CreateUser = "create_user";
+ public const string UpdateUser = "update_user";
+ public const string LockUser = "lock_user";
+ public const string UnlockUser = "unlock_user";
+ public const string CreateTenant = "create_tenant";
+ public const string UpdateTenant = "update_tenant";
+ public const string SuspendTenant = "suspend_tenant";
+ public const string ResumeTenant = "resume_tenant";
+ public const string CreateRole = "create_role";
+ public const string UpdateRole = "update_role";
+ public const string CreateClient = "create_client";
+ public const string UpdateClient = "update_client";
+ public const string RotateClientSecret = "rotate_client_secret";
+ public const string RevokeToken = "revoke_token";
+ public const string RotateSigningKey = "rotate_signing_key";
+ public const string RotateAckTokenKey = "rotate_ack_token_key";
+ public const string ReloadPlugins = "reload_plugins";
+ public const string CreateVulnPermalink = "create_vuln_permalink";
+ public const string IssueAckToken = "issue_ack_token";
+ public const string IssueVulnCsrfToken = "issue_vuln_csrf_token";
+ public const string IssueAttachmentToken = "issue_attachment_token";
+ public const string LogRemoteInference = "log_remote_inference";
+ public const string CreateVulnTicket = "create_vuln_ticket";
+ public const string UpdateBranding = "update_branding";
+ public const string RecordAirgapAudit = "record_airgap_audit";
+ }
+
+ /// Actions for the Policy module.
+ public static class Policy
+ {
+ public const string EnableSealed = "enable_sealed";
+ public const string DisableSealed = "disable_sealed";
+ public const string CreateRiskProfile = "create_risk_profile";
+ public const string ActivateRiskProfile = "activate_risk_profile";
+ public const string DeprecateRiskProfile = "deprecate_risk_profile";
+ public const string ArchiveRiskProfile = "archive_risk_profile";
+ public const string UpdateRiskProfile = "update_risk_profile";
+ public const string DeleteRiskProfile = "delete_risk_profile";
+ public const string CreateSnapshot = "create_snapshot";
+ public const string CreatePack = "create_pack";
+ public const string CreateRevision = "create_revision";
+ public const string ActivateRevision = "activate_revision";
+ public const string CreateOverride = "create_override";
+ public const string DeleteOverride = "delete_override";
+ public const string ApproveOverride = "approve_override";
+ public const string DisableOverride = "disable_override";
+ public const string EnableShadowMode = "enable_shadow_mode";
+ public const string DisableShadowMode = "disable_shadow_mode";
+ public const string CreateSimulation = "create_simulation";
+ public const string UpdateSimulation = "update_simulation";
+ public const string CancelSimulation = "cancel_simulation";
+ public const string BypassGate = "bypass_gate";
+ public const string CreateException = "create_exception";
+ public const string UpdateException = "update_exception";
+ public const string RevokeException = "revoke_exception";
+ public const string ApproveException = "approve_exception";
+ public const string RejectException = "reject_exception";
+ public const string CancelException = "cancel_exception";
+ public const string ActivateException = "activate_exception";
+ public const string ExtendException = "extend_exception";
+ public const string ResolveConflict = "resolve_conflict";
+ public const string DismissConflict = "dismiss_conflict";
+ public const string CreateBatchEvaluation = "create_batch_evaluation";
+ public const string CancelBatchEvaluation = "cancel_batch_evaluation";
+ public const string ToggleSealedMode = "toggle_sealed_mode";
+ public const string EmergencyUnseal = "emergency_unseal";
+ public const string RevokeSealedOverride = "revoke_sealed_override";
+ public const string UpdateTrustWeight = "update_trust_weight";
+ public const string DeleteTrustWeight = "delete_trust_weight";
+ public const string UpdateStalenessConfig = "update_staleness_config";
+ public const string CreateEffectivePolicy = "create_effective_policy";
+ public const string UpdateEffectivePolicy = "update_effective_policy";
+ public const string DeleteEffectivePolicy = "delete_effective_policy";
+ public const string GrantScope = "grant_scope";
+ public const string RevokeScope = "revoke_scope";
+ public const string CreateConflict = "create_conflict";
+ public const string CreateDeltaSnapshot = "create_delta_snapshot";
+ public const string EvaluateGate = "evaluate_gate";
+ }
+
+ /// Actions for the Scanner module.
+ public static class Scanner
+ {
+ public const string Submit = "submit";
+ public const string SubmitSbom = "submit_sbom";
+ public const string Upload = "upload";
+ public const string Validate = "validate";
+ public const string Verify = "verify";
+ public const string Import = "import";
+ public const string Create = "create";
+ public const string Update = "update";
+ public const string Delete = "delete";
+ public const string Test = "test";
+ public const string Pause = "pause";
+ public const string Resume = "resume";
+ public const string Activate = "activate";
+ public const string TriggerScan = "trigger_scan";
+ public const string Revoke = "revoke";
+ public const string ReceiveWebhook = "receive_webhook";
+ public const string UpdateStatus = "update_status";
+ public const string SubmitVex = "submit_vex";
+ public const string BulkQuery = "bulk_query";
+ public const string GenerateProof = "generate_proof";
+ public const string UploadSarif = "upload_sarif";
+ public const string Analyze = "analyze";
+ public const string Upgrade = "upgrade";
+ public const string BatchAction = "batch_action";
+ public const string Review = "review";
+ public const string Replay = "replay";
+ public const string Compute = "compute";
+ public const string Ingest = "ingest";
+ public const string Reconcile = "reconcile";
+ public const string AttachEntropy = "attach_entropy";
+ public const string AttachReplay = "attach_replay";
+ }
+
+ /// Actions for the Findings module.
+ public static class Findings
+ {
+ public const string CreateLedgerEvent = "create_ledger_event";
+ public const string CreateAttestationPointer = "create_attestation_pointer";
+ public const string UpdateAttestationPointer = "update_attestation_pointer";
+ public const string CreateSnapshot = "create_snapshot";
+ public const string DeleteSnapshot = "delete_snapshot";
+ public const string CreateAlertDecision = "create_alert_decision";
+ public const string VerifyAlertBundle = "verify_alert_bundle";
+ public const string RegisterVexIssuer = "register_vex_issuer";
+ public const string TransitionFindingState = "transition_finding_state";
+ public const string CreateWebhook = "create_webhook";
+ public const string UpdateWebhook = "update_webhook";
+ public const string DeleteWebhook = "delete_webhook";
+ public const string CreateVexDecision = "create_vex_decision";
+ public const string UpdateVexDecision = "update_vex_decision";
+ public const string CreateFixVerification = "create_fix_verification";
+ public const string UpdateFixVerification = "update_fix_verification";
+ public const string CreateAuditBundle = "create_audit_bundle";
+ public const string CalculateScore = "calculate_score";
+ public const string IngestRuntimeTrace = "ingest_runtime_trace";
+ }
+
+ /// Actions for the Release Orchestrator module.
+ public static class Release
+ {
+ public const string CreateRelease = "create_release";
+ public const string UpdateRelease = "update_release";
+ public const string DeleteRelease = "delete_release";
+ public const string MarkReady = "mark_ready";
+ public const string PromoteRelease = "promote_release";
+ public const string DeployRelease = "deploy_release";
+ public const string RollbackRelease = "rollback_release";
+ public const string CloneRelease = "clone_release";
+ public const string AddComponent = "add_component";
+ public const string UpdateComponent = "update_component";
+ public const string RemoveComponent = "remove_component";
+ public const string ApprovePromotion = "approve_promotion";
+ public const string RejectPromotion = "reject_promotion";
+ public const string ApprovalDecision = "approval_decision";
+ public const string RollbackRun = "rollback_run";
+ public const string VerifyEvidence = "verify_evidence";
+ public const string CreateDeployment = "create_deployment";
+ public const string PauseDeployment = "pause_deployment";
+ public const string ResumeDeployment = "resume_deployment";
+ public const string CancelDeployment = "cancel_deployment";
+ public const string RollbackDeployment = "rollback_deployment";
+ public const string RetryTarget = "retry_target";
+ public const string ApproveRelease = "approve_release";
+ public const string RejectRelease = "reject_release";
+ public const string BatchApprove = "batch_approve";
+ public const string BatchReject = "batch_reject";
+ public const string CreateScript = "create_script";
+ public const string UpdateScript = "update_script";
+ public const string DeleteScript = "delete_script";
+ }
+
+ /// Actions for the Scheduler (JobEngine) module.
+ public static class Scheduler
+ {
+ public const string CreateSchedule = "create_schedule";
+ public const string UpdateSchedule = "update_schedule";
+ public const string DeleteSchedule = "delete_schedule";
+ public const string PauseSchedule = "pause_schedule";
+ public const string ResumeSchedule = "resume_schedule";
+ public const string CreateRun = "create_run";
+ public const string CancelRun = "cancel_run";
+ public const string RetryRun = "retry_run";
+ public const string CreatePolicyRun = "create_policy_run";
+ public const string CreateGraphBuild = "create_graph_build";
+ public const string CreateGraphOverlay = "create_graph_overlay";
+ }
+
+ /// Actions for the Notify module.
+ public static class Notify
+ {
+ public const string CreateTemplate = "create_template";
+ public const string UpdateTemplate = "update_template";
+ public const string DeleteTemplate = "delete_template";
+ public const string CreateRule = "create_rule";
+ public const string UpdateRule = "update_rule";
+ public const string DeleteRule = "delete_rule";
+ public const string CreateQuietHours = "create_quiet_hours";
+ public const string UpdateQuietHours = "update_quiet_hours";
+ public const string DeleteQuietHours = "delete_quiet_hours";
+ public const string CreateOverride = "create_override";
+ public const string RevokeOverride = "revoke_override";
+ public const string UpdateThrottleConfig = "update_throttle_config";
+ public const string DeleteThrottleConfig = "delete_throttle_config";
+ public const string AcknowledgeIncident = "acknowledge_incident";
+ public const string ResolveIncident = "resolve_incident";
+ public const string CreateEscalationPolicy = "create_escalation_policy";
+ public const string UpdateEscalationPolicy = "update_escalation_policy";
+ public const string DeleteEscalationPolicy = "delete_escalation_policy";
+ public const string CreateOncallSchedule = "create_oncall_schedule";
+ public const string UpdateOncallSchedule = "update_oncall_schedule";
+ public const string DeleteOncallSchedule = "delete_oncall_schedule";
+ public const string CreateOncallOverride = "create_oncall_override";
+ public const string DeleteOncallOverride = "delete_oncall_override";
+ public const string StartEscalation = "start_escalation";
+ public const string ManualEscalate = "manual_escalate";
+ public const string StopEscalation = "stop_escalation";
+ }
+
+ /// Actions for the Evidence Locker module.
+ public static class Evidence
+ {
+ public const string Store = "store";
+ public const string Snapshot = "snapshot";
+ public const string Verify = "verify";
+ public const string Hold = "hold";
+ }
+
+ /// Actions for the Attestor (Signer) module.
+ public static class Attestor
+ {
+ public const string SignDsse = "sign_dsse";
+ public const string VerifyDsse = "verify_dsse";
+ public const string AddKey = "add_key";
+ public const string RevokeKey = "revoke_key";
+ public const string CreateCeremony = "create_ceremony";
+ public const string ApproveCeremony = "approve_ceremony";
+ public const string ExecuteCeremony = "execute_ceremony";
+ public const string CancelCeremony = "cancel_ceremony";
+ }
+
+ /// Actions for the Integrations module.
+ public static class Integrations
+ {
+ public const string Create = "create";
+ public const string Update = "update";
+ public const string Delete = "delete";
+ public const string Test = "test";
+ public const string Discover = "discover";
+ public const string RunCodeGuard = "run_code_guard";
+ }
+
+ /// Actions for the Platform module.
+ public static class Platform
+ {
+ public const string CreateSetupSession = "create_setup_session";
+ public const string ExecuteSetupStep = "execute_setup_step";
+ public const string SkipSetupStep = "skip_setup_step";
+ public const string FinalizeSetup = "finalize_setup";
+ public const string CreateScript = "create_script";
+ public const string UpdateScript = "update_script";
+ public const string DeleteScript = "delete_script";
+ public const string EvaluateScore = "evaluate_score";
+ public const string VerifyScore = "verify_score";
+ public const string CreateEnvironment = "create_environment";
+ public const string UpdateEnvironment = "update_environment";
+ public const string DeleteEnvironment = "delete_environment";
+ public const string UpdateEnvironmentSettings = "update_environment_settings";
+ public const string CreateTarget = "create_target";
+ public const string UpdateTarget = "update_target";
+ public const string DeleteTarget = "delete_target";
+ public const string CreateFreezeWindow = "create_freeze_window";
+ public const string UpdateFreezeWindow = "update_freeze_window";
+ public const string DeleteFreezeWindow = "delete_freeze_window";
+ public const string CreateQuotaAlert = "create_quota_alert";
+ public const string CompleteOnboardingStep = "complete_onboarding_step";
+ public const string SkipOnboarding = "skip_onboarding";
+ public const string UpdateDashboardPreferences = "update_dashboard_preferences";
+ public const string UpdateLanguagePreference = "update_language_preference";
+ public const string UpdateEmailPreference = "update_email_preference";
+ public const string CreateDashboardProfile = "create_dashboard_profile";
+ public const string CreateIdentityProvider = "create_identity_provider";
+ public const string UpdateIdentityProvider = "update_identity_provider";
+ public const string DeleteIdentityProvider = "delete_identity_provider";
+ public const string EnableIdentityProvider = "enable_identity_provider";
+ public const string DisableIdentityProvider = "disable_identity_provider";
+ public const string TestIdentityProvider = "test_identity_provider";
+ public const string ApplyIdentityProvider = "apply_identity_provider";
+ public const string UpdateEnvironmentSetting = "update_environment_setting";
+ public const string DeleteEnvironmentSetting = "delete_environment_setting";
+ public const string UpdateCryptoPreference = "update_crypto_preference";
+ public const string DeleteCryptoPreference = "delete_crypto_preference";
+ public const string CreateTrustKey = "create_trust_key";
+ public const string RotateTrustKey = "rotate_trust_key";
+ public const string RevokeTrustKey = "revoke_trust_key";
+ public const string RegisterTrustIssuer = "register_trust_issuer";
+ public const string BlockTrustIssuer = "block_trust_issuer";
+ public const string UnblockTrustIssuer = "unblock_trust_issuer";
+ public const string RegisterTrustCertificate = "register_trust_certificate";
+ public const string RevokeTrustCertificate = "revoke_trust_certificate";
+ public const string ConfigureTransparencyLog = "configure_transparency_log";
+ }
+
+ /// Actions for the Doctor module.
+ public static class Doctor
+ {
+ public const string StartRun = "start_run";
+ public const string Diagnose = "diagnose";
+ public const string DeleteReport = "delete_report";
+ }
+
+ /// Actions for the Signals module.
+ public static class Signals
+ {
+ public const string IngestCallgraph = "ingest_callgraph";
+ public const string IngestRuntimeFact = "ingest_runtime_fact";
+ public const string ComputeReachability = "compute_reachability";
+ public const string IngestUnknowns = "ingest_unknowns";
+ public const string SubmitExecutionEvidence = "submit_execution_evidence";
+ public const string RegisterBeacon = "register_beacon";
+ }
+
+ /// Actions for the Advisory AI module.
+ public static class AdvisoryAi
+ {
+ public const string CreateRun = "create_run";
+ public const string RecordDecision = "record_decision";
+ public const string RecordOutcome = "record_outcome";
+ }
+
+ /// Actions for the Risk Engine module.
+ public static class RiskEngine
+ {
+ public const string CreateScoreJob = "create_score_job";
+ public const string CreateSimulation = "create_simulation";
+ }
+}
diff --git a/src/__Libraries/StellaOps.Audit.Emission/AuditEmissionOptions.cs b/src/__Libraries/StellaOps.Audit.Emission/AuditEmissionOptions.cs
index 920e4b273..912bda381 100644
--- a/src/__Libraries/StellaOps.Audit.Emission/AuditEmissionOptions.cs
+++ b/src/__Libraries/StellaOps.Audit.Emission/AuditEmissionOptions.cs
@@ -27,4 +27,18 @@ public sealed class AuditEmissionOptions
/// Default: 3 seconds. Audit emission should be fast and non-blocking.
///
public int TimeoutSeconds { get; set; } = 3;
+
+ ///
+ /// Maximum request body size (in bytes) to capture in audit events.
+ /// Bodies larger than this limit are truncated with a marker.
+ /// Default: 65536 (64 KB).
+ ///
+ public int MaxBodySizeBytes { get; set; } = 65_536;
+
+ ///
+ /// Field name patterns for PII redaction. When set, these replace
+ /// entirely.
+ /// Each entry is matched case-insensitively against JSON property names.
+ ///
+ public List? RedactedFieldPatterns { get; set; }
}
diff --git a/src/__Libraries/StellaOps.Audit.Emission/AuditEmissionServiceExtensions.cs b/src/__Libraries/StellaOps.Audit.Emission/AuditEmissionServiceExtensions.cs
index 30422e5c8..5403a2679 100644
--- a/src/__Libraries/StellaOps.Audit.Emission/AuditEmissionServiceExtensions.cs
+++ b/src/__Libraries/StellaOps.Audit.Emission/AuditEmissionServiceExtensions.cs
@@ -53,6 +53,17 @@ public static class AuditEmissionServiceExtensions
{
options.TimeoutSeconds = timeout;
}
+
+ if (int.TryParse(configuration["AuditEmission:MaxBodySizeBytes"], out var maxBody) && maxBody > 0)
+ {
+ options.MaxBodySizeBytes = maxBody;
+ }
+
+ var redactedPatterns = configuration.GetSection("AuditEmission:RedactedFieldPatterns").Get>();
+ if (redactedPatterns is { Count: > 0 })
+ {
+ options.RedactedFieldPatterns = redactedPatterns;
+ }
});
services.AddHttpClient(HttpAuditEventEmitter.HttpClientName, (provider, client) =>
diff --git a/src/__Libraries/StellaOps.Audit.Emission/AuditModules.cs b/src/__Libraries/StellaOps.Audit.Emission/AuditModules.cs
new file mode 100644
index 000000000..98768b06d
--- /dev/null
+++ b/src/__Libraries/StellaOps.Audit.Emission/AuditModules.cs
@@ -0,0 +1,28 @@
+// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
+
+namespace StellaOps.Audit.Emission;
+
+///
+/// Well-known module name constants for audit event annotations.
+/// Use these instead of magic strings when calling
+/// or constructing
+/// instances.
+///
+public static class AuditModules
+{
+ public const string Authority = "authority";
+ public const string Policy = "policy";
+ public const string Scanner = "scanner";
+ public const string Findings = "findings";
+ public const string Release = "release";
+ public const string Scheduler = "scheduler";
+ public const string Notify = "notify";
+ public const string Evidence = "evidence";
+ public const string Attestor = "attestor";
+ public const string Integrations = "integrations";
+ public const string Platform = "platform";
+ public const string Doctor = "doctor";
+ public const string Signals = "signals";
+ public const string AdvisoryAi = "advisory-ai";
+ public const string RiskEngine = "riskengine";
+}
diff --git a/src/__Libraries/StellaOps.Audit.Emission/AuditPiiRedactor.cs b/src/__Libraries/StellaOps.Audit.Emission/AuditPiiRedactor.cs
new file mode 100644
index 000000000..db4474b29
--- /dev/null
+++ b/src/__Libraries/StellaOps.Audit.Emission/AuditPiiRedactor.cs
@@ -0,0 +1,164 @@
+// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
+
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Text.RegularExpressions;
+
+namespace StellaOps.Audit.Emission;
+
+///
+/// Recursively walks a JSON structure and replaces values whose keys match
+/// configurable PII/secret patterns with "[REDACTED]" .
+///
+public static class AuditPiiRedactor
+{
+ ///
+ /// Default field name patterns that are always redacted.
+ /// Matching is case-insensitive and uses Contains semantics.
+ ///
+ public static readonly IReadOnlyList DefaultRedactedPatterns =
+ [
+ "password",
+ "secret",
+ "token",
+ "apikey",
+ "connectionstring",
+ "credential",
+ "privatekey",
+ "signingkey"
+ ];
+
+ private const string RedactedValue = "[REDACTED]";
+
+ ///
+ /// Redacts sensitive fields in a parsed tree.
+ /// Returns a new tree; the original is not mutated.
+ ///
+ /// The JSON node to redact. May be null.
+ ///
+ /// Extra field-name patterns to redact beyond .
+ ///
+ ///
+ /// Patterns from .
+ /// When non-null these replace the defaults entirely.
+ ///
+ /// A redacted copy, or null if the input was null.
+ public static JsonNode? Redact(
+ JsonNode? node,
+ IReadOnlyList? additionalPatterns = null,
+ IReadOnlyList? configuredPatterns = null)
+ {
+ if (node is null)
+ {
+ return null;
+ }
+
+ var patterns = BuildPatternSet(additionalPatterns, configuredPatterns);
+ return RedactNode(node, patterns);
+ }
+
+ ///
+ /// Convenience overload that accepts raw JSON bytes, redacts, and returns
+ /// the redacted .
+ ///
+ public static JsonNode? Redact(
+ ReadOnlySpan utf8Json,
+ IReadOnlyList? additionalPatterns = null,
+ IReadOnlyList? configuredPatterns = null)
+ {
+ var node = JsonNode.Parse(utf8Json);
+ return Redact(node, additionalPatterns, configuredPatterns);
+ }
+
+ private static HashSet BuildPatternSet(
+ IReadOnlyList? additionalPatterns,
+ IReadOnlyList? configuredPatterns)
+ {
+ var basePatterns = configuredPatterns ?? DefaultRedactedPatterns;
+ var set = new HashSet(basePatterns.Count + (additionalPatterns?.Count ?? 0), StringComparer.OrdinalIgnoreCase);
+
+ foreach (var p in basePatterns)
+ {
+ set.Add(NormalizePattern(p));
+ }
+
+ if (additionalPatterns is not null)
+ {
+ foreach (var p in additionalPatterns)
+ {
+ set.Add(NormalizePattern(p));
+ }
+ }
+
+ return set;
+ }
+
+ private static string NormalizePattern(string pattern)
+ {
+ // Strip non-alphanumeric chars so "api_key", "apiKey", "api-key" all match "apikey"
+ return Regex.Replace(pattern, @"[^a-zA-Z0-9]", "", RegexOptions.None, TimeSpan.FromMilliseconds(100))
+ .ToLowerInvariant();
+ }
+
+ private static string NormalizeFieldName(string fieldName)
+ {
+ return Regex.Replace(fieldName, @"[^a-zA-Z0-9]", "", RegexOptions.None, TimeSpan.FromMilliseconds(100))
+ .ToLowerInvariant();
+ }
+
+ private static bool ShouldRedact(string fieldName, HashSet patterns)
+ {
+ var normalized = NormalizeFieldName(fieldName);
+ foreach (var pattern in patterns)
+ {
+ if (normalized.Contains(pattern, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static JsonNode? RedactNode(JsonNode node, HashSet patterns)
+ {
+ switch (node)
+ {
+ case JsonObject obj:
+ var result = new JsonObject();
+ foreach (var kvp in obj)
+ {
+ if (ShouldRedact(kvp.Key, patterns))
+ {
+ result[kvp.Key] = JsonValue.Create(RedactedValue);
+ }
+ else if (kvp.Value is null)
+ {
+ result[kvp.Key] = null;
+ }
+ else
+ {
+ result[kvp.Key] = RedactNode(kvp.Value, patterns);
+ }
+ }
+
+ return result;
+
+ case JsonArray arr:
+ var resultArr = new JsonArray();
+ foreach (var item in arr)
+ {
+ resultArr.Add(item is null ? null : RedactNode(item, patterns));
+ }
+
+ return resultArr;
+
+ case JsonValue val:
+ // Deep-copy primitive values
+ return JsonNode.Parse(val.ToJsonString());
+
+ default:
+ return JsonNode.Parse(node.ToJsonString());
+ }
+ }
+}
diff --git a/src/__Libraries/StellaOps.Audit.Emission/IAuditBeforeStateProvider.cs b/src/__Libraries/StellaOps.Audit.Emission/IAuditBeforeStateProvider.cs
new file mode 100644
index 000000000..e4d4e1c93
--- /dev/null
+++ b/src/__Libraries/StellaOps.Audit.Emission/IAuditBeforeStateProvider.cs
@@ -0,0 +1,34 @@
+// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
+
+namespace StellaOps.Audit.Emission;
+
+///
+/// Optional service interface that captures the "before" state of a resource
+/// prior to endpoint execution, enabling before/after diff in audit events.
+///
+/// Services register implementations in DI. The
+/// finds the provider whose matches the audited endpoint's module
+/// and calls before the endpoint executes.
+/// If no provider is registered or it throws, the before-state is simply omitted.
+///
+///
+public interface IAuditBeforeStateProvider
+{
+ ///
+ /// The module this provider handles (e.g., "scanner", "release").
+ /// Must match the module string on the .
+ ///
+ string Module { get; }
+
+ ///
+ /// Retrieves the current state of a resource before the endpoint mutates it.
+ ///
+ /// The type of resource (e.g., "environment", "scan_policy").
+ /// The raw resource identifier (usually a GUID string).
+ /// Cancellation token.
+ ///
+ /// A dictionary of key/value pairs representing the resource's current state,
+ /// or null if the resource does not exist or state capture is not applicable.
+ ///
+ ValueTask?> GetBeforeStateAsync(string resourceType, string resourceId, CancellationToken ct);
+}
diff --git a/src/__Libraries/StellaOps.Audit.Emission/IAuditResourceEnricher.cs b/src/__Libraries/StellaOps.Audit.Emission/IAuditResourceEnricher.cs
new file mode 100644
index 000000000..d7144091c
--- /dev/null
+++ b/src/__Libraries/StellaOps.Audit.Emission/IAuditResourceEnricher.cs
@@ -0,0 +1,34 @@
+// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
+
+namespace StellaOps.Audit.Emission;
+
+///
+/// Optional service interface that enriches audit resource payloads by resolving
+/// a raw resource ID (typically a GUID) into a human-readable name.
+///
+/// Services register implementations in DI. The
+/// finds the enricher whose matches the audited endpoint's module
+/// and calls in a fire-and-forget fashion.
+/// If no enricher is registered or it throws, the filter falls back to the raw ID.
+///
+///
+public interface IAuditResourceEnricher
+{
+ ///
+ /// The module this enricher handles (e.g., "scanner", "release").
+ /// Must match the module string on the .
+ ///
+ string Module { get; }
+
+ ///
+ /// Resolves a resource ID to a human-readable payload containing the display name.
+ ///
+ /// The type of resource (e.g., "environment", "scan_policy").
+ /// The raw resource identifier (usually a GUID string).
+ /// Cancellation token.
+ ///
+ /// An with the
+ /// populated. Returns a payload with the original ID and no name if resolution fails.
+ ///
+ ValueTask EnrichAsync(string resourceType, string resourceId, CancellationToken ct);
+}