consolidation of some of the modules, localization fixes, product advisories work, qa work

This commit is contained in:
master
2026-03-05 03:54:22 +02:00
parent 7bafcc3eef
commit 8e1cb9448d
3878 changed files with 72600 additions and 46861 deletions

View File

@@ -0,0 +1,550 @@
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
using System.Globalization;
using System.Text.Json;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Options;
namespace StellaOps.Timeline.WebService.Audit;
public sealed class HttpUnifiedAuditEventProvider : IUnifiedAuditEventProvider
{
public const string ClientName = "UnifiedAuditModules";
private static readonly StringComparer StringComparerOrdinal = StringComparer.Ordinal;
private static readonly DateTimeOffset TimestampFallback = DateTimeOffset.UnixEpoch;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IOptions<UnifiedAuditModuleEndpointsOptions> _options;
private readonly ILogger<HttpUnifiedAuditEventProvider> _logger;
public HttpUnifiedAuditEventProvider(
IHttpClientFactory httpClientFactory,
IOptions<UnifiedAuditModuleEndpointsOptions> options,
ILogger<HttpUnifiedAuditEventProvider> logger)
{
_httpClientFactory = httpClientFactory;
_options = options;
_logger = logger;
}
public async Task<IReadOnlyList<UnifiedAuditEvent>> GetEventsAsync(CancellationToken cancellationToken)
{
var endpointOptions = _options.Value;
var timeout = TimeSpan.FromSeconds(Math.Max(1, endpointOptions.RequestTimeoutSeconds));
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutSource.CancelAfter(timeout);
var getJobEngine = GetJobEngineEventsAsync(endpointOptions, timeoutSource.Token);
var getPolicy = GetPolicyEventsAsync(endpointOptions, timeoutSource.Token);
var getEvidence = GetEvidenceLockerEventsAsync(endpointOptions, timeoutSource.Token);
var getNotify = GetNotifyEventsAsync(endpointOptions, timeoutSource.Token);
await Task.WhenAll(getJobEngine, getPolicy, getEvidence, getNotify).ConfigureAwait(false);
return getJobEngine.Result
.Concat(getPolicy.Result)
.Concat(getEvidence.Result)
.Concat(getNotify.Result)
.OrderByDescending(e => e.Timestamp)
.ThenBy(e => e.Id, StringComparerOrdinal)
.ThenBy(e => e.Module, StringComparerOrdinal)
.ThenBy(e => e.Action, StringComparerOrdinal)
.ToList();
}
private async Task<IReadOnlyList<UnifiedAuditEvent>> GetJobEngineEventsAsync(
UnifiedAuditModuleEndpointsOptions options,
CancellationToken cancellationToken)
{
var uri = BuildUri(
options.JobEngineBaseUrl,
"/api/v1/jobengine/audit",
new Dictionary<string, string?> { ["limit"] = options.FetchLimitPerModule.ToString(CultureInfo.InvariantCulture) });
if (uri is null)
{
return Array.Empty<UnifiedAuditEvent>();
}
using var document = await GetJsonDocumentAsync(uri, cancellationToken).ConfigureAwait(false);
if (document is null)
{
return Array.Empty<UnifiedAuditEvent>();
}
if (!TryGetPropertyIgnoreCase(document.RootElement, "entries", out var entries) ||
entries.ValueKind != JsonValueKind.Array)
{
return Array.Empty<UnifiedAuditEvent>();
}
var events = new List<UnifiedAuditEvent>();
foreach (var entry in entries.EnumerateArray())
{
var entryId = GetString(entry, "entryId");
if (string.IsNullOrWhiteSpace(entryId))
{
continue;
}
var action = UnifiedAuditValueMapper.NormalizeAction(GetString(entry, "eventType"), GetString(entry, "description"));
var description = GetString(entry, "description") ?? GetString(entry, "eventType") ?? "JobEngine audit event";
var occurredAt = UnifiedAuditValueMapper.ParseTimestampOrDefault(GetString(entry, "occurredAt"), TimestampFallback);
var resourceType = GetString(entry, "resourceType") ?? "jobengine_resource";
var resourceId = GetString(entry, "resourceId") ?? entryId;
var actorId = GetString(entry, "actorId") ?? "jobengine-system";
var actorType = UnifiedAuditValueMapper.NormalizeActorType(GetString(entry, "actorType"));
var severity = UnifiedAuditValueMapper.NormalizeSeverity(
GetString(entry, "severity"),
action,
description);
var details = new Dictionary<string, object?>(StringComparerOrdinal)
{
["contentHash"] = GetString(entry, "contentHash"),
["sequenceNumber"] = GetString(entry, "sequenceNumber"),
["httpMethod"] = GetString(entry, "httpMethod"),
["requestPath"] = GetString(entry, "requestPath"),
["metadata"] = GetString(entry, "metadata")
};
var oldState = GetString(entry, "oldState");
var newState = GetString(entry, "newState");
if (!string.IsNullOrWhiteSpace(oldState) || !string.IsNullOrWhiteSpace(newState))
{
details["oldState"] = oldState;
details["newState"] = newState;
}
events.Add(new UnifiedAuditEvent
{
Id = entryId,
Timestamp = occurredAt,
Module = "jobengine",
Action = action,
Severity = severity,
Actor = new UnifiedAuditActor
{
Id = actorId,
Name = actorId,
Type = actorType,
IpAddress = GetString(entry, "actorIp"),
UserAgent = GetString(entry, "userAgent")
},
Resource = new UnifiedAuditResource
{
Type = resourceType,
Id = resourceId
},
Description = description,
Details = details,
Diff = !string.IsNullOrWhiteSpace(oldState) || !string.IsNullOrWhiteSpace(newState)
? new UnifiedAuditDiff
{
Before = oldState,
After = newState,
Fields = ["state"]
}
: null,
CorrelationId = GetString(entry, "correlationId"),
TenantId = GetString(entry, "tenantId"),
Tags = ["jobengine", action]
});
}
return events;
}
private async Task<IReadOnlyList<UnifiedAuditEvent>> GetPolicyEventsAsync(
UnifiedAuditModuleEndpointsOptions options,
CancellationToken cancellationToken)
{
var uri = BuildUri(
options.PolicyBaseUrl,
"/api/v1/governance/audit/events",
new Dictionary<string, string?>
{
["page"] = "1",
["pageSize"] = options.FetchLimitPerModule.ToString(CultureInfo.InvariantCulture)
});
if (uri is null)
{
return Array.Empty<UnifiedAuditEvent>();
}
using var document = await GetJsonDocumentAsync(uri, cancellationToken).ConfigureAwait(false);
if (document is null)
{
return Array.Empty<UnifiedAuditEvent>();
}
if (!TryGetPropertyIgnoreCase(document.RootElement, "events", out var entries) ||
entries.ValueKind != JsonValueKind.Array)
{
return Array.Empty<UnifiedAuditEvent>();
}
var events = new List<UnifiedAuditEvent>();
foreach (var entry in entries.EnumerateArray())
{
var id = GetString(entry, "id");
if (string.IsNullOrWhiteSpace(id))
{
continue;
}
var type = GetString(entry, "type");
var summary = GetString(entry, "summary") ?? "Policy governance audit event";
var action = UnifiedAuditValueMapper.NormalizeAction(type, summary);
events.Add(new UnifiedAuditEvent
{
Id = id,
Timestamp = UnifiedAuditValueMapper.ParseTimestampOrDefault(GetString(entry, "timestamp"), TimestampFallback),
Module = "policy",
Action = action,
Severity = UnifiedAuditValueMapper.NormalizeSeverity(GetString(entry, "severity"), action, summary),
Actor = new UnifiedAuditActor
{
Id = GetString(entry, "actor") ?? "policy-system",
Name = GetString(entry, "actor") ?? "policy-system",
Type = UnifiedAuditValueMapper.NormalizeActorType(GetString(entry, "actorType"))
},
Resource = new UnifiedAuditResource
{
Type = GetString(entry, "targetResourceType") ?? "policy_resource",
Id = GetString(entry, "targetResource") ?? id
},
Description = summary,
Details = new Dictionary<string, object?>(StringComparerOrdinal)
{
["eventType"] = type
},
CorrelationId = GetString(entry, "correlationId"),
TenantId = GetString(entry, "tenantId"),
Tags = ["policy", action]
});
}
return events;
}
private async Task<IReadOnlyList<UnifiedAuditEvent>> GetEvidenceLockerEventsAsync(
UnifiedAuditModuleEndpointsOptions options,
CancellationToken cancellationToken)
{
var uri = BuildUri(
options.EvidenceLockerBaseUrl,
"/api/v1/evidence/audit",
new Dictionary<string, string?> { ["limit"] = options.FetchLimitPerModule.ToString(CultureInfo.InvariantCulture) });
if (uri is null)
{
return Array.Empty<UnifiedAuditEvent>();
}
using var document = await GetJsonDocumentAsync(uri, cancellationToken).ConfigureAwait(false);
if (document is null)
{
return Array.Empty<UnifiedAuditEvent>();
}
if (!TryGetPropertyIgnoreCase(document.RootElement, "items", out var entries) ||
entries.ValueKind != JsonValueKind.Array)
{
return Array.Empty<UnifiedAuditEvent>();
}
var events = new List<UnifiedAuditEvent>();
foreach (var entry in entries.EnumerateArray())
{
var id = GetString(entry, "eventId");
if (string.IsNullOrWhiteSpace(id))
{
continue;
}
var eventType = GetString(entry, "eventType");
var action = UnifiedAuditValueMapper.NormalizeAction(eventType);
var subject = GetString(entry, "subject") ?? id;
events.Add(new UnifiedAuditEvent
{
Id = id,
Timestamp = UnifiedAuditValueMapper.ParseTimestampOrDefault(GetString(entry, "occurredAt"), TimestampFallback),
Module = "sbom",
Action = action,
Severity = UnifiedAuditValueMapper.NormalizeSeverity(GetString(entry, "severity"), action, eventType),
Actor = new UnifiedAuditActor
{
Id = "evidencelocker-system",
Name = "evidencelocker-system",
Type = "system"
},
Resource = new UnifiedAuditResource
{
Type = InferSubjectType(subject),
Id = subject
},
Description = eventType ?? "EvidenceLocker audit event",
Details = new Dictionary<string, object?>(StringComparerOrdinal)
{
["eventType"] = eventType,
["subject"] = subject
},
Tags = ["sbom", action]
});
}
return events;
}
private async Task<IReadOnlyList<UnifiedAuditEvent>> GetNotifyEventsAsync(
UnifiedAuditModuleEndpointsOptions options,
CancellationToken cancellationToken)
{
var uri = BuildUri(
options.NotifyBaseUrl,
"/api/v1/notify/audit",
new Dictionary<string, string?>
{
["limit"] = options.FetchLimitPerModule.ToString(CultureInfo.InvariantCulture),
["offset"] = "0"
});
if (uri is null)
{
return Array.Empty<UnifiedAuditEvent>();
}
using var document = await GetJsonDocumentAsync(uri, cancellationToken).ConfigureAwait(false);
if (document is null || document.RootElement.ValueKind != JsonValueKind.Array)
{
return Array.Empty<UnifiedAuditEvent>();
}
var events = new List<UnifiedAuditEvent>();
foreach (var entry in document.RootElement.EnumerateArray())
{
var id = GetString(entry, "id");
if (string.IsNullOrWhiteSpace(id))
{
continue;
}
var action = UnifiedAuditValueMapper.NormalizeAction(GetString(entry, "action"));
var description = GetString(entry, "action") ?? "Notify audit event";
var details = ReadDetails(entry, "details");
events.Add(new UnifiedAuditEvent
{
Id = id,
Timestamp = UnifiedAuditValueMapper.ParseTimestampOrDefault(GetString(entry, "createdAt"), TimestampFallback),
Module = "integrations",
Action = action,
Severity = UnifiedAuditValueMapper.NormalizeSeverity(GetString(entry, "severity"), action, description),
Actor = new UnifiedAuditActor
{
Id = GetString(entry, "userId") ?? "notify-system",
Name = GetString(entry, "userId") ?? "notify-system",
Type = "service"
},
Resource = new UnifiedAuditResource
{
Type = GetString(entry, "resourceType") ?? "notify_resource",
Id = GetString(entry, "resourceId") ?? id
},
Description = description,
Details = details,
CorrelationId = GetString(entry, "correlationId"),
TenantId = GetString(entry, "tenantId"),
Tags = ["integrations", action]
});
}
return events;
}
private async Task<JsonDocument?> GetJsonDocumentAsync(Uri uri, CancellationToken cancellationToken)
{
try
{
var client = _httpClientFactory.CreateClient(ClientName);
using var response = await client.GetAsync(uri, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
_logger.LogDebug(
"Unified audit module fetch returned HTTP {StatusCode} for {Uri}",
(int)response.StatusCode,
uri);
return null;
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
return await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
_logger.LogWarning("Unified audit module fetch timed out for {Uri}", uri);
return null;
}
catch (Exception ex) when (ex is HttpRequestException or JsonException)
{
_logger.LogWarning(ex, "Unified audit module fetch failed for {Uri}", uri);
return null;
}
}
private static IReadOnlyDictionary<string, object?> ReadDetails(JsonElement parent, string propertyName)
{
if (!TryGetPropertyIgnoreCase(parent, propertyName, out var detailsElement) ||
detailsElement.ValueKind == JsonValueKind.Null ||
detailsElement.ValueKind == JsonValueKind.Undefined)
{
return new Dictionary<string, object?>(StringComparerOrdinal);
}
if (detailsElement.ValueKind == JsonValueKind.Object)
{
return ConvertToDictionary(detailsElement);
}
if (detailsElement.ValueKind == JsonValueKind.String)
{
var raw = detailsElement.GetString();
if (string.IsNullOrWhiteSpace(raw))
{
return new Dictionary<string, object?>(StringComparerOrdinal);
}
try
{
using var parsed = JsonDocument.Parse(raw);
if (parsed.RootElement.ValueKind == JsonValueKind.Object)
{
return ConvertToDictionary(parsed.RootElement);
}
}
catch (JsonException)
{
// Intentionally ignored: keep details as a plain string.
}
return new Dictionary<string, object?>(StringComparerOrdinal)
{
[propertyName] = raw
};
}
return new Dictionary<string, object?>(StringComparerOrdinal)
{
[propertyName] = ConvertValue(detailsElement)
};
}
private static Dictionary<string, object?> ConvertToDictionary(JsonElement element)
{
var dictionary = new Dictionary<string, object?>(StringComparer.Ordinal);
foreach (var property in element.EnumerateObject())
{
dictionary[property.Name] = ConvertValue(property.Value);
}
return dictionary;
}
private static object? ConvertValue(JsonElement value)
{
return value.ValueKind switch
{
JsonValueKind.String => value.GetString(),
JsonValueKind.Number when value.TryGetInt64(out var int64Value) => int64Value,
JsonValueKind.Number when value.TryGetDecimal(out var decimalValue) => decimalValue,
JsonValueKind.Number => value.GetDouble(),
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Null => null,
JsonValueKind.Array => value.GetRawText(),
JsonValueKind.Object => value.GetRawText(),
_ => value.GetRawText()
};
}
private static string InferSubjectType(string subject)
{
if (subject.StartsWith("pack-", StringComparison.OrdinalIgnoreCase))
{
return "evidence_pack";
}
if (subject.StartsWith("run-", StringComparison.OrdinalIgnoreCase))
{
return "run";
}
return "subject";
}
private static Uri? BuildUri(string baseUrl, string path, IDictionary<string, string?> query)
{
if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out var baseUri))
{
return null;
}
var endpoint = new Uri(baseUri, path);
var filtered = query
.Where(pair => !string.IsNullOrWhiteSpace(pair.Value))
.ToDictionary(pair => pair.Key, pair => (string?)pair.Value, StringComparer.Ordinal);
var value = filtered.Count == 0
? endpoint.ToString()
: QueryHelpers.AddQueryString(endpoint.ToString(), filtered);
return Uri.TryCreate(value, UriKind.Absolute, out var uri) ? uri : null;
}
private static string? GetString(JsonElement element, string propertyName)
{
if (!TryGetPropertyIgnoreCase(element, propertyName, out var value) ||
value.ValueKind == JsonValueKind.Null ||
value.ValueKind == JsonValueKind.Undefined)
{
return null;
}
return value.ValueKind switch
{
JsonValueKind.String => value.GetString(),
JsonValueKind.Number => value.ToString(),
JsonValueKind.True => bool.TrueString.ToLowerInvariant(),
JsonValueKind.False => bool.FalseString.ToLowerInvariant(),
_ => value.GetRawText()
};
}
private static bool TryGetPropertyIgnoreCase(JsonElement element, string propertyName, out JsonElement value)
{
if (element.TryGetProperty(propertyName, out value))
{
return true;
}
foreach (var property in element.EnumerateObject())
{
if (propertyName.Equals(property.Name, StringComparison.OrdinalIgnoreCase))
{
value = property.Value;
return true;
}
}
value = default;
return false;
}
}

View File

@@ -0,0 +1,56 @@
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
namespace StellaOps.Timeline.WebService.Audit;
public interface IUnifiedAuditAggregationService
{
Task<UnifiedAuditEventsPagedResponse> GetEventsAsync(
UnifiedAuditQuery query,
string? cursor,
int limit,
CancellationToken cancellationToken);
Task<UnifiedAuditEvent?> GetEventByIdAsync(
string eventId,
CancellationToken cancellationToken);
Task<UnifiedAuditStatsSummary> GetStatsAsync(
DateTimeOffset? startDate,
DateTimeOffset? endDate,
CancellationToken cancellationToken);
Task<IReadOnlyList<UnifiedAuditTimelineEntry>> SearchTimelineAsync(
string query,
DateTimeOffset? startDate,
DateTimeOffset? endDate,
int limit,
CancellationToken cancellationToken);
Task<IReadOnlyList<UnifiedAuditCorrelationCluster>> GetCorrelationsAsync(
DateTimeOffset? startDate,
DateTimeOffset? endDate,
int limit,
CancellationToken cancellationToken);
Task<UnifiedAuditCorrelationCluster?> GetCorrelationAsync(
string correlationId,
CancellationToken cancellationToken);
Task<IReadOnlyList<UnifiedAuditAnomalyAlert>> GetAnomaliesAsync(
bool? acknowledged,
int limit,
CancellationToken cancellationToken);
Task<UnifiedAuditAnomalyAlert?> AcknowledgeAnomalyAsync(
string alertId,
string acknowledgedBy,
CancellationToken cancellationToken);
Task<UnifiedAuditExportResponse> RequestExportAsync(
UnifiedAuditExportRequest request,
CancellationToken cancellationToken);
Task<UnifiedAuditExportResponse?> GetExportStatusAsync(
string exportId,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,8 @@
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
namespace StellaOps.Timeline.WebService.Audit;
public interface IUnifiedAuditEventProvider
{
Task<IReadOnlyList<UnifiedAuditEvent>> GetEventsAsync(CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,739 @@
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
using System.Collections.Concurrent;
using System.Globalization;
using System.Threading;
namespace StellaOps.Timeline.WebService.Audit;
public sealed class UnifiedAuditAggregationService : IUnifiedAuditAggregationService
{
private static readonly StringComparer StringComparerOrdinal = StringComparer.Ordinal;
private static readonly StringComparer StringComparerOrdinalIgnoreCase = StringComparer.OrdinalIgnoreCase;
private readonly IUnifiedAuditEventProvider _eventProvider;
private readonly TimeProvider _timeProvider;
private readonly ILogger<UnifiedAuditAggregationService> _logger;
private readonly ConcurrentDictionary<string, AcknowledgementState> _acknowledgedAlerts =
new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, UnifiedAuditExportResponse> _exports =
new(StringComparer.Ordinal);
private long _exportSequence;
public UnifiedAuditAggregationService(
IUnifiedAuditEventProvider eventProvider,
TimeProvider timeProvider,
ILogger<UnifiedAuditAggregationService> logger)
{
_eventProvider = eventProvider;
_timeProvider = timeProvider;
_logger = logger;
}
public async Task<UnifiedAuditEventsPagedResponse> GetEventsAsync(
UnifiedAuditQuery query,
string? cursor,
int limit,
CancellationToken cancellationToken)
{
var clampedLimit = Math.Clamp(limit, 1, 200);
var events = await GetFilteredEventsAsync(query, cancellationToken).ConfigureAwait(false);
var startIndex = ResolveStartIndex(events, cursor);
var page = events.Skip(startIndex).Take(clampedLimit).ToList();
var hasMore = startIndex + page.Count < events.Count;
return new UnifiedAuditEventsPagedResponse
{
Items = page,
Cursor = hasMore && page.Count > 0 ? page[^1].Id : null,
HasMore = hasMore,
TotalCount = events.Count
};
}
public async Task<UnifiedAuditEvent?> GetEventByIdAsync(
string eventId,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(eventId))
{
return null;
}
var events = await GetFilteredEventsAsync(new UnifiedAuditQuery(), cancellationToken).ConfigureAwait(false);
return events.FirstOrDefault(e => e.Id.Equals(eventId, StringComparison.OrdinalIgnoreCase));
}
public async Task<UnifiedAuditStatsSummary> GetStatsAsync(
DateTimeOffset? startDate,
DateTimeOffset? endDate,
CancellationToken cancellationToken)
{
var now = _timeProvider.GetUtcNow();
var query = new UnifiedAuditQuery
{
StartDate = startDate,
EndDate = endDate
};
var events = await GetFilteredEventsAsync(query, cancellationToken).ConfigureAwait(false);
var byModule = CreateCountBuckets(UnifiedAuditCatalog.Modules);
var byAction = CreateCountBuckets(UnifiedAuditCatalog.Actions);
var bySeverity = CreateCountBuckets(UnifiedAuditCatalog.Severities);
foreach (var auditEvent in events)
{
byModule[auditEvent.Module] = byModule.GetValueOrDefault(auditEvent.Module) + 1;
byAction[auditEvent.Action] = byAction.GetValueOrDefault(auditEvent.Action) + 1;
bySeverity[auditEvent.Severity] = bySeverity.GetValueOrDefault(auditEvent.Severity) + 1;
}
var topActors = events
.GroupBy(
e => $"{e.Actor.Id}|{e.Actor.Name}|{e.Actor.Type}",
StringComparerOrdinalIgnoreCase)
.Select(group => new UnifiedAuditActorStats
{
Actor = group.First().Actor,
EventCount = group.LongCount()
})
.OrderByDescending(entry => entry.EventCount)
.ThenBy(entry => entry.Actor.Id, StringComparerOrdinal)
.Take(5)
.ToList();
var topResources = events
.GroupBy(
e => $"{e.Resource.Type}|{e.Resource.Id}|{e.Resource.Name}",
StringComparerOrdinalIgnoreCase)
.Select(group => new UnifiedAuditResourceStats
{
Resource = group.First().Resource,
EventCount = group.LongCount()
})
.OrderByDescending(entry => entry.EventCount)
.ThenBy(entry => entry.Resource.Type, StringComparerOrdinal)
.ThenBy(entry => entry.Resource.Id, StringComparerOrdinal)
.Take(5)
.ToList();
return new UnifiedAuditStatsSummary
{
Period = new UnifiedAuditStatsPeriod
{
Start = startDate ?? events.LastOrDefault()?.Timestamp ?? now.AddDays(-7),
End = endDate ?? events.FirstOrDefault()?.Timestamp ?? now
},
TotalEvents = events.Count,
ByModule = byModule,
ByAction = byAction,
BySeverity = bySeverity,
TopActors = topActors,
TopResources = topResources
};
}
public async Task<IReadOnlyList<UnifiedAuditTimelineEntry>> SearchTimelineAsync(
string query,
DateTimeOffset? startDate,
DateTimeOffset? endDate,
int limit,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(query))
{
return Array.Empty<UnifiedAuditTimelineEntry>();
}
var filtered = await GetFilteredEventsAsync(
new UnifiedAuditQuery
{
Search = query,
StartDate = startDate,
EndDate = endDate
},
cancellationToken)
.ConfigureAwait(false);
var clampedLimit = Math.Clamp(limit, 1, 200);
var grouped = filtered
.GroupBy(e => DateTimeOffset.FromUnixTimeSeconds(e.Timestamp.ToUnixTimeSeconds()))
.OrderByDescending(group => group.Key)
.Take(clampedLimit)
.Select(group =>
{
var orderedEvents = group
.OrderByDescending(e => e.Timestamp)
.ThenBy(e => e.Id, StringComparerOrdinal)
.ToList();
var clusterId = orderedEvents
.Select(e => e.CorrelationId)
.FirstOrDefault(id => !string.IsNullOrWhiteSpace(id));
return new UnifiedAuditTimelineEntry
{
Timestamp = group.Key,
Events = orderedEvents,
ClusterId = clusterId,
ClusterSize = orderedEvents.Count > 1 ? orderedEvents.Count : null
};
})
.ToList();
return grouped;
}
public async Task<IReadOnlyList<UnifiedAuditCorrelationCluster>> GetCorrelationsAsync(
DateTimeOffset? startDate,
DateTimeOffset? endDate,
int limit,
CancellationToken cancellationToken)
{
var events = await GetFilteredEventsAsync(
new UnifiedAuditQuery
{
StartDate = startDate,
EndDate = endDate
},
cancellationToken)
.ConfigureAwait(false);
var clusters = events
.Where(e => !string.IsNullOrWhiteSpace(e.CorrelationId))
.GroupBy(e => e.CorrelationId!, StringComparerOrdinalIgnoreCase)
.Where(group => group.Count() > 1)
.Select(BuildCorrelationCluster)
.OrderByDescending(cluster => cluster.RootEvent.Timestamp)
.ThenBy(cluster => cluster.CorrelationId, StringComparerOrdinal)
.Take(Math.Clamp(limit, 1, 200))
.ToList();
return clusters;
}
public async Task<UnifiedAuditCorrelationCluster?> GetCorrelationAsync(
string correlationId,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(correlationId))
{
return null;
}
var events = await GetFilteredEventsAsync(
new UnifiedAuditQuery { CorrelationId = correlationId },
cancellationToken)
.ConfigureAwait(false);
if (events.Count == 0)
{
return null;
}
return BuildCorrelationCluster(events.GroupBy(e => correlationId, StringComparerOrdinalIgnoreCase).Single());
}
public async Task<IReadOnlyList<UnifiedAuditAnomalyAlert>> GetAnomaliesAsync(
bool? acknowledged,
int limit,
CancellationToken cancellationToken)
{
var now = _timeProvider.GetUtcNow();
var events = await GetFilteredEventsAsync(
new UnifiedAuditQuery
{
StartDate = now.AddDays(-7),
EndDate = now
},
cancellationToken)
.ConfigureAwait(false);
var anomalies = BuildAnomalies(events, now)
.Select(ApplyAcknowledgement)
.Where(alert => !acknowledged.HasValue || alert.Acknowledged == acknowledged.Value)
.OrderByDescending(alert => alert.DetectedAt)
.ThenBy(alert => alert.Id, StringComparerOrdinal)
.Take(Math.Clamp(limit, 1, 200))
.ToList();
return anomalies;
}
public async Task<UnifiedAuditAnomalyAlert?> AcknowledgeAnomalyAsync(
string alertId,
string acknowledgedBy,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(alertId))
{
return null;
}
var alerts = await GetAnomaliesAsync(null, 500, cancellationToken).ConfigureAwait(false);
var target = alerts.FirstOrDefault(alert => alert.Id.Equals(alertId, StringComparison.OrdinalIgnoreCase));
if (target is null)
{
return null;
}
var acknowledgedAt = _timeProvider.GetUtcNow();
_acknowledgedAlerts[alertId] = new AcknowledgementState(acknowledgedBy, acknowledgedAt);
return target with
{
Acknowledged = true,
AcknowledgedBy = acknowledgedBy,
AcknowledgedAt = acknowledgedAt
};
}
public async Task<UnifiedAuditExportResponse> RequestExportAsync(
UnifiedAuditExportRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var query = BuildQueryFromFilters(request.Filters);
var events = await GetFilteredEventsAsync(query, cancellationToken).ConfigureAwait(false);
var sequence = Interlocked.Increment(ref _exportSequence);
var now = _timeProvider.GetUtcNow();
var response = new UnifiedAuditExportResponse
{
ExportId = $"audit-export-{sequence:D6}",
Status = "completed",
EventCount = events.Count,
CreatedAt = now,
CompletedAt = now,
ExpiresAt = now.AddDays(1)
};
_exports[response.ExportId] = response;
return response;
}
public Task<UnifiedAuditExportResponse?> GetExportStatusAsync(
string exportId,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(_exports.TryGetValue(exportId, out var response) ? response : null);
}
private async Task<IReadOnlyList<UnifiedAuditEvent>> GetFilteredEventsAsync(
UnifiedAuditQuery query,
CancellationToken cancellationToken)
{
var events = await _eventProvider.GetEventsAsync(cancellationToken).ConfigureAwait(false);
var filtered = events
.Where(auditEvent => MatchesQuery(auditEvent, query))
.OrderByDescending(auditEvent => auditEvent.Timestamp)
.ThenBy(auditEvent => auditEvent.Id, StringComparerOrdinal)
.ThenBy(auditEvent => auditEvent.Module, StringComparerOrdinal)
.ThenBy(auditEvent => auditEvent.Action, StringComparerOrdinal)
.ToList();
return filtered;
}
private static bool MatchesQuery(UnifiedAuditEvent auditEvent, UnifiedAuditQuery query)
{
if (query.Modules is { Count: > 0 } &&
!query.Modules.Contains(auditEvent.Module))
{
return false;
}
if (query.Actions is { Count: > 0 } &&
!query.Actions.Contains(auditEvent.Action))
{
return false;
}
if (query.Severities is { Count: > 0 } &&
!query.Severities.Contains(auditEvent.Severity))
{
return false;
}
if (!string.IsNullOrWhiteSpace(query.ActorId) &&
!auditEvent.Actor.Id.Equals(query.ActorId, StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (!string.IsNullOrWhiteSpace(query.ActorName))
{
var actorNameMatch =
ContainsIgnoreCase(auditEvent.Actor.Name, query.ActorName) ||
ContainsIgnoreCase(auditEvent.Actor.Email, query.ActorName) ||
ContainsIgnoreCase(auditEvent.Actor.Id, query.ActorName);
if (!actorNameMatch)
{
return false;
}
}
if (!string.IsNullOrWhiteSpace(query.ResourceType) &&
!auditEvent.Resource.Type.Equals(query.ResourceType, StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (!string.IsNullOrWhiteSpace(query.ResourceId) &&
!auditEvent.Resource.Id.Equals(query.ResourceId, StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (query.StartDate.HasValue && auditEvent.Timestamp < query.StartDate.Value)
{
return false;
}
if (query.EndDate.HasValue && auditEvent.Timestamp > query.EndDate.Value)
{
return false;
}
if (!string.IsNullOrWhiteSpace(query.CorrelationId) &&
!string.Equals(auditEvent.CorrelationId, query.CorrelationId, StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (!string.IsNullOrWhiteSpace(query.TenantId) &&
!string.Equals(auditEvent.TenantId, query.TenantId, StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (query.Tags is { Count: > 0 } &&
!auditEvent.Tags.Any(tag => query.Tags.Contains(tag)))
{
return false;
}
if (!string.IsNullOrWhiteSpace(query.Search) && !MatchesSearch(auditEvent, query.Search))
{
return false;
}
return true;
}
private static bool ContainsIgnoreCase(string? source, string expected) =>
!string.IsNullOrWhiteSpace(source) &&
source.Contains(expected, StringComparison.OrdinalIgnoreCase);
private static bool MatchesSearch(UnifiedAuditEvent auditEvent, string search)
{
if (ContainsIgnoreCase(auditEvent.Id, search) ||
ContainsIgnoreCase(auditEvent.Description, search) ||
ContainsIgnoreCase(auditEvent.Module, search) ||
ContainsIgnoreCase(auditEvent.Action, search) ||
ContainsIgnoreCase(auditEvent.Severity, search) ||
ContainsIgnoreCase(auditEvent.Actor.Id, search) ||
ContainsIgnoreCase(auditEvent.Actor.Name, search) ||
ContainsIgnoreCase(auditEvent.Actor.Email, search) ||
ContainsIgnoreCase(auditEvent.Resource.Type, search) ||
ContainsIgnoreCase(auditEvent.Resource.Id, search) ||
ContainsIgnoreCase(auditEvent.Resource.Name, search) ||
ContainsIgnoreCase(auditEvent.CorrelationId, search) ||
ContainsIgnoreCase(auditEvent.ParentEventId, search) ||
ContainsIgnoreCase(auditEvent.TenantId, search))
{
return true;
}
if (auditEvent.Tags.Any(tag => ContainsIgnoreCase(tag, search)))
{
return true;
}
return auditEvent.Details.Values.Any(value => ContainsIgnoreCase(value?.ToString(), search));
}
private static int ResolveStartIndex(IReadOnlyList<UnifiedAuditEvent> events, string? cursor)
{
if (string.IsNullOrWhiteSpace(cursor))
{
return 0;
}
var index = events
.Select((auditEvent, eventIndex) => new { auditEvent.Id, eventIndex })
.FirstOrDefault(item => item.Id.Equals(cursor, StringComparison.OrdinalIgnoreCase))
?.eventIndex;
return index.HasValue ? index.Value + 1 : 0;
}
private static Dictionary<string, long> CreateCountBuckets(IReadOnlyList<string> keys)
{
var dictionary = new Dictionary<string, long>(StringComparer.Ordinal);
foreach (var key in keys)
{
dictionary[key] = 0;
}
return dictionary;
}
private UnifiedAuditCorrelationCluster BuildCorrelationCluster(
IGrouping<string, UnifiedAuditEvent> correlationGroup)
{
var ordered = correlationGroup
.OrderBy(auditEvent => auditEvent.Timestamp)
.ThenBy(auditEvent => auditEvent.Id, StringComparerOrdinal)
.ToList();
var rootEvent = ordered[0];
var lastEvent = ordered[^1];
var duration = Math.Max(
0,
(long)(lastEvent.Timestamp - rootEvent.Timestamp).TotalMilliseconds);
var outcome = ordered.Any(e => e.Severity is "critical" or "error")
? "failure"
: ordered.Any(e => e.Severity == "warning")
? "partial"
: "success";
return new UnifiedAuditCorrelationCluster
{
CorrelationId = correlationGroup.Key,
RootEvent = rootEvent,
RelatedEvents = ordered.Skip(1).ToList(),
Duration = duration,
Outcome = outcome
};
}
private IReadOnlyList<UnifiedAuditAnomalyAlert> BuildAnomalies(
IReadOnlyList<UnifiedAuditEvent> events,
DateTimeOffset now)
{
if (events.Count == 0)
{
return Array.Empty<UnifiedAuditAnomalyAlert>();
}
var anomalies = new List<UnifiedAuditAnomalyAlert>();
var dominantModule = events
.GroupBy(e => e.Module, StringComparerOrdinalIgnoreCase)
.Select(group => new { Module = group.Key, Count = group.Count() })
.OrderByDescending(item => item.Count)
.ThenBy(item => item.Module, StringComparerOrdinal)
.First();
if (dominantModule.Count >= Math.Max(10, (int)Math.Ceiling(events.Count * 0.5)))
{
var affected = events
.Where(e => e.Module.Equals(dominantModule.Module, StringComparison.OrdinalIgnoreCase))
.Take(20)
.Select(e => e.Id)
.ToList();
anomalies.Add(new UnifiedAuditAnomalyAlert
{
Id = $"alert-unusual-volume-{dominantModule.Module}".ToLowerInvariant(),
DetectedAt = events.First(e => e.Module.Equals(dominantModule.Module, StringComparison.OrdinalIgnoreCase)).Timestamp,
Type = "unusual_volume",
Severity = "warning",
Description = $"High audit volume detected for module '{dominantModule.Module}' in the last 7 days.",
AffectedEvents = affected,
Acknowledged = false
});
}
var failedAuthEvents = events
.Where(e =>
e.Action == "fail" &&
(e.Module == "authority" ||
ContainsIgnoreCase(e.Description, "auth") ||
ContainsIgnoreCase(e.Resource.Type, "token")))
.Take(20)
.ToList();
if (failedAuthEvents.Count >= 3)
{
anomalies.Add(new UnifiedAuditAnomalyAlert
{
Id = "alert-failed-auth-spike",
DetectedAt = failedAuthEvents.First().Timestamp,
Type = "failed_auth_spike",
Severity = "error",
Description = "Spike in authentication-related failures detected.",
AffectedEvents = failedAuthEvents.Select(e => e.Id).ToList(),
Acknowledged = false
});
}
var privilegeEvents = events
.Where(e => e.Actor.Type == "user" && e.Action is "promote" or "approve" or "enable" or "issue" or "rotate")
.Take(20)
.ToList();
if (privilegeEvents.Count >= 3)
{
anomalies.Add(new UnifiedAuditAnomalyAlert
{
Id = "alert-privilege-escalation",
DetectedAt = privilegeEvents.First().Timestamp,
Type = "privilege_escalation",
Severity = "warning",
Description = "Multiple privilege-affecting actions were detected in a short interval.",
AffectedEvents = privilegeEvents.Select(e => e.Id).ToList(),
Acknowledged = false
});
}
var offHoursEvents = events
.Where(e => e.Timestamp.UtcDateTime.Hour is < 6 or >= 22)
.Take(20)
.ToList();
if (offHoursEvents.Count >= 3)
{
anomalies.Add(new UnifiedAuditAnomalyAlert
{
Id = "alert-off-hours-activity",
DetectedAt = offHoursEvents.First().Timestamp,
Type = "off_hours_activity",
Severity = "warning",
Description = "Sensitive operations were performed during off-hours.",
AffectedEvents = offHoursEvents.Select(e => e.Id).ToList(),
Acknowledged = false
});
}
var unusualPatternEvents = events
.Where(e => e.Severity is "error" or "critical")
.Take(20)
.ToList();
if (unusualPatternEvents.Count >= 2)
{
anomalies.Add(new UnifiedAuditAnomalyAlert
{
Id = "alert-unusual-pattern",
DetectedAt = unusualPatternEvents.First().Timestamp,
Type = "unusual_pattern",
Severity = "error",
Description = "A burst of high-severity audit events indicates an unusual pattern.",
AffectedEvents = unusualPatternEvents.Select(e => e.Id).ToList(),
Acknowledged = false
});
}
if (anomalies.Count == 0)
{
anomalies.Add(new UnifiedAuditAnomalyAlert
{
Id = "alert-baseline-unusual-pattern",
DetectedAt = events.FirstOrDefault()?.Timestamp ?? now,
Type = "unusual_pattern",
Severity = "info",
Description = "Baseline anomaly monitor is active with no elevated risk findings.",
AffectedEvents = events.Take(5).Select(e => e.Id).ToList(),
Acknowledged = false
});
}
return anomalies;
}
private UnifiedAuditAnomalyAlert ApplyAcknowledgement(UnifiedAuditAnomalyAlert alert)
{
if (!_acknowledgedAlerts.TryGetValue(alert.Id, out var state))
{
return alert;
}
return alert with
{
Acknowledged = true,
AcknowledgedBy = state.AcknowledgedBy,
AcknowledgedAt = state.AcknowledgedAt
};
}
private static UnifiedAuditQuery BuildQueryFromFilters(UnifiedAuditLogFilters filters)
{
return new UnifiedAuditQuery
{
Modules = NormalizeSet(filters.Modules, UnifiedAuditCatalog.Modules),
Actions = NormalizeSet(filters.Actions, UnifiedAuditCatalog.Actions),
Severities = NormalizeSet(filters.Severities, UnifiedAuditCatalog.Severities),
ActorId = NormalizeText(filters.ActorId),
ActorName = NormalizeText(filters.ActorName),
ResourceType = NormalizeText(filters.ResourceType),
ResourceId = NormalizeText(filters.ResourceId),
StartDate = ParseDate(filters.StartDate),
EndDate = ParseDate(filters.EndDate),
Search = NormalizeText(filters.Search),
CorrelationId = NormalizeText(filters.CorrelationId),
TenantId = NormalizeText(filters.TenantId),
Tags = NormalizeSet(filters.Tags, null)
};
}
private static HashSet<string>? NormalizeSet(
IReadOnlyList<string>? values,
IReadOnlyList<string>? allowedValues)
{
if (values is null || values.Count == 0)
{
return null;
}
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var value in values)
{
var normalized = NormalizeText(value);
if (string.IsNullOrWhiteSpace(normalized))
{
continue;
}
if (allowedValues is not null &&
!allowedValues.Contains(normalized, StringComparerOrdinalIgnoreCase))
{
continue;
}
set.Add(normalized);
}
return set.Count > 0 ? set : null;
}
private static string? NormalizeText(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
private static DateTimeOffset? ParseDate(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
{
return null;
}
if (DateTimeOffset.TryParse(
raw,
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out var parsed))
{
return parsed;
}
return null;
}
private sealed record AcknowledgementState(string AcknowledgedBy, DateTimeOffset AcknowledgedAt);
}

View File

@@ -0,0 +1,364 @@
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
using System.Globalization;
namespace StellaOps.Timeline.WebService.Audit;
public static class UnifiedAuditCatalog
{
public static readonly IReadOnlyList<string> Modules =
[
"authority",
"policy",
"jobengine",
"integrations",
"vex",
"scanner",
"attestor",
"sbom",
"scheduler"
];
public static readonly IReadOnlyList<string> Actions =
[
"create",
"update",
"delete",
"promote",
"demote",
"revoke",
"issue",
"refresh",
"test",
"fail",
"complete",
"start",
"submit",
"approve",
"reject",
"sign",
"verify",
"rotate",
"enable",
"disable",
"deadletter",
"replay"
];
public static readonly IReadOnlyList<string> Severities =
[
"info",
"warning",
"error",
"critical"
];
}
public static class UnifiedAuditValueMapper
{
public static string NormalizeAction(string? rawAction, string? description = null)
{
var source = (rawAction ?? string.Empty).Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(source))
{
source = (description ?? string.Empty).Trim().ToLowerInvariant();
}
foreach (var action in UnifiedAuditCatalog.Actions)
{
if (source.Contains(action, StringComparison.OrdinalIgnoreCase))
{
return action;
}
}
if (source.Contains("created", StringComparison.OrdinalIgnoreCase))
{
return "create";
}
if (source.Contains("updated", StringComparison.OrdinalIgnoreCase) ||
source.Contains("changed", StringComparison.OrdinalIgnoreCase) ||
source.Contains("modified", StringComparison.OrdinalIgnoreCase))
{
return "update";
}
if (source.Contains("deleted", StringComparison.OrdinalIgnoreCase) ||
source.Contains("removed", StringComparison.OrdinalIgnoreCase))
{
return "delete";
}
if (source.Contains("approved", StringComparison.OrdinalIgnoreCase))
{
return "approve";
}
if (source.Contains("rejected", StringComparison.OrdinalIgnoreCase))
{
return "reject";
}
if (source.Contains("failed", StringComparison.OrdinalIgnoreCase) ||
source.Contains("failure", StringComparison.OrdinalIgnoreCase) ||
source.Contains("error", StringComparison.OrdinalIgnoreCase))
{
return "fail";
}
if (source.Contains("started", StringComparison.OrdinalIgnoreCase) ||
source.Contains("begin", StringComparison.OrdinalIgnoreCase))
{
return "start";
}
if (source.Contains("completed", StringComparison.OrdinalIgnoreCase) ||
source.Contains("finished", StringComparison.OrdinalIgnoreCase))
{
return "complete";
}
return "update";
}
public static string NormalizeSeverity(string? rawSeverity, string action, string? description = null)
{
var source = (rawSeverity ?? string.Empty).Trim().ToLowerInvariant();
if (UnifiedAuditCatalog.Severities.Contains(source, StringComparer.Ordinal))
{
return source;
}
var text = (description ?? string.Empty).ToLowerInvariant();
if (text.Contains("critical", StringComparison.OrdinalIgnoreCase))
{
return "critical";
}
return action switch
{
"fail" or "reject" or "revoke" or "delete" or "deadletter" => "error",
"demote" or "disable" => "warning",
_ => "info",
};
}
public static string NormalizeActorType(string? rawType)
{
var source = (rawType ?? string.Empty).Trim().ToLowerInvariant();
return source switch
{
"user" => "user",
"system" => "system",
"service" => "service",
"automation" => "automation",
_ => "system"
};
}
public static string NormalizeModule(string rawModule)
{
var source = rawModule.Trim().ToLowerInvariant();
return source switch
{
"evidencelocker" => "sbom",
"notify" => "integrations",
_ => UnifiedAuditCatalog.Modules.Contains(source, StringComparer.Ordinal) ? source : "integrations"
};
}
public static DateTimeOffset ParseTimestampOrDefault(string? rawTimestamp, DateTimeOffset defaultValue)
{
if (string.IsNullOrWhiteSpace(rawTimestamp))
{
return defaultValue;
}
if (DateTimeOffset.TryParse(
rawTimestamp,
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out var parsed))
{
return parsed;
}
return defaultValue;
}
}
public sealed record UnifiedAuditActor
{
public required string Id { get; init; }
public required string Name { get; init; }
public string? Email { get; init; }
public required string Type { get; init; }
public IReadOnlyList<string>? Scopes { get; init; }
public string? IpAddress { get; init; }
public string? UserAgent { get; init; }
}
public sealed record UnifiedAuditResource
{
public required string Type { get; init; }
public required string Id { get; init; }
public string? Name { get; init; }
public string? Digest { get; init; }
public IReadOnlyDictionary<string, object?>? Metadata { get; init; }
}
public sealed record UnifiedAuditDiff
{
public object? Before { get; init; }
public object? After { get; init; }
public required IReadOnlyList<string> Fields { get; init; }
}
public sealed record UnifiedAuditEvent
{
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 UnifiedAuditActor Actor { get; init; }
public required UnifiedAuditResource Resource { get; init; }
public required string Description { get; init; }
public required IReadOnlyDictionary<string, object?> Details { get; init; }
public UnifiedAuditDiff? Diff { get; init; }
public string? CorrelationId { get; init; }
public string? ParentEventId { get; init; }
public string? TenantId { get; init; }
public required IReadOnlyList<string> Tags { get; init; }
}
public sealed record UnifiedAuditEventsPagedResponse
{
public required IReadOnlyList<UnifiedAuditEvent> Items { get; init; }
public string? Cursor { get; init; }
public bool HasMore { get; init; }
public long? TotalCount { get; init; }
}
public sealed record UnifiedAuditLogFilters
{
public IReadOnlyList<string>? Modules { get; init; }
public IReadOnlyList<string>? Actions { get; init; }
public IReadOnlyList<string>? Severities { get; init; }
public string? ActorId { get; init; }
public string? ActorName { get; init; }
public string? ResourceType { get; init; }
public string? ResourceId { get; init; }
public string? StartDate { get; init; }
public string? EndDate { get; init; }
public string? Search { get; init; }
public string? CorrelationId { get; init; }
public string? TenantId { get; init; }
public IReadOnlyList<string>? Tags { get; init; }
}
public sealed record UnifiedAuditExportRequest
{
public required UnifiedAuditLogFilters Filters { get; init; }
public required string Format { get; init; }
public bool IncludeDetails { get; init; }
public bool IncludeDiffs { get; init; }
}
public sealed record UnifiedAuditExportResponse
{
public required string ExportId { get; init; }
public required string Status { get; init; }
public string? DownloadUrl { get; init; }
public int? EventCount { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? CompletedAt { get; init; }
public DateTimeOffset? ExpiresAt { get; init; }
}
public sealed record UnifiedAuditStatsSummary
{
public required UnifiedAuditStatsPeriod Period { get; init; }
public long TotalEvents { get; init; }
public required IReadOnlyDictionary<string, long> ByModule { get; init; }
public required IReadOnlyDictionary<string, long> ByAction { get; init; }
public required IReadOnlyDictionary<string, long> BySeverity { get; init; }
public required IReadOnlyList<UnifiedAuditActorStats> TopActors { get; init; }
public required IReadOnlyList<UnifiedAuditResourceStats> TopResources { get; init; }
}
public sealed record UnifiedAuditStatsPeriod
{
public required DateTimeOffset Start { get; init; }
public required DateTimeOffset End { get; init; }
}
public sealed record UnifiedAuditActorStats
{
public required UnifiedAuditActor Actor { get; init; }
public long EventCount { get; init; }
}
public sealed record UnifiedAuditResourceStats
{
public required UnifiedAuditResource Resource { get; init; }
public long EventCount { get; init; }
}
public sealed record UnifiedAuditTimelineEntry
{
public required DateTimeOffset Timestamp { get; init; }
public required IReadOnlyList<UnifiedAuditEvent> Events { get; init; }
public string? ClusterId { get; init; }
public int? ClusterSize { get; init; }
}
public sealed record UnifiedAuditCorrelationCluster
{
public required string CorrelationId { get; init; }
public required UnifiedAuditEvent RootEvent { get; init; }
public required IReadOnlyList<UnifiedAuditEvent> RelatedEvents { get; init; }
public long Duration { get; init; }
public required string Outcome { get; init; }
}
public sealed record UnifiedAuditAnomalyAlert
{
public required string Id { get; init; }
public required DateTimeOffset DetectedAt { get; init; }
public required string Type { get; init; }
public required string Severity { get; init; }
public required string Description { get; init; }
public required IReadOnlyList<string> AffectedEvents { get; init; }
public bool Acknowledged { get; init; }
public string? AcknowledgedBy { get; init; }
public DateTimeOffset? AcknowledgedAt { get; init; }
}
public sealed record UnifiedAuditQuery
{
public HashSet<string>? Modules { get; init; }
public HashSet<string>? Actions { get; init; }
public HashSet<string>? Severities { get; init; }
public string? ActorId { get; init; }
public string? ActorName { get; init; }
public string? ResourceType { get; init; }
public string? ResourceId { get; init; }
public DateTimeOffset? StartDate { get; init; }
public DateTimeOffset? EndDate { get; init; }
public string? Search { get; init; }
public string? CorrelationId { get; init; }
public string? TenantId { get; init; }
public HashSet<string>? Tags { get; init; }
}
public sealed record UnifiedAuditModuleEndpointsOptions
{
public string JobEngineBaseUrl { get; set; } = "http://jobengine.stella-ops.local";
public string PolicyBaseUrl { get; set; } = "http://policy-gateway.stella-ops.local";
public string EvidenceLockerBaseUrl { get; set; } = "http://evidencelocker.stella-ops.local";
public string NotifyBaseUrl { get; set; } = "http://notify.stella-ops.local";
public int FetchLimitPerModule { get; set; } = 250;
public int RequestTimeoutSeconds { get; set; } = 2;
}

View File

@@ -0,0 +1,308 @@
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
using System.Globalization;
using System.Security.Claims;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Timeline.WebService.Audit;
using StellaOps.Timeline.WebService.Security;
namespace StellaOps.Timeline.WebService.Endpoints;
public static class UnifiedAuditEndpoints
{
public static void MapUnifiedAuditEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v1/audit")
.WithTags("Unified Audit")
.RequireTenant();
group.MapGet("/events", GetEventsAsync)
.WithName("GetUnifiedAuditEvents")
.WithDescription("Get unified audit events with filtering and cursor pagination.")
.RequireAuthorization(TimelinePolicies.Read);
group.MapGet("/events/{eventId}", GetEventByIdAsync)
.WithName("GetUnifiedAuditEventById")
.WithDescription("Get a single unified audit event by ID.")
.RequireAuthorization(TimelinePolicies.Read);
group.MapGet("/stats", GetStatsAsync)
.WithName("GetUnifiedAuditStats")
.WithDescription("Get unified audit statistics summary.")
.RequireAuthorization(TimelinePolicies.Read);
group.MapGet("/timeline/search", SearchTimelineAsync)
.WithName("SearchUnifiedAuditTimeline")
.WithDescription("Search unified audit timeline entries.")
.RequireAuthorization(TimelinePolicies.Read);
group.MapGet("/correlations", GetCorrelationsAsync)
.WithName("GetUnifiedAuditCorrelations")
.WithDescription("Get unified audit correlation clusters.")
.RequireAuthorization(TimelinePolicies.Read);
group.MapGet("/correlations/{correlationId}", GetCorrelationByIdAsync)
.WithName("GetUnifiedAuditCorrelationById")
.WithDescription("Get a unified audit correlation cluster by correlation ID.")
.RequireAuthorization(TimelinePolicies.Read);
group.MapGet("/anomalies", GetAnomaliesAsync)
.WithName("GetUnifiedAuditAnomalies")
.WithDescription("Get anomaly alerts from unified audit events.")
.RequireAuthorization(TimelinePolicies.Read);
group.MapPost("/anomalies/{alertId}/acknowledge", AcknowledgeAnomalyAsync)
.WithName("AcknowledgeUnifiedAuditAnomaly")
.WithDescription("Acknowledge an anomaly alert.")
.RequireAuthorization(TimelinePolicies.Write);
group.MapPost("/export", RequestExportAsync)
.WithName("RequestUnifiedAuditExport")
.WithDescription("Request a unified audit export.")
.RequireAuthorization(TimelinePolicies.Write);
group.MapGet("/export/{exportId}", GetExportStatusAsync)
.WithName("GetUnifiedAuditExportStatus")
.WithDescription("Get unified audit export status.")
.RequireAuthorization(TimelinePolicies.Read);
}
private static async Task<IResult> GetEventsAsync(
[AsParameters] UnifiedAuditEventsRequest request,
IUnifiedAuditAggregationService service,
CancellationToken cancellationToken)
{
var query = BuildQueryFromEventsRequest(request);
var response = await service.GetEventsAsync(
query,
request.Cursor,
request.Limit ?? 50,
cancellationToken)
.ConfigureAwait(false);
return Results.Ok(response);
}
private static async Task<IResult> GetEventByIdAsync(
string eventId,
IUnifiedAuditAggregationService service,
CancellationToken cancellationToken)
{
var eventItem = await service.GetEventByIdAsync(eventId, cancellationToken).ConfigureAwait(false);
return eventItem is null ? Results.NotFound() : Results.Ok(eventItem);
}
private static async Task<IResult> GetStatsAsync(
[FromQuery] string? startDate,
[FromQuery] string? endDate,
IUnifiedAuditAggregationService service,
CancellationToken cancellationToken)
{
var response = await service.GetStatsAsync(
ParseDate(startDate),
ParseDate(endDate),
cancellationToken)
.ConfigureAwait(false);
return Results.Ok(response);
}
private static async Task<IResult> SearchTimelineAsync(
[FromQuery(Name = "q")] string? query,
[FromQuery] string? startDate,
[FromQuery] string? endDate,
[FromQuery] int? limit,
IUnifiedAuditAggregationService service,
CancellationToken cancellationToken)
{
var response = await service.SearchTimelineAsync(
query ?? string.Empty,
ParseDate(startDate),
ParseDate(endDate),
limit ?? 100,
cancellationToken)
.ConfigureAwait(false);
return Results.Ok(response);
}
private static async Task<IResult> GetCorrelationsAsync(
[FromQuery] string? startDate,
[FromQuery] string? endDate,
[FromQuery] int? limit,
IUnifiedAuditAggregationService service,
CancellationToken cancellationToken)
{
var response = await service.GetCorrelationsAsync(
ParseDate(startDate),
ParseDate(endDate),
limit ?? 50,
cancellationToken)
.ConfigureAwait(false);
return Results.Ok(response);
}
private static async Task<IResult> GetCorrelationByIdAsync(
string correlationId,
IUnifiedAuditAggregationService service,
CancellationToken cancellationToken)
{
var response = await service.GetCorrelationAsync(correlationId, cancellationToken).ConfigureAwait(false);
return response is null ? Results.NotFound() : Results.Ok(response);
}
private static async Task<IResult> GetAnomaliesAsync(
[FromQuery] bool? acknowledged,
[FromQuery] int? limit,
IUnifiedAuditAggregationService service,
CancellationToken cancellationToken)
{
var response = await service.GetAnomaliesAsync(
acknowledged,
limit ?? 50,
cancellationToken)
.ConfigureAwait(false);
return Results.Ok(response);
}
private static async Task<IResult> AcknowledgeAnomalyAsync(
string alertId,
ClaimsPrincipal user,
IUnifiedAuditAggregationService service,
CancellationToken cancellationToken)
{
var acknowledgedBy = ResolveActor(user);
var response = await service.AcknowledgeAnomalyAsync(
alertId,
acknowledgedBy,
cancellationToken)
.ConfigureAwait(false);
return response is null ? Results.NotFound() : Results.Ok(response);
}
private static async Task<IResult> RequestExportAsync(
UnifiedAuditExportRequest request,
IUnifiedAuditAggregationService service,
CancellationToken cancellationToken)
{
if (request.Filters is null)
{
return Results.BadRequest(new { error = "filters_required" });
}
var response = await service.RequestExportAsync(request, cancellationToken).ConfigureAwait(false);
return Results.Ok(response);
}
private static async Task<IResult> GetExportStatusAsync(
string exportId,
IUnifiedAuditAggregationService service,
CancellationToken cancellationToken)
{
var response = await service.GetExportStatusAsync(exportId, cancellationToken).ConfigureAwait(false);
return response is null ? Results.NotFound() : Results.Ok(response);
}
private static UnifiedAuditQuery BuildQueryFromEventsRequest(UnifiedAuditEventsRequest request)
{
return new UnifiedAuditQuery
{
Modules = ParseCsvSet(request.Modules, UnifiedAuditCatalog.Modules),
Actions = ParseCsvSet(request.Actions, UnifiedAuditCatalog.Actions),
Severities = ParseCsvSet(request.Severities, UnifiedAuditCatalog.Severities),
ActorId = NormalizeText(request.ActorId),
ActorName = NormalizeText(request.ActorName),
ResourceType = NormalizeText(request.ResourceType),
ResourceId = NormalizeText(request.ResourceId),
StartDate = ParseDate(request.StartDate),
EndDate = ParseDate(request.EndDate),
Search = NormalizeText(request.Search),
CorrelationId = NormalizeText(request.CorrelationId),
TenantId = NormalizeText(request.TenantId),
Tags = ParseCsvSet(request.Tags, null)
};
}
private static HashSet<string>? ParseCsvSet(string? raw, IReadOnlyList<string>? allowedValues)
{
if (string.IsNullOrWhiteSpace(raw))
{
return null;
}
var values = raw
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(value => value.Trim().ToLowerInvariant())
.Where(value => !string.IsNullOrWhiteSpace(value))
.ToList();
if (values.Count == 0)
{
return null;
}
if (allowedValues is not null)
{
values = values
.Where(value => allowedValues.Contains(value, StringComparer.OrdinalIgnoreCase))
.ToList();
}
return values.Count == 0
? null
: new HashSet<string>(values, StringComparer.OrdinalIgnoreCase);
}
private static DateTimeOffset? ParseDate(string? rawValue)
{
if (string.IsNullOrWhiteSpace(rawValue))
{
return null;
}
if (DateTimeOffset.TryParse(
rawValue,
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out var parsed))
{
return parsed;
}
return null;
}
private static string? NormalizeText(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
private static string ResolveActor(ClaimsPrincipal user)
{
return user.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? user.FindFirst("sub")?.Value
?? user.Identity?.Name
?? "system";
}
}
public sealed record UnifiedAuditEventsRequest
{
public string? Modules { get; init; }
public string? Actions { get; init; }
public string? Severities { get; init; }
public string? ActorId { get; init; }
public string? ActorName { get; init; }
public string? ResourceType { get; init; }
public string? ResourceId { get; init; }
public string? StartDate { get; init; }
public string? EndDate { get; init; }
public string? Search { get; init; }
public string? CorrelationId { get; init; }
public string? TenantId { get; init; }
public string? Tags { get; init; }
public string? Cursor { get; init; }
public int? Limit { get; init; }
}

View File

@@ -5,6 +5,7 @@ using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Eventing;
using StellaOps.Router.AspNet;
using StellaOps.Timeline.Core;
using StellaOps.Timeline.WebService.Audit;
using StellaOps.Timeline.WebService.Endpoints;
using StellaOps.Timeline.WebService.Security;
@@ -13,6 +14,45 @@ var builder = WebApplication.CreateBuilder(args);
// Add services
builder.Services.AddStellaOpsEventing(builder.Configuration);
builder.Services.AddTimelineServices(builder.Configuration);
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.Configure<UnifiedAuditModuleEndpointsOptions>(options =>
{
options.JobEngineBaseUrl = builder.Configuration["UnifiedAudit:Sources:JobEngine"]
?? builder.Configuration["STELLAOPS_JOBENGINE_URL"]
?? options.JobEngineBaseUrl;
options.PolicyBaseUrl = builder.Configuration["UnifiedAudit:Sources:Policy"]
?? builder.Configuration["STELLAOPS_POLICY_GATEWAY_URL"]
?? options.PolicyBaseUrl;
options.EvidenceLockerBaseUrl = builder.Configuration["UnifiedAudit:Sources:EvidenceLocker"]
?? builder.Configuration["STELLAOPS_EVIDENCELOCKER_URL"]
?? options.EvidenceLockerBaseUrl;
options.NotifyBaseUrl = builder.Configuration["UnifiedAudit:Sources:Notify"]
?? builder.Configuration["STELLAOPS_NOTIFY_URL"]
?? options.NotifyBaseUrl;
if (int.TryParse(builder.Configuration["UnifiedAudit:FetchLimitPerModule"], out var fetchLimit))
{
options.FetchLimitPerModule = fetchLimit;
}
if (int.TryParse(builder.Configuration["UnifiedAudit:RequestTimeoutSeconds"], out var timeoutSeconds))
{
options.RequestTimeoutSeconds = timeoutSeconds;
}
});
builder.Services.AddHttpClient(HttpUnifiedAuditEventProvider.ClientName, (provider, client) =>
{
var options = provider.GetRequiredService<Microsoft.Extensions.Options.IOptions<UnifiedAuditModuleEndpointsOptions>>().Value;
client.Timeout = TimeSpan.FromSeconds(Math.Max(1, options.RequestTimeoutSeconds));
});
builder.Services.AddSingleton<IUnifiedAuditEventProvider, HttpUnifiedAuditEventProvider>();
builder.Services.AddSingleton<IUnifiedAuditAggregationService, UnifiedAuditAggregationService>();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
@@ -73,6 +113,7 @@ await app.LoadTranslationsAsync();
app.MapTimelineEndpoints();
app.MapReplayEndpoints();
app.MapExportEndpoints();
app.MapUnifiedAuditEndpoints();
app.MapHealthEndpoints();
app.TryRefreshStellaRouterEndpoints(routerEnabled);

View File

@@ -0,0 +1,191 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Cryptography.Audit;
using StellaOps.Router.AspNet;
using StellaOps.TimelineIndexer.Core.Abstractions;
using StellaOps.TimelineIndexer.Core.Models;
using StellaOps.TimelineIndexer.Infrastructure.DependencyInjection;
using StellaOps.Localization;
using StellaOps.TimelineIndexer.WebService;
using static StellaOps.Localization.T;
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true);
builder.Configuration.AddJsonFile("appsettings.Development.json", optional: true, reloadOnChange: true);
builder.Configuration.AddEnvironmentVariables();
builder.Configuration.AddEnvironmentVariables(prefix: "TIMELINE_");
builder.Services.AddTimelineIndexerPostgres(builder.Configuration);
builder.Services.AddSingleton<IAuthEventSink, TimelineAuthorizationAuditSink>();
builder.Services.AddStellaOpsResourceServerAuthentication(
builder.Configuration,
configure: options =>
{
options.RequiredScopes.Clear();
});
builder.Services.AddAuthorization(options =>
{
options.AddObservabilityResourcePolicies();
options.DefaultPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.AddRequirements(new StellaOpsScopeRequirement(new[] { StellaOpsScopes.TimelineRead }))
.Build();
options.FallbackPolicy = options.DefaultPolicy;
});
builder.Services.AddOpenApi();
builder.Services.AddStellaOpsTenantServices();
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
builder.Services.AddStellaOpsLocalization(builder.Configuration);
builder.Services.AddTranslationBundle(System.Reflection.Assembly.GetExecutingAssembly());
// Stella Router integration
var routerEnabled = builder.Services.AddRouterMicroservice(
builder.Configuration,
serviceName: "timelineindexer",
version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0",
routerOptionsSection: "Router");
builder.TryAddStellaOpsLocalBinding("timelineindexer");
var app = builder.Build();
app.LogStellaOpsLocalHostname("timelineindexer");
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseStellaOpsCors();
app.UseStellaOpsLocalization();
app.UseAuthentication();
app.UseAuthorization();
app.UseStellaOpsTenantMiddleware();
app.TryUseStellaRouter(routerEnabled);
await app.LoadTranslationsAsync();
MapTimelineEndpoints(app.MapGroup("/api/v1").RequireTenant(), routeNamePrefix: "timeline_api_v1");
MapTimelineEndpoints(app.MapGroup(string.Empty).RequireTenant(), routeNamePrefix: "timeline");
// Refresh Router endpoint cache
app.TryRefreshStellaRouterEndpoints(routerEnabled);
await app.RunAsync().ConfigureAwait(false);
static string GetTenantId(HttpContext ctx)
{
// Temporary: allow explicit header override; fallback to claim if present.
if (ctx.Request.Headers.TryGetValue("X-Tenant", out var header) && !string.IsNullOrWhiteSpace(header))
{
return header!;
}
var tenant = ctx.User.FindFirst("tenant")?.Value;
if (!string.IsNullOrWhiteSpace(tenant))
{
return tenant!;
}
throw new InvalidOperationException("Tenant not provided");
}
static void MapTimelineEndpoints(RouteGroupBuilder routes, string routeNamePrefix)
{
routes.MapGet("/timeline", async (
HttpContext ctx,
ITimelineQueryService service,
[FromQuery] string? eventType,
[FromQuery] string? source,
[FromQuery] string? correlationId,
[FromQuery] string? traceId,
[FromQuery] string? severity,
[FromQuery] DateTimeOffset? since,
[FromQuery] long? after,
[FromQuery] int? limit,
CancellationToken cancellationToken) =>
{
var tenantId = GetTenantId(ctx);
var options = new TimelineQueryOptions
{
EventType = eventType,
Source = source,
CorrelationId = correlationId,
TraceId = traceId,
Severity = severity,
Since = since,
AfterEventSeq = after,
Limit = limit ?? 100
};
var items = await service.QueryAsync(tenantId, options, cancellationToken).ConfigureAwait(false);
return Results.Ok(items);
})
.WithName($"{routeNamePrefix}_query")
.WithSummary("List timeline events")
.WithDescription(_t("timelineindexer.timeline.query_description"))
.WithTags("timeline")
.Produces<IReadOnlyList<TimelineEventView>>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.RequireAuthorization(StellaOpsResourceServerPolicies.TimelineRead);
routes.MapGet("/timeline/{eventId}", async (
HttpContext ctx,
ITimelineQueryService service,
string eventId,
CancellationToken cancellationToken) =>
{
var tenantId = GetTenantId(ctx);
var item = await service.GetAsync(tenantId, eventId, cancellationToken).ConfigureAwait(false);
return item is null ? Results.NotFound() : Results.Ok(item);
})
.WithName($"{routeNamePrefix}_get_by_id")
.WithSummary("Get timeline event")
.WithDescription(_t("timelineindexer.timeline.get_by_id_description"))
.WithTags("timeline")
.Produces<TimelineEventView>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status401Unauthorized)
.RequireAuthorization(StellaOpsResourceServerPolicies.TimelineRead);
routes.MapGet("/timeline/{eventId}/evidence", async (
HttpContext ctx,
ITimelineQueryService service,
string eventId,
CancellationToken cancellationToken) =>
{
var tenantId = GetTenantId(ctx);
var evidence = await service.GetEvidenceAsync(tenantId, eventId, cancellationToken).ConfigureAwait(false);
return evidence is null ? Results.NotFound() : Results.Ok(evidence);
})
.WithName($"{routeNamePrefix}_get_evidence")
.WithSummary("Get event evidence")
.WithDescription(_t("timelineindexer.timeline.get_evidence_description"))
.WithTags("timeline")
.Produces<TimelineEvidenceView>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status401Unauthorized)
.RequireAuthorization(StellaOpsResourceServerPolicies.TimelineRead);
routes.MapPost("/timeline/events", () =>
Results.Accepted("/timeline/events", new TimelineIngestAcceptedResponse("indexed")))
.WithName($"{routeNamePrefix}_ingest_event")
.WithSummary("Ingest timeline event")
.WithDescription(_t("timelineindexer.timeline.ingest_description"))
.WithTags("timeline")
.Produces<TimelineIngestAcceptedResponse>(StatusCodes.Status202Accepted)
.Produces(StatusCodes.Status401Unauthorized)
.RequireAuthorization(StellaOpsResourceServerPolicies.TimelineWrite);
}
public sealed record TimelineIngestAcceptedResponse(string Status);

View File

@@ -0,0 +1,27 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:10231",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"STELLAOPS_WEBSERVICES_CORS": "true",
"STELLAOPS_WEBSERVICES_CORS_ORIGIN": "https://stella-ops.local,https://stella-ops.local:10000,https://localhost:10000"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:10230;http://localhost:10231",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"STELLAOPS_WEBSERVICES_CORS": "true",
"STELLAOPS_WEBSERVICES_CORS_ORIGIN": "https://stella-ops.local,https://stella-ops.local:10000,https://localhost:10000"
}
}
}
}

View File

@@ -0,0 +1,77 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\__Libraries\StellaOps.TimelineIndexer.Core\StellaOps.TimelineIndexer.Core.csproj" />
<ProjectReference Include="..\__Libraries\StellaOps.TimelineIndexer.Infrastructure\StellaOps.TimelineIndexer.Infrastructure.csproj" />
<ProjectReference Include="..\..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
<ProjectReference Include="..\..\Authority\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="..\..\Router/__Libraries/StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Localization\StellaOps.Localization.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Translations\*.json" />
</ItemGroup>
<Target Name="PackRouterTransportPlugins" AfterTargets="Publish">
<PropertyGroup>
<RouterTransportPluginProject Condition="'$(RouterTransportPluginProject)' == ''">$(MSBuildThisFileDirectory)..\..\Router\__Libraries\StellaOps.Router.Transport.Messaging\StellaOps.Router.Transport.Messaging.csproj</RouterTransportPluginProject>
<MessagingTransportPluginProject Condition="'$(MessagingTransportPluginProject)' == ''">$(MSBuildThisFileDirectory)..\..\Router\__Libraries\StellaOps.Messaging.Transport.Valkey\StellaOps.Messaging.Transport.Valkey.csproj</MessagingTransportPluginProject>
<RouterTransportPluginSourceDir Condition="'$(RouterTransportPluginSourceDir)' == ''">$(MSBuildThisFileDirectory)..\..\Router\__Libraries\StellaOps.Router.Transport.Messaging\bin\$(Configuration)\$(TargetFramework)</RouterTransportPluginSourceDir>
<MessagingTransportPluginSourceDir Condition="'$(MessagingTransportPluginSourceDir)' == ''">$(MSBuildThisFileDirectory)..\..\Router\__Libraries\StellaOps.Messaging.Transport.Valkey\bin\$(Configuration)\$(TargetFramework)</MessagingTransportPluginSourceDir>
</PropertyGroup>
<MSBuild
Projects="$(RouterTransportPluginProject);$(MessagingTransportPluginProject)"
Targets="Restore;Build"
Properties="Configuration=$(Configuration);TargetFramework=$(TargetFramework);CopyLocalLockFileAssemblies=true"
BuildInParallel="false" />
<ItemGroup>
<_RouterTransportPlugins Include="$(RouterTransportPluginSourceDir)\StellaOps*.dll" />
<_RouterTransportPluginMetadata Include="$(RouterTransportPluginSourceDir)\*.deps.json" />
<_MessagingTransportPlugins Include="$(MessagingTransportPluginSourceDir)\StellaOps*.dll" />
<_MessagingTransportPlugins Include="$(MessagingTransportPluginSourceDir)\StackExchange.Redis.dll" Condition="Exists('$(MessagingTransportPluginSourceDir)\StackExchange.Redis.dll')" />
<_MessagingTransportPlugins Include="$(MessagingTransportPluginSourceDir)\Pipelines.Sockets.Unofficial.dll" Condition="Exists('$(MessagingTransportPluginSourceDir)\Pipelines.Sockets.Unofficial.dll')" />
<_MessagingTransportPlugins Include="$(MessagingTransportPluginSourceDir)\System.IO.Hashing.dll" Condition="Exists('$(MessagingTransportPluginSourceDir)\System.IO.Hashing.dll')" />
<_MessagingTransportPluginMetadata Include="$(MessagingTransportPluginSourceDir)\*.deps.json" />
</ItemGroup>
<MakeDir Directories="$(PublishDir)plugins/router/transports" />
<MakeDir Directories="$(PublishDir)plugins/messaging" />
<Copy
SourceFiles="@(_RouterTransportPlugins)"
DestinationFolder="$(PublishDir)plugins/router/transports"
SkipUnchangedFiles="true" />
<Copy
SourceFiles="@(_RouterTransportPluginMetadata)"
DestinationFolder="$(PublishDir)plugins/router/transports"
SkipUnchangedFiles="true" />
<Copy
SourceFiles="@(_MessagingTransportPlugins)"
DestinationFolder="$(PublishDir)plugins/messaging"
SkipUnchangedFiles="true" />
<Copy
SourceFiles="@(_MessagingTransportPluginMetadata)"
DestinationFolder="$(PublishDir)plugins/messaging"
SkipUnchangedFiles="true" />
</Target>
<PropertyGroup Label="StellaOpsReleaseVersion">
<Version>1.0.0-alpha1</Version>
<InformationalVersion>1.0.0-alpha1</InformationalVersion>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,6 @@
@StellaOps.TimelineIndexer.WebService_HostAddress = http://localhost:5194
GET {{StellaOps.TimelineIndexer.WebService_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@@ -0,0 +1,9 @@
# StellaOps.TimelineIndexer.WebService Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| RVM-04 | DONE | Adopted `AddRouterMicroservice()` (plugin-driven transport registration), added `/api/v1/timeline*` alias routes, fixed plugin publish packaging with clean-container restore/build, and validated compose-default Valkey messaging registration + gateway OpenAPI endpoint/schema visibility. |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/TimelineIndexer/StellaOps.TimelineIndexer/StellaOps.TimelineIndexer.WebService/StellaOps.TimelineIndexer.WebService.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -0,0 +1,26 @@
using Microsoft.Extensions.Logging;
using StellaOps.Cryptography.Audit;
using System.Linq;
namespace StellaOps.TimelineIndexer.WebService;
/// <summary>
/// Logs authorization outcomes for timeline read/write operations.
/// </summary>
public sealed class TimelineAuthorizationAuditSink(ILogger<TimelineAuthorizationAuditSink> logger) : IAuthEventSink
{
public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken)
{
logger.LogInformation(
"Auth {Outcome} for {EventType} tenant={Tenant} scopes={Scopes} subject={Subject} correlation={Correlation}",
record.Outcome,
record.EventType,
record.Tenant.Value ?? "<none>",
record.Scopes.Any() ? string.Join(" ", record.Scopes) : "<none>",
record.Subject?.SubjectId.Value ?? "<unknown>",
record.CorrelationId ?? "<none>");
return ValueTask.CompletedTask;
}
}

View File

@@ -0,0 +1,8 @@
{
"_meta": { "locale": "en-US", "namespace": "timelineindexer", "version": "1.0" },
"timelineindexer.timeline.query_description": "Returns timeline events filtered by tenant and optional query parameters.",
"timelineindexer.timeline.get_by_id_description": "Returns a single timeline event by event identifier for the current tenant.",
"timelineindexer.timeline.get_evidence_description": "Returns evidence linkage for a timeline event, including bundle and attestation references.",
"timelineindexer.timeline.ingest_description": "Queues an event ingestion request for asynchronous timeline indexing."
}

View File

@@ -0,0 +1,61 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Information"
}
},
"Authority": {
"ResourceServer": {
"Authority": "https://authority.localtest.me",
"Audiences": [
"api://timeline-indexer"
],
"RequiredTenants": [
"tenant-default"
]
}
},
"Postgres": {
"Timeline": {
"ConnectionString": "Host=localhost;Database=timeline;Username=timeline;Password=timeline;",
"SchemaName": "timeline",
"CommandTimeoutSeconds": 30
}
},
"TimelineIndexer": {
"Router": {
"Enabled": false,
"Region": "local",
"DefaultTimeoutSeconds": 30,
"HeartbeatIntervalSeconds": 10,
"TransportPlugins": {
"Directory": "plugins/router/transports",
"SearchPattern": "StellaOps.Router.Transport.*.dll"
},
"Gateways": [
{
"Host": "router.stella-ops.local",
"Port": 9100,
"TransportType": "Messaging"
}
],
"Messaging": {
"Transport": "valkey",
"PluginDirectory": "plugins/messaging",
"SearchPattern": "StellaOps.Messaging.Transport.*.dll",
"RequestQueueTemplate": "router:requests:{service}",
"ResponseQueueName": "router:responses",
"ConsumerGroup": "timelineindexer",
"RequestTimeout": "30s",
"LeaseDuration": "5m",
"BatchSize": 10,
"HeartbeatInterval": "10s",
"valkey": {
"ConnectionString": "cache.stella-ops.local:6379"
}
}
}
},
"AllowedHosts": "*"
}

View File

@@ -0,0 +1,61 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Authority": {
"ResourceServer": {
"Authority": "https://authority.localtest.me",
"Audiences": [
"api://timeline-indexer"
],
"RequiredTenants": [
"tenant-default"
]
}
},
"Postgres": {
"Timeline": {
"ConnectionString": "Host=localhost;Database=timeline;Username=timeline;Password=timeline;",
"SchemaName": "timeline",
"CommandTimeoutSeconds": 30
}
},
"TimelineIndexer": {
"Router": {
"Enabled": false,
"Region": "local",
"DefaultTimeoutSeconds": 30,
"HeartbeatIntervalSeconds": 10,
"TransportPlugins": {
"Directory": "plugins/router/transports",
"SearchPattern": "StellaOps.Router.Transport.*.dll"
},
"Gateways": [
{
"Host": "router.stella-ops.local",
"Port": 9100,
"TransportType": "Messaging"
}
],
"Messaging": {
"Transport": "valkey",
"PluginDirectory": "plugins/messaging",
"SearchPattern": "StellaOps.Messaging.Transport.*.dll",
"RequestQueueTemplate": "router:requests:{service}",
"ResponseQueueName": "router:responses",
"ConsumerGroup": "timelineindexer",
"RequestTimeout": "30s",
"LeaseDuration": "5m",
"BatchSize": 10,
"HeartbeatInterval": "10s",
"valkey": {
"ConnectionString": "cache.stella-ops.local:6379"
}
}
}
},
"AllowedHosts": "*"
}

View File

@@ -0,0 +1,29 @@
using Microsoft.Extensions.Configuration;
using StellaOps.TimelineIndexer.Core.Abstractions;
using StellaOps.TimelineIndexer.Infrastructure.DependencyInjection;
using StellaOps.TimelineIndexer.Infrastructure.Options;
using StellaOps.TimelineIndexer.Infrastructure.Subscriptions;
using StellaOps.TimelineIndexer.Worker;
using StellaOps.Worker.Health;
var builder = WebApplication.CreateSlimBuilder(args);
builder.Configuration.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true);
builder.Configuration.AddJsonFile("appsettings.Development.json", optional: true, reloadOnChange: true);
builder.Configuration.AddEnvironmentVariables();
builder.Configuration.AddEnvironmentVariables(prefix: "TIMELINE_");
builder.Services.AddTimelineIndexerPostgres(builder.Configuration);
builder.Services.AddOptions<TimelineIngestionOptions>()
.Bind(builder.Configuration.GetSection("Ingestion"));
builder.Services.AddSingleton<TimelineEnvelopeParser>();
builder.Services.AddSingleton<ITimelineEventSubscriber, NatsTimelineEventSubscriber>();
builder.Services.AddSingleton<ITimelineEventSubscriber, RedisTimelineEventSubscriber>();
builder.Services.AddSingleton<ITimelineEventSubscriber, NullTimelineEventSubscriber>();
builder.Services.AddHostedService<TimelineIngestionWorker>();
builder.Services.AddWorkerHealthChecks();
var app = builder.Build();
app.MapWorkerHealthEndpoints();
app.Run();

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"StellaOps.TimelineIndexer.Worker": {
"commandName": "Project",
"dotnetRunMessages": true,
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,41 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<UserSecretsId>dotnet-StellaOps.TimelineIndexer.Worker-f6dbdeac-9eb5-4250-9384-ef93fc70f770</UserSecretsId>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<!-- FrameworkReference Microsoft.AspNetCore.App is provided by Sdk.Web -->
<!-- Microsoft.Extensions.Hosting is provided by Sdk.Worker -->
<ItemGroup>
<ProjectReference Include="..\__Libraries\StellaOps.TimelineIndexer.Core\StellaOps.TimelineIndexer.Core.csproj"/>
<ProjectReference Include="..\__Libraries\StellaOps.TimelineIndexer.Infrastructure\StellaOps.TimelineIndexer.Infrastructure.csproj"/>
<ProjectReference Include="../../__Libraries/StellaOps.Worker.Health/StellaOps.Worker.Health.csproj"/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,8 @@
# StellaOps.TimelineIndexer.Worker Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/TimelineIndexer/StellaOps.TimelineIndexer/StellaOps.TimelineIndexer.Worker/StellaOps.TimelineIndexer.Worker.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -0,0 +1,76 @@
using StellaOps.TimelineIndexer.Core.Abstractions;
using StellaOps.TimelineIndexer.Core.Models;
using System.Collections.Concurrent;
using System.Diagnostics.Metrics;
using System.Linq;
namespace StellaOps.TimelineIndexer.Worker;
/// <summary>
/// Background consumer that reads timeline events from configured subscribers and persists them via the ingestion service.
/// </summary>
public sealed class TimelineIngestionWorker(
IEnumerable<ITimelineEventSubscriber> subscribers,
ITimelineIngestionService ingestionService,
ILogger<TimelineIngestionWorker> logger,
TimeProvider? timeProvider = null) : BackgroundService
{
private static readonly Meter Meter = new("StellaOps.TimelineIndexer", "1.0.0");
private static readonly Counter<long> IngestedCounter = Meter.CreateCounter<long>("timeline.ingested");
private static readonly Counter<long> DuplicateCounter = Meter.CreateCounter<long>("timeline.duplicates");
private static readonly Counter<long> FailedCounter = Meter.CreateCounter<long>("timeline.failed");
private static readonly Histogram<double> LagHistogram = Meter.CreateHistogram<double>("timeline.ingest.lag.seconds");
private readonly IEnumerable<ITimelineEventSubscriber> _subscribers = subscribers;
private readonly ITimelineIngestionService _ingestion = ingestionService;
private readonly ILogger<TimelineIngestionWorker> _logger = logger;
private readonly ConcurrentDictionary<(string tenant, string eventId), byte> _sessionSeen = new();
private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System;
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
var tasks = _subscribers.Select(subscriber => ConsumeAsync(subscriber, stoppingToken)).ToArray();
return Task.WhenAll(tasks);
}
private async Task ConsumeAsync(ITimelineEventSubscriber subscriber, CancellationToken cancellationToken)
{
await foreach (var envelope in subscriber.SubscribeAsync(cancellationToken))
{
var key = (envelope.TenantId, envelope.EventId);
if (!_sessionSeen.TryAdd(key, 0))
{
DuplicateCounter.Add(1);
_logger.LogDebug("Skipped duplicate timeline event {EventId} for tenant {Tenant}", envelope.EventId, envelope.TenantId);
continue;
}
try
{
var result = await _ingestion.IngestAsync(envelope, cancellationToken).ConfigureAwait(false);
if (result.Inserted)
{
IngestedCounter.Add(1);
LagHistogram.Record((_timeProvider.GetUtcNow() - envelope.OccurredAt).TotalSeconds);
_logger.LogInformation("Ingested timeline event {EventId} from {Source} (tenant {Tenant})", envelope.EventId, envelope.Source, envelope.TenantId);
}
else
{
DuplicateCounter.Add(1);
_logger.LogDebug("Store reported duplicate for event {EventId} tenant {Tenant}", envelope.EventId, envelope.TenantId);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Respect shutdown.
break;
}
catch (Exception ex)
{
FailedCounter.Add(1);
_logger.LogError(ex, "Failed to ingest timeline event {EventId} for tenant {Tenant}", envelope.EventId, envelope.TenantId);
}
}
}
}

View File

@@ -0,0 +1,34 @@
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"Postgres": {
"Timeline": {
"ConnectionString": "Host=localhost;Database=timeline;Username=timeline;Password=timeline;",
"SchemaName": "timeline",
"CommandTimeoutSeconds": 30
}
},
"Ingestion": {
"Nats": {
"Enabled": false,
"Url": "nats://localhost:4222",
"Subject": "orch.event",
"QueueGroup": "timeline-indexer",
"Prefetch": 64
},
"Redis": {
"Enabled": false,
"ConnectionString": "localhost:6379",
"Stream": "timeline.events",
"ConsumerGroup": "timeline-indexer",
"ConsumerName": "timeline-worker",
"ValueField": "data",
"MaxBatchSize": 128,
"PollIntervalMilliseconds": 250
}
}
}

View File

@@ -0,0 +1,34 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"Postgres": {
"Timeline": {
"ConnectionString": "Host=localhost;Database=timeline;Username=timeline;Password=timeline;",
"SchemaName": "timeline",
"CommandTimeoutSeconds": 30
}
},
"Ingestion": {
"Nats": {
"Enabled": false,
"Url": "nats://localhost:4222",
"Subject": "orch.event",
"QueueGroup": "timeline-indexer",
"Prefetch": 64
},
"Redis": {
"Enabled": false,
"ConnectionString": "localhost:6379",
"Stream": "timeline.events",
"ConsumerGroup": "timeline-indexer",
"ConsumerName": "timeline-worker",
"ValueField": "data",
"MaxBatchSize": 128,
"PollIntervalMilliseconds": 250
}
}
}

View File

@@ -0,0 +1,16 @@
using StellaOps.TimelineIndexer.Core.Models;
namespace StellaOps.TimelineIndexer.Core.Abstractions;
/// <summary>
/// Persistence contract for timeline event ingestion.
/// Implementations must enforce tenant isolation and idempotency on (tenant_id, event_id).
/// </summary>
public interface ITimelineEventStore
{
/// <summary>
/// Inserts the event atomically (headers, payloads, digests).
/// Must be idempotent on (tenant_id, event_id) and return whether a new row was created.
/// </summary>
Task<bool> InsertAsync(TimelineEventEnvelope envelope, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,12 @@
using StellaOps.TimelineIndexer.Core.Models;
namespace StellaOps.TimelineIndexer.Core.Abstractions;
/// <summary>
/// Abstraction over transport-specific event subscriptions (NATS/Redis/etc.).
/// Implementations yield tenant-scoped timeline event envelopes in publish order.
/// </summary>
public interface ITimelineEventSubscriber
{
IAsyncEnumerable<TimelineEventEnvelope> SubscribeAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,12 @@
using StellaOps.TimelineIndexer.Core.Models;
using StellaOps.TimelineIndexer.Core.Models.Results;
namespace StellaOps.TimelineIndexer.Core.Abstractions;
/// <summary>
/// High-level ingestion service that validates, hashes, and persists timeline events.
/// </summary>
public interface ITimelineIngestionService
{
Task<TimelineIngestResult> IngestAsync(TimelineEventEnvelope envelope, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,10 @@
using StellaOps.TimelineIndexer.Core.Models;
namespace StellaOps.TimelineIndexer.Core.Abstractions;
public interface ITimelineQueryService
{
Task<IReadOnlyList<TimelineEventView>> QueryAsync(string tenantId, TimelineQueryOptions options, CancellationToken cancellationToken = default);
Task<TimelineEventView?> GetAsync(string tenantId, string eventId, CancellationToken cancellationToken = default);
Task<TimelineEvidenceView?> GetEvidenceAsync(string tenantId, string eventId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,10 @@
using StellaOps.TimelineIndexer.Core.Models;
namespace StellaOps.TimelineIndexer.Core.Abstractions;
public interface ITimelineQueryStore
{
Task<IReadOnlyList<TimelineEventView>> QueryAsync(string tenantId, TimelineQueryOptions options, CancellationToken cancellationToken);
Task<TimelineEventView?> GetAsync(string tenantId, string eventId, CancellationToken cancellationToken);
Task<TimelineEvidenceView?> GetEvidenceAsync(string tenantId, string eventId, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,3 @@
namespace StellaOps.TimelineIndexer.Core.Models.Results;
public sealed record TimelineIngestResult(bool Inserted);

View File

@@ -0,0 +1,29 @@
namespace StellaOps.TimelineIndexer.Core.Models;
/// <summary>
/// Canonical ingestion envelope for timeline events.
/// Maps closely to orchestrator/notify envelopes while remaining transport-agnostic.
/// </summary>
public sealed class TimelineEventEnvelope
{
public required string EventId { get; init; }
public required string TenantId { get; init; }
public required string EventType { get; init; }
public required string Source { get; init; }
public required DateTimeOffset OccurredAt { get; init; }
public string? CorrelationId { get; init; }
public string? TraceId { get; init; }
public string? Actor { get; init; }
public string Severity { get; init; } = "info";
public string? PayloadHash { get; set; }
public string RawPayloadJson { get; init; } = "{}";
public string? NormalizedPayloadJson { get; init; }
public IDictionary<string, string>? Attributes { get; init; }
public string? BundleDigest { get; init; }
public Guid? BundleId { get; init; }
public string? AttestationSubject { get; init; }
public string? AttestationDigest { get; init; }
public string? ManifestUri { get; init; }
}

View File

@@ -0,0 +1,28 @@
namespace StellaOps.TimelineIndexer.Core.Models;
/// <summary>
/// Projected timeline event for query responses.
/// </summary>
public sealed class TimelineEventView
{
public required long EventSeq { get; init; }
public required string EventId { get; init; }
public required string TenantId { get; init; }
public required string EventType { get; init; }
public required string Source { get; init; }
public required DateTimeOffset OccurredAt { get; init; }
public required DateTimeOffset ReceivedAt { get; init; }
public string? CorrelationId { get; init; }
public string? TraceId { get; init; }
public string? Actor { get; init; }
public string Severity { get; init; } = "info";
public string? PayloadHash { get; init; }
public IDictionary<string, string>? Attributes { get; init; }
public string? RawPayloadJson { get; init; }
public string? NormalizedPayloadJson { get; init; }
public Guid? BundleId { get; init; }
public string? BundleDigest { get; init; }
public string? AttestationSubject { get; init; }
public string? AttestationDigest { get; init; }
public string? ManifestUri { get; init; }
}

View File

@@ -0,0 +1,16 @@
namespace StellaOps.TimelineIndexer.Core.Models;
/// <summary>
/// Evidence linkage for a timeline event, pointing to sealed bundle/attestation artifacts.
/// </summary>
public sealed class TimelineEvidenceView
{
public required string EventId { get; init; }
public required string TenantId { get; init; }
public Guid? BundleId { get; init; }
public string? BundleDigest { get; init; }
public string? AttestationSubject { get; init; }
public string? AttestationDigest { get; init; }
public string? ManifestUri { get; init; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,16 @@
namespace StellaOps.TimelineIndexer.Core.Models;
/// <summary>
/// Query filters for timeline listing.
/// </summary>
public sealed record TimelineQueryOptions
{
public string? EventType { get; init; }
public string? CorrelationId { get; init; }
public string? TraceId { get; init; }
public string? Source { get; init; }
public string? Severity { get; init; }
public DateTimeOffset? Since { get; init; }
public long? AfterEventSeq { get; init; }
public int Limit { get; init; } = 100;
}

View File

@@ -0,0 +1,49 @@
using StellaOps.TimelineIndexer.Core.Abstractions;
using StellaOps.TimelineIndexer.Core.Models;
using StellaOps.TimelineIndexer.Core.Models.Results;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.TimelineIndexer.Core.Services;
/// <summary>
/// Validates and persists timeline events with deterministic hashing.
/// </summary>
public sealed class TimelineIngestionService(ITimelineEventStore store) : ITimelineIngestionService
{
public async Task<TimelineIngestResult> IngestAsync(TimelineEventEnvelope envelope, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(envelope);
Validate(envelope);
if (string.IsNullOrWhiteSpace(envelope.PayloadHash))
{
envelope.PayloadHash = ComputePayloadHash(envelope.RawPayloadJson);
}
var inserted = await store.InsertAsync(envelope, cancellationToken).ConfigureAwait(false);
return new TimelineIngestResult(inserted);
}
private static void Validate(TimelineEventEnvelope envelope)
{
if (string.IsNullOrWhiteSpace(envelope.EventId))
throw new ArgumentException("event_id is required", nameof(envelope));
if (string.IsNullOrWhiteSpace(envelope.TenantId))
throw new ArgumentException("tenant_id is required", nameof(envelope));
if (string.IsNullOrWhiteSpace(envelope.EventType))
throw new ArgumentException("event_type is required", nameof(envelope));
if (string.IsNullOrWhiteSpace(envelope.Source))
throw new ArgumentException("source is required", nameof(envelope));
if (string.IsNullOrWhiteSpace(envelope.RawPayloadJson))
throw new ArgumentException("raw payload is required", nameof(envelope));
}
internal static string ComputePayloadHash(string payloadJson)
{
var bytes = Encoding.UTF8.GetBytes(payloadJson ?? string.Empty);
var hash = SHA256.HashData(bytes);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}

View File

@@ -0,0 +1,60 @@
using StellaOps.TimelineIndexer.Core.Abstractions;
using StellaOps.TimelineIndexer.Core.Models;
namespace StellaOps.TimelineIndexer.Core.Services;
public sealed class TimelineQueryService(ITimelineQueryStore store) : ITimelineQueryService
{
public Task<IReadOnlyList<TimelineEventView>> QueryAsync(string tenantId, TimelineQueryOptions options, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentNullException.ThrowIfNull(options);
return store.QueryAsync(tenantId, Normalize(options), cancellationToken);
}
public Task<TimelineEventView?> GetAsync(string tenantId, string eventId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(eventId);
return store.GetAsync(tenantId, eventId, cancellationToken);
}
public async Task<TimelineEvidenceView?> GetEvidenceAsync(string tenantId, string eventId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(eventId);
var evidence = await store.GetEvidenceAsync(tenantId, eventId, cancellationToken).ConfigureAwait(false);
if (evidence is null)
{
return null;
}
var manifest = evidence.ManifestUri;
if (manifest is null && evidence.BundleId is not null)
{
manifest = $"bundles/{evidence.BundleId:N}/manifest.dsse.json";
}
var subject = evidence.AttestationSubject ?? evidence.BundleDigest;
return new TimelineEvidenceView
{
EventId = evidence.EventId,
TenantId = evidence.TenantId,
BundleId = evidence.BundleId,
BundleDigest = evidence.BundleDigest,
AttestationSubject = subject,
AttestationDigest = evidence.AttestationDigest,
ManifestUri = manifest,
CreatedAt = evidence.CreatedAt
};
}
private static TimelineQueryOptions Normalize(TimelineQueryOptions options)
{
var limit = options.Limit;
if (limit <= 0) limit = 100;
if (limit > 500) limit = 500;
return options with { Limit = limit };
}
}

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,8 @@
# StellaOps.TimelineIndexer.Core Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/TimelineIndexer/StellaOps.TimelineIndexer/StellaOps.TimelineIndexer.Core/StellaOps.TimelineIndexer.Core.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -0,0 +1,114 @@
-- 001_initial_schema.sql
-- Establishes Timeline Indexer schema, RLS scaffolding, and evidence linkage tables.
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE SCHEMA IF NOT EXISTS timeline;
CREATE SCHEMA IF NOT EXISTS timeline_app;
-- Enforce tenant context for all RLS policies
CREATE OR REPLACE FUNCTION timeline_app.require_current_tenant()
RETURNS text
LANGUAGE plpgsql
AS $$
DECLARE
tenant_text text;
BEGIN
tenant_text := current_setting('app.current_tenant', true);
IF tenant_text IS NULL OR length(tenant_text) = 0 THEN
RAISE EXCEPTION 'app.current_tenant is not set for the current session';
END IF;
RETURN tenant_text;
END;
$$;
-- Severity enum keeps ordering deterministic and compact
DO $$
BEGIN
CREATE TYPE timeline.event_severity AS ENUM ('info', 'notice', 'warn', 'error', 'critical');
EXCEPTION
WHEN duplicate_object THEN NULL;
END
$$;
-- Core event header table (dedupe + ordering)
CREATE TABLE IF NOT EXISTS timeline.timeline_events
(
event_seq bigserial PRIMARY KEY,
event_id text NOT NULL,
tenant_id text NOT NULL,
source text NOT NULL,
event_type text NOT NULL,
occurred_at timestamptz NOT NULL,
received_at timestamptz NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC'),
correlation_id text,
trace_id text,
actor text,
severity timeline.event_severity NOT NULL DEFAULT 'info',
payload_hash text CHECK (payload_hash IS NULL OR payload_hash ~ '^sha256:[0-9a-f]{64}$'),
attributes jsonb NOT NULL DEFAULT '{}'::jsonb,
UNIQUE (tenant_id, event_id)
);
CREATE INDEX IF NOT EXISTS ix_timeline_events_tenant_occurred
ON timeline.timeline_events (tenant_id, occurred_at DESC, event_seq DESC);
CREATE INDEX IF NOT EXISTS ix_timeline_events_type
ON timeline.timeline_events (tenant_id, event_type, occurred_at DESC);
ALTER TABLE timeline.timeline_events ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS timeline_events_isolation ON timeline.timeline_events;
CREATE POLICY timeline_events_isolation
ON timeline.timeline_events
USING (tenant_id = timeline_app.require_current_tenant())
WITH CHECK (tenant_id = timeline_app.require_current_tenant());
-- Raw and normalized payloads per event
CREATE TABLE IF NOT EXISTS timeline.timeline_event_details
(
event_id text NOT NULL,
tenant_id text NOT NULL,
envelope_version text NOT NULL,
raw_payload jsonb NOT NULL,
normalized_payload jsonb,
created_at timestamptz NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC'),
CONSTRAINT fk_event_details FOREIGN KEY (event_id, tenant_id)
REFERENCES timeline.timeline_events (event_id, tenant_id) ON DELETE CASCADE,
PRIMARY KEY (event_id, tenant_id)
);
ALTER TABLE timeline.timeline_event_details ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS timeline_event_details_isolation ON timeline.timeline_event_details;
CREATE POLICY timeline_event_details_isolation
ON timeline.timeline_event_details
USING (tenant_id = timeline_app.require_current_tenant())
WITH CHECK (tenant_id = timeline_app.require_current_tenant());
-- Evidence linkage (bundle/attestation manifests)
CREATE TABLE IF NOT EXISTS timeline.timeline_event_digests
(
digest_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id text NOT NULL,
event_id text NOT NULL,
bundle_id uuid,
bundle_digest text,
attestation_subject text,
attestation_digest text,
manifest_uri text,
created_at timestamptz NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC'),
CONSTRAINT fk_event_digest_event FOREIGN KEY (event_id, tenant_id)
REFERENCES timeline.timeline_events (event_id, tenant_id) ON DELETE CASCADE,
CONSTRAINT ck_bundle_digest_sha CHECK (bundle_digest IS NULL OR bundle_digest ~ '^sha256:[0-9a-f]{64}$'),
CONSTRAINT ck_attestation_digest_sha CHECK (attestation_digest IS NULL OR attestation_digest ~ '^sha256:[0-9a-f]{64}$')
);
CREATE INDEX IF NOT EXISTS ix_timeline_digests_event
ON timeline.timeline_event_digests (tenant_id, event_id);
CREATE INDEX IF NOT EXISTS ix_timeline_digests_bundle
ON timeline.timeline_event_digests (tenant_id, bundle_digest);
ALTER TABLE timeline.timeline_event_digests ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS timeline_event_digests_isolation ON timeline.timeline_event_digests;
CREATE POLICY timeline_event_digests_isolation
ON timeline.timeline_event_digests
USING (tenant_id = timeline_app.require_current_tenant())
WITH CHECK (tenant_id = timeline_app.require_current_tenant());

View File

@@ -0,0 +1,119 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.TimelineIndexer.Core.Abstractions;
using StellaOps.TimelineIndexer.Core.Models;
using StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
using System.Text.Json;
namespace StellaOps.TimelineIndexer.Infrastructure.Db;
/// <summary>
/// Postgres-backed implementation of ITimelineEventStore.
/// </summary>
public sealed class TimelineEventStore(TimelineIndexerDataSource dataSource, ILogger<TimelineEventStore> logger)
: RepositoryBase<TimelineIndexerDataSource>(dataSource, logger), ITimelineEventStore
{
public async Task<bool> InsertAsync(TimelineEventEnvelope envelope, CancellationToken cancellationToken = default)
{
await using var connection = await DataSource.OpenConnectionAsync(envelope.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = TimelineIndexerDbContextFactory.Create(connection, CommandTimeoutSeconds);
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
dbContext.timeline_events.Add(CreateTimelineEvent(envelope));
try
{
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
{
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
return false;
}
dbContext.timeline_event_details.Add(CreateTimelineEventDetail(envelope));
if (HasDigestPayload(envelope))
{
dbContext.timeline_event_digests.Add(CreateTimelineEventDigest(envelope));
}
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
return true;
}
private static timeline_event CreateTimelineEvent(TimelineEventEnvelope envelope)
{
return new timeline_event
{
event_id = envelope.EventId,
tenant_id = envelope.TenantId,
source = envelope.Source,
event_type = envelope.EventType,
occurred_at = envelope.OccurredAt.UtcDateTime,
correlation_id = envelope.CorrelationId,
trace_id = envelope.TraceId,
actor = envelope.Actor,
severity = TimelineEventSeverityExtensions.ParseOrDefault(envelope.Severity),
payload_hash = envelope.PayloadHash,
attributes = envelope.Attributes is null
? "{}"
: JsonSerializer.Serialize(envelope.Attributes)
};
}
private static timeline_event_detail CreateTimelineEventDetail(TimelineEventEnvelope envelope)
{
return new timeline_event_detail
{
event_id = envelope.EventId,
tenant_id = envelope.TenantId,
envelope_version = "orch.event.v1",
raw_payload = envelope.RawPayloadJson,
normalized_payload = envelope.NormalizedPayloadJson
};
}
private static timeline_event_digest CreateTimelineEventDigest(TimelineEventEnvelope envelope)
{
return new timeline_event_digest
{
tenant_id = envelope.TenantId,
event_id = envelope.EventId,
bundle_id = envelope.BundleId,
bundle_digest = envelope.BundleDigest,
attestation_subject = envelope.AttestationSubject,
attestation_digest = envelope.AttestationDigest,
manifest_uri = envelope.ManifestUri
};
}
private static bool HasDigestPayload(TimelineEventEnvelope envelope)
{
return envelope.BundleDigest is not null
|| envelope.AttestationDigest is not null
|| envelope.ManifestUri is not null
|| envelope.BundleId is not null;
}
private static bool IsUniqueViolation(DbUpdateException exception)
{
Exception? current = exception;
while (current is not null)
{
if (current is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation })
{
return true;
}
current = current.InnerException;
}
return false;
}
}

View File

@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore;
using Npgsql;
using StellaOps.TimelineIndexer.Infrastructure.EfCore.CompiledModels;
using StellaOps.TimelineIndexer.Infrastructure.EfCore.Context;
namespace StellaOps.TimelineIndexer.Infrastructure.Db;
internal static class TimelineIndexerDbContextFactory
{
public static TimelineIndexerDbContext Create(NpgsqlConnection connection, int commandTimeoutSeconds)
{
var optionsBuilder = new DbContextOptionsBuilder<TimelineIndexerDbContext>()
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
// Force usage of the static compiled model module for fast startup and deterministic metadata initialization.
optionsBuilder.UseModel(TimelineIndexerDbContextModel.Instance);
var options = optionsBuilder.Options;
return new TimelineIndexerDbContext(options);
}
}

View File

@@ -0,0 +1,48 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Infrastructure.Postgres.Migrations;
using StellaOps.Infrastructure.Postgres.Options;
using System.Reflection;
namespace StellaOps.TimelineIndexer.Infrastructure.Db;
/// <summary>
/// Runs embedded SQL migrations for the Timeline Indexer schema.
/// </summary>
public sealed class TimelineIndexerMigrationRunner
{
private readonly PostgresOptions _options;
private readonly ILogger<TimelineIndexerMigrationRunner> _logger;
private const string ResourcePrefix = "StellaOps.TimelineIndexer.Infrastructure.Db.Migrations";
public TimelineIndexerMigrationRunner(
IOptions<PostgresOptions> options,
ILogger<TimelineIndexerMigrationRunner> logger)
{
_options = options.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Apply all pending migrations from embedded resources.
/// </summary>
public Task<int> RunAsync(CancellationToken cancellationToken = default)
{
var schema = string.IsNullOrWhiteSpace(_options.SchemaName)
? TimelineIndexerDataSource.DefaultSchemaName
: _options.SchemaName!;
var runner = new MigrationRunner(
_options.ConnectionString,
schema,
moduleName: "TimelineIndexer",
_logger);
return runner.RunFromAssemblyAsync(
assembly: Assembly.GetExecutingAssembly(),
resourcePrefix: ResourcePrefix,
cancellationToken);
}
}

View File

@@ -0,0 +1,236 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.TimelineIndexer.Core.Abstractions;
using StellaOps.TimelineIndexer.Core.Models;
using StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
using System.Text.Json;
namespace StellaOps.TimelineIndexer.Infrastructure.Db;
public sealed class TimelineQueryStore(TimelineIndexerDataSource dataSource, ILogger<TimelineQueryStore> logger)
: RepositoryBase<TimelineIndexerDataSource>(dataSource, logger), ITimelineQueryStore
{
public async Task<IReadOnlyList<TimelineEventView>> QueryAsync(string tenantId, TimelineQueryOptions options, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(options);
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = TimelineIndexerDbContextFactory.Create(connection, CommandTimeoutSeconds);
var query = dbContext.timeline_events
.AsNoTracking()
.Where(e => e.tenant_id == tenantId);
if (!string.IsNullOrWhiteSpace(options.EventType))
{
query = query.Where(e => e.event_type == options.EventType);
}
if (!string.IsNullOrWhiteSpace(options.Source))
{
query = query.Where(e => e.source == options.Source);
}
if (!string.IsNullOrWhiteSpace(options.CorrelationId))
{
query = query.Where(e => e.correlation_id == options.CorrelationId);
}
if (!string.IsNullOrWhiteSpace(options.TraceId))
{
query = query.Where(e => e.trace_id == options.TraceId);
}
if (!string.IsNullOrWhiteSpace(options.Severity))
{
if (!TimelineEventSeverityExtensions.TryParse(options.Severity, out var severity))
{
return [];
}
query = query.Where(e => e.severity == severity);
}
if (options.Since is not null)
{
var sinceUtc = options.Since.Value.UtcDateTime;
query = query.Where(e => e.occurred_at >= sinceUtc);
}
if (options.AfterEventSeq is not null)
{
var afterSeq = options.AfterEventSeq.Value;
query = query.Where(e => e.event_seq < afterSeq);
}
var rows = await query
.OrderByDescending(e => e.occurred_at)
.ThenByDescending(e => e.event_seq)
.Take(Math.Clamp(options.Limit, 1, 500))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return rows
.Select(MapEvent)
.ToList();
}
public async Task<TimelineEventView?> GetAsync(string tenantId, string eventId, CancellationToken cancellationToken)
{
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = TimelineIndexerDbContextFactory.Create(connection, CommandTimeoutSeconds);
var eventRow = await dbContext.timeline_events
.AsNoTracking()
.FirstOrDefaultAsync(
e => e.tenant_id == tenantId && e.event_id == eventId,
cancellationToken)
.ConfigureAwait(false);
if (eventRow is null)
{
return null;
}
var detailRow = await dbContext.timeline_event_details
.AsNoTracking()
.FirstOrDefaultAsync(
d => d.tenant_id == tenantId && d.event_id == eventId,
cancellationToken)
.ConfigureAwait(false);
var digestRow = await dbContext.timeline_event_digests
.AsNoTracking()
.Where(d => d.tenant_id == tenantId && d.event_id == eventId)
.OrderByDescending(d => d.created_at)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
return MapEventDetail(eventRow, detailRow, digestRow);
}
public async Task<TimelineEvidenceView?> GetEvidenceAsync(string tenantId, string eventId, CancellationToken cancellationToken)
{
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = TimelineIndexerDbContextFactory.Create(connection, CommandTimeoutSeconds);
var digest = await dbContext.timeline_event_digests
.AsNoTracking()
.Where(d => d.tenant_id == tenantId && d.event_id == eventId)
.OrderByDescending(d => d.created_at)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
return digest is null ? null : MapEvidence(digest);
}
private static TimelineEventView MapEvent(timeline_event row) => new()
{
EventSeq = row.event_seq,
EventId = row.event_id,
TenantId = row.tenant_id,
EventType = row.event_type,
Source = row.source,
OccurredAt = ToUtcOffset(row.occurred_at),
ReceivedAt = ToUtcOffset(row.received_at),
CorrelationId = row.correlation_id,
TraceId = row.trace_id,
Actor = row.actor,
Severity = row.severity.ToWireValue(),
PayloadHash = row.payload_hash
};
private static TimelineEventView MapEventDetail(
timeline_event eventRow,
timeline_event_detail? detailRow,
timeline_event_digest? digestRow)
{
return new TimelineEventView
{
EventSeq = eventRow.event_seq,
EventId = eventRow.event_id,
TenantId = eventRow.tenant_id,
EventType = eventRow.event_type,
Source = eventRow.source,
OccurredAt = ToUtcOffset(eventRow.occurred_at),
ReceivedAt = ToUtcOffset(eventRow.received_at),
CorrelationId = eventRow.correlation_id,
TraceId = eventRow.trace_id,
Actor = eventRow.actor,
Severity = eventRow.severity.ToWireValue(),
PayloadHash = eventRow.payload_hash,
Attributes = DeserializeAttributes(eventRow.attributes),
RawPayloadJson = detailRow?.raw_payload,
NormalizedPayloadJson = detailRow?.normalized_payload,
BundleId = digestRow?.bundle_id,
BundleDigest = digestRow?.bundle_digest,
AttestationSubject = digestRow?.attestation_subject,
AttestationDigest = digestRow?.attestation_digest,
ManifestUri = digestRow?.manifest_uri
};
}
private static TimelineEvidenceView MapEvidence(timeline_event_digest row)
{
var bundleDigest = row.bundle_digest;
var attestationSubject = row.attestation_subject;
if (string.IsNullOrWhiteSpace(attestationSubject))
{
attestationSubject = bundleDigest;
}
var bundleId = row.bundle_id;
var manifestUri = row.manifest_uri;
if (manifestUri is null && bundleId is not null)
{
manifestUri = $"bundles/{bundleId:N}/manifest.dsse.json";
}
return new TimelineEvidenceView
{
EventId = row.event_id,
TenantId = row.tenant_id,
BundleId = bundleId,
BundleDigest = bundleDigest,
AttestationSubject = attestationSubject,
AttestationDigest = row.attestation_digest,
ManifestUri = manifestUri,
CreatedAt = ToUtcOffset(row.created_at)
};
}
private static IDictionary<string, string>? DeserializeAttributes(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
try
{
return JsonSerializer.Deserialize<Dictionary<string, string>>(raw);
}
catch
{
return null;
}
}
private static DateTimeOffset ToUtcOffset(DateTime value)
{
if (value.Kind == DateTimeKind.Utc)
{
return new DateTimeOffset(value, TimeSpan.Zero);
}
if (value.Kind == DateTimeKind.Local)
{
return new DateTimeOffset(value.ToUniversalTime(), TimeSpan.Zero);
}
return new DateTimeOffset(DateTime.SpecifyKind(value, DateTimeKind.Utc), TimeSpan.Zero);
}
}

View File

@@ -0,0 +1,36 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.TimelineIndexer.Core.Abstractions;
using StellaOps.TimelineIndexer.Core.Services;
using StellaOps.TimelineIndexer.Infrastructure.Db;
namespace StellaOps.TimelineIndexer.Infrastructure.DependencyInjection;
/// <summary>
/// Timeline Indexer PostgreSQL service registration helpers.
/// </summary>
public static class ServiceCollectionExtensions
{
private const string DefaultSection = "Postgres:Timeline";
/// <summary>
/// Registers Postgres options, data source, and migration runner for the Timeline Indexer.
/// </summary>
public static IServiceCollection AddTimelineIndexerPostgres(
this IServiceCollection services,
IConfiguration configuration,
string sectionName = DefaultSection)
{
services.Configure<PostgresOptions>(configuration.GetSection(sectionName));
services.AddSingleton<TimelineIndexerDataSource>();
services.AddSingleton<TimelineIndexerMigrationRunner>();
services.AddHostedService<TimelineIndexerMigrationHostedService>();
services.AddScoped<ITimelineEventStore, TimelineEventStore>();
services.AddScoped<ITimelineIngestionService, TimelineIngestionService>();
services.AddScoped<ITimelineQueryStore, TimelineQueryStore>();
services.AddScoped<ITimelineQueryService, TimelineQueryService>();
return services;
}
}

View File

@@ -0,0 +1,23 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.TimelineIndexer.Infrastructure.Db;
namespace StellaOps.TimelineIndexer.Infrastructure.DependencyInjection;
/// <summary>
/// Executes TimelineIndexer schema migrations during application startup.
/// </summary>
internal sealed class TimelineIndexerMigrationHostedService(
TimelineIndexerMigrationRunner migrationRunner,
ILogger<TimelineIndexerMigrationHostedService> logger) : IHostedService
{
public async Task StartAsync(CancellationToken cancellationToken)
{
var applied = await migrationRunner.RunAsync(cancellationToken).ConfigureAwait(false);
logger.LogInformation(
"TimelineIndexer startup migrations completed; applied {AppliedCount} migration(s).",
applied);
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}

View File

@@ -0,0 +1,9 @@
// <auto-generated />
using Microsoft.EntityFrameworkCore.Infrastructure;
using StellaOps.TimelineIndexer.Infrastructure.EfCore.CompiledModels;
using StellaOps.TimelineIndexer.Infrastructure.EfCore.Context;
#pragma warning disable 219, 612, 618
#nullable disable
[assembly: DbContextModel(typeof(TimelineIndexerDbContext), typeof(TimelineIndexerDbContextModel))]

View File

@@ -0,0 +1,48 @@
// <auto-generated />
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using StellaOps.TimelineIndexer.Infrastructure.EfCore.Context;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.CompiledModels
{
[DbContext(typeof(TimelineIndexerDbContext))]
public partial class TimelineIndexerDbContextModel : RuntimeModel
{
private static readonly bool _useOldBehavior31751 =
System.AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue31751", out var enabled31751) && enabled31751;
static TimelineIndexerDbContextModel()
{
var model = new TimelineIndexerDbContextModel();
if (_useOldBehavior31751)
{
model.Initialize();
}
else
{
var thread = new System.Threading.Thread(RunInitialization, 10 * 1024 * 1024);
thread.Start();
thread.Join();
void RunInitialization()
{
model.Initialize();
}
}
model.Customize();
_instance = (TimelineIndexerDbContextModel)model.FinalizeModel();
}
private static TimelineIndexerDbContextModel _instance;
public static IModel Instance => _instance;
partial void Initialize();
partial void Customize();
}
}

View File

@@ -0,0 +1,37 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.CompiledModels
{
public partial class TimelineIndexerDbContextModel
{
private TimelineIndexerDbContextModel()
: base(skipDetectChanges: false, modelId: new Guid("f31ee807-87a4-4417-9dd0-2d0ae1361676"), entityTypeCount: 3)
{
}
partial void Initialize()
{
var timeline_event = Timeline_eventEntityType.Create(this);
var timeline_event_detail = Timeline_event_detailEntityType.Create(this);
var timeline_event_digest = Timeline_event_digestEntityType.Create(this);
Timeline_event_detailEntityType.CreateForeignKey1(timeline_event_detail, timeline_event);
Timeline_event_digestEntityType.CreateForeignKey1(timeline_event_digest, timeline_event);
Timeline_eventEntityType.CreateAnnotations(timeline_event);
Timeline_event_detailEntityType.CreateAnnotations(timeline_event_detail);
Timeline_event_digestEntityType.CreateAnnotations(timeline_event_digest);
AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
AddAnnotation("ProductVersion", "10.0.0");
AddAnnotation("Relational:MaxIdentifierLength", 63);
}
}
}

View File

@@ -0,0 +1,177 @@
// <auto-generated />
using System;
using System.Reflection;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.CompiledModels
{
[EntityFrameworkInternal]
public partial class Timeline_eventEntityType
{
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
{
var runtimeEntityType = model.AddEntityType(
"StellaOps.TimelineIndexer.Infrastructure.EfCore.Models.timeline_event",
typeof(timeline_event),
baseEntityType,
propertyCount: 13,
navigationCount: 2,
namedIndexCount: 3,
keyCount: 2);
var event_seq = runtimeEntityType.AddProperty(
"event_seq",
typeof(long),
propertyInfo: typeof(timeline_event).GetProperty("event_seq", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event).GetField("<event_seq>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
afterSaveBehavior: PropertySaveBehavior.Throw,
sentinel: 0L);
event_seq.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
var actor = runtimeEntityType.AddProperty(
"actor",
typeof(string),
propertyInfo: typeof(timeline_event).GetProperty("actor", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event).GetField("<actor>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
actor.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
var attributes = runtimeEntityType.AddProperty(
"attributes",
typeof(string),
propertyInfo: typeof(timeline_event).GetProperty("attributes", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event).GetField("<attributes>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
attributes.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
attributes.AddAnnotation("Relational:ColumnType", "jsonb");
attributes.AddAnnotation("Relational:DefaultValueSql", "'{}'::jsonb");
var correlation_id = runtimeEntityType.AddProperty(
"correlation_id",
typeof(string),
propertyInfo: typeof(timeline_event).GetProperty("correlation_id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event).GetField("<correlation_id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
correlation_id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
var event_id = runtimeEntityType.AddProperty(
"event_id",
typeof(string),
propertyInfo: typeof(timeline_event).GetProperty("event_id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event).GetField("<event_id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
afterSaveBehavior: PropertySaveBehavior.Throw);
event_id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
var event_type = runtimeEntityType.AddProperty(
"event_type",
typeof(string),
propertyInfo: typeof(timeline_event).GetProperty("event_type", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event).GetField("<event_type>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
event_type.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
var occurred_at = runtimeEntityType.AddProperty(
"occurred_at",
typeof(DateTime),
propertyInfo: typeof(timeline_event).GetProperty("occurred_at", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event).GetField("<occurred_at>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
occurred_at.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
var payload_hash = runtimeEntityType.AddProperty(
"payload_hash",
typeof(string),
propertyInfo: typeof(timeline_event).GetProperty("payload_hash", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event).GetField("<payload_hash>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
payload_hash.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
var received_at = runtimeEntityType.AddProperty(
"received_at",
typeof(DateTime),
propertyInfo: typeof(timeline_event).GetProperty("received_at", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event).GetField("<received_at>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
received_at.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
received_at.AddAnnotation("Relational:DefaultValueSql", "(now() AT TIME ZONE 'UTC'::text)");
var severity = runtimeEntityType.AddProperty(
"severity",
typeof(TimelineEventSeverity),
propertyInfo: typeof(timeline_event).GetProperty("severity", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event).GetField("<severity>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
severity.SetSentinelFromProviderValue(0);
severity.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
severity.AddAnnotation("Relational:ColumnType", "timeline.event_severity");
severity.AddAnnotation("Relational:DefaultValue", TimelineEventSeverity.Info);
var source = runtimeEntityType.AddProperty(
"source",
typeof(string),
propertyInfo: typeof(timeline_event).GetProperty("source", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event).GetField("<source>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
source.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
var tenant_id = runtimeEntityType.AddProperty(
"tenant_id",
typeof(string),
propertyInfo: typeof(timeline_event).GetProperty("tenant_id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event).GetField("<tenant_id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
afterSaveBehavior: PropertySaveBehavior.Throw);
tenant_id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
var trace_id = runtimeEntityType.AddProperty(
"trace_id",
typeof(string),
propertyInfo: typeof(timeline_event).GetProperty("trace_id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event).GetField("<trace_id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
trace_id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
var key = runtimeEntityType.AddKey(
new[] { event_seq });
runtimeEntityType.SetPrimaryKey(key);
key.AddAnnotation("Relational:Name", "timeline_events_pkey");
var key0 = runtimeEntityType.AddKey(
new[] { event_id, tenant_id });
var ix_timeline_events_tenant_occurred = runtimeEntityType.AddIndex(
new[] { tenant_id, occurred_at, event_seq },
name: "ix_timeline_events_tenant_occurred");
var ix_timeline_events_type = runtimeEntityType.AddIndex(
new[] { tenant_id, event_type, occurred_at },
name: "ix_timeline_events_type");
var timeline_events_tenant_id_event_id_key = runtimeEntityType.AddIndex(
new[] { tenant_id, event_id },
name: "timeline_events_tenant_id_event_id_key",
unique: true);
return runtimeEntityType;
}
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
{
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
runtimeEntityType.AddAnnotation("Relational:Schema", "timeline");
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
runtimeEntityType.AddAnnotation("Relational:TableName", "timeline_events");
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
Customize(runtimeEntityType);
}
static partial void Customize(RuntimeEntityType runtimeEntityType);
}
}

View File

@@ -0,0 +1,128 @@
// <auto-generated />
using System;
using System.Reflection;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.CompiledModels
{
[EntityFrameworkInternal]
public partial class Timeline_event_detailEntityType
{
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
{
var runtimeEntityType = model.AddEntityType(
"StellaOps.TimelineIndexer.Infrastructure.EfCore.Models.timeline_event_detail",
typeof(timeline_event_detail),
baseEntityType,
propertyCount: 6,
navigationCount: 1,
foreignKeyCount: 1,
keyCount: 1);
var event_id = runtimeEntityType.AddProperty(
"event_id",
typeof(string),
propertyInfo: typeof(timeline_event_detail).GetProperty("event_id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event_detail).GetField("<event_id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
afterSaveBehavior: PropertySaveBehavior.Throw);
event_id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
var tenant_id = runtimeEntityType.AddProperty(
"tenant_id",
typeof(string),
propertyInfo: typeof(timeline_event_detail).GetProperty("tenant_id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event_detail).GetField("<tenant_id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
afterSaveBehavior: PropertySaveBehavior.Throw);
tenant_id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
var created_at = runtimeEntityType.AddProperty(
"created_at",
typeof(DateTime),
propertyInfo: typeof(timeline_event_detail).GetProperty("created_at", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event_detail).GetField("<created_at>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
created_at.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
created_at.AddAnnotation("Relational:DefaultValueSql", "(now() AT TIME ZONE 'UTC'::text)");
var envelope_version = runtimeEntityType.AddProperty(
"envelope_version",
typeof(string),
propertyInfo: typeof(timeline_event_detail).GetProperty("envelope_version", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event_detail).GetField("<envelope_version>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
envelope_version.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
var normalized_payload = runtimeEntityType.AddProperty(
"normalized_payload",
typeof(string),
propertyInfo: typeof(timeline_event_detail).GetProperty("normalized_payload", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event_detail).GetField("<normalized_payload>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
normalized_payload.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
normalized_payload.AddAnnotation("Relational:ColumnType", "jsonb");
var raw_payload = runtimeEntityType.AddProperty(
"raw_payload",
typeof(string),
propertyInfo: typeof(timeline_event_detail).GetProperty("raw_payload", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event_detail).GetField("<raw_payload>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
raw_payload.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
raw_payload.AddAnnotation("Relational:ColumnType", "jsonb");
var key = runtimeEntityType.AddKey(
new[] { event_id, tenant_id });
runtimeEntityType.SetPrimaryKey(key);
key.AddAnnotation("Relational:Name", "timeline_event_details_pkey");
return runtimeEntityType;
}
public static RuntimeForeignKey CreateForeignKey1(RuntimeEntityType declaringEntityType, RuntimeEntityType principalEntityType)
{
var runtimeForeignKey = declaringEntityType.AddForeignKey(new[] { declaringEntityType.FindProperty("event_id"), declaringEntityType.FindProperty("tenant_id") },
principalEntityType.FindKey(new[] { principalEntityType.FindProperty("event_id"), principalEntityType.FindProperty("tenant_id") }),
principalEntityType,
deleteBehavior: DeleteBehavior.Cascade,
unique: true,
required: true);
var timeline_event = declaringEntityType.AddNavigation("timeline_event",
runtimeForeignKey,
onDependent: true,
typeof(timeline_event),
propertyInfo: typeof(timeline_event_detail).GetProperty("timeline_event", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event_detail).GetField("<timeline_event>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
var timeline_event_detail = principalEntityType.AddNavigation("timeline_event_detail",
runtimeForeignKey,
onDependent: false,
typeof(timeline_event_detail),
propertyInfo: typeof(timeline_event).GetProperty("timeline_event_detail", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event).GetField("<timeline_event_detail>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
runtimeForeignKey.AddAnnotation("Relational:Name", "fk_event_details");
return runtimeForeignKey;
}
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
{
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
runtimeEntityType.AddAnnotation("Relational:Schema", "timeline");
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
runtimeEntityType.AddAnnotation("Relational:TableName", "timeline_event_details");
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
Customize(runtimeEntityType);
}
static partial void Customize(RuntimeEntityType runtimeEntityType);
}
}

View File

@@ -0,0 +1,166 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using System.Reflection;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.CompiledModels
{
[EntityFrameworkInternal]
public partial class Timeline_event_digestEntityType
{
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
{
var runtimeEntityType = model.AddEntityType(
"StellaOps.TimelineIndexer.Infrastructure.EfCore.Models.timeline_event_digest",
typeof(timeline_event_digest),
baseEntityType,
propertyCount: 9,
navigationCount: 1,
foreignKeyCount: 1,
unnamedIndexCount: 1,
namedIndexCount: 2,
keyCount: 1);
var digest_id = runtimeEntityType.AddProperty(
"digest_id",
typeof(Guid),
propertyInfo: typeof(timeline_event_digest).GetProperty("digest_id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event_digest).GetField("<digest_id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
afterSaveBehavior: PropertySaveBehavior.Throw,
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
digest_id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
digest_id.AddAnnotation("Relational:DefaultValueSql", "gen_random_uuid()");
var attestation_digest = runtimeEntityType.AddProperty(
"attestation_digest",
typeof(string),
propertyInfo: typeof(timeline_event_digest).GetProperty("attestation_digest", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event_digest).GetField("<attestation_digest>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
attestation_digest.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
var attestation_subject = runtimeEntityType.AddProperty(
"attestation_subject",
typeof(string),
propertyInfo: typeof(timeline_event_digest).GetProperty("attestation_subject", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event_digest).GetField("<attestation_subject>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
attestation_subject.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
var bundle_digest = runtimeEntityType.AddProperty(
"bundle_digest",
typeof(string),
propertyInfo: typeof(timeline_event_digest).GetProperty("bundle_digest", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event_digest).GetField("<bundle_digest>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
bundle_digest.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
var bundle_id = runtimeEntityType.AddProperty(
"bundle_id",
typeof(Guid?),
propertyInfo: typeof(timeline_event_digest).GetProperty("bundle_id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event_digest).GetField("<bundle_id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
bundle_id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
var created_at = runtimeEntityType.AddProperty(
"created_at",
typeof(DateTime),
propertyInfo: typeof(timeline_event_digest).GetProperty("created_at", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event_digest).GetField("<created_at>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
created_at.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
created_at.AddAnnotation("Relational:DefaultValueSql", "(now() AT TIME ZONE 'UTC'::text)");
var event_id = runtimeEntityType.AddProperty(
"event_id",
typeof(string),
propertyInfo: typeof(timeline_event_digest).GetProperty("event_id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event_digest).GetField("<event_id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
event_id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
var manifest_uri = runtimeEntityType.AddProperty(
"manifest_uri",
typeof(string),
propertyInfo: typeof(timeline_event_digest).GetProperty("manifest_uri", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event_digest).GetField("<manifest_uri>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
manifest_uri.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
var tenant_id = runtimeEntityType.AddProperty(
"tenant_id",
typeof(string),
propertyInfo: typeof(timeline_event_digest).GetProperty("tenant_id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event_digest).GetField("<tenant_id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
tenant_id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
var key = runtimeEntityType.AddKey(
new[] { digest_id });
runtimeEntityType.SetPrimaryKey(key);
key.AddAnnotation("Relational:Name", "timeline_event_digests_pkey");
var index = runtimeEntityType.AddIndex(
new[] { event_id, tenant_id });
var ix_timeline_digests_bundle = runtimeEntityType.AddIndex(
new[] { tenant_id, bundle_digest },
name: "ix_timeline_digests_bundle");
var ix_timeline_digests_event = runtimeEntityType.AddIndex(
new[] { tenant_id, event_id },
name: "ix_timeline_digests_event");
return runtimeEntityType;
}
public static RuntimeForeignKey CreateForeignKey1(RuntimeEntityType declaringEntityType, RuntimeEntityType principalEntityType)
{
var runtimeForeignKey = declaringEntityType.AddForeignKey(new[] { declaringEntityType.FindProperty("event_id"), declaringEntityType.FindProperty("tenant_id") },
principalEntityType.FindKey(new[] { principalEntityType.FindProperty("event_id"), principalEntityType.FindProperty("tenant_id") }),
principalEntityType,
deleteBehavior: DeleteBehavior.Cascade,
required: true);
var timeline_event = declaringEntityType.AddNavigation("timeline_event",
runtimeForeignKey,
onDependent: true,
typeof(timeline_event),
propertyInfo: typeof(timeline_event_digest).GetProperty("timeline_event", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event_digest).GetField("<timeline_event>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
var timeline_event_digests = principalEntityType.AddNavigation("timeline_event_digests",
runtimeForeignKey,
onDependent: false,
typeof(ICollection<timeline_event_digest>),
propertyInfo: typeof(timeline_event).GetProperty("timeline_event_digests", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(timeline_event).GetField("<timeline_event_digests>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
runtimeForeignKey.AddAnnotation("Relational:Name", "fk_event_digest_event");
return runtimeForeignKey;
}
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
{
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
runtimeEntityType.AddAnnotation("Relational:Schema", "timeline");
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
runtimeEntityType.AddAnnotation("Relational:TableName", "timeline_event_digests");
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
Customize(runtimeEntityType);
}
static partial void Customize(RuntimeEntityType runtimeEntityType);
}
}

View File

@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore;
using StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.Context;
public partial class TimelineIndexerDbContext
{
partial void OnModelCreatingPartial(ModelBuilder modelBuilder)
{
modelBuilder.Entity<timeline_event>(entity =>
{
entity.Property(e => e.severity)
.HasColumnType("timeline.event_severity")
.HasDefaultValue(TimelineEventSeverity.Info);
entity.HasOne(e => e.timeline_event_detail)
.WithOne(d => d.timeline_event)
.HasPrincipalKey<timeline_event>(e => new { e.event_id, e.tenant_id })
.HasForeignKey<timeline_event_detail>(d => new { d.event_id, d.tenant_id })
.OnDelete(DeleteBehavior.Cascade)
.HasConstraintName("fk_event_details");
entity.HasMany(e => e.timeline_event_digests)
.WithOne(d => d.timeline_event)
.HasPrincipalKey(e => new { e.event_id, e.tenant_id })
.HasForeignKey(d => new { d.event_id, d.tenant_id })
.OnDelete(DeleteBehavior.Cascade)
.HasConstraintName("fk_event_digest_event");
});
}
}

View File

@@ -0,0 +1,74 @@
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.Context;
public partial class TimelineIndexerDbContext : DbContext
{
public TimelineIndexerDbContext(DbContextOptions<TimelineIndexerDbContext> options)
: base(options)
{
}
public virtual DbSet<timeline_event> timeline_events { get; set; }
public virtual DbSet<timeline_event_detail> timeline_event_details { get; set; }
public virtual DbSet<timeline_event_digest> timeline_event_digests { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder
.HasPostgresEnum("timeline", "event_severity", new[] { "info", "notice", "warn", "error", "critical" })
.HasPostgresExtension("pgcrypto");
modelBuilder.Entity<timeline_event>(entity =>
{
entity.HasKey(e => e.event_seq).HasName("timeline_events_pkey");
entity.ToTable("timeline_events", "timeline");
entity.HasIndex(e => new { e.tenant_id, e.occurred_at, e.event_seq }, "ix_timeline_events_tenant_occurred").IsDescending(false, true, true);
entity.HasIndex(e => new { e.tenant_id, e.event_type, e.occurred_at }, "ix_timeline_events_type").IsDescending(false, false, true);
entity.HasIndex(e => new { e.tenant_id, e.event_id }, "timeline_events_tenant_id_event_id_key").IsUnique();
entity.Property(e => e.attributes)
.HasDefaultValueSql("'{}'::jsonb")
.HasColumnType("jsonb");
entity.Property(e => e.received_at).HasDefaultValueSql("(now() AT TIME ZONE 'UTC'::text)");
});
modelBuilder.Entity<timeline_event_detail>(entity =>
{
entity.HasKey(e => new { e.event_id, e.tenant_id }).HasName("timeline_event_details_pkey");
entity.ToTable("timeline_event_details", "timeline");
entity.Property(e => e.created_at).HasDefaultValueSql("(now() AT TIME ZONE 'UTC'::text)");
entity.Property(e => e.normalized_payload).HasColumnType("jsonb");
entity.Property(e => e.raw_payload).HasColumnType("jsonb");
});
modelBuilder.Entity<timeline_event_digest>(entity =>
{
entity.HasKey(e => e.digest_id).HasName("timeline_event_digests_pkey");
entity.ToTable("timeline_event_digests", "timeline");
entity.HasIndex(e => new { e.tenant_id, e.bundle_digest }, "ix_timeline_digests_bundle");
entity.HasIndex(e => new { e.tenant_id, e.event_id }, "ix_timeline_digests_event");
entity.Property(e => e.digest_id).HasDefaultValueSql("gen_random_uuid()");
entity.Property(e => e.created_at).HasDefaultValueSql("(now() AT TIME ZONE 'UTC'::text)");
});
OnModelCreatingPartial(modelBuilder);
}
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
}

View File

@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.Context;
public sealed class TimelineIndexerDesignTimeDbContextFactory : IDesignTimeDbContextFactory<TimelineIndexerDbContext>
{
private const string DefaultConnectionString = "Host=localhost;Port=55433;Database=postgres;Username=postgres;Password=postgres";
private const string ConnectionStringEnvironmentVariable = "STELLAOPS_TIMELINEINDEXER_EF_CONNECTION";
public TimelineIndexerDbContext CreateDbContext(string[] args)
{
var connectionString = ResolveConnectionString();
var options = new DbContextOptionsBuilder<TimelineIndexerDbContext>()
.UseNpgsql(connectionString)
.Options;
return new TimelineIndexerDbContext(options);
}
private static string ResolveConnectionString()
{
var fromEnvironment = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable);
return string.IsNullOrWhiteSpace(fromEnvironment) ? DefaultConnectionString : fromEnvironment;
}
}

View File

@@ -0,0 +1,24 @@
using NpgsqlTypes;
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
/// <summary>
/// CLR mapping for timeline.event_severity enum in PostgreSQL.
/// </summary>
public enum TimelineEventSeverity
{
[PgName("info")]
Info,
[PgName("notice")]
Notice,
[PgName("warn")]
Warn,
[PgName("error")]
Error,
[PgName("critical")]
Critical
}

View File

@@ -0,0 +1,30 @@
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
internal static class TimelineEventSeverityExtensions
{
public static TimelineEventSeverity ParseOrDefault(string? value)
=> TryParse(value, out var parsed) ? parsed : TimelineEventSeverity.Info;
public static bool TryParse(string? value, out TimelineEventSeverity parsed)
{
parsed = TimelineEventSeverity.Info;
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
return Enum.TryParse(value, ignoreCase: true, out parsed);
}
public static string ToWireValue(this TimelineEventSeverity value)
=> value switch
{
TimelineEventSeverity.Info => "info",
TimelineEventSeverity.Notice => "notice",
TimelineEventSeverity.Warn => "warn",
TimelineEventSeverity.Error => "error",
TimelineEventSeverity.Critical => "critical",
_ => "info"
};
}

View File

@@ -0,0 +1,10 @@
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
public partial class timeline_event
{
public TimelineEventSeverity severity { get; set; } = TimelineEventSeverity.Info;
public virtual timeline_event_detail? timeline_event_detail { get; set; }
public virtual ICollection<timeline_event_digest> timeline_event_digests { get; set; } = new List<timeline_event_digest>();
}

View File

@@ -0,0 +1,31 @@
using System;
using System.Collections.Generic;
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
public partial class timeline_event
{
public long event_seq { get; set; }
public string event_id { get; set; } = null!;
public string tenant_id { get; set; } = null!;
public string source { get; set; } = null!;
public string event_type { get; set; } = null!;
public DateTime occurred_at { get; set; }
public DateTime received_at { get; set; }
public string? correlation_id { get; set; }
public string? trace_id { get; set; }
public string? actor { get; set; }
public string? payload_hash { get; set; }
public string attributes { get; set; } = null!;
}

View File

@@ -0,0 +1,6 @@
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
public partial class timeline_event_detail
{
public virtual timeline_event timeline_event { get; set; } = null!;
}

View File

@@ -0,0 +1,19 @@
using System;
using System.Collections.Generic;
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
public partial class timeline_event_detail
{
public string event_id { get; set; } = null!;
public string tenant_id { get; set; } = null!;
public string envelope_version { get; set; } = null!;
public string raw_payload { get; set; } = null!;
public string? normalized_payload { get; set; }
public DateTime created_at { get; set; }
}

View File

@@ -0,0 +1,6 @@
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
public partial class timeline_event_digest
{
public virtual timeline_event timeline_event { get; set; } = null!;
}

View File

@@ -0,0 +1,25 @@
using System;
using System.Collections.Generic;
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
public partial class timeline_event_digest
{
public Guid digest_id { get; set; }
public string tenant_id { get; set; } = null!;
public string event_id { get; set; } = null!;
public Guid? bundle_id { get; set; }
public string? bundle_digest { get; set; }
public string? attestation_subject { get; set; }
public string? attestation_digest { get; set; }
public string? manifest_uri { get; set; }
public DateTime created_at { get; set; }
}

View File

@@ -0,0 +1,83 @@
using System;
namespace StellaOps.TimelineIndexer.Infrastructure.Options;
/// <summary>
/// Configuration for timeline ingestion transports (NATS, Redis).
/// </summary>
public sealed class TimelineIngestionOptions
{
public NatsIngestionOptions Nats { get; init; } = new();
public RedisIngestionOptions Redis { get; init; } = new();
}
public sealed class NatsIngestionOptions
{
/// <summary>
/// Enables NATS subscription when true.
/// </summary>
public bool Enabled { get; init; }
/// <summary>
/// NATS server URL (e.g., nats://localhost:4222).
/// </summary>
public string Url { get; init; } = "nats://localhost:4222";
/// <summary>
/// Subject to subscribe to for orchestrator events.
/// </summary>
public string Subject { get; init; } = "orch.event";
/// <summary>
/// Queue group for shared subscriptions to preserve ordering per subject.
/// </summary>
public string QueueGroup { get; init; } = "timeline-indexer";
/// <summary>
/// Maximum in-flight messages per subscriber.
/// </summary>
public int Prefetch { get; init; } = 64;
}
public sealed class RedisIngestionOptions
{
/// <summary>
/// Enables Redis Stream subscription when true.
/// </summary>
public bool Enabled { get; init; }
/// <summary>
/// Redis connection string (e.g., localhost:6379 or rediss://...).
/// </summary>
public string ConnectionString { get; init; } = "localhost:6379";
/// <summary>
/// Stream name carrying timeline events.
/// </summary>
public string Stream { get; init; } = "timeline.events";
/// <summary>
/// Consumer group used for ordered consumption.
/// </summary>
public string ConsumerGroup { get; init; } = "timeline-indexer";
/// <summary>
/// Consumer name used when reading from the group.
/// </summary>
public string ConsumerName { get; init; } = Environment.MachineName ?? "timeline-indexer";
/// <summary>
/// Field that contains the JSON payload within the stream entry.
/// </summary>
public string ValueField { get; init; } = "data";
/// <summary>
/// Maximum entries fetched per polling iteration.
/// </summary>
public int MaxBatchSize { get; init; } = 128;
/// <summary>
/// Polling interval in milliseconds when no entries are available.
/// </summary>
public int PollIntervalMilliseconds { get; init; } = 250;
}

View File

@@ -0,0 +1,45 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\StellaOps.TimelineIndexer.Core\StellaOps.TimelineIndexer.Core.csproj"/>
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj"/>
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<EmbeddedResource Include="Db/Migrations/*.sql" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="NATS.Client.Core" />
<PackageReference Include="Npgsql" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="StackExchange.Redis" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,67 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NATS.Client.Core;
using StellaOps.TimelineIndexer.Core.Abstractions;
using StellaOps.TimelineIndexer.Core.Models;
using StellaOps.TimelineIndexer.Infrastructure.Options;
using System.Runtime.CompilerServices;
using System.Text;
namespace StellaOps.TimelineIndexer.Infrastructure.Subscriptions;
/// <summary>
/// NATS-based subscriber that yields orchestrator envelopes for ingestion.
/// </summary>
public sealed class NatsTimelineEventSubscriber : ITimelineEventSubscriber
{
private readonly IOptions<TimelineIngestionOptions> _options;
private readonly TimelineEnvelopeParser _parser;
private readonly ILogger<NatsTimelineEventSubscriber> _logger;
public NatsTimelineEventSubscriber(
IOptions<TimelineIngestionOptions> options,
TimelineEnvelopeParser parser,
ILogger<NatsTimelineEventSubscriber> logger)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_parser = parser ?? throw new ArgumentNullException(nameof(parser));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async IAsyncEnumerable<TimelineEventEnvelope> SubscribeAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var cfg = _options.Value.Nats;
if (!cfg.Enabled)
{
yield break;
}
await using var connection = new NatsConnection(new NatsOpts
{
Url = cfg.Url,
Name = "timeline-indexer"
});
await foreach (var msg in connection.SubscribeAsync<byte[]>(
cfg.Subject,
queueGroup: cfg.QueueGroup,
cancellationToken: cancellationToken))
{
cancellationToken.ThrowIfCancellationRequested();
var json = msg.Data is { Length: > 0 }
? Encoding.UTF8.GetString(msg.Data)
: string.Empty;
if (_parser.TryParse(json, out var envelope, out var reason))
{
yield return envelope;
}
else
{
_logger.LogWarning("Dropped NATS event on {Subject}: {Reason}", cfg.Subject, reason);
}
}
}
}

View File

@@ -0,0 +1,16 @@
using StellaOps.TimelineIndexer.Core.Abstractions;
using StellaOps.TimelineIndexer.Core.Models;
namespace StellaOps.TimelineIndexer.Infrastructure.Subscriptions;
/// <summary>
/// Default no-op subscriber used until transport bindings are configured.
/// Keeps the ingestion worker running without requiring live brokers.
/// </summary>
public sealed class NullTimelineEventSubscriber : ITimelineEventSubscriber
{
public IAsyncEnumerable<TimelineEventEnvelope> SubscribeAsync(CancellationToken cancellationToken = default)
{
return AsyncEnumerable.Empty<TimelineEventEnvelope>();
}
}

View File

@@ -0,0 +1,127 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StackExchange.Redis;
using StellaOps.TimelineIndexer.Core.Abstractions;
using StellaOps.TimelineIndexer.Core.Models;
using StellaOps.TimelineIndexer.Infrastructure.Options;
using System.Runtime.CompilerServices;
namespace StellaOps.TimelineIndexer.Infrastructure.Subscriptions;
/// <summary>
/// Redis Stream subscriber that reads orchestrator events and yields timeline envelopes.
/// </summary>
public sealed class RedisTimelineEventSubscriber : ITimelineEventSubscriber, IAsyncDisposable
{
private readonly IOptions<TimelineIngestionOptions> _options;
private readonly TimelineEnvelopeParser _parser;
private readonly ILogger<RedisTimelineEventSubscriber> _logger;
private ConnectionMultiplexer? _connection;
public RedisTimelineEventSubscriber(
IOptions<TimelineIngestionOptions> options,
TimelineEnvelopeParser parser,
ILogger<RedisTimelineEventSubscriber> logger)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_parser = parser ?? throw new ArgumentNullException(nameof(parser));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async IAsyncEnumerable<TimelineEventEnvelope> SubscribeAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var cfg = _options.Value.Redis;
if (!cfg.Enabled)
{
yield break;
}
_connection = await ConnectionMultiplexer.ConnectAsync(cfg.ConnectionString);
var db = _connection.GetDatabase();
await EnsureGroupAsync(db, cfg, cancellationToken).ConfigureAwait(false);
while (!cancellationToken.IsCancellationRequested)
{
StreamEntry[] entries;
try
{
entries = await db.StreamReadGroupAsync(
cfg.Stream,
cfg.ConsumerGroup,
cfg.ConsumerName,
">",
count: cfg.MaxBatchSize,
flags: CommandFlags.DemandMaster).ConfigureAwait(false);
}
catch (RedisServerException ex) when (ex.Message.Contains("NOGROUP", StringComparison.OrdinalIgnoreCase))
{
await EnsureGroupAsync(db, cfg, cancellationToken).ConfigureAwait(false);
continue;
}
if (entries.Length == 0)
{
await Task.Delay(cfg.PollIntervalMilliseconds, cancellationToken).ConfigureAwait(false);
continue;
}
foreach (var entry in entries)
{
if (!TryGetValue(entry, cfg.ValueField, out var json))
{
_logger.LogWarning("Redis entry {EntryId} missing expected field {Field}", entry.Id, cfg.ValueField);
await db.StreamAcknowledgeAsync(cfg.Stream, cfg.ConsumerGroup, entry.Id).ConfigureAwait(false);
continue;
}
if (_parser.TryParse(json!, out var envelope, out var reason))
{
yield return envelope;
}
else
{
_logger.LogWarning("Redis entry {EntryId} dropped: {Reason}", entry.Id, reason);
}
await db.StreamAcknowledgeAsync(cfg.Stream, cfg.ConsumerGroup, entry.Id).ConfigureAwait(false);
}
}
}
private static async Task EnsureGroupAsync(IDatabase db, RedisIngestionOptions cfg, CancellationToken cancellationToken)
{
try
{
await db.StreamCreateConsumerGroupAsync(cfg.Stream, cfg.ConsumerGroup, "$", true).ConfigureAwait(false);
}
catch (RedisServerException ex) when (ex.Message.Contains("BUSYGROUP", StringComparison.OrdinalIgnoreCase))
{
// Group already exists; nothing to do.
}
}
private static bool TryGetValue(in StreamEntry entry, string fieldName, out string? value)
{
foreach (var nv in entry.Values)
{
if (string.Equals(nv.Name, fieldName, StringComparison.OrdinalIgnoreCase))
{
value = nv.Value.HasValue ? nv.Value.ToString() : null;
return true;
}
}
value = null;
return false;
}
public async ValueTask DisposeAsync()
{
if (_connection is not null)
{
await _connection.DisposeAsync().ConfigureAwait(false);
}
}
}

View File

@@ -0,0 +1,217 @@
using StellaOps.TimelineIndexer.Core.Models;
using System.Text.Json;
namespace StellaOps.TimelineIndexer.Infrastructure.Subscriptions;
/// <summary>
/// Normalises incoming orchestrator/notification envelopes into <see cref="TimelineEventEnvelope"/> instances.
/// </summary>
public sealed class TimelineEnvelopeParser
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = false,
PropertyNameCaseInsensitive = true
};
private readonly TimeProvider _timeProvider;
public TimelineEnvelopeParser(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public bool TryParse(string rawJson, out TimelineEventEnvelope envelope, out string? failureReason)
{
if (string.IsNullOrWhiteSpace(rawJson))
{
envelope = default!;
failureReason = "Payload was empty";
return false;
}
try
{
using var doc = JsonDocument.Parse(rawJson);
var root = doc.RootElement;
var eventId = FirstString(root, "eventId", "event_id", "id", "messageId");
var tenantId = FirstString(root, "tenant", "tenantId", "tenant_id");
var eventType = FirstString(root, "kind", "eventType", "event_type", "type");
var source = FirstString(root, "source", "producer") ?? "unknown";
var correlationId = FirstString(root, "correlationId", "correlation_id");
var traceId = FirstString(root, "traceId", "trace_id");
var actor = ExtractActor(root);
var severity = (FirstString(root, "severity") ?? "info").ToLowerInvariant();
var occurredAt = FirstDateTime(root, "occurredAt", "occurred_at", "timestamp", "ts") ?? _timeProvider.GetUtcNow();
var normalizedPayload = ExtractNormalizedPayload(root);
var attributes = ExtractAttributes(root);
envelope = new TimelineEventEnvelope
{
EventId = eventId ?? throw new InvalidOperationException("event_id is required"),
TenantId = tenantId ?? throw new InvalidOperationException("tenant_id is required"),
EventType = eventType ?? throw new InvalidOperationException("event_type is required"),
Source = source,
OccurredAt = occurredAt,
CorrelationId = correlationId,
TraceId = traceId,
Actor = actor,
Severity = severity,
RawPayloadJson = JsonSerializer.Serialize(root, SerializerOptions),
NormalizedPayloadJson = normalizedPayload,
Attributes = attributes,
BundleDigest = FirstString(root, "bundleDigest"),
BundleId = FirstGuid(root, "bundleId"),
AttestationSubject = FirstString(root, "attestationSubject"),
AttestationDigest = FirstString(root, "attestationDigest"),
ManifestUri = FirstString(root, "manifestUri")
};
failureReason = null;
return true;
}
catch (Exception ex)
{
envelope = default!;
failureReason = ex.Message;
return false;
}
}
private static string? ExtractActor(JsonElement root)
{
if (TryGetProperty(root, "actor", out var actorElement))
{
if (actorElement.ValueKind == JsonValueKind.String)
{
return actorElement.GetString();
}
if (actorElement.ValueKind == JsonValueKind.Object)
{
if (actorElement.TryGetProperty("subject", out var subject) && subject.ValueKind == JsonValueKind.String)
{
return subject.GetString();
}
if (actorElement.TryGetProperty("user", out var user) && user.ValueKind == JsonValueKind.String)
{
return user.GetString();
}
}
}
return null;
}
private static string? ExtractNormalizedPayload(JsonElement root)
{
if (TryGetProperty(root, "payload", out var payload))
{
return JsonSerializer.Serialize(payload, SerializerOptions);
}
if (TryGetProperty(root, "data", out var data) && data.ValueKind is JsonValueKind.Object)
{
return JsonSerializer.Serialize(data, SerializerOptions);
}
return null;
}
private static IDictionary<string, string>? ExtractAttributes(JsonElement root)
{
if (!TryGetProperty(root, "attributes", out var attributes) || attributes.ValueKind != JsonValueKind.Object)
{
return null;
}
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var property in attributes.EnumerateObject())
{
var value = property.Value.ValueKind switch
{
JsonValueKind.String => property.Value.GetString(),
JsonValueKind.Number => property.Value.ToString(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
_ => null
};
if (!string.IsNullOrWhiteSpace(value))
{
dict[property.Name] = value!;
}
}
return dict.Count == 0 ? null : dict;
}
private static bool TryGetProperty(JsonElement root, string name, out JsonElement value)
{
if (root.TryGetProperty(name, out value))
{
return true;
}
foreach (var property in root.EnumerateObject())
{
if (string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase))
{
value = property.Value;
return true;
}
}
value = default;
return false;
}
private static string? FirstString(JsonElement root, params string[] names)
{
foreach (var name in names)
{
if (TryGetProperty(root, name, out var value) && value.ValueKind == JsonValueKind.String)
{
var str = value.GetString();
if (!string.IsNullOrWhiteSpace(str))
{
return str;
}
}
}
return null;
}
private static Guid? FirstGuid(JsonElement root, params string[] names)
{
var text = FirstString(root, names);
if (Guid.TryParse(text, out var guid))
{
return guid;
}
return null;
}
private static DateTimeOffset? FirstDateTime(JsonElement root, params string[] names)
{
foreach (var name in names)
{
if (TryGetProperty(root, name, out var value) && value.ValueKind == JsonValueKind.String)
{
var text = value.GetString();
if (!string.IsNullOrWhiteSpace(text) && DateTimeOffset.TryParse(text, out var dto))
{
return dto;
}
}
}
return null;
}
}

View File

@@ -0,0 +1,14 @@
# StellaOps.TimelineIndexer.Infrastructure Task Board
This board mirrors active sprint tasks for this module.
Source of truth:
- `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`
- `docs/implplan/SPRINT_20260222_063_TimelineIndexer_smallest_webservice_dal_to_efcore.md`
| Task ID | Status | Notes |
| --- | --- | --- |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/TimelineIndexer/StellaOps.TimelineIndexer/StellaOps.TimelineIndexer.Infrastructure/StellaOps.TimelineIndexer.Infrastructure.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| TLI-EF-01 | DONE | EF Core scaffold baseline generated for timeline schema tables. |
| TLI-EF-02 | DONE | `TimelineEventStore` and `TimelineQueryStore` converted from raw SQL/Npgsql commands to EF Core DAL. |
| TLI-EF-03 | DONE | Sequential build/test validation complete and TimelineIndexer docs updated for EF persistence flow. |
| TLI-EF-04 | DONE | Compiled model generated (`EfCore/CompiledModels`) and static module wired via `UseModel(TimelineIndexerDbContextModel.Instance)`. |

View File

@@ -0,0 +1,40 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Connections;
using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
namespace StellaOps.TimelineIndexer.Infrastructure;
/// <summary>
/// PostgreSQL data source for the Timeline Indexer module.
/// Sets the default schema and carries tenant context via app.current_tenant.
/// </summary>
public sealed class TimelineIndexerDataSource : DataSourceBase
{
public const string DefaultSchemaName = "timeline";
public TimelineIndexerDataSource(IOptions<PostgresOptions> options, ILogger<TimelineIndexerDataSource> logger)
: base(EnsureSchema(options.Value), logger)
{
}
protected override string ModuleName => "TimelineIndexer";
protected override void ConfigureDataSourceBuilder(NpgsqlDataSourceBuilder builder)
{
base.ConfigureDataSourceBuilder(builder);
builder.MapEnum<TimelineEventSeverity>("timeline.event_severity");
}
private static PostgresOptions EnsureSchema(PostgresOptions baseOptions)
{
if (string.IsNullOrWhiteSpace(baseOptions.SchemaName))
{
baseOptions.SchemaName = DefaultSchemaName;
}
return baseOptions;
}
}

View File

@@ -6,6 +6,7 @@ using System.Security.Claims;
using System.Text.Encodings.Web;
using FluentAssertions;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
@@ -18,6 +19,7 @@ using StellaOps.Eventing.Models;
using StellaOps.Eventing.Storage;
using StellaOps.Eventing;
using StellaOps.HybridLogicalClock;
using StellaOps.Timeline.WebService.Audit;
using StellaOps.Timeline.WebService.Endpoints;
using Xunit;
@@ -249,6 +251,86 @@ public sealed class TimelineApiIntegrationTests : IClassFixture<TimelineWebAppli
bundleContent.Should().Contain(correlationId);
}
[Fact]
[Trait("Intent", "Operational")]
public async Task UnifiedAudit_PageLoadEndpoints_ReturnSuccess()
{
var statsResponse = await _client.GetAsync("/api/v1/audit/stats");
var eventsResponse = await _client.GetAsync("/api/v1/audit/events?limit=10");
var anomaliesResponse = await _client.GetAsync("/api/v1/audit/anomalies?limit=5");
statsResponse.StatusCode.Should().Be(HttpStatusCode.OK);
eventsResponse.StatusCode.Should().Be(HttpStatusCode.OK);
anomaliesResponse.StatusCode.Should().Be(HttpStatusCode.OK);
}
[Fact]
[Trait("Intent", "Operational")]
public async Task UnifiedAudit_EventByIdAndCorrelationsEndpoints_ReturnData()
{
var eventsResponse = await _client.GetFromJsonAsync<UnifiedAuditEventsPagedResponse>("/api/v1/audit/events?limit=20");
eventsResponse.Should().NotBeNull();
eventsResponse!.Items.Should().NotBeEmpty();
var selected = eventsResponse.Items.First(e => !string.IsNullOrWhiteSpace(e.CorrelationId));
var eventByIdResponse = await _client.GetAsync($"/api/v1/audit/events/{selected.Id}");
eventByIdResponse.StatusCode.Should().Be(HttpStatusCode.OK);
var correlationResponse = await _client.GetAsync($"/api/v1/audit/correlations/{selected.CorrelationId}");
correlationResponse.StatusCode.Should().Be(HttpStatusCode.OK);
}
[Fact]
[Trait("Intent", "Operational")]
public async Task UnifiedAudit_SearchTimeline_ReturnsEntriesForQuery()
{
var response = await _client.GetAsync("/api/v1/audit/timeline/search?q=policy&limit=10");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var entries = await response.Content.ReadFromJsonAsync<IReadOnlyList<UnifiedAuditTimelineEntry>>();
entries.Should().NotBeNull();
entries!.Should().NotBeEmpty();
entries.SelectMany(e => e.Events).Any(e => e.Module == "policy").Should().BeTrue();
}
[Fact]
[Trait("Intent", "Operational")]
public async Task UnifiedAudit_AnomalyAcknowledgeAndExportLifecycle_ReturnSuccess()
{
var anomalies = await _client.GetFromJsonAsync<IReadOnlyList<UnifiedAuditAnomalyAlert>>("/api/v1/audit/anomalies?limit=10");
anomalies.Should().NotBeNull();
anomalies!.Should().NotBeEmpty();
var target = anomalies[0];
var acknowledgeResponse = await _client.PostAsJsonAsync($"/api/v1/audit/anomalies/{target.Id}/acknowledge", new { });
acknowledgeResponse.StatusCode.Should().Be(HttpStatusCode.OK);
var acknowledged = await acknowledgeResponse.Content.ReadFromJsonAsync<UnifiedAuditAnomalyAlert>();
acknowledged.Should().NotBeNull();
acknowledged!.Acknowledged.Should().BeTrue();
var exportRequest = new UnifiedAuditExportRequest
{
Filters = new UnifiedAuditLogFilters
{
Modules = ["policy", "jobengine"]
},
Format = "json",
IncludeDetails = true,
IncludeDiffs = false
};
var exportResponse = await _client.PostAsJsonAsync("/api/v1/audit/export", exportRequest);
exportResponse.StatusCode.Should().Be(HttpStatusCode.OK);
var export = await exportResponse.Content.ReadFromJsonAsync<UnifiedAuditExportResponse>();
export.Should().NotBeNull();
export!.Status.Should().Be("completed");
var exportStatusResponse = await _client.GetAsync($"/api/v1/audit/export/{export.ExportId}");
exportStatusResponse.StatusCode.Should().Be(HttpStatusCode.OK);
}
private async Task SeedEventsAsync(string correlationId, int count)
{
using var scope = _factory.Services.CreateScope();
@@ -328,6 +410,11 @@ public sealed class TimelineWebApplicationFactory : WebApplicationFactory<Stella
services.RemoveAll<ITimelineEventStore>();
services.RemoveAll<ITimelineEventEmitter>();
services.RemoveAll<IHybridLogicalClock>();
services.RemoveAll<IUnifiedAuditEventProvider>();
services.RemoveAll<IConfigureOptions<AuthenticationOptions>>();
services.RemoveAll<IPostConfigureOptions<AuthenticationOptions>>();
services.RemoveAll<IConfigureOptions<JwtBearerOptions>>();
services.RemoveAll<IPostConfigureOptions<JwtBearerOptions>>();
services.AddSingleton<ITimelineEventStore, InMemoryTimelineEventStore>();
services.AddSingleton<ITimelineEventEmitter, NoOpTimelineEventEmitter>();
@@ -337,13 +424,18 @@ public sealed class TimelineWebApplicationFactory : WebApplicationFactory<Stella
"test-node",
new InMemoryHlcStateStore(),
NullLogger<StellaOps.HybridLogicalClock.HybridLogicalClock>.Instance));
services.AddSingleton<IUnifiedAuditEventProvider, InMemoryUnifiedAuditEventProvider>();
// Override authentication with a test handler that always succeeds
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = "TimelineTest";
options.DefaultChallengeScheme = "TimelineTest";
}).AddScheme<AuthenticationSchemeOptions, TimelineTestAuthHandler>("TimelineTest", _ => { });
})
.AddScheme<AuthenticationSchemeOptions, TimelineTestAuthHandler>("TimelineTest", _ => { })
.AddScheme<AuthenticationSchemeOptions, TimelineTestAuthHandler>(
StellaOps.Auth.Abstractions.StellaOpsAuthenticationDefaults.AuthenticationScheme,
_ => { });
});
}
}
@@ -373,6 +465,186 @@ internal sealed class TimelineTestAuthHandler : AuthenticationHandler<Authentica
}
}
internal sealed class InMemoryUnifiedAuditEventProvider : IUnifiedAuditEventProvider
{
private static readonly DateTimeOffset BaseTime = new(2026, 3, 3, 10, 0, 0, TimeSpan.Zero);
private static readonly IReadOnlyList<UnifiedAuditEvent> Events =
[
new UnifiedAuditEvent
{
Id = "policy-evt-0001",
Timestamp = BaseTime.AddMinutes(1),
Module = "policy",
Action = "promote",
Severity = "warning",
Actor = new UnifiedAuditActor
{
Id = "alice",
Name = "Alice Reviewer",
Type = "user",
Email = "alice@example.com"
},
Resource = new UnifiedAuditResource
{
Type = "policy_pack",
Id = "pack-001",
Name = "Base Policy Pack"
},
Description = "Policy promotion queued for stage.",
Details = new Dictionary<string, object?>
{
["packId"] = "pack-001"
},
CorrelationId = "corr-1001",
TenantId = "test-tenant",
Tags = ["policy", "promotion"]
},
new UnifiedAuditEvent
{
Id = "policy-evt-0002",
Timestamp = BaseTime.AddMinutes(2),
Module = "policy",
Action = "approve",
Severity = "info",
Actor = new UnifiedAuditActor
{
Id = "alice",
Name = "Alice Reviewer",
Type = "user",
Email = "alice@example.com"
},
Resource = new UnifiedAuditResource
{
Type = "policy_pack",
Id = "pack-001",
Name = "Base Policy Pack"
},
Description = "Promotion approved.",
Details = new Dictionary<string, object?>
{
["approvalId"] = "approval-001"
},
CorrelationId = "corr-1001",
TenantId = "test-tenant",
Tags = ["policy", "approval"]
},
new UnifiedAuditEvent
{
Id = "jobengine-evt-0001",
Timestamp = BaseTime.AddMinutes(3),
Module = "jobengine",
Action = "fail",
Severity = "error",
Actor = new UnifiedAuditActor
{
Id = "jobengine-system",
Name = "jobengine-system",
Type = "system"
},
Resource = new UnifiedAuditResource
{
Type = "workflow_run",
Id = "run-001"
},
Description = "Workflow run failed due to unreachable target.",
Details = new Dictionary<string, object?>
{
["runId"] = "run-001"
},
CorrelationId = "corr-1001",
TenantId = "test-tenant",
Tags = ["jobengine", "failure"]
},
new UnifiedAuditEvent
{
Id = "authority-evt-0001",
Timestamp = BaseTime.AddMinutes(4),
Module = "authority",
Action = "fail",
Severity = "error",
Actor = new UnifiedAuditActor
{
Id = "authority-system",
Name = "authority-system",
Type = "system"
},
Resource = new UnifiedAuditResource
{
Type = "token",
Id = "tok-001"
},
Description = "Authentication failure during token refresh.",
Details = new Dictionary<string, object?>
{
["reason"] = "invalid_grant"
},
CorrelationId = "corr-auth-01",
TenantId = "test-tenant",
Tags = ["authority", "auth"]
},
new UnifiedAuditEvent
{
Id = "integrations-evt-0001",
Timestamp = BaseTime.AddMinutes(5),
Module = "integrations",
Action = "update",
Severity = "info",
Actor = new UnifiedAuditActor
{
Id = "svc-git",
Name = "svc-git",
Type = "service"
},
Resource = new UnifiedAuditResource
{
Type = "integration",
Id = "gitlab-main"
},
Description = "Integration configuration updated.",
Details = new Dictionary<string, object?>
{
["integrationId"] = "gitlab-main"
},
TenantId = "test-tenant",
Tags = ["integrations"]
},
new UnifiedAuditEvent
{
Id = "sbom-evt-0001",
Timestamp = BaseTime.AddMinutes(6),
Module = "sbom",
Action = "issue",
Severity = "info",
Actor = new UnifiedAuditActor
{
Id = "evidencelocker-system",
Name = "evidencelocker-system",
Type = "system"
},
Resource = new UnifiedAuditResource
{
Type = "evidence_pack",
Id = "pack-9001"
},
Description = "Evidence pack sealed and indexed.",
Details = new Dictionary<string, object?>
{
["packId"] = "pack-9001"
},
CorrelationId = "corr-2001",
TenantId = "test-tenant",
Tags = ["sbom", "evidence"]
}
];
public Task<IReadOnlyList<UnifiedAuditEvent>> GetEventsAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(Events);
}
}
internal sealed class NoOpTimelineEventEmitter : ITimelineEventEmitter
{
public Task<TimelineEvent> EmitAsync<TPayload>(

View File

@@ -0,0 +1,127 @@
using System.Text.Json;
using StellaOps.TimelineIndexer.Core.Models;
using StellaOps.TimelineIndexer.Infrastructure.Subscriptions;
using StellaOps.TimelineIndexer.Core.Abstractions;
using StellaOps.TestKit;
namespace StellaOps.TimelineIndexer.Tests;
/// <summary>
/// Offline integration test that wires the real parser + query store against the golden EB1 sealed bundle fixtures.
/// </summary>
public class EvidenceLinkageIntegrationTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ParsesAndReturnsEvidenceFromSealedBundle()
{
var bundleId = Guid.Parse("11111111-1111-1111-1111-111111111111");
var tenantId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
var merkleRoot = "sha256:c15ab4d1348da9e5000a5d3da50790ea120d865cafb0961845ed6f1e96927596";
var manifestUri = "bundles/11111111111111111111111111111111/manifest.dsse.json";
var manifestPath = ResolveFixturePath("src/__Tests/EvidenceLocker/Bundles/Golden/sealed/manifest.json");
var expectedPath = ResolveFixturePath("src/__Tests/EvidenceLocker/Bundles/Golden/sealed/expected.json");
var manifestJson = await File.ReadAllTextAsync(manifestPath, CancellationToken.None);
var expectedJson = await File.ReadAllTextAsync(expectedPath, CancellationToken.None);
var parser = new TimelineEnvelopeParser();
var ok = parser.TryParse(EnvelopeForManifest(manifestJson), out var envelope, out var reason);
Assert.True(ok, reason);
envelope = new TimelineEventEnvelope
{
EventId = "evt-eb1-demo",
TenantId = tenantId,
EventType = envelope.EventType,
Source = envelope.Source,
OccurredAt = envelope.OccurredAt,
CorrelationId = envelope.CorrelationId,
TraceId = envelope.TraceId,
Actor = envelope.Actor,
Severity = envelope.Severity,
PayloadHash = envelope.PayloadHash,
RawPayloadJson = envelope.RawPayloadJson,
NormalizedPayloadJson = envelope.NormalizedPayloadJson,
Attributes = envelope.Attributes,
BundleId = bundleId,
BundleDigest = merkleRoot,
AttestationSubject = merkleRoot,
AttestationDigest = merkleRoot,
ManifestUri = manifestUri
};
var store = new InMemoryQueryStore(envelope);
var evidence = await store.GetEvidenceAsync(tenantId, envelope.EventId, CancellationToken.None);
Assert.NotNull(evidence);
Assert.Equal(bundleId, evidence!.BundleId);
Assert.Equal(merkleRoot, evidence.BundleDigest);
Assert.Equal(manifestUri, evidence.ManifestUri);
using var doc = JsonDocument.Parse(expectedJson);
var subject = doc.RootElement.GetProperty("subject").GetString();
Assert.Equal(subject, evidence.AttestationSubject);
}
private static string EnvelopeForManifest(string manifestJson)
{
return $@"{{
""eventId"": ""evt-eb1-demo"",
""tenant"": ""aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"",
""kind"": ""export.bundle.sealed"",
""occurredAt"": ""2025-12-04T00:00:00Z"",
""source"": ""evidence-locker"",
""payload"": {{""manifest"": {{""raw"": {manifestJson} }}}},
""bundleId"": ""11111111-1111-1111-1111-111111111111""
}}";
}
private static string ResolveFixturePath(string relative)
{
var baseDir = AppContext.BaseDirectory;
// bin/Debug/net10.0/ -> StellaOps.TimelineIndexer.Tests -> TimelineIndexer -> src -> repo root
var root = Path.GetFullPath(Path.Combine(baseDir, "..", "..", "..", "..", "..", "..", ".."));
return Path.GetFullPath(Path.Combine(root, relative));
}
private sealed class InMemoryQueryStore : ITimelineQueryStore
{
private readonly TimelineEventEnvelope _envelope;
public InMemoryQueryStore(TimelineEventEnvelope envelope)
{
_envelope = envelope;
}
public Task<IReadOnlyList<TimelineEventView>> QueryAsync(string tenantId, TimelineQueryOptions options, CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyList<TimelineEventView>>(Array.Empty<TimelineEventView>());
public Task<TimelineEventView?> GetAsync(string tenantId, string eventId, CancellationToken cancellationToken)
=> Task.FromResult<TimelineEventView?>(null);
public Task<TimelineEvidenceView?> GetEvidenceAsync(string tenantId, string eventId, CancellationToken cancellationToken)
{
if (!string.Equals(tenantId, _envelope.TenantId, StringComparison.OrdinalIgnoreCase) ||
!string.Equals(eventId, _envelope.EventId, StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult<TimelineEvidenceView?>(null);
}
return Task.FromResult<TimelineEvidenceView?>(new TimelineEvidenceView
{
EventId = _envelope.EventId,
TenantId = _envelope.TenantId,
BundleId = _envelope.BundleId,
BundleDigest = _envelope.BundleDigest,
AttestationSubject = _envelope.AttestationSubject ?? _envelope.BundleDigest,
AttestationDigest = _envelope.AttestationDigest,
ManifestUri = _envelope.ManifestUri,
CreatedAt = DateTimeOffset.UtcNow
});
}
}
}

View File

@@ -0,0 +1,41 @@
using System.Collections.Concurrent;
using System.Threading.Channels;
using StellaOps.TimelineIndexer.Core.Abstractions;
using StellaOps.TimelineIndexer.Core.Models;
namespace StellaOps.TimelineIndexer.Tests;
public sealed class InMemoryTimelineEventSubscriber : ITimelineEventSubscriber
{
private readonly Channel<TimelineEventEnvelope> _channel;
public InMemoryTimelineEventSubscriber(IEnumerable<TimelineEventEnvelope>? seed = null)
{
_channel = Channel.CreateUnbounded<TimelineEventEnvelope>(new UnboundedChannelOptions
{
SingleReader = false,
SingleWriter = false
});
if (seed is not null)
{
foreach (var envelope in seed)
{
_channel.Writer.TryWrite(envelope);
}
_channel.Writer.TryComplete();
}
}
public void Enqueue(TimelineEventEnvelope envelope)
{
_channel.Writer.TryWrite(envelope);
}
public void Complete() => _channel.Writer.TryComplete();
public IAsyncEnumerable<TimelineEventEnvelope> SubscribeAsync(CancellationToken cancellationToken = default)
{
return _channel.Reader.ReadAllAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,105 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<UseXunitV3>true</UseXunitV3>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
</ItemGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest"/>
</ItemGroup>
<ItemGroup>
<Using Include="Xunit"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.TimelineIndexer.Core/StellaOps.TimelineIndexer.Core.csproj"/>
<ProjectReference Include="../../__Libraries/StellaOps.TimelineIndexer.Infrastructure/StellaOps.TimelineIndexer.Infrastructure.csproj"/>
<ProjectReference Include="../../StellaOps.TimelineIndexer.Worker/StellaOps.TimelineIndexer.Worker.csproj"/>
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="System"/>
<Using Include="System.Threading.Tasks"/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,8 @@
# StellaOps.TimelineIndexer.Tests Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/TimelineIndexer/StellaOps.TimelineIndexer/StellaOps.TimelineIndexer.Tests/StellaOps.TimelineIndexer.Tests.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -0,0 +1,70 @@
using StellaOps.TimelineIndexer.Infrastructure.Subscriptions;
using StellaOps.TestKit;
namespace StellaOps.TimelineIndexer.Tests;
public class TimelineEnvelopeParserTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Parser_Maps_Required_Fields()
{
const string json = """
{
"eventId": "11111111-1111-1111-1111-111111111111",
"tenant": "tenant-a",
"kind": "scanner.event.report.ready",
"occurredAt": "2025-12-01T12:00:00Z",
"source": "scanner.webservice",
"correlationId": "corr-1",
"traceId": "trace-1",
"attributes": {"key":"value"},
"payload": {"reportId": "report-1"}
}
""";
var parser = new TimelineEnvelopeParser();
var parsed = parser.TryParse(json, out var envelope, out var reason);
Assert.True(parsed, reason);
Assert.Equal("tenant-a", envelope.TenantId);
Assert.Equal("scanner.event.report.ready", envelope.EventType);
Assert.Equal("trace-1", envelope.TraceId);
Assert.Equal("corr-1", envelope.CorrelationId);
Assert.NotNull(envelope.Attributes);
Assert.Equal("value", envelope.Attributes!["key"]);
Assert.NotNull(envelope.RawPayloadJson);
Assert.NotNull(envelope.NormalizedPayloadJson);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Parser_Maps_Evidence_Metadata()
{
const string json = """
{
"eventId": "22222222-2222-2222-2222-222222222222",
"tenantId": "tenant-b",
"kind": "export.bundle.sealed",
"occurredAt": "2025-12-02T01:02:03Z",
"bundleId": "9f34f8c6-7a7c-4d63-9c70-2ae6e8f8c6aa",
"bundleDigest": "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd",
"attestationSubject": "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd",
"attestationDigest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd",
"manifestUri": "bundles/9f34f8c6/manifest.dsse.json"
}
""";
var parser = new TimelineEnvelopeParser();
var parsed = parser.TryParse(json, out var envelope, out var reason);
Assert.True(parsed, reason);
Assert.Equal(Guid.Parse("9f34f8c6-7a7c-4d63-9c70-2ae6e8f8c6aa"), envelope.BundleId);
Assert.Equal("sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd", envelope.BundleDigest);
Assert.Equal("sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd", envelope.AttestationSubject);
Assert.Equal("sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd", envelope.AttestationDigest);
Assert.Equal("bundles/9f34f8c6/manifest.dsse.json", envelope.ManifestUri);
}
}

View File

@@ -0,0 +1,359 @@
// -----------------------------------------------------------------------------
// TimelineIndexerCoreLogicTests.cs
// Sprint: SPRINT_5100_0010_0002_graph_timeline_tests
// Tasks: TIMELINE-5100-001, TIMELINE-5100-002
// Description: L0 Core logic tests and S1 idempotency tests for TimelineIndexer
// -----------------------------------------------------------------------------
using System.Security.Cryptography;
using System.Text;
using FluentAssertions;
using StellaOps.TimelineIndexer.Core.Abstractions;
using StellaOps.TimelineIndexer.Core.Models;
using StellaOps.TimelineIndexer.Core.Services;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.TimelineIndexer.Tests;
/// <summary>
/// L0 Core Logic Tests and S1 Idempotency Tests
/// Task TIMELINE-5100-001: L0 Event parsing (envelope → domain model → stored event)
/// Task TIMELINE-5100-002: S1 Idempotency tests (same event_id, tenant → single insert)
/// </summary>
public sealed class TimelineIndexerCoreLogicTests
{
#region TIMELINE-5100-001: Event Parsing Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Parse_EnvelopeToDomainModel_PreservesAllFields()
{
// Arrange
var store = new CountingStore();
var service = new TimelineIngestionService(store);
var occurredAt = DateTimeOffset.Parse("2025-06-15T14:30:00Z");
var envelope = new TimelineEventEnvelope
{
EventId = "evt-parse-001",
TenantId = "tenant-parse",
EventType = "scan.completed",
Source = "scanner",
OccurredAt = occurredAt,
RawPayloadJson = """{"findings":42,"severity":"high"}"""
};
// Act
await service.IngestAsync(envelope, CancellationToken.None);
// Assert
store.LastEnvelope.Should().NotBeNull();
store.LastEnvelope!.EventId.Should().Be("evt-parse-001");
store.LastEnvelope.TenantId.Should().Be("tenant-parse");
store.LastEnvelope.EventType.Should().Be("scan.completed");
store.LastEnvelope.Source.Should().Be("scanner");
store.LastEnvelope.OccurredAt.Should().Be(occurredAt);
store.LastEnvelope.RawPayloadJson.Should().Be("""{"findings":42,"severity":"high"}""");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Parse_ComputesPayloadHash_WhenMissing()
{
// Arrange
var store = new CountingStore();
var service = new TimelineIngestionService(store);
var payload = """{"status":"ok"}""";
var envelope = new TimelineEventEnvelope
{
EventId = "evt-hash-001",
TenantId = "tenant-hash",
EventType = "test",
Source = "test",
OccurredAt = DateTimeOffset.UtcNow,
RawPayloadJson = payload
// PayloadHash intentionally not set
};
// Act
await service.IngestAsync(envelope, CancellationToken.None);
// Assert
store.LastEnvelope.Should().NotBeNull();
store.LastEnvelope!.PayloadHash.Should().NotBeNullOrEmpty();
store.LastEnvelope.PayloadHash.Should().StartWith("sha256:");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Parse_PayloadHash_IsDeterministic()
{
// Arrange
var store1 = new CountingStore();
var store2 = new CountingStore();
var service1 = new TimelineIngestionService(store1);
var service2 = new TimelineIngestionService(store2);
var payload = """{"deterministic":"test"}""";
var envelope1 = new TimelineEventEnvelope
{
EventId = "evt-det-001",
TenantId = "tenant-det",
EventType = "test",
Source = "test",
OccurredAt = DateTimeOffset.Parse("2025-01-01T00:00:00Z"),
RawPayloadJson = payload
};
var envelope2 = new TimelineEventEnvelope
{
EventId = "evt-det-002",
TenantId = "tenant-det",
EventType = "test",
Source = "test",
OccurredAt = DateTimeOffset.Parse("2025-01-01T00:00:00Z"),
RawPayloadJson = payload
};
// Act
await service1.IngestAsync(envelope1, CancellationToken.None);
await service2.IngestAsync(envelope2, CancellationToken.None);
// Assert - Same payload should produce same hash
store1.LastEnvelope!.PayloadHash.Should().Be(store2.LastEnvelope!.PayloadHash);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Parse_PreservesEvidenceMetadata()
{
// Arrange
var store = new CountingStore();
var service = new TimelineIngestionService(store);
var bundleId = Guid.NewGuid();
var envelope = new TimelineEventEnvelope
{
EventId = "evt-evidence-001",
TenantId = "tenant-evidence",
EventType = "export.bundle.sealed",
Source = "exporter",
OccurredAt = DateTimeOffset.UtcNow,
RawPayloadJson = "{}",
BundleId = bundleId,
BundleDigest = "sha256:bundledigest123",
AttestationSubject = "sha256:attestsubject456",
AttestationDigest = "sha256:attestdigest789",
ManifestUri = $"bundles/{bundleId:N}/manifest.dsse.json"
};
// Act
await service.IngestAsync(envelope, CancellationToken.None);
// Assert
store.LastEnvelope.Should().NotBeNull();
store.LastEnvelope!.BundleId.Should().Be(bundleId);
store.LastEnvelope.BundleDigest.Should().Be("sha256:bundledigest123");
store.LastEnvelope.AttestationSubject.Should().Be("sha256:attestsubject456");
store.LastEnvelope.AttestationDigest.Should().Be("sha256:attestdigest789");
store.LastEnvelope.ManifestUri.Should().Contain(bundleId.ToString("N"));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Parse_DifferentPayloads_ProduceDifferentHashes()
{
// Arrange
var store1 = new CountingStore();
var store2 = new CountingStore();
var service1 = new TimelineIngestionService(store1);
var service2 = new TimelineIngestionService(store2);
var envelope1 = new TimelineEventEnvelope
{
EventId = "evt-diff-001",
TenantId = "tenant-diff",
EventType = "test",
Source = "test",
OccurredAt = DateTimeOffset.UtcNow,
RawPayloadJson = """{"value":1}"""
};
var envelope2 = new TimelineEventEnvelope
{
EventId = "evt-diff-002",
TenantId = "tenant-diff",
EventType = "test",
Source = "test",
OccurredAt = DateTimeOffset.UtcNow,
RawPayloadJson = """{"value":2}"""
};
// Act
await service1.IngestAsync(envelope1, CancellationToken.None);
await service2.IngestAsync(envelope2, CancellationToken.None);
// Assert
store1.LastEnvelope!.PayloadHash.Should().NotBe(store2.LastEnvelope!.PayloadHash);
}
#endregion
#region TIMELINE-5100-002: Idempotency Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Idempotency_SameEventId_SingleInsert()
{
// Arrange
var store = new CountingStore();
var service = new TimelineIngestionService(store);
var envelope = new TimelineEventEnvelope
{
EventId = "evt-idem-001",
TenantId = "tenant-idem",
EventType = "test",
Source = "test",
OccurredAt = DateTimeOffset.UtcNow,
RawPayloadJson = "{}"
};
// Act
var result1 = await service.IngestAsync(envelope, CancellationToken.None);
var result2 = await service.IngestAsync(envelope, CancellationToken.None);
var result3 = await service.IngestAsync(envelope, CancellationToken.None);
// Assert
result1.Inserted.Should().BeTrue("First insert should succeed");
result2.Inserted.Should().BeFalse("Second insert should be idempotent");
result3.Inserted.Should().BeFalse("Third insert should be idempotent");
store.InsertCount.Should().Be(3, "Store receives all calls but returns false for duplicates");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Idempotency_SameEventIdDifferentTenant_BothInsert()
{
// Arrange
var store = new CountingStore();
var service = new TimelineIngestionService(store);
var envelope1 = new TimelineEventEnvelope
{
EventId = "evt-tenant-001",
TenantId = "tenant-A",
EventType = "test",
Source = "test",
OccurredAt = DateTimeOffset.UtcNow,
RawPayloadJson = "{}"
};
var envelope2 = new TimelineEventEnvelope
{
EventId = "evt-tenant-001", // Same event ID
TenantId = "tenant-B", // Different tenant
EventType = "test",
Source = "test",
OccurredAt = DateTimeOffset.UtcNow,
RawPayloadJson = "{}"
};
// Act
var result1 = await service.IngestAsync(envelope1, CancellationToken.None);
var result2 = await service.IngestAsync(envelope2, CancellationToken.None);
// Assert - Same event ID but different tenants should both insert
result1.Inserted.Should().BeTrue();
result2.Inserted.Should().BeTrue();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Idempotency_DifferentEventIdSameTenant_BothInsert()
{
// Arrange
var store = new CountingStore();
var service = new TimelineIngestionService(store);
var envelope1 = new TimelineEventEnvelope
{
EventId = "evt-multi-001",
TenantId = "tenant-multi",
EventType = "test",
Source = "test",
OccurredAt = DateTimeOffset.UtcNow,
RawPayloadJson = "{}"
};
var envelope2 = new TimelineEventEnvelope
{
EventId = "evt-multi-002",
TenantId = "tenant-multi",
EventType = "test",
Source = "test",
OccurredAt = DateTimeOffset.UtcNow,
RawPayloadJson = "{}"
};
// Act
var result1 = await service.IngestAsync(envelope1, CancellationToken.None);
var result2 = await service.IngestAsync(envelope2, CancellationToken.None);
// Assert
result1.Inserted.Should().BeTrue();
result2.Inserted.Should().BeTrue();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Idempotency_ConcurrentDuplicates_OnlyOneInserts()
{
// Arrange
var store = new CountingStore();
var service = new TimelineIngestionService(store);
var envelope = new TimelineEventEnvelope
{
EventId = "evt-concurrent-001",
TenantId = "tenant-concurrent",
EventType = "test",
Source = "test",
OccurredAt = DateTimeOffset.UtcNow,
RawPayloadJson = "{}"
};
// Act - Submit many concurrent duplicates
var tasks = Enumerable.Range(0, 10)
.Select(_ => service.IngestAsync(envelope, CancellationToken.None))
.ToList();
var results = await Task.WhenAll(tasks);
// Assert - Only one should report Inserted = true
results.Count(r => r.Inserted).Should().Be(1, "Only one concurrent insert should succeed");
}
#endregion
#region Helper Classes
private sealed class CountingStore : ITimelineEventStore
{
private readonly HashSet<(string tenant, string id)> _seen = new();
public TimelineEventEnvelope? LastEnvelope { get; private set; }
public int InsertCount { get; private set; }
public Task<bool> InsertAsync(TimelineEventEnvelope envelope, CancellationToken cancellationToken = default)
{
LastEnvelope = envelope;
InsertCount++;
var key = (envelope.TenantId, envelope.EventId);
var inserted = _seen.Add(key);
return Task.FromResult(inserted);
}
}
#endregion
}

View File

@@ -0,0 +1,99 @@
using StellaOps.TimelineIndexer.Core.Abstractions;
using StellaOps.TimelineIndexer.Core.Models;
using StellaOps.TimelineIndexer.Core.Services;
using StellaOps.TestKit;
namespace StellaOps.TimelineIndexer.Tests;
public class TimelineIngestionServiceTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Ingest_ComputesHash_WhenMissing()
{
var store = new FakeStore();
var service = new TimelineIngestionService(store);
var envelope = new TimelineEventEnvelope
{
EventId = "evt-1",
TenantId = "tenant-a",
EventType = "job.completed",
Source = "orchestrator",
OccurredAt = DateTimeOffset.UtcNow,
RawPayloadJson = """{"ok":true}"""
};
var result = await service.IngestAsync(envelope, CancellationToken.None);
Assert.True(result.Inserted);
Assert.Equal("sha256:4062edaf750fb8074e7e83e0c9028c94e32468a8b6f1614774328ef045150f93", store.LastEnvelope?.PayloadHash);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Ingest_IsIdempotent_OnSameEventId()
{
var store = new FakeStore();
var service = new TimelineIngestionService(store);
var envelope = new TimelineEventEnvelope
{
EventId = "evt-dup",
TenantId = "tenant-a",
EventType = "job.completed",
Source = "orchestrator",
OccurredAt = DateTimeOffset.UtcNow,
RawPayloadJson = "{}"
};
var first = await service.IngestAsync(envelope, CancellationToken.None);
var second = await service.IngestAsync(envelope, CancellationToken.None);
Assert.True(first.Inserted);
Assert.False(second.Inserted);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Ingest_PersistsEvidenceMetadata_WhenPresent()
{
var store = new FakeStore();
var service = new TimelineIngestionService(store);
var envelope = new TimelineEventEnvelope
{
EventId = "evt-evidence",
TenantId = "tenant-e",
EventType = "export.bundle.sealed",
Source = "exporter",
OccurredAt = DateTimeOffset.Parse("2025-12-02T01:02:03Z"),
RawPayloadJson = "{}",
BundleId = Guid.Parse("9f34f8c6-7a7c-4d63-9c70-2ae6e8f8c6aa"),
BundleDigest = "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd",
AttestationSubject = "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd",
AttestationDigest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd",
ManifestUri = "bundles/9f34f8c6/manifest.dsse.json"
};
var result = await service.IngestAsync(envelope, CancellationToken.None);
Assert.True(result.Inserted);
Assert.Equal(envelope.BundleId, store.LastEnvelope?.BundleId);
Assert.Equal(envelope.BundleDigest, store.LastEnvelope?.BundleDigest);
Assert.Equal(envelope.AttestationSubject, store.LastEnvelope?.AttestationSubject);
Assert.Equal(envelope.AttestationDigest, store.LastEnvelope?.AttestationDigest);
Assert.Equal(envelope.ManifestUri, store.LastEnvelope?.ManifestUri);
}
private sealed class FakeStore : ITimelineEventStore
{
private readonly HashSet<(string tenant, string id)> _seen = new();
public TimelineEventEnvelope? LastEnvelope { get; private set; }
public Task<bool> InsertAsync(TimelineEventEnvelope envelope, CancellationToken cancellationToken = default)
{
LastEnvelope = envelope;
var key = (envelope.TenantId, envelope.EventId);
var inserted = _seen.Add(key);
return Task.FromResult(inserted);
}
}
}

View File

@@ -0,0 +1,124 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.TimelineIndexer.Core.Abstractions;
using StellaOps.TimelineIndexer.Core.Models;
using StellaOps.TimelineIndexer.Core.Models.Results;
using StellaOps.TimelineIndexer.Core.Services;
using StellaOps.TimelineIndexer.Worker;
using StellaOps.TestKit;
namespace StellaOps.TimelineIndexer.Tests;
public sealed class TimelineIngestionWorkerTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Worker_Ingests_And_Dedupes()
{
var subscriber = new InMemoryTimelineEventSubscriber();
var store = new RecordingStore();
var serviceCollection = new ServiceCollection();
serviceCollection.AddSingleton<ITimelineEventSubscriber>(subscriber);
serviceCollection.AddSingleton<ITimelineEventStore>(store);
serviceCollection.AddSingleton<ITimelineIngestionService, TimelineIngestionService>();
serviceCollection.AddSingleton<IHostedService, TimelineIngestionWorker>();
serviceCollection.AddLogging();
using var host = serviceCollection.BuildServiceProvider();
var hosted = host.GetRequiredService<IHostedService>();
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
await hosted.StartAsync(cts.Token);
var evt = new TimelineEventEnvelope
{
EventId = "evt-1",
TenantId = "tenant-a",
EventType = "test",
Source = "unit",
OccurredAt = DateTimeOffset.UtcNow,
RawPayloadJson = "{}"
};
subscriber.Enqueue(evt);
subscriber.Enqueue(evt); // duplicate
subscriber.Complete();
await Task.Delay(200, cts.Token);
await hosted.StopAsync(cts.Token);
Assert.Equal(1, store.InsertCalls); // duplicate dropped
Assert.Equal("sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", store.LastHash); // hash of "{}"
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Worker_Passes_Evidence_Metadata()
{
var subscriber = new InMemoryTimelineEventSubscriber();
var store = new RecordingStore();
var services = new ServiceCollection();
services.AddSingleton<ITimelineEventSubscriber>(subscriber);
services.AddSingleton<ITimelineEventStore>(store);
services.AddSingleton<ITimelineIngestionService, TimelineIngestionService>();
services.AddSingleton<IHostedService, TimelineIngestionWorker>();
services.AddLogging();
using var provider = services.BuildServiceProvider();
var hosted = provider.GetRequiredService<IHostedService>();
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
await hosted.StartAsync(cts.Token);
var evt = new TimelineEventEnvelope
{
EventId = "evt-evidence-worker",
TenantId = "tenant-e",
EventType = "export.bundle.sealed",
Source = "exporter",
OccurredAt = DateTimeOffset.UtcNow,
RawPayloadJson = "{}",
BundleId = Guid.Parse("9f34f8c6-7a7c-4d63-9c70-2ae6e8f8c6aa"),
BundleDigest = "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd",
AttestationSubject = "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd",
AttestationDigest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd",
ManifestUri = "bundles/9f34f8c6/manifest.dsse.json"
};
subscriber.Enqueue(evt);
subscriber.Complete();
await Task.Delay(200, cts.Token);
await hosted.StopAsync(cts.Token);
Assert.Equal(evt.BundleId, store.LastBundleId);
Assert.Equal(evt.BundleDigest, store.LastBundleDigest);
Assert.Equal(evt.AttestationSubject, store.LastAttestationSubject);
Assert.Equal(evt.AttestationDigest, store.LastAttestationDigest);
Assert.Equal(evt.ManifestUri, store.LastManifestUri);
}
private sealed class RecordingStore : ITimelineEventStore
{
private readonly HashSet<(string tenant, string id)> _seen = new();
public int InsertCalls { get; private set; }
public string? LastHash { get; private set; }
public Guid? LastBundleId { get; private set; }
public string? LastBundleDigest { get; private set; }
public string? LastAttestationSubject { get; private set; }
public string? LastAttestationDigest { get; private set; }
public string? LastManifestUri { get; private set; }
public Task<bool> InsertAsync(TimelineEventEnvelope envelope, CancellationToken cancellationToken = default)
{
InsertCalls++;
LastHash = envelope.PayloadHash;
LastBundleId = envelope.BundleId;
LastBundleDigest = envelope.BundleDigest;
LastAttestationSubject = envelope.AttestationSubject;
LastAttestationDigest = envelope.AttestationDigest;
LastManifestUri = envelope.ManifestUri;
return Task.FromResult(_seen.Add((envelope.TenantId, envelope.EventId)));
}
}
}

View File

@@ -0,0 +1,415 @@
// -----------------------------------------------------------------------------
// TimelineIntegrationTests.cs
// Sprint: SPRINT_5100_0010_0002_graph_timeline_tests
// Task: TIMELINE-5100-006
// Description: Integration tests (event → index → query returns event)
// -----------------------------------------------------------------------------
using FluentAssertions;
using StellaOps.TimelineIndexer.Core.Abstractions;
using StellaOps.TimelineIndexer.Core.Models;
using StellaOps.TimelineIndexer.Core.Services;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.TimelineIndexer.Tests;
/// <summary>
/// Integration Tests
/// Task TIMELINE-5100-006: Event → index → query returns event with correct data
/// </summary>
public sealed class TimelineIntegrationTests
{
#region Full Pipeline Integration
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FullPipeline_IngestThenQuery_ReturnsEvent()
{
// Arrange
var store = new InMemoryTimelineStore();
var ingestionService = new TimelineIngestionService(store);
var queryService = new TimelineQueryService(store);
var envelope = new TimelineEventEnvelope
{
EventId = "evt-integration-001",
TenantId = "tenant-integration",
EventType = "scan.completed",
Source = "scanner",
OccurredAt = DateTimeOffset.Parse("2025-06-15T10:00:00Z"),
RawPayloadJson = """{"findings":5,"severity":"medium"}"""
};
// Act - Ingest
var ingestResult = await ingestionService.IngestAsync(envelope, CancellationToken.None);
// Act - Query
var queryResult = await queryService.GetAsync("tenant-integration", "evt-integration-001", CancellationToken.None);
// Assert
ingestResult.Inserted.Should().BeTrue();
queryResult.Should().NotBeNull();
queryResult!.EventId.Should().Be("evt-integration-001");
queryResult.TenantId.Should().Be("tenant-integration");
queryResult.EventType.Should().Be("scan.completed");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FullPipeline_QueryByTenant_ReturnsOnlyTenantEvents()
{
// Arrange
var store = new InMemoryTimelineStore();
var ingestionService = new TimelineIngestionService(store);
var queryService = new TimelineQueryService(store);
// Ingest events for different tenants
await ingestionService.IngestAsync(new TimelineEventEnvelope
{
EventId = "evt-tenant1-001",
TenantId = "tenant-1",
EventType = "test",
Source = "test",
OccurredAt = DateTimeOffset.UtcNow,
RawPayloadJson = "{}"
}, CancellationToken.None);
await ingestionService.IngestAsync(new TimelineEventEnvelope
{
EventId = "evt-tenant2-001",
TenantId = "tenant-2",
EventType = "test",
Source = "test",
OccurredAt = DateTimeOffset.UtcNow,
RawPayloadJson = "{}"
}, CancellationToken.None);
// Act - Query for tenant-1 events
var options = new TimelineQueryOptions { Limit = 100 };
var tenant1Events = await queryService.QueryAsync("tenant-1", options, CancellationToken.None);
// Assert
tenant1Events.Should().ContainSingle();
tenant1Events.Should().OnlyContain(e => e.TenantId == "tenant-1");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FullPipeline_QueryWithLimit_RespectsLimit()
{
// Arrange
var store = new InMemoryTimelineStore();
var ingestionService = new TimelineIngestionService(store);
var queryService = new TimelineQueryService(store);
// Ingest multiple events
for (int i = 1; i <= 10; i++)
{
await ingestionService.IngestAsync(new TimelineEventEnvelope
{
EventId = $"evt-limit-{i:D3}",
TenantId = "tenant-limit",
EventType = "test",
Source = "test",
OccurredAt = DateTimeOffset.UtcNow.AddMinutes(i),
RawPayloadJson = $"{{\"seq\":{i}}}"
}, CancellationToken.None);
}
// Act - Query with limit
var options = new TimelineQueryOptions { Limit = 5 };
var events = await queryService.QueryAsync("tenant-limit", options, CancellationToken.None);
// Assert
events.Should().HaveCount(5);
}
#endregion
#region Evidence Metadata Integration
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FullPipeline_EvidenceMetadata_RoundTrips()
{
// Arrange
var store = new InMemoryTimelineStore();
var ingestionService = new TimelineIngestionService(store);
var queryService = new TimelineQueryService(store);
var bundleId = Guid.NewGuid();
var envelope = new TimelineEventEnvelope
{
EventId = "evt-evidence-round",
TenantId = "tenant-evidence",
EventType = "export.bundle.sealed",
Source = "exporter",
OccurredAt = DateTimeOffset.UtcNow,
RawPayloadJson = "{}",
BundleId = bundleId,
BundleDigest = "sha256:bundlehash123",
AttestationSubject = "sha256:attestsubject",
AttestationDigest = "sha256:attestdigest",
ManifestUri = $"bundles/{bundleId:N}/manifest.dsse.json"
};
// Act
await ingestionService.IngestAsync(envelope, CancellationToken.None);
var evidence = await queryService.GetEvidenceAsync("tenant-evidence", "evt-evidence-round", CancellationToken.None);
// Assert
evidence.Should().NotBeNull();
evidence!.BundleId.Should().Be(bundleId);
evidence.BundleDigest.Should().Be("sha256:bundlehash123");
evidence.AttestationSubject.Should().Be("sha256:attestsubject");
evidence.AttestationDigest.Should().Be("sha256:attestdigest");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FullPipeline_PayloadHash_IsPersisted()
{
// Arrange
var store = new InMemoryTimelineStore();
var ingestionService = new TimelineIngestionService(store);
var queryService = new TimelineQueryService(store);
var envelope = new TimelineEventEnvelope
{
EventId = "evt-hash-persist",
TenantId = "tenant-hash",
EventType = "test",
Source = "test",
OccurredAt = DateTimeOffset.UtcNow,
RawPayloadJson = """{"data":"test-data"}"""
};
// Act
await ingestionService.IngestAsync(envelope, CancellationToken.None);
var stored = store.GetStoredEnvelope("tenant-hash", "evt-hash-persist");
// Assert
stored.Should().NotBeNull();
stored!.PayloadHash.Should().NotBeNullOrEmpty();
stored.PayloadHash.Should().StartWith("sha256:");
}
#endregion
#region Determinism Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FullPipeline_SameInput_ProducesSameOutput()
{
// Arrange
var store1 = new InMemoryTimelineStore();
var store2 = new InMemoryTimelineStore();
var service1 = new TimelineIngestionService(store1);
var service2 = new TimelineIngestionService(store2);
var timestamp = DateTimeOffset.Parse("2025-06-15T12:00:00Z");
var envelope = new TimelineEventEnvelope
{
EventId = "evt-determinism",
TenantId = "tenant-determinism",
EventType = "test",
Source = "test",
OccurredAt = timestamp,
RawPayloadJson = """{"key":"value"}"""
};
// Act
await service1.IngestAsync(envelope, CancellationToken.None);
await service2.IngestAsync(envelope, CancellationToken.None);
var stored1 = store1.GetStoredEnvelope("tenant-determinism", "evt-determinism");
var stored2 = store2.GetStoredEnvelope("tenant-determinism", "evt-determinism");
// Assert - Both should have identical hashes
stored1!.PayloadHash.Should().Be(stored2!.PayloadHash);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FullPipeline_QueryOrdering_IsDeterministic()
{
// Arrange
var store = new InMemoryTimelineStore();
var ingestionService = new TimelineIngestionService(store);
var queryService = new TimelineQueryService(store);
// Ingest in random order
var events = new[]
{
("evt-03", DateTimeOffset.Parse("2025-06-15T10:30:00Z")),
("evt-01", DateTimeOffset.Parse("2025-06-15T10:00:00Z")),
("evt-05", DateTimeOffset.Parse("2025-06-15T11:00:00Z")),
("evt-02", DateTimeOffset.Parse("2025-06-15T10:15:00Z")),
("evt-04", DateTimeOffset.Parse("2025-06-15T10:45:00Z"))
};
foreach (var (id, occurredAt) in events)
{
await ingestionService.IngestAsync(new TimelineEventEnvelope
{
EventId = id,
TenantId = "tenant-order",
EventType = "test",
Source = "test",
OccurredAt = occurredAt,
RawPayloadJson = "{}"
}, CancellationToken.None);
}
// Act - Query multiple times
var options = new TimelineQueryOptions { Limit = 100 };
var result1 = await queryService.QueryAsync("tenant-order", options, CancellationToken.None);
var result2 = await queryService.QueryAsync("tenant-order", options, CancellationToken.None);
var result3 = await queryService.QueryAsync("tenant-order", options, CancellationToken.None);
// Assert - All queries should return same order
var ids1 = result1.Select(e => e.EventId).ToList();
var ids2 = result2.Select(e => e.EventId).ToList();
var ids3 = result3.Select(e => e.EventId).ToList();
ids1.Should().BeEquivalentTo(ids2, options => options.WithStrictOrdering());
ids2.Should().BeEquivalentTo(ids3, options => options.WithStrictOrdering());
}
#endregion
#region Error Handling
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Query_NonExistentEvent_ReturnsNull()
{
// Arrange
var store = new InMemoryTimelineStore();
var queryService = new TimelineQueryService(store);
// Act
var result = await queryService.GetAsync("tenant-missing", "evt-nonexistent", CancellationToken.None);
// Assert
result.Should().BeNull();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Query_NonExistentTenant_ReturnsEmpty()
{
// Arrange
var store = new InMemoryTimelineStore();
var ingestionService = new TimelineIngestionService(store);
var queryService = new TimelineQueryService(store);
// Ingest an event for a different tenant
await ingestionService.IngestAsync(new TimelineEventEnvelope
{
EventId = "evt-exists",
TenantId = "tenant-exists",
EventType = "test",
Source = "test",
OccurredAt = DateTimeOffset.UtcNow,
RawPayloadJson = "{}"
}, CancellationToken.None);
// Act
var options = new TimelineQueryOptions { Limit = 100 };
var results = await queryService.QueryAsync("tenant-nonexistent", options, CancellationToken.None);
// Assert
results.Should().BeEmpty();
}
#endregion
#region Helper Classes
private sealed class InMemoryTimelineStore : ITimelineEventStore, ITimelineQueryStore
{
private readonly Dictionary<(string tenant, string id), TimelineEventEnvelope> _events = new();
private readonly Dictionary<(string tenant, string id), TimelineEvidenceView> _evidence = new();
public TimelineEventEnvelope? GetStoredEnvelope(string tenant, string eventId)
{
return _events.TryGetValue((tenant, eventId), out var envelope) ? envelope : null;
}
public Task<bool> InsertAsync(TimelineEventEnvelope envelope, CancellationToken cancellationToken = default)
{
var key = (envelope.TenantId, envelope.EventId);
if (_events.ContainsKey(key))
return Task.FromResult(false);
_events[key] = envelope;
// Store evidence if present
if (envelope.BundleId.HasValue)
{
_evidence[key] = new TimelineEvidenceView
{
EventId = envelope.EventId,
TenantId = envelope.TenantId,
BundleId = envelope.BundleId.Value,
BundleDigest = envelope.BundleDigest!,
AttestationSubject = envelope.AttestationSubject!,
AttestationDigest = envelope.AttestationDigest!,
ManifestUri = envelope.ManifestUri,
CreatedAt = DateTimeOffset.UtcNow
};
}
return Task.FromResult(true);
}
public Task<IReadOnlyList<TimelineEventView>> QueryAsync(string tenantId, TimelineQueryOptions options, CancellationToken cancellationToken)
{
var events = _events
.Where(kvp => kvp.Key.tenant == tenantId)
.Select((kvp, idx) => new TimelineEventView
{
EventSeq = idx + 1,
EventId = kvp.Value.EventId,
TenantId = kvp.Value.TenantId,
EventType = kvp.Value.EventType,
Source = kvp.Value.Source,
OccurredAt = kvp.Value.OccurredAt,
ReceivedAt = kvp.Value.OccurredAt
})
.OrderBy(e => e.OccurredAt)
.Take(options.Limit)
.ToList();
return Task.FromResult<IReadOnlyList<TimelineEventView>>(events);
}
public Task<TimelineEventView?> GetAsync(string tenantId, string eventId, CancellationToken cancellationToken)
{
if (_events.TryGetValue((tenantId, eventId), out var envelope))
{
return Task.FromResult<TimelineEventView?>(new TimelineEventView
{
EventSeq = 1,
EventId = envelope.EventId,
TenantId = envelope.TenantId,
EventType = envelope.EventType,
Source = envelope.Source,
OccurredAt = envelope.OccurredAt,
ReceivedAt = envelope.OccurredAt
});
}
return Task.FromResult<TimelineEventView?>(null);
}
public Task<TimelineEvidenceView?> GetEvidenceAsync(string tenantId, string eventId, CancellationToken cancellationToken)
{
return Task.FromResult(_evidence.TryGetValue((tenantId, eventId), out var evidence) ? evidence : null);
}
}
#endregion
}

View File

@@ -0,0 +1,99 @@
using StellaOps.TimelineIndexer.Core.Abstractions;
using StellaOps.TimelineIndexer.Core.Models;
using StellaOps.TimelineIndexer.Core.Services;
using StellaOps.TestKit;
namespace StellaOps.TimelineIndexer.Tests;
public class TimelineQueryServiceTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task QueryAsync_ClampsLimit()
{
var store = new FakeStore();
var service = new TimelineQueryService(store);
var options = new TimelineQueryOptions { Limit = 2000 };
await service.QueryAsync("tenant-a", options, CancellationToken.None);
Assert.Equal(500, store.LastOptions?.Limit);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetAsync_PassesTenantAndId()
{
var store = new FakeStore();
var service = new TimelineQueryService(store);
await service.GetAsync("tenant-1", "evt-1", CancellationToken.None);
Assert.Equal(("tenant-1", "evt-1"), store.LastGet);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetEvidenceAsync_PassesTenantAndId()
{
var store = new FakeStore();
var service = new TimelineQueryService(store);
await service.GetEvidenceAsync("tenant-x", "evt-evidence", CancellationToken.None);
Assert.Equal(("tenant-x", "evt-evidence"), store.LastEvidenceGet);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetEvidenceAsync_FillsManifestUriFromBundleId_WhenMissing()
{
var bundleId = Guid.Parse("11111111-1111-1111-1111-111111111111");
var store = new FakeStore
{
Evidence = new TimelineEvidenceView
{
EventId = "evt",
TenantId = "tenant",
BundleId = bundleId,
BundleDigest = "sha256:deadbeef",
AttestationSubject = "sha256:deadbeef",
AttestationDigest = "sha256:feedface",
ManifestUri = null,
CreatedAt = DateTimeOffset.UtcNow
}
};
var service = new TimelineQueryService(store);
var evidence = await service.GetEvidenceAsync("tenant", "evt", CancellationToken.None);
Assert.NotNull(evidence);
Assert.Equal($"bundles/{bundleId:N}/manifest.dsse.json", evidence!.ManifestUri);
}
private sealed class FakeStore : ITimelineQueryStore
{
public TimelineQueryOptions? LastOptions { get; private set; }
public (string tenant, string id)? LastGet { get; private set; }
public (string tenant, string id)? LastEvidenceGet { get; private set; }
public TimelineEvidenceView? Evidence { get; set; }
public Task<IReadOnlyList<TimelineEventView>> QueryAsync(string tenantId, TimelineQueryOptions options, CancellationToken cancellationToken)
{
LastOptions = options;
return Task.FromResult<IReadOnlyList<TimelineEventView>>(Array.Empty<TimelineEventView>());
}
public Task<TimelineEventView?> GetAsync(string tenantId, string eventId, CancellationToken cancellationToken)
{
LastGet = (tenantId, eventId);
return Task.FromResult<TimelineEventView?>(null);
}
public Task<TimelineEvidenceView?> GetEvidenceAsync(string tenantId, string eventId, CancellationToken cancellationToken)
{
LastEvidenceGet = (tenantId, eventId);
return Task.FromResult(Evidence);
}
}
}

View File

@@ -0,0 +1,67 @@
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.TimelineIndexer.Tests;
public sealed class TimelineSchemaTests
{
private static string FindMigrationPath()
{
var dir = AppContext.BaseDirectory;
for (var i = 0; i < 12 && dir is not null; i++)
{
var candidate = Path.Combine(dir, "Db", "Migrations", "001_initial_schema.sql");
if (File.Exists(candidate))
{
return candidate;
}
var infraCandidate = Path.Combine(dir, "StellaOps.TimelineIndexer.Infrastructure", "Db", "Migrations", "001_initial_schema.sql");
if (File.Exists(infraCandidate))
{
return infraCandidate;
}
dir = Directory.GetParent(dir)?.FullName;
}
throw new FileNotFoundException("Expected migration file was not found after traversing upward.", "(Db/Migrations/001_initial_schema.sql)");
}
private static string ReadMigrationSql()
{
var path = FindMigrationPath();
return File.ReadAllText(path);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void MigrationFile_Exists()
{
var path = FindMigrationPath();
Assert.True(File.Exists(path), $"Migration script missing at {path}");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Migration_EnablesRlsPolicies()
{
var sql = ReadMigrationSql();
Assert.Contains("timeline_app.require_current_tenant", sql, StringComparison.Ordinal);
Assert.Contains("timeline_events_isolation", sql, StringComparison.Ordinal);
Assert.Contains("timeline_event_details_isolation", sql, StringComparison.Ordinal);
Assert.Contains("timeline_event_digests_isolation", sql, StringComparison.Ordinal);
Assert.Contains("ENABLE ROW LEVEL SECURITY", sql, StringComparison.OrdinalIgnoreCase);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Migration_DefinesUniqueEventConstraint()
{
var sql = ReadMigrationSql();
Assert.Contains("UNIQUE (tenant_id, event_id)", sql, StringComparison.Ordinal);
Assert.Contains("event_seq bigserial", sql, StringComparison.Ordinal);
}
}

View File

@@ -0,0 +1,465 @@
// -----------------------------------------------------------------------------
// TimelineWorkerEndToEndTests.cs
// Sprint: SPRINT_5100_0010_0002_graph_timeline_tests
// Tasks: TIMELINE-5100-003, TIMELINE-5100-004, TIMELINE-5100-005
// Description: WK1 Worker end-to-end, retry tests, and OTel correlation tests
// -----------------------------------------------------------------------------
using System.Diagnostics;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using StellaOps.TimelineIndexer.Core.Abstractions;
using StellaOps.TimelineIndexer.Core.Models;
using StellaOps.TimelineIndexer.Core.Services;
using StellaOps.TimelineIndexer.Worker;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.TimelineIndexer.Tests;
/// <summary>
/// WK1 Worker Layer Tests
/// Task TIMELINE-5100-003: Worker end-to-end (subscribe → process → ack)
/// Task TIMELINE-5100-004: Retry tests (transient fail → retry → success)
/// Task TIMELINE-5100-005: OTel correlation (trace_id from event propagates to span)
/// </summary>
public sealed class TimelineWorkerEndToEndTests
{
#region TIMELINE-5100-003: Worker End-to-End Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Worker_SubscribesAndProcessesEvents()
{
// Arrange
var subscriber = new InMemoryTimelineEventSubscriber();
var store = new TrackingStore();
var services = new ServiceCollection();
services.AddSingleton<ITimelineEventSubscriber>(subscriber);
services.AddSingleton<ITimelineEventStore>(store);
services.AddSingleton<ITimelineIngestionService, TimelineIngestionService>();
services.AddSingleton<IHostedService, TimelineIngestionWorker>();
services.AddLogging();
using var provider = services.BuildServiceProvider();
var hosted = provider.GetRequiredService<IHostedService>();
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
// Act
await hosted.StartAsync(cts.Token);
var evt = new TimelineEventEnvelope
{
EventId = "evt-e2e-001",
TenantId = "tenant-e2e",
EventType = "scan.started",
Source = "scanner",
OccurredAt = DateTimeOffset.UtcNow,
RawPayloadJson = """{"scan_id":"scan-123"}"""
};
subscriber.Enqueue(evt);
subscriber.Complete();
await Task.Delay(300, cts.Token);
await hosted.StopAsync(cts.Token);
// Assert
store.ProcessedEvents.Should().ContainSingle(e => e.EventId == "evt-e2e-001");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Worker_ProcessesMultipleEventsInOrder()
{
// Arrange
var subscriber = new InMemoryTimelineEventSubscriber();
var store = new TrackingStore();
var services = new ServiceCollection();
services.AddSingleton<ITimelineEventSubscriber>(subscriber);
services.AddSingleton<ITimelineEventStore>(store);
services.AddSingleton<ITimelineIngestionService, TimelineIngestionService>();
services.AddSingleton<IHostedService, TimelineIngestionWorker>();
services.AddLogging();
using var provider = services.BuildServiceProvider();
var hosted = provider.GetRequiredService<IHostedService>();
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
// Act
await hosted.StartAsync(cts.Token);
for (int i = 1; i <= 5; i++)
{
subscriber.Enqueue(new TimelineEventEnvelope
{
EventId = $"evt-order-{i:D3}",
TenantId = "tenant-order",
EventType = "test",
Source = "test",
OccurredAt = DateTimeOffset.UtcNow.AddSeconds(i),
RawPayloadJson = $"{{\"seq\":{i}}}"
});
}
subscriber.Complete();
await Task.Delay(500, cts.Token);
await hosted.StopAsync(cts.Token);
// Assert - All events should be processed
store.ProcessedEvents.Should().HaveCount(5);
store.ProcessedEvents.Select(e => e.EventId).Should().BeEquivalentTo(
new[] { "evt-order-001", "evt-order-002", "evt-order-003", "evt-order-004", "evt-order-005" });
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Worker_DeduplicatesEvents()
{
// Arrange
var subscriber = new InMemoryTimelineEventSubscriber();
var store = new TrackingStore();
var services = new ServiceCollection();
services.AddSingleton<ITimelineEventSubscriber>(subscriber);
services.AddSingleton<ITimelineEventStore>(store);
services.AddSingleton<ITimelineIngestionService, TimelineIngestionService>();
services.AddSingleton<IHostedService, TimelineIngestionWorker>();
services.AddLogging();
using var provider = services.BuildServiceProvider();
var hosted = provider.GetRequiredService<IHostedService>();
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
// Act
await hosted.StartAsync(cts.Token);
var duplicateEvent = new TimelineEventEnvelope
{
EventId = "evt-dup-001",
TenantId = "tenant-dup",
EventType = "test",
Source = "test",
OccurredAt = DateTimeOffset.UtcNow,
RawPayloadJson = "{}"
};
// Enqueue same event multiple times
subscriber.Enqueue(duplicateEvent);
subscriber.Enqueue(duplicateEvent);
subscriber.Enqueue(duplicateEvent);
subscriber.Complete();
await Task.Delay(300, cts.Token);
await hosted.StopAsync(cts.Token);
// Assert - Only one should be stored
store.UniqueInsertCount.Should().Be(1);
}
#endregion
#region TIMELINE-5100-004: Retry Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Worker_RetriesOnTransientFailure()
{
// Arrange
var subscriber = new InMemoryTimelineEventSubscriber();
var store = new TransientFailureStore(failCount: 2);
var services = new ServiceCollection();
services.AddSingleton<ITimelineEventSubscriber>(subscriber);
services.AddSingleton<ITimelineEventStore>(store);
services.AddSingleton<ITimelineIngestionService, TimelineIngestionService>();
services.AddSingleton<IHostedService, TimelineIngestionWorker>();
services.AddLogging();
using var provider = services.BuildServiceProvider();
var hosted = provider.GetRequiredService<IHostedService>();
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
// Act
await hosted.StartAsync(cts.Token);
subscriber.Enqueue(new TimelineEventEnvelope
{
EventId = "evt-retry-001",
TenantId = "tenant-retry",
EventType = "test",
Source = "test",
OccurredAt = DateTimeOffset.UtcNow,
RawPayloadJson = "{}"
});
subscriber.Complete();
await Task.Delay(1000, cts.Token);
await hosted.StopAsync(cts.Token);
// Assert - Should eventually succeed after retries
store.TotalAttempts.Should().BeGreaterThanOrEqualTo(1);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Worker_ContinuesProcessingAfterFailure()
{
// Arrange
var subscriber = new InMemoryTimelineEventSubscriber();
var store = new SelectiveFailureStore(failEventIds: new[] { "evt-fail" });
var services = new ServiceCollection();
services.AddSingleton<ITimelineEventSubscriber>(subscriber);
services.AddSingleton<ITimelineEventStore>(store);
services.AddSingleton<ITimelineIngestionService, TimelineIngestionService>();
services.AddSingleton<IHostedService, TimelineIngestionWorker>();
services.AddLogging();
using var provider = services.BuildServiceProvider();
var hosted = provider.GetRequiredService<IHostedService>();
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
// Act
await hosted.StartAsync(cts.Token);
subscriber.Enqueue(new TimelineEventEnvelope
{
EventId = "evt-before",
TenantId = "tenant-continue",
EventType = "test",
Source = "test",
OccurredAt = DateTimeOffset.UtcNow,
RawPayloadJson = "{}"
});
subscriber.Enqueue(new TimelineEventEnvelope
{
EventId = "evt-fail", // This will fail
TenantId = "tenant-continue",
EventType = "test",
Source = "test",
OccurredAt = DateTimeOffset.UtcNow,
RawPayloadJson = "{}"
});
subscriber.Enqueue(new TimelineEventEnvelope
{
EventId = "evt-after",
TenantId = "tenant-continue",
EventType = "test",
Source = "test",
OccurredAt = DateTimeOffset.UtcNow,
RawPayloadJson = "{}"
});
subscriber.Complete();
await Task.Delay(500, cts.Token);
await hosted.StopAsync(cts.Token);
// Assert - Events before and after should be processed
store.SuccessfulEvents.Should().Contain("evt-before");
store.SuccessfulEvents.Should().Contain("evt-after");
}
#endregion
#region TIMELINE-5100-005: OTel Correlation Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Worker_PropagatesTraceContext()
{
// Arrange
var capturedActivities = new List<Activity>();
using var listener = new ActivityListener
{
ShouldListenTo = source => source.Name.Contains("TimelineIndexer") || source.Name.Contains("StellaOps"),
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllData,
ActivityStarted = activity => capturedActivities.Add(activity)
};
ActivitySource.AddActivityListener(listener);
var subscriber = new InMemoryTimelineEventSubscriber();
var store = new TrackingStore();
var services = new ServiceCollection();
services.AddSingleton<ITimelineEventSubscriber>(subscriber);
services.AddSingleton<ITimelineEventStore>(store);
services.AddSingleton<ITimelineIngestionService, TimelineIngestionService>();
services.AddSingleton<IHostedService, TimelineIngestionWorker>();
services.AddLogging();
using var provider = services.BuildServiceProvider();
var hosted = provider.GetRequiredService<IHostedService>();
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
// Act
await hosted.StartAsync(cts.Token);
subscriber.Enqueue(new TimelineEventEnvelope
{
EventId = "evt-trace-001",
TenantId = "tenant-trace",
EventType = "test",
Source = "test",
OccurredAt = DateTimeOffset.UtcNow,
RawPayloadJson = "{}"
});
subscriber.Complete();
await Task.Delay(300, cts.Token);
await hosted.StopAsync(cts.Token);
// Assert - If activities were captured, they should have proper context
// Note: If no activities captured, the test documents expected behavior
if (capturedActivities.Any())
{
capturedActivities.Should().AllSatisfy(a =>
{
a.TraceId.Should().NotBe(default(ActivityTraceId));
});
}
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Worker_IncludesTenantInSpanTags()
{
// Arrange
var capturedActivities = new List<Activity>();
using var listener = new ActivityListener
{
ShouldListenTo = source => source.Name.Contains("TimelineIndexer") || source.Name.Contains("StellaOps"),
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllData,
ActivityStarted = activity => capturedActivities.Add(activity)
};
ActivitySource.AddActivityListener(listener);
var subscriber = new InMemoryTimelineEventSubscriber();
var store = new TrackingStore();
var services = new ServiceCollection();
services.AddSingleton<ITimelineEventSubscriber>(subscriber);
services.AddSingleton<ITimelineEventStore>(store);
services.AddSingleton<ITimelineIngestionService, TimelineIngestionService>();
services.AddSingleton<IHostedService, TimelineIngestionWorker>();
services.AddLogging();
using var provider = services.BuildServiceProvider();
var hosted = provider.GetRequiredService<IHostedService>();
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
// Act
await hosted.StartAsync(cts.Token);
subscriber.Enqueue(new TimelineEventEnvelope
{
EventId = "evt-tenant-trace",
TenantId = "tenant-traced",
EventType = "test",
Source = "test",
OccurredAt = DateTimeOffset.UtcNow,
RawPayloadJson = "{}"
});
subscriber.Complete();
await Task.Delay(300, cts.Token);
await hosted.StopAsync(cts.Token);
// Assert - Check for tenant tag in activities
if (capturedActivities.Any())
{
var tenantActivities = capturedActivities
.Where(a => a.Tags.Any(t => t.Key.Contains("tenant")))
.ToList();
// Document expected behavior even if not yet implemented
tenantActivities.Should().NotBeNull();
}
}
#endregion
#region Helper Classes
private sealed class TrackingStore : ITimelineEventStore
{
private readonly HashSet<(string tenant, string id)> _seen = new();
private readonly List<TimelineEventEnvelope> _processed = new();
public IReadOnlyList<TimelineEventEnvelope> ProcessedEvents => _processed;
public int UniqueInsertCount => _seen.Count;
public Task<bool> InsertAsync(TimelineEventEnvelope envelope, CancellationToken cancellationToken = default)
{
_processed.Add(envelope);
var key = (envelope.TenantId, envelope.EventId);
var inserted = _seen.Add(key);
return Task.FromResult(inserted);
}
}
private sealed class TransientFailureStore : ITimelineEventStore
{
private readonly int _failCount;
private int _attempts;
public int TotalAttempts => _attempts;
public TransientFailureStore(int failCount)
{
_failCount = failCount;
}
public Task<bool> InsertAsync(TimelineEventEnvelope envelope, CancellationToken cancellationToken = default)
{
_attempts++;
if (_attempts <= _failCount)
{
throw new InvalidOperationException($"Transient failure {_attempts}");
}
return Task.FromResult(true);
}
}
private sealed class SelectiveFailureStore : ITimelineEventStore
{
private readonly HashSet<string> _failEventIds;
private readonly HashSet<(string tenant, string id)> _seen = new();
private readonly List<string> _successful = new();
public IReadOnlyList<string> SuccessfulEvents => _successful;
public SelectiveFailureStore(string[] failEventIds)
{
_failEventIds = new HashSet<string>(failEventIds);
}
public Task<bool> InsertAsync(TimelineEventEnvelope envelope, CancellationToken cancellationToken = default)
{
if (_failEventIds.Contains(envelope.EventId))
{
throw new InvalidOperationException($"Configured failure for {envelope.EventId}");
}
var key = (envelope.TenantId, envelope.EventId);
var inserted = _seen.Add(key);
if (inserted)
{
_successful.Add(envelope.EventId);
}
return Task.FromResult(inserted);
}
}
#endregion
}

View File

@@ -0,0 +1,12 @@
using StellaOps.TestKit;
namespace StellaOps.TimelineIndexer.Tests;
public class UnitTest1
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Test1()
{
}
}

View File

@@ -0,0 +1,3 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json"
}