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:
136
docs/modules/audit/AUDIT_EMISSION_GUIDE.md
Normal file
136
docs/modules/audit/AUDIT_EMISSION_GUIDE.md
Normal file
@@ -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
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Audit.Emission\StellaOps.Audit.Emission.csproj" />
|
||||
```
|
||||
|
||||
Adjust the relative path as needed for your service's location in the repo.
|
||||
|
||||
## Step 2: Register in DI (Program.cs)
|
||||
|
||||
Add a single line to your service's `Program.cs`:
|
||||
|
||||
```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<AuditActionFilter>()
|
||||
.WithMetadata(new AuditActionAttribute("concelier", "create"));
|
||||
|
||||
app.MapPut("/api/v1/environments/{id}", UpdateEnvironment)
|
||||
.AddEndpointFilter<AuditActionFilter>()
|
||||
.WithMetadata(new AuditActionAttribute("concelier", "update"));
|
||||
|
||||
app.MapDelete("/api/v1/environments/{id}", DeleteEnvironment)
|
||||
.AddEndpointFilter<AuditActionFilter>()
|
||||
.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` |
|
||||
@@ -9,7 +9,8 @@ public sealed record PlatformItemResponse<T>(
|
||||
DateTimeOffset DataAsOf,
|
||||
bool Cached,
|
||||
int CacheTtlSeconds,
|
||||
T Item);
|
||||
T Item,
|
||||
bool IsDemo = false);
|
||||
|
||||
public sealed record PlatformListResponse<T>(
|
||||
string TenantId,
|
||||
@@ -21,4 +22,5 @@ public sealed record PlatformListResponse<T>(
|
||||
int Count,
|
||||
int? Limit = null,
|
||||
int? Offset = null,
|
||||
string? Query = null);
|
||||
string? Query = null,
|
||||
bool IsDemo = false);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -26,7 +26,8 @@ public static class QuotaCompatibilityEndpoints
|
||||
consumption = BuildConsumptionMetrics(now),
|
||||
tenantCount = 3,
|
||||
activeAlerts = 1,
|
||||
recentViolations = 4
|
||||
recentViolations = 4,
|
||||
isDemo = true
|
||||
});
|
||||
})
|
||||
.WithName("QuotaCompatibility.GetDashboard");
|
||||
|
||||
@@ -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<string, int> ByStatus,
|
||||
int WithExceptions,
|
||||
int CriticalOpen,
|
||||
string ComputedAt);
|
||||
string ComputedAt,
|
||||
bool IsDemo = false);
|
||||
|
||||
public sealed record ScannerVulnerabilityDto(
|
||||
string VulnId,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -352,6 +352,7 @@ export class MockVulnerabilityApiService implements VulnerabilityApi {
|
||||
hasMore: offset + items.length < total,
|
||||
etag: '"vuln-list-v1"',
|
||||
traceId,
|
||||
isDemo: true,
|
||||
}).pipe(delay(200));
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,12 +13,12 @@ import { RouterLink } from '@angular/router';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="usage-settings">
|
||||
<h1 class="page-title">Usage & Limits</h1>
|
||||
<h1 class="page-title">Usage & Limits <span class="demo-badge">(Demo)</span></h1>
|
||||
<p class="page-subtitle">Monitor usage and configure quotas</p>
|
||||
|
||||
<div class="usage-grid">
|
||||
<div class="usage-card">
|
||||
<h3>Scans</h3>
|
||||
<h3>Scans <span class="demo-chip">(Demo)</span></h3>
|
||||
<div class="usage-bar">
|
||||
<div class="usage-bar__fill" style="width: 65%"></div>
|
||||
</div>
|
||||
@@ -26,7 +26,7 @@ import { RouterLink } from '@angular/router';
|
||||
</div>
|
||||
|
||||
<div class="usage-card">
|
||||
<h3>Storage</h3>
|
||||
<h3>Storage <span class="demo-chip">(Demo)</span></h3>
|
||||
<div class="usage-bar">
|
||||
<div class="usage-bar__fill" style="width: 42%"></div>
|
||||
</div>
|
||||
@@ -34,7 +34,7 @@ import { RouterLink } from '@angular/router';
|
||||
</div>
|
||||
|
||||
<div class="usage-card">
|
||||
<h3>Evidence Packets</h3>
|
||||
<h3>Evidence Packets <span class="demo-chip">(Demo)</span></h3>
|
||||
<div class="usage-bar">
|
||||
<div class="usage-bar__fill" style="width: 28%"></div>
|
||||
</div>
|
||||
@@ -42,7 +42,7 @@ import { RouterLink } from '@angular/router';
|
||||
</div>
|
||||
|
||||
<div class="usage-card">
|
||||
<h3>API Requests</h3>
|
||||
<h3>API Requests <span class="demo-chip">(Demo)</span></h3>
|
||||
<div class="usage-bar">
|
||||
<div class="usage-bar__fill" style="width: 15%"></div>
|
||||
</div>
|
||||
@@ -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 {}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<section class="triage-artifacts" data-testid="triage-artifacts-page">
|
||||
<header class="triage-artifacts__header">
|
||||
<div>
|
||||
<h1>Artifact workspace</h1>
|
||||
<h1>
|
||||
Artifact workspace
|
||||
@if (isDemo()) {
|
||||
<span class="demo-badge">(Demo)</span>
|
||||
}
|
||||
</h1>
|
||||
<p class="triage-artifacts__subtitle">
|
||||
Triage live artifacts by lane, then open a single evidence-first decision workspace.
|
||||
</p>
|
||||
@@ -165,6 +170,9 @@
|
||||
</td>
|
||||
<td class="triage-table__td">
|
||||
<code class="artifact-id">{{ row.artifactId }}</code>
|
||||
@if (isDemo()) {
|
||||
<span class="demo-chip">(Demo)</span>
|
||||
}
|
||||
@if (row.readyToDeploy) {
|
||||
<span class="ready-pill" title="Signed evidence is available and no open findings remain.">
|
||||
Ready to deploy
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -73,6 +73,7 @@ export class TriageArtifactsComponent implements OnInit {
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly vulnerabilities = signal<readonly Vulnerability[]>([]);
|
||||
readonly isDemo = signal(false);
|
||||
|
||||
readonly search = signal('');
|
||||
readonly environment = signal<EnvironmentHint | 'all'>('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');
|
||||
|
||||
@@ -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<NavSectionGroup[]>(() => {
|
||||
const orderedGroups = new Map<string, NavSectionGroup>();
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ const STORAGE_KEY = 'stellaops.sidebar.preferences';
|
||||
|
||||
const DEFAULTS: SidebarPreferences = {
|
||||
sidebarCollapsed: false,
|
||||
collapsedGroups: [],
|
||||
collapsedGroups: ['audit-evidence', 'setup-admin'],
|
||||
collapsedSections: [],
|
||||
};
|
||||
|
||||
|
||||
@@ -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