Files
git.stella-ops.org/src/AdvisoryAI/StellaOps.AdvisoryAI/Actions/ActionAuditLedger.cs
2026-01-10 20:38:13 +02:00

152 lines
4.7 KiB
C#

// <copyright file="ActionAuditLedger.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Concurrent;
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.AdvisoryAI.Actions;
/// <summary>
/// In-memory action audit ledger for development and testing.
/// In production, this would use PostgreSQL with proper indexing.
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-006
/// </summary>
internal sealed class ActionAuditLedger : IActionAuditLedger
{
private readonly ConcurrentDictionary<string, ActionAuditEntry> _entries = new();
private readonly ILogger<ActionAuditLedger> _logger;
private readonly AuditLedgerOptions _options;
public ActionAuditLedger(
IOptions<AuditLedgerOptions> options,
ILogger<ActionAuditLedger> logger)
{
_options = options?.Value ?? new AuditLedgerOptions();
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public Task RecordAsync(ActionAuditEntry entry, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(entry);
_entries[entry.EntryId] = entry;
_logger.LogDebug(
"Recorded audit entry {EntryId}: {ActionType} by {Actor} -> {Outcome}",
entry.EntryId, entry.ActionType, entry.Actor, entry.Outcome);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<ImmutableArray<ActionAuditEntry>> QueryAsync(
ActionAuditQuery query,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(query);
var entries = _entries.Values.AsEnumerable();
// Apply filters
if (!string.IsNullOrEmpty(query.TenantId))
{
entries = entries.Where(e => e.TenantId.Equals(query.TenantId, StringComparison.OrdinalIgnoreCase));
}
if (!string.IsNullOrEmpty(query.ActionType))
{
entries = entries.Where(e => e.ActionType.Equals(query.ActionType, StringComparison.OrdinalIgnoreCase));
}
if (!string.IsNullOrEmpty(query.Actor))
{
entries = entries.Where(e => e.Actor.Equals(query.Actor, StringComparison.OrdinalIgnoreCase));
}
if (query.Outcome.HasValue)
{
entries = entries.Where(e => e.Outcome == query.Outcome.Value);
}
if (!string.IsNullOrEmpty(query.RunId))
{
entries = entries.Where(e => e.RunId != null && e.RunId.Equals(query.RunId, StringComparison.OrdinalIgnoreCase));
}
if (!string.IsNullOrEmpty(query.CveId))
{
entries = entries.Where(e => e.CveId != null && e.CveId.Equals(query.CveId, StringComparison.OrdinalIgnoreCase));
}
if (!string.IsNullOrEmpty(query.ImageDigest))
{
entries = entries.Where(e => e.ImageDigest != null && e.ImageDigest.Equals(query.ImageDigest, StringComparison.OrdinalIgnoreCase));
}
if (query.FromTimestamp.HasValue)
{
entries = entries.Where(e => e.Timestamp >= query.FromTimestamp.Value);
}
if (query.ToTimestamp.HasValue)
{
entries = entries.Where(e => e.Timestamp < query.ToTimestamp.Value);
}
// Order by timestamp descending, apply pagination
var result = entries
.OrderByDescending(e => e.Timestamp)
.Skip(query.Offset)
.Take(query.Limit)
.ToImmutableArray();
return Task.FromResult(result);
}
/// <inheritdoc />
public Task<ActionAuditEntry?> GetAsync(
string entryId,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrEmpty(entryId);
_entries.TryGetValue(entryId, out var entry);
return Task.FromResult(entry);
}
/// <inheritdoc />
public Task<ImmutableArray<ActionAuditEntry>> GetByRunAsync(
string runId,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrEmpty(runId);
var entries = _entries.Values
.Where(e => e.RunId != null && e.RunId.Equals(runId, StringComparison.OrdinalIgnoreCase))
.OrderBy(e => e.Timestamp)
.ToImmutableArray();
return Task.FromResult(entries);
}
}
/// <summary>
/// Configuration options for the audit ledger.
/// </summary>
public sealed class AuditLedgerOptions
{
/// <summary>
/// Days to retain audit entries.
/// </summary>
public int RetentionDays { get; set; } = 365;
/// <summary>
/// Whether audit logging is enabled.
/// </summary>
public bool Enabled { get; set; } = true;
}