From 189171c594fa33daad050bde95a10a29edb63ec6 Mon Sep 17 00:00:00 2001 From: master <> Date: Mon, 16 Mar 2026 14:48:18 +0200 Subject: [PATCH] Sidebar 5-group restructure + demo data badges + audit emission infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/modules/audit/AUDIT_EMISSION_GUIDE.md | 136 ++++++ .../Contracts/PlatformResponseModels.cs | 6 +- .../Endpoints/PackAdapterEndpoints.cs | 24 +- .../Endpoints/QuotaCompatibilityEndpoints.cs | 3 +- .../Controllers/VulnerabilitiesController.cs | 12 +- .../CompositeUnifiedAuditEventProvider.cs | 40 ++ .../Audit/IngestAuditEventStore.cs | 41 ++ .../Endpoints/UnifiedAuditEndpoints.cs | 105 +++++ .../StellaOps.Timeline.WebService/Program.cs | 5 +- .../src/app/core/api/vulnerability.client.ts | 1 + .../src/app/core/api/vulnerability.models.ts | 2 + .../usage/usage-settings-page.component.ts | 27 +- .../triage/triage-artifacts.component.html | 10 +- .../triage/triage-artifacts.component.scss | 20 + .../triage/triage-artifacts.component.ts | 2 + .../app-sidebar/app-sidebar.component.ts | 408 +++++++++++++----- .../app-sidebar/sidebar-preference.service.ts | 2 +- .../AuditActionAttribute.cs | 52 +++ .../AuditActionFilter.cs | 254 +++++++++++ .../AuditEmissionOptions.cs | 30 ++ .../AuditEmissionServiceExtensions.cs | 71 +++ .../AuditEventPayload.cs | 41 ++ .../HttpAuditEventEmitter.cs | 89 ++++ .../IAuditEventEmitter.cs | 17 + .../StellaOps.Audit.Emission.csproj | 18 + 25 files changed, 1295 insertions(+), 121 deletions(-) create mode 100644 docs/modules/audit/AUDIT_EMISSION_GUIDE.md create mode 100644 src/Timeline/StellaOps.Timeline.WebService/Audit/CompositeUnifiedAuditEventProvider.cs create mode 100644 src/Timeline/StellaOps.Timeline.WebService/Audit/IngestAuditEventStore.cs create mode 100644 src/__Libraries/StellaOps.Audit.Emission/AuditActionAttribute.cs create mode 100644 src/__Libraries/StellaOps.Audit.Emission/AuditActionFilter.cs create mode 100644 src/__Libraries/StellaOps.Audit.Emission/AuditEmissionOptions.cs create mode 100644 src/__Libraries/StellaOps.Audit.Emission/AuditEmissionServiceExtensions.cs create mode 100644 src/__Libraries/StellaOps.Audit.Emission/AuditEventPayload.cs create mode 100644 src/__Libraries/StellaOps.Audit.Emission/HttpAuditEventEmitter.cs create mode 100644 src/__Libraries/StellaOps.Audit.Emission/IAuditEventEmitter.cs create mode 100644 src/__Libraries/StellaOps.Audit.Emission/StellaOps.Audit.Emission.csproj diff --git a/docs/modules/audit/AUDIT_EMISSION_GUIDE.md b/docs/modules/audit/AUDIT_EMISSION_GUIDE.md new file mode 100644 index 000000000..8c791f589 --- /dev/null +++ b/docs/modules/audit/AUDIT_EMISSION_GUIDE.md @@ -0,0 +1,136 @@ +# 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. + +## Overview + +The audit emission infrastructure provides: + +1. **`AuditActionAttribute`** -- marks an endpoint for automatic audit event emission +2. **`AuditActionFilter`** -- ASP.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`** -> `IngestAuditEventStore` -> merged into unified audit query results. + +## Step 1: Add project reference + +In your service's `.csproj`, add a reference to the shared library: + +```xml + +``` + +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`: + +```csharp +using StellaOps.Audit.Emission; + +// After other service registrations: +builder.Services.AddAuditEmission(builder.Configuration); +``` + +This registers: +- `AuditActionFilter` (scoped endpoint filter) +- `HttpAuditEventEmitter` as `IAuditEventEmitter` (singleton) +- `AuditEmissionOptions` bound from configuration + +## Step 3: Tag endpoints + +Add the `AuditActionFilter` and `AuditActionAttribute` metadata to any endpoint you want audited: + +```csharp +using StellaOps.Audit.Emission; + +app.MapPost("/api/v1/environments", CreateEnvironment) + .AddEndpointFilter() + .WithMetadata(new AuditActionAttribute("concelier", "create")); + +app.MapPut("/api/v1/environments/{id}", UpdateEnvironment) + .AddEndpointFilter() + .WithMetadata(new AuditActionAttribute("concelier", "update")); + +app.MapDelete("/api/v1/environments/{id}", DeleteEnvironment) + .AddEndpointFilter() + .WithMetadata(new AuditActionAttribute("concelier", "delete")); +``` + +### Attribute parameters + +| Parameter | Required | Description | +|---------------|----------|-------------| +| `module` | Yes | Module name from `UnifiedAuditCatalog.Modules` (e.g., `"authority"`, `"policy"`, `"jobengine"`, `"vex"`, `"scanner"`, `"integrations"`) | +| `action` | Yes | Action name from `UnifiedAuditCatalog.Actions` (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. | + +## Step 4: Configuration (optional) + +The emitter reads configuration from the `AuditEmission` section or environment variables: + +```json +{ + "AuditEmission": { + "TimelineBaseUrl": "http://timeline.stella-ops.local", + "Enabled": true, + "TimeoutSeconds": 3 + } +} +``` + +Environment variable overrides: +- `STELLAOPS_TIMELINE_URL` -- overrides `TimelineBaseUrl` +- `AuditEmission__Enabled` -- set to `false` to disable emission +- `AuditEmission__TimeoutSeconds` -- HTTP timeout for ingest calls + +## 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. +3. If present, it builds an `AuditEventPayload` containing: + - **Module** and **Action** from the attribute + - **Actor** from `HttpContext.User` claims (`sub`, `name`, `email`, `stellaops:tenant`) + - **Resource** from route parameters (first matching `id`, `resourceId`, etc., or first GUID value) + - **Severity** inferred from HTTP response status code (2xx=info, 4xx=warning, 5xx=error) + - **Description** auto-generated: `"{Action} {module} resource {resourceId}"` + - **CorrelationId** from `X-Correlation-Id` header or `HttpContext.TraceIdentifier` +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. + +## Timeline ingest endpoint + +The Timeline service exposes: + +``` +POST /api/v1/audit/ingest +``` + +- **Auth**: Requires `timeline:write` scope +- **Body**: JSON matching the `AuditEventPayload` schema (camelCase) +- **Response**: `202 Accepted` with `{ "eventId": "...", "status": "accepted" }` +- **Gateway route**: Already covered by the existing `/api/v1/audit(.*)` route in `router-gateway-local.json` + +Ingested events are stored in an in-memory ring buffer (max 10,000 events) and merged with the HTTP-polled events from other modules (JobEngine, Policy, EvidenceLocker, Notify) in the unified audit query results. + +## Architecture decisions + +- **Fire-and-forget emission**: Audit events are sent asynchronously after the endpoint responds. This ensures zero latency impact on the audited endpoint. +- **No compile-time dependency on Timeline**: The `AuditEventPayload` DTOs in the emission library are wire-compatible with `UnifiedAuditEvent` but live in a separate namespace, avoiding circular dependencies. +- **In-memory ingest store**: For the alpha phase, ingested events are stored in memory. A future sprint will add Postgres persistence for the ingest store. +- **Composite event provider**: The Timeline service merges HTTP-polled events with ingested events, so all audit data appears in a single unified stream. + +## 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` | +| DI extension | `src/__Libraries/StellaOps.Audit.Emission/AuditEmissionServiceExtensions.cs` | +| Ingest endpoint | `src/Timeline/StellaOps.Timeline.WebService/Endpoints/UnifiedAuditEndpoints.cs` | +| Ingest store | `src/Timeline/StellaOps.Timeline.WebService/Audit/IngestAuditEventStore.cs` | +| Composite provider | `src/Timeline/StellaOps.Timeline.WebService/Audit/CompositeUnifiedAuditEventProvider.cs` | diff --git a/src/Platform/StellaOps.Platform.WebService/Contracts/PlatformResponseModels.cs b/src/Platform/StellaOps.Platform.WebService/Contracts/PlatformResponseModels.cs index 0cc1a8c0e..9f620d868 100644 --- a/src/Platform/StellaOps.Platform.WebService/Contracts/PlatformResponseModels.cs +++ b/src/Platform/StellaOps.Platform.WebService/Contracts/PlatformResponseModels.cs @@ -9,7 +9,8 @@ public sealed record PlatformItemResponse( DateTimeOffset DataAsOf, bool Cached, int CacheTtlSeconds, - T Item); + T Item, + bool IsDemo = false); public sealed record PlatformListResponse( string TenantId, @@ -21,4 +22,5 @@ public sealed record PlatformListResponse( int Count, int? Limit = null, int? Offset = null, - string? Query = null); + string? Query = null, + bool IsDemo = false); diff --git a/src/Platform/StellaOps.Platform.WebService/Endpoints/PackAdapterEndpoints.cs b/src/Platform/StellaOps.Platform.WebService/Endpoints/PackAdapterEndpoints.cs index b155111f2..d1dfae13d 100644 --- a/src/Platform/StellaOps.Platform.WebService/Endpoints/PackAdapterEndpoints.cs +++ b/src/Platform/StellaOps.Platform.WebService/Endpoints/PackAdapterEndpoints.cs @@ -36,7 +36,8 @@ public static class PackAdapterEndpoints SnapshotAt, Cached: false, CacheTtlSeconds: 0, - payload)); + payload, + IsDemo: true)); }) .WithTags("Dashboard") .WithName("GetDashboardSummary") @@ -64,7 +65,8 @@ public static class PackAdapterEndpoints SnapshotAt, Cached: false, CacheTtlSeconds: 0, - payload)); + payload, + IsDemo: true)); }) .WithName("GetDataIntegritySummary") .WithSummary("Pack v2 data-integrity card summary.") @@ -86,7 +88,8 @@ public static class PackAdapterEndpoints SnapshotAt, Cached: false, CacheTtlSeconds: 0, - payload)); + payload, + IsDemo: true)); }) .WithName("GetDataIntegrityReport") .WithSummary("Pack v2 nightly data-integrity report projection.") @@ -109,7 +112,8 @@ public static class PackAdapterEndpoints Cached: false, CacheTtlSeconds: 0, feeds, - feeds.Count)); + feeds.Count, + IsDemo: true)); }) .WithName("GetFeedsFreshness") .WithSummary("Pack v2 advisory/feed freshness projection.") @@ -131,7 +135,8 @@ public static class PackAdapterEndpoints SnapshotAt, Cached: false, CacheTtlSeconds: 0, - payload)); + payload, + IsDemo: true)); }) .WithName("GetScanPipelineHealth") .WithSummary("Pack v2 scan-pipeline health projection.") @@ -153,7 +158,8 @@ public static class PackAdapterEndpoints SnapshotAt, Cached: false, CacheTtlSeconds: 0, - payload)); + payload, + IsDemo: true)); }) .WithName("GetReachabilityIngestHealth") .WithSummary("Pack v2 reachability ingest health projection.") @@ -245,7 +251,8 @@ public static class PackAdapterEndpoints SnapshotAt, Cached: false, CacheTtlSeconds: 0, - payload)); + payload, + IsDemo: true)); }) .WithName("GetAdministrationTrustSigning") .WithSummary("Pack v2 administration A6 trust and signing projection.") @@ -673,7 +680,8 @@ public static class PackAdapterEndpoints SnapshotAt, Cached: false, CacheTtlSeconds: 0, - payload)); + payload, + IsDemo: true)); } private static bool TryResolveContext( diff --git a/src/Platform/StellaOps.Platform.WebService/Endpoints/QuotaCompatibilityEndpoints.cs b/src/Platform/StellaOps.Platform.WebService/Endpoints/QuotaCompatibilityEndpoints.cs index 0a94148d7..ca6ad2eac 100644 --- a/src/Platform/StellaOps.Platform.WebService/Endpoints/QuotaCompatibilityEndpoints.cs +++ b/src/Platform/StellaOps.Platform.WebService/Endpoints/QuotaCompatibilityEndpoints.cs @@ -26,7 +26,8 @@ public static class QuotaCompatibilityEndpoints consumption = BuildConsumptionMetrics(now), tenantCount = 3, activeAlerts = 1, - recentViolations = 4 + recentViolations = 4, + isDemo = true }); }) .WithName("QuotaCompatibility.GetDashboard"); diff --git a/src/Scanner/StellaOps.Scanner.WebService/Controllers/VulnerabilitiesController.cs b/src/Scanner/StellaOps.Scanner.WebService/Controllers/VulnerabilitiesController.cs index e2021edcf..b3fd485ab 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/Controllers/VulnerabilitiesController.cs +++ b/src/Scanner/StellaOps.Scanner.WebService/Controllers/VulnerabilitiesController.cs @@ -40,7 +40,8 @@ public sealed class VulnerabilitiesController : ControllerBase Total: total, Page: normalizedPage, PageSize: normalizedPageSize, - HasMore: offset + items.Length < total)); + HasMore: offset + items.Length < total, + IsDemo: true)); } [HttpGet("status")] @@ -70,7 +71,8 @@ public sealed class VulnerabilitiesController : ControllerBase }, WithExceptions: all.Count(item => item.HasException), CriticalOpen: all.Count(item => item.Severity == "critical" && item.Status == "open"), - ComputedAt: FixedComputedAt.ToString("O"))); + ComputedAt: FixedComputedAt.ToString("O"), + IsDemo: true)); } [HttpGet("{vulnId}")] @@ -131,7 +133,8 @@ public sealed record ScannerVulnerabilitiesResponseDto( int Total, int Page, int PageSize, - bool HasMore); + bool HasMore, + bool IsDemo = false); public sealed record ScannerVulnerabilityStatsDto( int Total, @@ -139,7 +142,8 @@ public sealed record ScannerVulnerabilityStatsDto( IReadOnlyDictionary ByStatus, int WithExceptions, int CriticalOpen, - string ComputedAt); + string ComputedAt, + bool IsDemo = false); public sealed record ScannerVulnerabilityDto( string VulnId, diff --git a/src/Timeline/StellaOps.Timeline.WebService/Audit/CompositeUnifiedAuditEventProvider.cs b/src/Timeline/StellaOps.Timeline.WebService/Audit/CompositeUnifiedAuditEventProvider.cs new file mode 100644 index 000000000..e0e64477f --- /dev/null +++ b/src/Timeline/StellaOps.Timeline.WebService/Audit/CompositeUnifiedAuditEventProvider.cs @@ -0,0 +1,40 @@ +// Copyright (c) StellaOps. Licensed under the BUSL-1.1. + +namespace StellaOps.Timeline.WebService.Audit; + +/// +/// Combines events from the HTTP module polling provider with events +/// received via the ingest endpoint, producing a unified event stream. +/// +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> 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(); + } +} diff --git a/src/Timeline/StellaOps.Timeline.WebService/Audit/IngestAuditEventStore.cs b/src/Timeline/StellaOps.Timeline.WebService/Audit/IngestAuditEventStore.cs new file mode 100644 index 000000000..14c36581a --- /dev/null +++ b/src/Timeline/StellaOps.Timeline.WebService/Audit/IngestAuditEventStore.cs @@ -0,0 +1,41 @@ +// Copyright (c) StellaOps. Licensed under the BUSL-1.1. + +using System.Collections.Concurrent; + +namespace StellaOps.Timeline.WebService.Audit; + +/// +/// 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 +/// . +/// +public sealed class IngestAuditEventStore +{ + private readonly ConcurrentQueue _events = new(); + + /// Maximum number of events to retain in memory. + private const int MaxRetained = 10_000; + + /// + /// Adds an event to the in-memory store. + /// When the store exceeds , the oldest events are discarded. + /// + public void Add(UnifiedAuditEvent auditEvent) + { + _events.Enqueue(auditEvent); + + // Trim oldest events when the queue grows too large + while (_events.Count > MaxRetained) + { + _events.TryDequeue(out _); + } + } + + /// + /// Returns a snapshot of all ingested events. + /// + public IReadOnlyList GetAll() + { + return _events.ToArray(); + } +} diff --git a/src/Timeline/StellaOps.Timeline.WebService/Endpoints/UnifiedAuditEndpoints.cs b/src/Timeline/StellaOps.Timeline.WebService/Endpoints/UnifiedAuditEndpoints.cs index fc6db1a49..28ccac272 100644 --- a/src/Timeline/StellaOps.Timeline.WebService/Endpoints/UnifiedAuditEndpoints.cs +++ b/src/Timeline/StellaOps.Timeline.WebService/Endpoints/UnifiedAuditEndpoints.cs @@ -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(), + 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 GetEventsAsync( @@ -306,3 +373,41 @@ public sealed record UnifiedAuditEventsRequest public string? Cursor { get; init; } public int? Limit { get; init; } } + +/// +/// Request body for the POST /api/v1/audit/ingest endpoint. +/// Wire-compatible with the AuditEventPayload emitted by the +/// StellaOps.Audit.Emission shared library. +/// +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? Details { get; init; } + public string? CorrelationId { get; init; } + public string? TenantId { get; init; } + public IReadOnlyList? 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; } +} diff --git a/src/Timeline/StellaOps.Timeline.WebService/Program.cs b/src/Timeline/StellaOps.Timeline.WebService/Program.cs index a59240cb2..8eb0da3c3 100644 --- a/src/Timeline/StellaOps.Timeline.WebService/Program.cs +++ b/src/Timeline/StellaOps.Timeline.WebService/Program.cs @@ -51,7 +51,10 @@ builder.Services.AddHttpClient(HttpUnifiedAuditEventProvider.ClientName, (provid client.Timeout = TimeSpan.FromSeconds(Math.Max(1, options.RequestTimeoutSeconds)); }); -builder.Services.AddSingleton(); +// Audit event providers: HTTP polling from modules + in-memory ingest store +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddEndpointsApiExplorer(); diff --git a/src/Web/StellaOps.Web/src/app/core/api/vulnerability.client.ts b/src/Web/StellaOps.Web/src/app/core/api/vulnerability.client.ts index 80ac98c60..cd0913156 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/vulnerability.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/vulnerability.client.ts @@ -352,6 +352,7 @@ export class MockVulnerabilityApiService implements VulnerabilityApi { hasMore: offset + items.length < total, etag: '"vuln-list-v1"', traceId, + isDemo: true, }).pipe(delay(200)); } diff --git a/src/Web/StellaOps.Web/src/app/core/api/vulnerability.models.ts b/src/Web/StellaOps.Web/src/app/core/api/vulnerability.models.ts index f2a2eb65d..1b0a111c6 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/vulnerability.models.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/vulnerability.models.ts @@ -89,6 +89,8 @@ export interface VulnerabilitiesResponse { readonly etag?: string; /** Trace ID for the request. */ readonly traceId?: string; + /** Whether the response contains demo/seed data. */ + readonly isDemo?: boolean; } /** diff --git a/src/Web/StellaOps.Web/src/app/features/settings/usage/usage-settings-page.component.ts b/src/Web/StellaOps.Web/src/app/features/settings/usage/usage-settings-page.component.ts index d53f752f7..c352a8e89 100644 --- a/src/Web/StellaOps.Web/src/app/features/settings/usage/usage-settings-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/settings/usage/usage-settings-page.component.ts @@ -13,12 +13,12 @@ import { RouterLink } from '@angular/router'; changeDetection: ChangeDetectionStrategy.OnPush, template: `
-

Usage & Limits

+

Usage & Limits (Demo)

Monitor usage and configure quotas

-

Scans

+

Scans (Demo)

@@ -26,7 +26,7 @@ import { RouterLink } from '@angular/router';
-

Storage

+

Storage (Demo)

@@ -34,7 +34,7 @@ import { RouterLink } from '@angular/router';
-

Evidence Packets

+

Evidence Packets (Demo)

@@ -42,7 +42,7 @@ import { RouterLink } from '@angular/router';
-

API Requests

+

API Requests (Demo)

@@ -104,6 +104,23 @@ import { RouterLink } from '@angular/router'; text-decoration: none; } .btn--secondary { background: var(--color-surface-secondary); border: 1px solid var(--color-border-primary); } + .demo-badge { + display: inline-block; + padding: 0.125rem 0.5rem; + font-size: 0.75rem; + font-weight: var(--font-weight-semibold); + color: var(--color-warning, #b45309); + background: var(--color-warning-bg, #fef3c7); + border-radius: var(--radius-sm, 4px); + vertical-align: middle; + margin-left: 0.5rem; + } + .demo-chip { + font-size: 0.625rem; + font-weight: normal; + color: var(--color-warning, #b45309); + opacity: 0.85; + } `] }) export class UsageSettingsPageComponent {} diff --git a/src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.html b/src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.html index c2f88067b..639c50af4 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.html +++ b/src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.html @@ -1,7 +1,12 @@
-

Artifact workspace

+

+ Artifact workspace + @if (isDemo()) { + (Demo) + } +

Triage live artifacts by lane, then open a single evidence-first decision workspace.

@@ -165,6 +170,9 @@ {{ row.artifactId }} + @if (isDemo()) { + (Demo) + } @if (row.readyToDeploy) { Ready to deploy diff --git a/src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.scss b/src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.scss index 7599621a3..f96a945ef 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.scss @@ -370,6 +370,26 @@ font-size: var(--font-size-sm); } +.demo-badge { + display: inline-block; + padding: 0.125rem 0.5rem; + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + color: var(--color-status-warning-text, #b45309); + background: var(--color-status-warning-bg, #fef3c7); + border-radius: var(--radius-sm); + vertical-align: middle; + margin-left: var(--space-2); +} + +.demo-chip { + margin-left: var(--space-1); + font-size: var(--font-size-xs); + font-weight: normal; + color: var(--color-status-warning-text, #b45309); + opacity: 0.85; +} + .empty-state { padding: var(--space-6); color: var(--color-text-muted); diff --git a/src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.ts index fc0079e65..fc444643a 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.ts @@ -73,6 +73,7 @@ export class TriageArtifactsComponent implements OnInit { readonly loading = signal(false); readonly error = signal(null); readonly vulnerabilities = signal([]); + readonly isDemo = signal(false); readonly search = signal(''); readonly environment = signal('all'); @@ -203,6 +204,7 @@ export class TriageArtifactsComponent implements OnInit { try { const resp = await firstValueFrom(this.api.listVulnerabilities({ includeReachability: true })); this.vulnerabilities.set(resp.items); + this.isDemo.set(resp.isDemo === true); this.pruneSelection(); } catch (err) { this.error.set(err instanceof Error ? err.message : 'Failed to load vulnerabilities'); diff --git a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts index 1d657174a..ca8287941 100644 --- a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts +++ b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts @@ -624,11 +624,11 @@ export class AppSidebarComponent implements AfterViewInit { private readonly pendingApprovalsBadgeLoading = signal(false); /** - * Navigation sections - pre-alpha canonical IA. - * Root modules: Mission Control, Releases, Security, Evidence, Ops, Setup. + * Navigation sections - canonical 5-group IA. + * Groups: Release Control, Security, Operations, Audit & Evidence, Setup & Admin. */ readonly navSections: NavSection[] = [ - // ── Release Control ────────────────────────────────────────────── + // ── Group 1: Release Control ───────────────────────────────────── { id: 'dashboard', label: 'Dashboard', @@ -668,67 +668,70 @@ export class AppSidebarComponent implements AfterViewInit { route: '/releases/health', icon: 'activity', }, - { - id: 'rel-approvals', - label: 'Approvals', - route: '/releases/approvals', - icon: 'check-circle', - badge: 0, - requireAnyScope: [ - StellaOpsScopes.RELEASE_PUBLISH, - StellaOpsScopes.POLICY_REVIEW, - StellaOpsScopes.POLICY_APPROVE, - StellaOpsScopes.EXCEPTION_APPROVE, - ], - }, - { - id: 'rel-promotions', - label: 'Promotions', - route: '/releases/promotions', - icon: 'git-merge', - requireAnyScope: [ - StellaOpsScopes.RELEASE_READ, - StellaOpsScopes.RELEASE_WRITE, - StellaOpsScopes.RELEASE_PUBLISH, - ], - }, - { id: 'rel-hotfix-list', label: 'Hotfixes', route: '/releases/hotfixes', icon: 'zap' }, + { id: 'rel-deployments', label: 'Deployments', route: '/releases/deployments', icon: 'upload-cloud' }, ], }, { - id: 'ops', - label: 'Operations', - icon: 'settings', - route: '/ops/operations', + id: 'promotions', + label: 'Promotions', + icon: 'git-merge', + route: '/releases/promotions', menuGroupId: 'release-control', menuGroupLabel: 'Release Control', - sparklineData$: () => this.doctorTrendService.platformTrend(), requireAnyScope: [ - StellaOpsScopes.UI_ADMIN, - StellaOpsScopes.ORCH_READ, - StellaOpsScopes.ORCH_OPERATE, - StellaOpsScopes.HEALTH_READ, - StellaOpsScopes.NOTIFY_VIEWER, - StellaOpsScopes.POLICY_READ, - ], - children: [ - { id: 'ops-jobs', label: 'Scheduled Jobs', route: '/ops/operations/jobengine', icon: 'clock' }, - { id: 'ops-signals', label: 'Signals', route: '/ops/operations/signals', icon: 'radio' }, - { id: 'ops-offline-kit', label: 'Offline Kit', route: '/ops/operations/offline-kit', icon: 'download-cloud' }, - { id: 'ops-environments', label: 'Environments', route: '/ops/operations/environments', icon: 'globe' }, - { id: 'ops-policy', label: 'Policy', route: '/ops/policy', icon: 'shield' }, - { id: 'ops-platform-setup', label: 'Platform Setup', route: '/ops/platform-setup', icon: 'cog' }, - { id: 'ops-notifications', label: 'Notifications', route: '/ops/operations/notifications', icon: 'bell' }, + StellaOpsScopes.RELEASE_READ, + StellaOpsScopes.RELEASE_WRITE, + StellaOpsScopes.RELEASE_PUBLISH, ], }, - // ── Security & Audit ───────────────────────────────────────────── + { + id: 'approvals', + label: 'Approvals', + icon: 'check-circle', + route: '/releases/approvals', + menuGroupId: 'release-control', + menuGroupLabel: 'Release Control', + badge$: () => this.pendingApprovalsCount(), + requireAnyScope: [ + StellaOpsScopes.RELEASE_PUBLISH, + StellaOpsScopes.POLICY_REVIEW, + StellaOpsScopes.POLICY_APPROVE, + StellaOpsScopes.EXCEPTION_APPROVE, + ], + }, + { + id: 'hotfixes', + label: 'Hotfixes', + icon: 'zap', + route: '/releases/hotfixes', + menuGroupId: 'release-control', + menuGroupLabel: 'Release Control', + requireAnyScope: [ + StellaOpsScopes.RELEASE_READ, + StellaOpsScopes.RELEASE_WRITE, + ], + }, + // ── Group 2: Security ──────────────────────────────────────────── { id: 'vulnerabilities', + label: 'Vulnerabilities', + icon: 'list', + route: '/triage/artifacts', + menuGroupId: 'security', + menuGroupLabel: 'Security', + requireAnyScope: [ + StellaOpsScopes.SCANNER_READ, + StellaOpsScopes.FINDINGS_READ, + StellaOpsScopes.VULN_VIEW, + ], + }, + { + id: 'security-posture', label: 'Security Posture', icon: 'shield', route: '/security', - menuGroupId: 'security-audit', - menuGroupLabel: 'Security & Audit', + menuGroupId: 'security', + menuGroupLabel: 'Security', sparklineData$: () => this.doctorTrendService.securityTrend(), requireAnyScope: [ StellaOpsScopes.SCANNER_READ, @@ -740,28 +743,145 @@ export class AppSidebarComponent implements AfterViewInit { StellaOpsScopes.VULN_VIEW, ], children: [ - { id: 'sec-posture', label: 'Posture', route: '/security', icon: 'shield' }, - { id: 'sec-triage', label: 'Vulnerabilities', route: '/triage/artifacts', icon: 'list' }, { id: 'sec-supply-chain', label: 'Supply-Chain Data', route: '/security/supply-chain-data', icon: 'graph' }, { id: 'sec-reachability', label: 'Reachability', route: '/security/reachability', icon: 'cpu' }, { id: 'sec-unknowns', label: 'Unknowns', route: '/security/unknowns', icon: 'help-circle' }, - { - id: 'sec-scan-image', - label: 'Scan Image', - route: '/security/scan', - icon: 'search', - requireAnyScope: [StellaOpsScopes.SCANNER_READ], - }, - { id: 'sec-reports', label: 'Reports', route: '/security/reports', icon: 'book-open' }, ], }, { - id: 'audit', - label: 'Audit', + id: 'scan-image', + label: 'Scan Image', + icon: 'search', + route: '/security/scan', + menuGroupId: 'security', + menuGroupLabel: 'Security', + requireAnyScope: [StellaOpsScopes.SCANNER_READ], + }, + { + id: 'sec-reports', + label: 'Reports', + icon: 'book-open', + route: '/security/reports', + menuGroupId: 'security', + menuGroupLabel: 'Security', + requireAnyScope: [ + StellaOpsScopes.SCANNER_READ, + StellaOpsScopes.FINDINGS_READ, + ], + }, + // ── Group 3: Operations ────────────────────────────────────────── + { + id: 'ops', + label: 'Operations Hub', + icon: 'settings', + route: '/ops/operations', + menuGroupId: 'operations', + menuGroupLabel: 'Operations', + sparklineData$: () => this.doctorTrendService.platformTrend(), + requireAnyScope: [ + StellaOpsScopes.UI_ADMIN, + StellaOpsScopes.ORCH_READ, + StellaOpsScopes.ORCH_OPERATE, + StellaOpsScopes.HEALTH_READ, + StellaOpsScopes.NOTIFY_VIEWER, + StellaOpsScopes.POLICY_READ, + ], + }, + { + id: 'ops-jobs', + label: 'Scheduled Jobs', + icon: 'clock', + route: '/ops/operations/jobengine', + menuGroupId: 'operations', + menuGroupLabel: 'Operations', + requireAnyScope: [ + StellaOpsScopes.ORCH_READ, + StellaOpsScopes.ORCH_OPERATE, + ], + }, + { + id: 'ops-signals', + label: 'Signals', + icon: 'radio', + route: '/ops/operations/signals', + menuGroupId: 'operations', + menuGroupLabel: 'Operations', + requireAnyScope: [ + StellaOpsScopes.ORCH_READ, + StellaOpsScopes.HEALTH_READ, + ], + }, + { + id: 'ops-environments', + label: 'Environments', + icon: 'globe', + route: '/ops/operations/environments', + menuGroupId: 'operations', + menuGroupLabel: 'Operations', + requireAnyScope: [ + StellaOpsScopes.ORCH_READ, + StellaOpsScopes.ORCH_OPERATE, + ], + }, + { + id: 'ops-policy', + label: 'Policy', + icon: 'shield', + route: '/ops/policy', + menuGroupId: 'operations', + menuGroupLabel: 'Operations', + requireAnyScope: [StellaOpsScopes.POLICY_READ], + }, + { + id: 'ops-diagnostics', + label: 'Diagnostics', + icon: 'stethoscope', + route: '/ops/operations/doctor', + menuGroupId: 'operations', + menuGroupLabel: 'Operations', + requireAnyScope: [StellaOpsScopes.HEALTH_READ, StellaOpsScopes.UI_ADMIN], + }, + { + id: 'ops-notifications', + label: 'Notifications', + icon: 'bell', + route: '/ops/operations/notifications', + menuGroupId: 'operations', + menuGroupLabel: 'Operations', + requireAnyScope: [StellaOpsScopes.NOTIFY_VIEWER], + }, + { + id: 'ops-feeds-airgap', + label: 'Feeds & Airgap', + icon: 'download-cloud', + route: '/ops/operations/feeds-airgap', + menuGroupId: 'operations', + menuGroupLabel: 'Operations', + requireAnyScope: [ + StellaOpsScopes.ADVISORY_READ, + StellaOpsScopes.VEX_READ, + ], + }, + { + id: 'ops-offline-kit', + label: 'Offline Kit', + icon: 'download-cloud', + route: '/ops/operations/offline-kit', + menuGroupId: 'operations', + menuGroupLabel: 'Operations', + requireAnyScope: [ + StellaOpsScopes.UI_ADMIN, + StellaOpsScopes.ORCH_OPERATE, + ], + }, + // ── Group 4: Audit & Evidence ──────────────────────────────────── + { + id: 'evidence-overview', + label: 'Evidence Overview', icon: 'file-text', route: '/evidence/overview', - menuGroupId: 'security-audit', - menuGroupLabel: 'Security & Audit', + menuGroupId: 'audit-evidence', + menuGroupLabel: 'Audit & Evidence', requireAnyScope: [ StellaOpsScopes.RELEASE_READ, StellaOpsScopes.POLICY_AUDIT, @@ -769,44 +889,132 @@ export class AppSidebarComponent implements AfterViewInit { StellaOpsScopes.SIGNER_READ, StellaOpsScopes.VEX_EXPORT, ], - children: [ - { id: 'ev-overview', label: 'Overview', route: '/evidence/overview', icon: 'home' }, - { id: 'ev-capsules', label: 'Decision Capsules', route: '/evidence/capsules', icon: 'archive' }, - { id: 'ev-verify', label: 'Replay & Verify', route: '/evidence/verify-replay', icon: 'refresh' }, - { id: 'ev-exports', label: 'Export Center', route: '/evidence/exports', icon: 'download' }, - { id: 'ev-audit', label: 'Logs', route: '/evidence/audit-log', icon: 'book-open' }, - { id: 'ev-bundles', label: 'Bundles', route: '/triage/audit-bundles', icon: 'archive' }, + }, + { + id: 'evidence-capsules', + label: 'Decision Capsules', + icon: 'archive', + route: '/evidence/capsules', + menuGroupId: 'audit-evidence', + menuGroupLabel: 'Audit & Evidence', + requireAnyScope: [ + StellaOpsScopes.RELEASE_READ, + StellaOpsScopes.POLICY_AUDIT, ], }, - // ── Platform & Setup ───────────────────────────────────────────── { - id: 'setup', - label: 'Setup', - icon: 'server', - route: '/setup/system', - menuGroupId: 'platform-setup', - menuGroupLabel: 'Platform & Setup', + id: 'evidence-verify', + label: 'Replay & Verify', + icon: 'refresh', + route: '/evidence/verify-replay', + menuGroupId: 'audit-evidence', + menuGroupLabel: 'Audit & Evidence', + requireAnyScope: [ + StellaOpsScopes.RELEASE_READ, + StellaOpsScopes.SIGNER_READ, + ], + }, + { + id: 'evidence-exports', + label: 'Export Center', + icon: 'download', + route: '/evidence/exports', + menuGroupId: 'audit-evidence', + menuGroupLabel: 'Audit & Evidence', + requireAnyScope: [ + StellaOpsScopes.VEX_EXPORT, + StellaOpsScopes.RELEASE_READ, + ], + }, + { + id: 'evidence-audit-log', + label: 'Audit Log', + icon: 'book-open', + route: '/evidence/audit-log', + menuGroupId: 'audit-evidence', + menuGroupLabel: 'Audit & Evidence', + requireAnyScope: [ + StellaOpsScopes.POLICY_AUDIT, + StellaOpsScopes.AUTHORITY_AUDIT_READ, + ], + }, + { + id: 'evidence-bundles', + label: 'Bundles', + icon: 'archive', + route: '/triage/audit-bundles', + menuGroupId: 'audit-evidence', + menuGroupLabel: 'Audit & Evidence', + requireAnyScope: [ + StellaOpsScopes.RELEASE_READ, + StellaOpsScopes.POLICY_AUDIT, + ], + }, + // ── Group 5: Setup & Admin ─────────────────────────────────────── + { + id: 'setup-topology', + label: 'Topology', + icon: 'globe', + route: '/setup/topology/overview', + menuGroupId: 'setup-admin', + menuGroupLabel: 'Setup & Admin', requireAnyScope: [ StellaOpsScopes.UI_ADMIN, - StellaOpsScopes.RELEASE_READ, StellaOpsScopes.ORCH_READ, StellaOpsScopes.ORCH_OPERATE, ], - children: [ - { id: 'setup-topology', label: 'Topology', route: '/setup/topology/overview', icon: 'globe' }, - { - id: 'setup-diagnostics', - label: 'Diagnostics', - route: '/ops/operations/doctor', - icon: 'stethoscope', - requireAnyScope: [StellaOpsScopes.HEALTH_READ, StellaOpsScopes.UI_ADMIN], - }, - { id: 'setup-integrations', label: 'Integrations', route: '/setup/integrations', icon: 'plug' }, - { id: 'setup-iam', label: 'Identity & Access', route: '/setup/identity-access', icon: 'user' }, - { id: 'setup-trust-signing', label: 'Trust & Signing', route: '/setup/trust-signing', icon: 'shield' }, - { id: 'setup-branding', label: 'Tenant & Branding', route: '/setup/tenant-branding', icon: 'paintbrush' }, + }, + { + id: 'setup-integrations', + label: 'Integrations', + icon: 'plug', + route: '/setup/integrations', + menuGroupId: 'setup-admin', + menuGroupLabel: 'Setup & Admin', + requireAnyScope: [ + StellaOpsScopes.UI_ADMIN, + StellaOpsScopes.ORCH_OPERATE, ], }, + { + id: 'setup-iam', + label: 'Identity & Access', + icon: 'user', + route: '/setup/identity-access', + menuGroupId: 'setup-admin', + menuGroupLabel: 'Setup & Admin', + requireAnyScope: [StellaOpsScopes.UI_ADMIN], + }, + { + id: 'setup-trust-signing', + label: 'Trust & Signing', + icon: 'shield', + route: '/setup/trust-signing', + menuGroupId: 'setup-admin', + menuGroupLabel: 'Setup & Admin', + requireAnyScope: [ + StellaOpsScopes.UI_ADMIN, + StellaOpsScopes.SIGNER_READ, + ], + }, + { + id: 'setup-branding', + label: 'Tenant & Branding', + icon: 'paintbrush', + route: '/setup/tenant-branding', + menuGroupId: 'setup-admin', + menuGroupLabel: 'Setup & Admin', + requireAnyScope: [StellaOpsScopes.UI_ADMIN], + }, + { + id: 'setup-system', + label: 'System Settings', + icon: 'server', + route: '/setup/system', + menuGroupId: 'setup-admin', + menuGroupLabel: 'Setup & Admin', + requireAnyScope: [StellaOpsScopes.UI_ADMIN], + }, ]; /** Navigation sections filtered by user scopes */ @@ -828,7 +1036,7 @@ export class AppSidebarComponent implements AfterViewInit { /** Menu groups rendered in deterministic order for scanability */ readonly displaySectionGroups = computed(() => { const orderedGroups = new Map(); - const groupOrder = ['release-control', 'security-audit', 'platform-setup', 'misc']; + const groupOrder = ['release-control', 'security', 'operations', 'audit-evidence', 'setup-admin', 'misc']; for (const groupId of groupOrder) { orderedGroups.set(groupId, { @@ -900,10 +1108,14 @@ export class AppSidebarComponent implements AfterViewInit { switch (groupId) { case 'release-control': return 'Release Control'; - case 'security-audit': - return 'Security & Audit'; - case 'platform-setup': - return 'Platform & Setup'; + case 'security': + return 'Security'; + case 'operations': + return 'Operations'; + case 'audit-evidence': + return 'Audit & Evidence'; + case 'setup-admin': + return 'Setup & Admin'; default: return 'Global Menu'; } diff --git a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/sidebar-preference.service.ts b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/sidebar-preference.service.ts index 01c1640da..10bfc64ae 100644 --- a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/sidebar-preference.service.ts +++ b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/sidebar-preference.service.ts @@ -10,7 +10,7 @@ const STORAGE_KEY = 'stellaops.sidebar.preferences'; const DEFAULTS: SidebarPreferences = { sidebarCollapsed: false, - collapsedGroups: [], + collapsedGroups: ['audit-evidence', 'setup-admin'], collapsedSections: [], }; diff --git a/src/__Libraries/StellaOps.Audit.Emission/AuditActionAttribute.cs b/src/__Libraries/StellaOps.Audit.Emission/AuditActionAttribute.cs new file mode 100644 index 000000000..be3e10f8c --- /dev/null +++ b/src/__Libraries/StellaOps.Audit.Emission/AuditActionAttribute.cs @@ -0,0 +1,52 @@ +// Copyright (c) StellaOps. Licensed under the BUSL-1.1. + +namespace StellaOps.Audit.Emission; + +/// +/// Marks an endpoint for automatic audit event emission. +/// When applied, the will emit a +/// UnifiedAuditEvent to the Timeline service after the endpoint executes. +/// +/// +/// +/// app.MapPost("/api/v1/environments", CreateEnvironment) +/// .AddEndpointFilter<AuditActionFilter>() +/// .WithMetadata(new AuditActionAttribute("concelier", "create")); +/// +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = false)] +public sealed class AuditActionAttribute : Attribute +{ + /// + /// The module name that owns the audited action. + /// Must be one of the well-known modules in UnifiedAuditCatalog.Modules + /// (e.g., "authority", "policy", "jobengine", "vex", "scanner", "integrations"). + /// + public string Module { get; } + + /// + /// The action being performed. + /// Must be one of the well-known actions in UnifiedAuditCatalog.Actions + /// (e.g., "create", "update", "delete", "promote", "approve", "reject"). + /// + public string Action { get; } + + /// + /// 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"). + /// + public string? ResourceType { get; set; } + + /// + /// Creates a new audit action attribute. + /// + /// The owning module name (e.g., "concelier"). + /// The action name (e.g., "create"). + public AuditActionAttribute(string module, string action) + { + ArgumentException.ThrowIfNullOrWhiteSpace(module); + ArgumentException.ThrowIfNullOrWhiteSpace(action); + Module = module; + Action = action; + } +} diff --git a/src/__Libraries/StellaOps.Audit.Emission/AuditActionFilter.cs b/src/__Libraries/StellaOps.Audit.Emission/AuditActionFilter.cs new file mode 100644 index 000000000..c139c1e77 --- /dev/null +++ b/src/__Libraries/StellaOps.Audit.Emission/AuditActionFilter.cs @@ -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; + +/// +/// ASP.NET Core endpoint filter that automatically emits UnifiedAuditEvent +/// payloads to the Timeline service's ingestion endpoint after an endpoint executes. +/// +/// The filter reads metadata from the endpoint. +/// If the attribute is not present, the filter is a no-op passthrough. +/// +/// +/// +/// Usage in minimal API registration: +/// +/// app.MapPost("/api/v1/resources", CreateResource) +/// .AddEndpointFilter<AuditActionFilter>() +/// .WithMetadata(new AuditActionAttribute("mymodule", "create")); +/// +/// +public sealed class AuditActionFilter : IEndpointFilter +{ + private readonly IAuditEventEmitter _emitter; + private readonly ILogger _logger; + + public AuditActionFilter(IAuditEventEmitter emitter, ILogger logger) + { + _emitter = emitter; + _logger = logger; + } + + public async ValueTask 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(); + 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 + { + ["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..]; + } +} diff --git a/src/__Libraries/StellaOps.Audit.Emission/AuditEmissionOptions.cs b/src/__Libraries/StellaOps.Audit.Emission/AuditEmissionOptions.cs new file mode 100644 index 000000000..920e4b273 --- /dev/null +++ b/src/__Libraries/StellaOps.Audit.Emission/AuditEmissionOptions.cs @@ -0,0 +1,30 @@ +// Copyright (c) StellaOps. Licensed under the BUSL-1.1. + +namespace StellaOps.Audit.Emission; + +/// +/// Configuration options for audit event emission. +/// Bind from configuration section AuditEmission or set via environment variables. +/// +public sealed class AuditEmissionOptions +{ + /// + /// Base URL of the Timeline service that hosts the audit ingest endpoint. + /// Default: http://timeline.stella-ops.local. + /// Override via AuditEmission:TimelineBaseUrl or STELLAOPS_TIMELINE_URL. + /// + public string TimelineBaseUrl { get; set; } = "http://timeline.stella-ops.local"; + + /// + /// Whether audit emission is enabled. Set to false to disable + /// all audit event posting (events are silently dropped). + /// Default: true. + /// + public bool Enabled { get; set; } = true; + + /// + /// HTTP request timeout in seconds for the audit ingest call. + /// Default: 3 seconds. Audit emission should be fast and non-blocking. + /// + public int TimeoutSeconds { get; set; } = 3; +} diff --git a/src/__Libraries/StellaOps.Audit.Emission/AuditEmissionServiceExtensions.cs b/src/__Libraries/StellaOps.Audit.Emission/AuditEmissionServiceExtensions.cs new file mode 100644 index 000000000..30422e5c8 --- /dev/null +++ b/src/__Libraries/StellaOps.Audit.Emission/AuditEmissionServiceExtensions.cs @@ -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; + +/// +/// DI registration extension for audit event emission. +/// Call once in your service's Program.cs +/// to enable the endpoint filter. +/// +public static class AuditEmissionServiceExtensions +{ + /// + /// Registers the audit event emission infrastructure: + /// + /// (endpoint filter) + /// (HTTP emitter to Timeline service) + /// (configuration binding) + /// + /// + /// The service collection. + /// The application configuration root. + /// The service collection for chaining. + /// + /// + /// // In Program.cs: + /// builder.Services.AddAuditEmission(builder.Configuration); + /// + /// // Then on endpoints: + /// app.MapPost("/api/v1/environments", CreateEnvironment) + /// .AddEndpointFilter<AuditActionFilter>() + /// .WithMetadata(new AuditActionAttribute("concelier", "create")); + /// + /// + public static IServiceCollection AddAuditEmission( + this IServiceCollection services, + IConfiguration configuration) + { + services.Configure(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>() + .Value; + client.Timeout = TimeSpan.FromSeconds(Math.Max(1, opts.TimeoutSeconds)); + }); + + services.AddSingleton(); + services.AddScoped(); + + return services; + } +} diff --git a/src/__Libraries/StellaOps.Audit.Emission/AuditEventPayload.cs b/src/__Libraries/StellaOps.Audit.Emission/AuditEventPayload.cs new file mode 100644 index 000000000..36d09f528 --- /dev/null +++ b/src/__Libraries/StellaOps.Audit.Emission/AuditEventPayload.cs @@ -0,0 +1,41 @@ +// Copyright (c) StellaOps. Licensed under the BUSL-1.1. + +namespace StellaOps.Audit.Emission; + +/// +/// Lightweight DTO that mirrors the UnifiedAuditEvent structure from the +/// Timeline service. This avoids a compile-time dependency on the Timeline +/// assembly while remaining wire-compatible for JSON serialization. +/// +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 Details { get; init; } + public string? CorrelationId { get; init; } + public string? TenantId { get; init; } + public required IReadOnlyList 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; } +} diff --git a/src/__Libraries/StellaOps.Audit.Emission/HttpAuditEventEmitter.cs b/src/__Libraries/StellaOps.Audit.Emission/HttpAuditEventEmitter.cs new file mode 100644 index 000000000..1da586a37 --- /dev/null +++ b/src/__Libraries/StellaOps.Audit.Emission/HttpAuditEventEmitter.cs @@ -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; + +/// +/// Sends audit events to the Timeline service's POST /api/v1/audit/ingest endpoint. +/// Failures are logged but never propagated -- audit emission must not block the calling service. +/// +public sealed class HttpAuditEventEmitter : IAuditEventEmitter +{ + /// + /// Named HTTP client identifier used for DI registration. + /// + 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 _options; + private readonly ILogger _logger; + + public HttpAuditEventEmitter( + IHttpClientFactory httpClientFactory, + IOptions options, + ILogger 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); + } + } +} diff --git a/src/__Libraries/StellaOps.Audit.Emission/IAuditEventEmitter.cs b/src/__Libraries/StellaOps.Audit.Emission/IAuditEventEmitter.cs new file mode 100644 index 000000000..5e3a0509e --- /dev/null +++ b/src/__Libraries/StellaOps.Audit.Emission/IAuditEventEmitter.cs @@ -0,0 +1,17 @@ +// Copyright (c) StellaOps. Licensed under the BUSL-1.1. + +namespace StellaOps.Audit.Emission; + +/// +/// Emits audit events to the unified audit log. +/// The default implementation posts events to the Timeline service's ingestion endpoint. +/// +public interface IAuditEventEmitter +{ + /// + /// 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. + /// + Task EmitAsync(AuditEventPayload auditEvent, CancellationToken cancellationToken); +} diff --git a/src/__Libraries/StellaOps.Audit.Emission/StellaOps.Audit.Emission.csproj b/src/__Libraries/StellaOps.Audit.Emission/StellaOps.Audit.Emission.csproj new file mode 100644 index 000000000..c047b45ff --- /dev/null +++ b/src/__Libraries/StellaOps.Audit.Emission/StellaOps.Audit.Emission.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + enable + enable + preview + true + StellaOps.Audit.Emission + StellaOps.Audit.Emission + Shared audit event emission infrastructure for StellaOps services. Provides an endpoint filter and DI registration to automatically emit UnifiedAuditEvents to the Timeline service. + + + + + + +