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,40 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
|
||||
namespace StellaOps.Timeline.WebService.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Combines events from the HTTP module polling provider with events
|
||||
/// received via the ingest endpoint, producing a unified event stream.
|
||||
/// </summary>
|
||||
public sealed class CompositeUnifiedAuditEventProvider : IUnifiedAuditEventProvider
|
||||
{
|
||||
private readonly HttpUnifiedAuditEventProvider _httpProvider;
|
||||
private readonly IngestAuditEventStore _ingestStore;
|
||||
|
||||
public CompositeUnifiedAuditEventProvider(
|
||||
HttpUnifiedAuditEventProvider httpProvider,
|
||||
IngestAuditEventStore ingestStore)
|
||||
{
|
||||
_httpProvider = httpProvider;
|
||||
_ingestStore = ingestStore;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<UnifiedAuditEvent>> GetEventsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var httpEvents = await _httpProvider.GetEventsAsync(cancellationToken).ConfigureAwait(false);
|
||||
var ingestedEvents = _ingestStore.GetAll();
|
||||
|
||||
if (ingestedEvents.Count == 0)
|
||||
{
|
||||
return httpEvents;
|
||||
}
|
||||
|
||||
return httpEvents
|
||||
.Concat(ingestedEvents)
|
||||
.OrderByDescending(e => e.Timestamp)
|
||||
.ThenBy(e => e.Id, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.Module, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.Action, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Timeline.WebService.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe in-memory store for audit events received via the ingest endpoint.
|
||||
/// Events stored here are merged into the unified audit query results by the
|
||||
/// <see cref="CompositeUnifiedAuditEventProvider"/>.
|
||||
/// </summary>
|
||||
public sealed class IngestAuditEventStore
|
||||
{
|
||||
private readonly ConcurrentQueue<UnifiedAuditEvent> _events = new();
|
||||
|
||||
/// <summary>Maximum number of events to retain in memory.</summary>
|
||||
private const int MaxRetained = 10_000;
|
||||
|
||||
/// <summary>
|
||||
/// Adds an event to the in-memory store.
|
||||
/// When the store exceeds <see cref="MaxRetained"/>, the oldest events are discarded.
|
||||
/// </summary>
|
||||
public void Add(UnifiedAuditEvent auditEvent)
|
||||
{
|
||||
_events.Enqueue(auditEvent);
|
||||
|
||||
// Trim oldest events when the queue grows too large
|
||||
while (_events.Count > MaxRetained)
|
||||
{
|
||||
_events.TryDequeue(out _);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a snapshot of all ingested events.
|
||||
/// </summary>
|
||||
public IReadOnlyList<UnifiedAuditEvent> GetAll()
|
||||
{
|
||||
return _events.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,73 @@ public static class UnifiedAuditEndpoints
|
||||
.WithName("GetUnifiedAuditExportStatus")
|
||||
.WithDescription("Get unified audit export status.")
|
||||
.RequireAuthorization(TimelinePolicies.Read);
|
||||
|
||||
// Ingest endpoint: accepts audit events from other services via the AuditActionFilter.
|
||||
// Uses a separate group without RequireTenant so service-to-service calls can ingest
|
||||
// events using only the Write policy (tenant is carried inside the event payload).
|
||||
var ingestGroup = app.MapGroup("/api/v1/audit")
|
||||
.WithTags("Unified Audit Ingest");
|
||||
|
||||
ingestGroup.MapPost("/ingest", IngestEventAsync)
|
||||
.WithName("IngestUnifiedAuditEvent")
|
||||
.WithDescription("Ingest a single audit event from a service. Returns 202 Accepted.")
|
||||
.RequireAuthorization(TimelinePolicies.Write);
|
||||
}
|
||||
|
||||
private static IResult IngestEventAsync(
|
||||
UnifiedAuditIngestRequest request,
|
||||
IngestAuditEventStore store,
|
||||
ILoggerFactory loggerFactory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Module) || string.IsNullOrWhiteSpace(request.Action))
|
||||
{
|
||||
return Results.BadRequest(new { error = "module_and_action_required" });
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var action = UnifiedAuditValueMapper.NormalizeAction(request.Action, request.Description);
|
||||
var module = UnifiedAuditValueMapper.NormalizeModule(request.Module);
|
||||
var severity = UnifiedAuditValueMapper.NormalizeSeverity(request.Severity, action, request.Description);
|
||||
|
||||
var auditEvent = new UnifiedAuditEvent
|
||||
{
|
||||
Id = !string.IsNullOrWhiteSpace(request.Id) ? request.Id : $"ingest-{Guid.NewGuid():N}",
|
||||
Timestamp = request.Timestamp ?? now,
|
||||
Module = module,
|
||||
Action = action,
|
||||
Severity = severity,
|
||||
Actor = new UnifiedAuditActor
|
||||
{
|
||||
Id = request.Actor?.Id ?? "system",
|
||||
Name = request.Actor?.Name ?? request.Actor?.Id ?? "system",
|
||||
Email = request.Actor?.Email,
|
||||
Type = UnifiedAuditValueMapper.NormalizeActorType(request.Actor?.Type),
|
||||
IpAddress = request.Actor?.IpAddress,
|
||||
UserAgent = request.Actor?.UserAgent
|
||||
},
|
||||
Resource = new UnifiedAuditResource
|
||||
{
|
||||
Type = request.Resource?.Type ?? "resource",
|
||||
Id = request.Resource?.Id ?? "unknown",
|
||||
Name = request.Resource?.Name
|
||||
},
|
||||
Description = request.Description ?? $"{action} {module} resource",
|
||||
Details = request.Details ?? new Dictionary<string, object?>(),
|
||||
CorrelationId = request.CorrelationId,
|
||||
TenantId = request.TenantId,
|
||||
Tags = request.Tags ?? [module, action]
|
||||
};
|
||||
|
||||
store.Add(auditEvent);
|
||||
|
||||
var logger = loggerFactory.CreateLogger("StellaOps.Timeline.AuditIngest");
|
||||
logger.LogDebug(
|
||||
"Ingested audit event {EventId} ({Module}.{Action})",
|
||||
auditEvent.Id,
|
||||
auditEvent.Module,
|
||||
auditEvent.Action);
|
||||
|
||||
return Results.Accepted(value: new { eventId = auditEvent.Id, status = "accepted" });
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetEventsAsync(
|
||||
@@ -306,3 +373,41 @@ public sealed record UnifiedAuditEventsRequest
|
||||
public string? Cursor { get; init; }
|
||||
public int? Limit { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request body for the <c>POST /api/v1/audit/ingest</c> endpoint.
|
||||
/// Wire-compatible with the <c>AuditEventPayload</c> emitted by the
|
||||
/// <c>StellaOps.Audit.Emission</c> shared library.
|
||||
/// </summary>
|
||||
public sealed record UnifiedAuditIngestRequest
|
||||
{
|
||||
public string? Id { get; init; }
|
||||
public DateTimeOffset? Timestamp { get; init; }
|
||||
public string? Module { get; init; }
|
||||
public string? Action { get; init; }
|
||||
public string? Severity { get; init; }
|
||||
public UnifiedAuditIngestActorRequest? Actor { get; init; }
|
||||
public UnifiedAuditIngestResourceRequest? Resource { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public IReadOnlyDictionary<string, object?>? Details { get; init; }
|
||||
public string? CorrelationId { get; init; }
|
||||
public string? TenantId { get; init; }
|
||||
public IReadOnlyList<string>? Tags { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UnifiedAuditIngestActorRequest
|
||||
{
|
||||
public string? Id { get; init; }
|
||||
public string? Name { get; init; }
|
||||
public string? Email { get; init; }
|
||||
public string? Type { get; init; }
|
||||
public string? IpAddress { get; init; }
|
||||
public string? UserAgent { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UnifiedAuditIngestResourceRequest
|
||||
{
|
||||
public string? Type { get; init; }
|
||||
public string? Id { get; init; }
|
||||
public string? Name { get; init; }
|
||||
}
|
||||
|
||||
@@ -51,7 +51,10 @@ builder.Services.AddHttpClient(HttpUnifiedAuditEventProvider.ClientName, (provid
|
||||
client.Timeout = TimeSpan.FromSeconds(Math.Max(1, options.RequestTimeoutSeconds));
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<IUnifiedAuditEventProvider, HttpUnifiedAuditEventProvider>();
|
||||
// Audit event providers: HTTP polling from modules + in-memory ingest store
|
||||
builder.Services.AddSingleton<HttpUnifiedAuditEventProvider>();
|
||||
builder.Services.AddSingleton<IngestAuditEventStore>();
|
||||
builder.Services.AddSingleton<IUnifiedAuditEventProvider, CompositeUnifiedAuditEventProvider>();
|
||||
builder.Services.AddSingleton<IUnifiedAuditAggregationService, UnifiedAuditAggregationService>();
|
||||
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
|
||||
Reference in New Issue
Block a user