Sidebar 5-group restructure + demo data badges + audit emission infrastructure
Sprint 4 — Sidebar restructure (S4-T01+T02):
5 groups: Release Control, Security, Operations, Audit & Evidence, Setup & Admin
Groups 4+5 collapsed by default for new users
Operations extracted from Release Control into own group
Audit extracted from Security into own group
groupOrder and resolveMenuGroupLabel updated
Approvals badge moved to section-level
Sprint 2 — Demo data badges (S2-T04+T05):
Backend: isDemo=true on all compatibility/seed responses in
PackAdapterEndpoints, QuotaCompatibilityEndpoints, VulnerabilitiesController
Frontend: "(Demo)" badges on Usage & Limits page quotas
Frontend: "(Demo)" badges on triage artifact list when seed data
New PlatformItemResponse/PlatformListResponse with IsDemo field
Sprint 6 — Audit emission infrastructure (S6-T01+T02):
New shared library: src/__Libraries/StellaOps.Audit.Emission/
- AuditActionAttribute: [AuditAction("module", "action")] endpoint tag
- AuditActionFilter: IEndpointFilter that auto-emits UnifiedAuditEvent
- HttpAuditEventEmitter: POSTs to Timeline /api/v1/audit/ingest
- Single-line DI: services.AddAuditEmission(configuration)
Timeline service: POST /api/v1/audit/ingest ingestion endpoint
- IngestAuditEventStore: 10k-event ring buffer
- CompositeUnifiedAuditEventProvider: merges HTTP-polled + ingested
Documentation: docs/modules/audit/AUDIT_EMISSION_GUIDE.md
Angular build: 0 errors. .NET builds: 0 errors.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
|
||||
namespace StellaOps.Audit.Emission;
|
||||
|
||||
/// <summary>
|
||||
/// Marks an endpoint for automatic audit event emission.
|
||||
/// When applied, the <see cref="AuditActionFilter"/> will emit a
|
||||
/// <c>UnifiedAuditEvent</c> to the Timeline service after the endpoint executes.
|
||||
/// </summary>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// app.MapPost("/api/v1/environments", CreateEnvironment)
|
||||
/// .AddEndpointFilter<AuditActionFilter>()
|
||||
/// .WithMetadata(new AuditActionAttribute("concelier", "create"));
|
||||
/// </code>
|
||||
/// </example>
|
||||
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = false)]
|
||||
public sealed class AuditActionAttribute : Attribute
|
||||
{
|
||||
/// <summary>
|
||||
/// The module name that owns the audited action.
|
||||
/// Must be one of the well-known modules in <c>UnifiedAuditCatalog.Modules</c>
|
||||
/// (e.g., "authority", "policy", "jobengine", "vex", "scanner", "integrations").
|
||||
/// </summary>
|
||||
public string Module { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The action being performed.
|
||||
/// Must be one of the well-known actions in <c>UnifiedAuditCatalog.Actions</c>
|
||||
/// (e.g., "create", "update", "delete", "promote", "approve", "reject").
|
||||
/// </summary>
|
||||
public string Action { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional resource type override. If not set, the filter infers the resource type
|
||||
/// from the route template (e.g., "/api/v1/environments/{id}" yields "environments").
|
||||
/// </summary>
|
||||
public string? ResourceType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new audit action attribute.
|
||||
/// </summary>
|
||||
/// <param name="module">The owning module name (e.g., "concelier").</param>
|
||||
/// <param name="action">The action name (e.g., "create").</param>
|
||||
public AuditActionAttribute(string module, string action)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(module);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(action);
|
||||
Module = module;
|
||||
Action = action;
|
||||
}
|
||||
}
|
||||
254
src/__Libraries/StellaOps.Audit.Emission/AuditActionFilter.cs
Normal file
254
src/__Libraries/StellaOps.Audit.Emission/AuditActionFilter.cs
Normal file
@@ -0,0 +1,254 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Audit.Emission;
|
||||
|
||||
/// <summary>
|
||||
/// ASP.NET Core endpoint filter that automatically emits <c>UnifiedAuditEvent</c>
|
||||
/// payloads to the Timeline service's ingestion endpoint after an endpoint executes.
|
||||
/// <para>
|
||||
/// The filter reads <see cref="AuditActionAttribute"/> metadata from the endpoint.
|
||||
/// If the attribute is not present, the filter is a no-op passthrough.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Usage in minimal API registration:
|
||||
/// <code>
|
||||
/// app.MapPost("/api/v1/resources", CreateResource)
|
||||
/// .AddEndpointFilter<AuditActionFilter>()
|
||||
/// .WithMetadata(new AuditActionAttribute("mymodule", "create"));
|
||||
/// </code>
|
||||
/// </remarks>
|
||||
public sealed class AuditActionFilter : IEndpointFilter
|
||||
{
|
||||
private readonly IAuditEventEmitter _emitter;
|
||||
private readonly ILogger<AuditActionFilter> _logger;
|
||||
|
||||
public AuditActionFilter(IAuditEventEmitter emitter, ILogger<AuditActionFilter> logger)
|
||||
{
|
||||
_emitter = emitter;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async ValueTask<object?> InvokeAsync(
|
||||
EndpointFilterInvocationContext context,
|
||||
EndpointFilterDelegate next)
|
||||
{
|
||||
// Execute the endpoint first
|
||||
var result = await next(context).ConfigureAwait(false);
|
||||
|
||||
// Check for the audit attribute
|
||||
var auditAttr = context.HttpContext.GetEndpoint()?.Metadata.GetMetadata<AuditActionAttribute>();
|
||||
if (auditAttr is null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
// Fire-and-forget: emit the audit event asynchronously without blocking the response
|
||||
_ = EmitAuditEventSafeAsync(context.HttpContext, auditAttr, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task EmitAuditEventSafeAsync(
|
||||
HttpContext httpContext,
|
||||
AuditActionAttribute auditAttr,
|
||||
object? result)
|
||||
{
|
||||
try
|
||||
{
|
||||
var auditEvent = BuildAuditEvent(httpContext, auditAttr, result);
|
||||
await _emitter.EmitAsync(auditEvent, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Audit emission must never fail the request. Log and swallow.
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to emit audit event for {Module}.{Action} on {Path}",
|
||||
auditAttr.Module,
|
||||
auditAttr.Action,
|
||||
httpContext.Request.Path);
|
||||
}
|
||||
}
|
||||
|
||||
internal static AuditEventPayload BuildAuditEvent(
|
||||
HttpContext httpContext,
|
||||
AuditActionAttribute auditAttr,
|
||||
object? result)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var user = httpContext.User;
|
||||
var request = httpContext.Request;
|
||||
var response = httpContext.Response;
|
||||
|
||||
// Resolve actor from claims
|
||||
var actorId = ResolveClaimValue(user, "sub", ClaimTypes.NameIdentifier) ?? "system";
|
||||
var actorName = ResolveClaimValue(user, "name", ClaimTypes.Name) ?? actorId;
|
||||
var actorEmail = ResolveClaimValue(user, "email", ClaimTypes.Email);
|
||||
var tenantId = ResolveClaimValue(user, "stellaops:tenant", "tenant");
|
||||
|
||||
// Resolve resource from route values
|
||||
var resourceId = ResolveResourceId(httpContext);
|
||||
var resourceType = auditAttr.ResourceType ?? InferResourceType(request.Path.Value);
|
||||
|
||||
// Determine severity from HTTP status code
|
||||
var severity = InferSeverity(response.StatusCode);
|
||||
|
||||
// Build description
|
||||
var description = $"{Capitalize(auditAttr.Action)} {auditAttr.Module} resource";
|
||||
if (!string.IsNullOrWhiteSpace(resourceId))
|
||||
{
|
||||
description = $"{description} {resourceId}";
|
||||
}
|
||||
|
||||
// Correlation ID from headers or trace
|
||||
var correlationId = request.Headers.TryGetValue("X-Correlation-Id", out var corrValues)
|
||||
? corrValues.FirstOrDefault()
|
||||
: httpContext.TraceIdentifier;
|
||||
|
||||
return new AuditEventPayload
|
||||
{
|
||||
Id = $"audit-{Guid.NewGuid():N}",
|
||||
Timestamp = now,
|
||||
Module = auditAttr.Module.ToLowerInvariant(),
|
||||
Action = auditAttr.Action.ToLowerInvariant(),
|
||||
Severity = severity,
|
||||
Actor = new AuditActorPayload
|
||||
{
|
||||
Id = actorId,
|
||||
Name = actorName,
|
||||
Email = actorEmail,
|
||||
Type = DetermineActorType(user),
|
||||
IpAddress = httpContext.Connection.RemoteIpAddress?.ToString(),
|
||||
UserAgent = request.Headers.UserAgent.FirstOrDefault()
|
||||
},
|
||||
Resource = new AuditResourcePayload
|
||||
{
|
||||
Type = resourceType,
|
||||
Id = resourceId ?? "unknown"
|
||||
},
|
||||
Description = description,
|
||||
Details = new Dictionary<string, object?>
|
||||
{
|
||||
["httpMethod"] = request.Method,
|
||||
["requestPath"] = request.Path.Value,
|
||||
["statusCode"] = response.StatusCode
|
||||
},
|
||||
CorrelationId = correlationId,
|
||||
TenantId = tenantId,
|
||||
Tags = [auditAttr.Module.ToLowerInvariant(), auditAttr.Action.ToLowerInvariant()]
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ResolveClaimValue(ClaimsPrincipal user, params string[] claimTypes)
|
||||
{
|
||||
foreach (var claimType in claimTypes)
|
||||
{
|
||||
var value = user.FindFirst(claimType)?.Value;
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ResolveResourceId(HttpContext httpContext)
|
||||
{
|
||||
var routeValues = httpContext.Request.RouteValues;
|
||||
|
||||
// Try common route param names for resource identification
|
||||
string[] candidateKeys = ["id", "resourceId", "environmentId", "agentId", "jobId", "policyId", "scanId"];
|
||||
foreach (var key in candidateKeys)
|
||||
{
|
||||
if (routeValues.TryGetValue(key, out var value) && value is not null)
|
||||
{
|
||||
var str = value.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(str))
|
||||
{
|
||||
return str;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: first GUID-like route value
|
||||
foreach (var kvp in routeValues)
|
||||
{
|
||||
if (kvp.Value is string s && Guid.TryParse(s, out _))
|
||||
{
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
internal static string InferResourceType(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return "resource";
|
||||
}
|
||||
|
||||
// Extract from path pattern: /api/v1/{resourceType}/...
|
||||
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
for (var i = 0; i < segments.Length; i++)
|
||||
{
|
||||
// Skip "api" and version segments (v1, v2, etc.)
|
||||
if (segments[i].Equals("api", StringComparison.OrdinalIgnoreCase) ||
|
||||
(segments[i].StartsWith('v') && segments[i].Length <= 3 && char.IsDigit(segments[i][^1])))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Return the first meaningful segment as the resource type
|
||||
return segments[i].ToLowerInvariant();
|
||||
}
|
||||
|
||||
return "resource";
|
||||
}
|
||||
|
||||
internal static string InferSeverity(int statusCode)
|
||||
{
|
||||
return statusCode switch
|
||||
{
|
||||
>= 200 and < 300 => "info",
|
||||
>= 400 and < 500 => "warning",
|
||||
>= 500 => "error",
|
||||
_ => "info"
|
||||
};
|
||||
}
|
||||
|
||||
private static string DetermineActorType(ClaimsPrincipal user)
|
||||
{
|
||||
if (!user.Identity?.IsAuthenticated ?? true)
|
||||
{
|
||||
return "system";
|
||||
}
|
||||
|
||||
var clientId = user.FindFirst("client_id")?.Value;
|
||||
if (!string.IsNullOrWhiteSpace(clientId) &&
|
||||
user.FindFirst("sub") is null)
|
||||
{
|
||||
return "service"; // Client credentials flow (no sub claim)
|
||||
}
|
||||
|
||||
return "user";
|
||||
}
|
||||
|
||||
private static string Capitalize(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return char.ToUpperInvariant(value[0]) + value[1..];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
|
||||
namespace StellaOps.Audit.Emission;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for audit event emission.
|
||||
/// Bind from configuration section <c>AuditEmission</c> or set via environment variables.
|
||||
/// </summary>
|
||||
public sealed class AuditEmissionOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Base URL of the Timeline service that hosts the audit ingest endpoint.
|
||||
/// Default: <c>http://timeline.stella-ops.local</c>.
|
||||
/// Override via <c>AuditEmission:TimelineBaseUrl</c> or <c>STELLAOPS_TIMELINE_URL</c>.
|
||||
/// </summary>
|
||||
public string TimelineBaseUrl { get; set; } = "http://timeline.stella-ops.local";
|
||||
|
||||
/// <summary>
|
||||
/// Whether audit emission is enabled. Set to <c>false</c> to disable
|
||||
/// all audit event posting (events are silently dropped).
|
||||
/// Default: <c>true</c>.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP request timeout in seconds for the audit ingest call.
|
||||
/// Default: 3 seconds. Audit emission should be fast and non-blocking.
|
||||
/// </summary>
|
||||
public int TimeoutSeconds { get; set; } = 3;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace StellaOps.Audit.Emission;
|
||||
|
||||
/// <summary>
|
||||
/// DI registration extension for audit event emission.
|
||||
/// Call <see cref="AddAuditEmission"/> once in your service's <c>Program.cs</c>
|
||||
/// to enable the <see cref="AuditActionFilter"/> endpoint filter.
|
||||
/// </summary>
|
||||
public static class AuditEmissionServiceExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers the audit event emission infrastructure:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="AuditActionFilter"/> (endpoint filter)</item>
|
||||
/// <item><see cref="HttpAuditEventEmitter"/> (HTTP emitter to Timeline service)</item>
|
||||
/// <item><see cref="AuditEmissionOptions"/> (configuration binding)</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configuration">The application configuration root.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// // In Program.cs:
|
||||
/// builder.Services.AddAuditEmission(builder.Configuration);
|
||||
///
|
||||
/// // Then on endpoints:
|
||||
/// app.MapPost("/api/v1/environments", CreateEnvironment)
|
||||
/// .AddEndpointFilter<AuditActionFilter>()
|
||||
/// .WithMetadata(new AuditActionAttribute("concelier", "create"));
|
||||
/// </code>
|
||||
/// </example>
|
||||
public static IServiceCollection AddAuditEmission(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
services.Configure<AuditEmissionOptions>(options =>
|
||||
{
|
||||
options.TimelineBaseUrl = configuration["AuditEmission:TimelineBaseUrl"]
|
||||
?? configuration["STELLAOPS_TIMELINE_URL"]
|
||||
?? options.TimelineBaseUrl;
|
||||
|
||||
if (bool.TryParse(configuration["AuditEmission:Enabled"], out var enabled))
|
||||
{
|
||||
options.Enabled = enabled;
|
||||
}
|
||||
|
||||
if (int.TryParse(configuration["AuditEmission:TimeoutSeconds"], out var timeout) && timeout > 0)
|
||||
{
|
||||
options.TimeoutSeconds = timeout;
|
||||
}
|
||||
});
|
||||
|
||||
services.AddHttpClient(HttpAuditEventEmitter.HttpClientName, (provider, client) =>
|
||||
{
|
||||
var opts = provider
|
||||
.GetRequiredService<Microsoft.Extensions.Options.IOptions<AuditEmissionOptions>>()
|
||||
.Value;
|
||||
client.Timeout = TimeSpan.FromSeconds(Math.Max(1, opts.TimeoutSeconds));
|
||||
});
|
||||
|
||||
services.AddSingleton<IAuditEventEmitter, HttpAuditEventEmitter>();
|
||||
services.AddScoped<AuditActionFilter>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
|
||||
namespace StellaOps.Audit.Emission;
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight DTO that mirrors the <c>UnifiedAuditEvent</c> structure from the
|
||||
/// Timeline service. This avoids a compile-time dependency on the Timeline
|
||||
/// assembly while remaining wire-compatible for JSON serialization.
|
||||
/// </summary>
|
||||
public sealed record AuditEventPayload
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
public required string Module { get; init; }
|
||||
public required string Action { get; init; }
|
||||
public required string Severity { get; init; }
|
||||
public required AuditActorPayload Actor { get; init; }
|
||||
public required AuditResourcePayload Resource { get; init; }
|
||||
public required string Description { get; init; }
|
||||
public required IReadOnlyDictionary<string, object?> Details { get; init; }
|
||||
public string? CorrelationId { get; init; }
|
||||
public string? TenantId { get; init; }
|
||||
public required IReadOnlyList<string> Tags { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AuditActorPayload
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public string? Email { get; init; }
|
||||
public required string Type { get; init; }
|
||||
public string? IpAddress { get; init; }
|
||||
public string? UserAgent { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AuditResourcePayload
|
||||
{
|
||||
public required string Type { get; init; }
|
||||
public required string Id { get; init; }
|
||||
public string? Name { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Audit.Emission;
|
||||
|
||||
/// <summary>
|
||||
/// Sends audit events to the Timeline service's <c>POST /api/v1/audit/ingest</c> endpoint.
|
||||
/// Failures are logged but never propagated -- audit emission must not block the calling service.
|
||||
/// </summary>
|
||||
public sealed class HttpAuditEventEmitter : IAuditEventEmitter
|
||||
{
|
||||
/// <summary>
|
||||
/// Named HTTP client identifier used for DI registration.
|
||||
/// </summary>
|
||||
public const string HttpClientName = "StellaOps.AuditEmission";
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IOptions<AuditEmissionOptions> _options;
|
||||
private readonly ILogger<HttpAuditEventEmitter> _logger;
|
||||
|
||||
public HttpAuditEventEmitter(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOptions<AuditEmissionOptions> options,
|
||||
ILogger<HttpAuditEventEmitter> logger)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_options = options;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task EmitAsync(AuditEventPayload auditEvent, CancellationToken cancellationToken)
|
||||
{
|
||||
var options = _options.Value;
|
||||
if (!options.Enabled)
|
||||
{
|
||||
_logger.LogDebug("Audit emission is disabled; skipping event {EventId}", auditEvent.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient(HttpClientName);
|
||||
var uri = new Uri(new Uri(options.TimelineBaseUrl), "/api/v1/audit/ingest");
|
||||
|
||||
using var response = await client.PostAsJsonAsync(
|
||||
uri,
|
||||
auditEvent,
|
||||
SerializerOptions,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Audit ingest returned HTTP {StatusCode} for event {EventId}",
|
||||
(int)response.StatusCode,
|
||||
auditEvent.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Audit event {EventId} emitted successfully ({Module}.{Action})",
|
||||
auditEvent.Id,
|
||||
auditEvent.Module,
|
||||
auditEvent.Action);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogWarning("Audit emission timed out for event {EventId}", auditEvent.Id);
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or JsonException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Audit emission failed for event {EventId}", auditEvent.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
|
||||
namespace StellaOps.Audit.Emission;
|
||||
|
||||
/// <summary>
|
||||
/// Emits audit events to the unified audit log.
|
||||
/// The default implementation posts events to the Timeline service's ingestion endpoint.
|
||||
/// </summary>
|
||||
public interface IAuditEventEmitter
|
||||
{
|
||||
/// <summary>
|
||||
/// Sends an audit event to the unified audit log.
|
||||
/// Implementations must be resilient to transient failures and must never
|
||||
/// throw exceptions that would affect the calling endpoint's response.
|
||||
/// </summary>
|
||||
Task EmitAsync(AuditEventPayload auditEvent, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Audit.Emission</RootNamespace>
|
||||
<AssemblyName>StellaOps.Audit.Emission</AssemblyName>
|
||||
<Description>Shared audit event emission infrastructure for StellaOps services. Provides an endpoint filter and DI registration to automatically emit UnifiedAuditEvents to the Timeline service.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user