Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@
<PackageVersion Include="Aspire.Hosting.PostgreSQL" Version="13.1.2" />
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.1.0" />
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="10.1.0" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.14.0" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.14.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.14.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.14.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.14.0" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
<!-- Analyzers -->
<PackageVersion Include="NetEscapades.EnumGenerators" Version="1.0.0-beta11" />
<PackageVersion Include="Roslynator.Analyzers" Version="4.12.11" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using Wolverine;
using Wolverine.Attributes;

[assembly: WolverineModule(typeof(SimpleModule.AuditLogs.AuditLogsWolverineExtension))]

namespace SimpleModule.AuditLogs;

#pragma warning disable CA1812 // Instantiated by Wolverine via [WolverineModule]
internal sealed class AuditLogsWolverineExtension : IWolverineExtension
#pragma warning restore CA1812
{
public void Configure(WolverineOptions options)
{
options.Discovery.IncludeAssembly(typeof(AuditLogsWolverineExtension).Assembly);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,43 @@
using SimpleModule.AuditLogs.Enrichment;
using SimpleModule.AuditLogs.Pipeline;
using SimpleModule.Settings.Contracts;
using ZiggyCreatures.Caching.Fusion;

namespace SimpleModule.AuditLogs.Middleware;

public sealed class AuditMiddleware(RequestDelegate next)
public sealed class AuditMiddleware(RequestDelegate next, IFusionCache cache)
{
private static readonly string[] ExcludedMethodsForBody = ["HEAD", "OPTIONS"];

private static readonly string[] DefaultExcludedPaths =
[
"/health",
"/metrics",
"/_content",
"/js/",
"/css/",
"/favicon",
];

private static readonly FusionCacheEntryOptions AuditConfigCacheOptions = new()
{
Duration = TimeSpan.FromSeconds(60),
};

private static readonly AuditRequestSettings FallbackSettings = new(
CaptureHttp: true,
CaptureRequestBodies: true,
CaptureQueryStrings: true,
CaptureUserAgent: false,
ExcludedPaths: DefaultExcludedPaths
);

private sealed record AuditRequestSettings(
bool CaptureHttp,
bool CaptureRequestBodies,
bool CaptureQueryStrings,
bool CaptureUserAgent,
IReadOnlyList<string> ExcludedPaths
string[] ExcludedPaths
);

public async Task InvokeAsync(HttpContext context)
Expand All @@ -30,9 +54,9 @@ public async Task InvokeAsync(HttpContext context)
return;
}

// Load all settings once at the start (parallel batch loading)
var settings = context.RequestServices.GetService<ISettingsContracts>();
var auditSettings = await LoadSettingsAsync(settings);
// Single composite cache entry — avoids 5 separate FusionCache lookups per
// request and resolves the scoped ISettingsContracts only on cache miss.
var auditSettings = await LoadSettingsAsync(context.RequestServices, cache);

// Check if HTTP capture is enabled
if (!auditSettings.CaptureHttp)
Expand Down Expand Up @@ -115,20 +139,24 @@ public async Task InvokeAsync(HttpContext context)
channel.Enqueue(entry);
}

private static async Task<AuditRequestSettings> LoadSettingsAsync(ISettingsContracts? settings)
private static async Task<AuditRequestSettings> LoadSettingsAsync(
IServiceProvider services,
IFusionCache cache
)
{
if (settings is null)
{
return new AuditRequestSettings(
CaptureHttp: true,
CaptureRequestBodies: true,
CaptureQueryStrings: true,
CaptureUserAgent: false,
ExcludedPaths: GetDefaultExcludedPaths()
);
}
return await cache.GetOrSetAsync<AuditRequestSettings>(
AuditCacheKeys.RequestConfig,
async (_, _) =>
{
var settings = services.GetService<ISettingsContracts>();
return settings is null ? FallbackSettings : await BuildSettingsAsync(settings);
},
AuditConfigCacheOptions
);
}

// Fetch all settings in parallel
private static async Task<AuditRequestSettings> BuildSettingsAsync(ISettingsContracts settings)
{
var (captureHttp, captureBody, captureQs, captureUa, excludedPathsRaw) =
await LoadAllSettingsAsync(settings);

Expand Down Expand Up @@ -175,29 +203,29 @@ ISettingsContracts settings
return (captureHttp, captureBody, captureQs, captureUa, results[4]);
}

private static List<string> ParseExcludedPaths(string? rawPaths)
private static string[] ParseExcludedPaths(string? rawPaths)
{
var paths = GetDefaultExcludedPaths();

if (string.IsNullOrWhiteSpace(rawPaths))
{
return paths;
return DefaultExcludedPaths;
}

var configured = rawPaths
.Split(',')
.Select(p => p.Trim())
.Where(p => !string.IsNullOrWhiteSpace(p))
.ToList();
var configured = rawPaths.Split(
',',
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries
);
if (configured.Length == 0)
{
return DefaultExcludedPaths;
}

paths.AddRange(configured);
return paths;
var merged = new string[DefaultExcludedPaths.Length + configured.Length];
DefaultExcludedPaths.CopyTo(merged, 0);
configured.CopyTo(merged, DefaultExcludedPaths.Length);
return merged;
}

private static List<string> GetDefaultExcludedPaths() =>
["/health", "/metrics", "/_content", "/js/", "/css/", "/favicon"];

private static bool IsExcludedPath(string path, IEnumerable<string> configuredPaths)
private static bool IsExcludedPath(string path, string[] configuredPaths)
{
foreach (var prefix in configuredPaths)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace SimpleModule.AuditLogs.Pipeline;

internal static class AuditCacheKeys
{
public const string RequestConfig = "auditlogs:request-config";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using SimpleModule.Core.Settings;
using SimpleModule.Settings.Contracts.Events;
using ZiggyCreatures.Caching.Fusion;

namespace SimpleModule.AuditLogs.Pipeline;

internal static class AuditConfigCacheInvalidator
{
public static ValueTask Handle(SettingChangedEvent @event, IFusionCache cache)
{
if (@event.Scope != SettingScope.System)
{
return ValueTask.CompletedTask;
}

if (!@event.Key.StartsWith("auditlogs.", StringComparison.Ordinal))
{
return ValueTask.CompletedTask;
}

return cache.RemoveAsync(AuditCacheKeys.RequestConfig);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,25 +23,54 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
if (await channel.Reader.WaitToReadAsync(stoppingToken))
if (!await channel.Reader.WaitToReadAsync(stoppingToken))
{
batch.Clear();
var deadline = DateTimeOffset.UtcNow.Add(opts.WriterFlushInterval);

while (
batch.Count < opts.WriterBatchSize
&& DateTimeOffset.UtcNow < deadline
&& channel.Reader.TryRead(out var entry)
)
break;
}

batch.Clear();

while (batch.Count < opts.WriterBatchSize && channel.Reader.TryRead(out var entry))
{
batch.Add(entry);
}

if (batch.Count < opts.WriterBatchSize)
{
using var linger = CancellationTokenSource.CreateLinkedTokenSource(
stoppingToken
);
linger.CancelAfter(opts.WriterFlushInterval);

try
{
batch.Add(entry);
}
while (batch.Count < opts.WriterBatchSize)
{
if (!await channel.Reader.WaitToReadAsync(linger.Token))
{
break;
}

if (batch.Count > 0)
while (
batch.Count < opts.WriterBatchSize
&& channel.Reader.TryRead(out var entry)
)
{
batch.Add(entry);
}
}
}
catch (OperationCanceledException)
{
await FlushBatchAsync(batch, stoppingToken);
// Linger deadline OR shutdown — fall through and persist
// whatever was already drained instead of dropping it.
}
}

if (batch.Count > 0)
{
await FlushBatchAsync(batch);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
Expand All @@ -64,13 +93,13 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
}
if (batch.Count > 0)
{
await FlushBatchAsync(batch, CancellationToken.None);
await FlushBatchAsync(batch);
}

LogStopped(logger);
}

private async Task FlushBatchAsync(List<AuditEntry> batch, CancellationToken ct)
private async Task FlushBatchAsync(List<AuditEntry> batch)
{
await using var scope = scopeFactory.CreateAsyncScope();
var contracts = scope.ServiceProvider.GetRequiredService<IAuditLogContracts>();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using NSubstitute;
using SimpleModule.AuditLogs.Middleware;
using SimpleModule.AuditLogs.Pipeline;
using SimpleModule.Core.Settings;
using SimpleModule.Settings.Contracts;
using ZiggyCreatures.Caching.Fusion;
using ZiggyCreatures.Caching.Fusion.NullObjects;

namespace AuditLogs.Tests.Unit;

public class AuditMiddlewareTests
public sealed class AuditMiddlewareTests : IDisposable
{
private readonly NullFusionCache _cache = new(Options.Create(new FusionCacheOptions()));

public void Dispose() => _cache.Dispose();

[Fact]
public async Task InvokeAsync_LoadsSettingsOnce_AndCachesForRequest()
{
Expand All @@ -24,7 +31,7 @@ public async Task InvokeAsync_LoadsSettingsOnce_AndCachesForRequest()
// Configure settings to return default values
settings.GetSettingAsync(Arg.Any<string>(), Arg.Any<SettingScope>()).Returns("true");

var middleware = new AuditMiddleware(next);
var middleware = new AuditMiddleware(next, _cache);

// Act
await middleware.InvokeAsync(context);
Expand Down Expand Up @@ -69,7 +76,7 @@ public async Task InvokeAsync_SkipsProcessing_WhenHttpCaptureDisabled()
.Returns("false");
settings.GetSettingAsync(Arg.Any<string>(), Arg.Any<SettingScope>()).Returns("true");

var middleware = new AuditMiddleware(next);
var middleware = new AuditMiddleware(next, _cache);

// Act
await middleware.InvokeAsync(context);
Expand All @@ -95,7 +102,7 @@ public async Task InvokeAsync_SkipsProcessing_ForExcludedPath()

settings.GetSettingAsync(Arg.Any<string>(), Arg.Any<SettingScope>()).Returns("true");

var middleware = new AuditMiddleware(next);
var middleware = new AuditMiddleware(next, _cache);

// Act
await middleware.InvokeAsync(context);
Expand Down Expand Up @@ -134,7 +141,7 @@ public async Task InvokeAsync_ExcludesPath_WhenConfigured()
.GetSettingAsync("auditlogs.excluded.paths", Arg.Any<SettingScope>())
.Returns("/api/custom-exclude");

var middleware = new AuditMiddleware(next);
var middleware = new AuditMiddleware(next, _cache);

// Act
await middleware.InvokeAsync(context);
Expand Down Expand Up @@ -171,7 +178,7 @@ public async Task InvokeAsync_MergesHardcodedAndConfiguredExcludedPaths()
.GetSettingAsync("auditlogs.excluded.paths", Arg.Any<SettingScope>())
.Returns("/api/internal");

var middleware = new AuditMiddleware(next);
var middleware = new AuditMiddleware(next, _cache);

// Act - test hardcoded /health path
await middleware.InvokeAsync(context);
Expand All @@ -195,7 +202,7 @@ public async Task InvokeAsync_PathMatching_IsCaseInsensitive()

settings.GetSettingAsync(Arg.Any<string>(), Arg.Any<SettingScope>()).Returns("true");

var middleware = new AuditMiddleware(next);
var middleware = new AuditMiddleware(next, _cache);

// Act
await middleware.InvokeAsync(context);
Expand All @@ -216,7 +223,7 @@ public async Task InvokeAsync_UsesDefaultSettings_WhenSettingsServiceIsNull()
context.Request.Method = "GET";
context.RequestServices = CreateServiceProviderWithoutSettings(channel);

var middleware = new AuditMiddleware(next);
var middleware = new AuditMiddleware(next, _cache);

// Act
await middleware.InvokeAsync(context);
Expand All @@ -240,7 +247,7 @@ public async Task InvokeAsync_DefaultValues_AreCorrect()

settings.GetSettingAsync(Arg.Any<string>(), Arg.Any<SettingScope>()).Returns((string?)null); // Simulate no setting found

var middleware = new AuditMiddleware(next);
var middleware = new AuditMiddleware(next, _cache);

// Act
await middleware.InvokeAsync(context);
Expand All @@ -261,7 +268,7 @@ public async Task InvokeAsync_HandlesNullSettingsGracefully()
context.Request.Method = "POST";
context.RequestServices = CreateServiceProviderWithoutSettings(channel);

var middleware = new AuditMiddleware(next);
var middleware = new AuditMiddleware(next, _cache);

// Act & Assert - should not throw
var action = () => middleware.InvokeAsync(context);
Expand Down
Loading
Loading