feat(auditlog): add AuditLogOptions + validator (#23)
This commit is contained in:
@@ -1,10 +1,36 @@
|
||||
namespace ScadaLink.AuditLog.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for Audit Log (#23). Bound from the "AuditLog" section of
|
||||
/// <c>appsettings.json</c>. Bundle E (M1) ships the type so the DI scaffold can
|
||||
/// register it; the full property set + validator are added by Task 9.
|
||||
/// Configuration for Audit Log (#23). Bound from the <c>AuditLog</c> section of
|
||||
/// <c>appsettings.json</c>. Defaults reflect the design (alog.md §6, §10): an
|
||||
/// 8 KiB payload-summary cap, a 64 KiB cap on error rows, and a 365-day central
|
||||
/// retention window with monthly partition-switch purge. The default
|
||||
/// header-redact list covers HTTP auth headers; per-target overrides extend
|
||||
/// (never replace) the global redactor set.
|
||||
/// </summary>
|
||||
public sealed class AuditLogOptions
|
||||
{
|
||||
/// <summary>Default payload-summary cap in bytes (default 8 KiB).</summary>
|
||||
public int DefaultCapBytes { get; set; } = 8192;
|
||||
|
||||
/// <summary>Payload-summary cap on error rows in bytes (default 64 KiB).</summary>
|
||||
public int ErrorCapBytes { get; set; } = 65536;
|
||||
|
||||
/// <summary>HTTP headers redacted by default before persistence.</summary>
|
||||
public List<string> HeaderRedactList { get; set; } = new()
|
||||
{
|
||||
"Authorization",
|
||||
"X-Api-Key",
|
||||
"Cookie",
|
||||
"Set-Cookie",
|
||||
};
|
||||
|
||||
/// <summary>Body-content redactors applied globally (regex patterns).</summary>
|
||||
public List<string> GlobalBodyRedactors { get; set; } = new();
|
||||
|
||||
/// <summary>Per-target redaction overrides keyed by target identifier.</summary>
|
||||
public Dictionary<string, PerTargetRedactionOverride> PerTargetOverrides { get; set; } = new();
|
||||
|
||||
/// <summary>Central retention window in days (default 365, range [30, 3650]).</summary>
|
||||
public int RetentionDays { get; set; } = 365;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ScadaLink.AuditLog.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Validates <see cref="AuditLogOptions"/> on startup. The caps drive payload
|
||||
/// truncation in the M2+ writers, so an unset/zero cap would let arbitrarily
|
||||
/// large blobs into the central <c>AuditLog</c> table. <see cref="AuditLogOptions.ErrorCapBytes"/>
|
||||
/// must be at least as large as <see cref="AuditLogOptions.DefaultCapBytes"/>
|
||||
/// because the error cap is meant to capture <em>more</em> detail than the
|
||||
/// happy-path summary, not less. <see cref="AuditLogOptions.RetentionDays"/> is
|
||||
/// bounded to <c>[30, 3650]</c> to keep purge windows sane: too short would
|
||||
/// drop in-flight investigations, too long would defeat the partition-switch
|
||||
/// purge's purpose.
|
||||
/// </summary>
|
||||
public sealed class AuditLogOptionsValidator : IValidateOptions<AuditLogOptions>
|
||||
{
|
||||
/// <summary>Inclusive lower bound for <see cref="AuditLogOptions.RetentionDays"/>.</summary>
|
||||
public const int MinRetentionDays = 30;
|
||||
|
||||
/// <summary>Inclusive upper bound for <see cref="AuditLogOptions.RetentionDays"/>.</summary>
|
||||
public const int MaxRetentionDays = 3650;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValidateOptionsResult Validate(string? name, AuditLogOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var failures = new List<string>();
|
||||
|
||||
if (options.DefaultCapBytes <= 0)
|
||||
{
|
||||
failures.Add(
|
||||
$"AuditLog:{nameof(AuditLogOptions.DefaultCapBytes)} ({options.DefaultCapBytes}) " +
|
||||
"must be > 0; it drives payload-summary truncation in audit writers.");
|
||||
}
|
||||
|
||||
if (options.ErrorCapBytes < options.DefaultCapBytes)
|
||||
{
|
||||
failures.Add(
|
||||
$"AuditLog:{nameof(AuditLogOptions.ErrorCapBytes)} ({options.ErrorCapBytes}) " +
|
||||
$"must be >= {nameof(AuditLogOptions.DefaultCapBytes)} ({options.DefaultCapBytes}); " +
|
||||
"the error-row cap is intended to capture more detail than the happy-path summary.");
|
||||
}
|
||||
|
||||
if (options.RetentionDays < MinRetentionDays || options.RetentionDays > MaxRetentionDays)
|
||||
{
|
||||
failures.Add(
|
||||
$"AuditLog:{nameof(AuditLogOptions.RetentionDays)} ({options.RetentionDays}) " +
|
||||
$"must be in [{MinRetentionDays}, {MaxRetentionDays}] days.");
|
||||
}
|
||||
|
||||
return failures.Count == 0
|
||||
? ValidateOptionsResult.Success
|
||||
: ValidateOptionsResult.Fail(failures);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace ScadaLink.AuditLog.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Per-target redaction override applied additively on top of
|
||||
/// <see cref="AuditLogOptions.GlobalBodyRedactors"/> and the
|
||||
/// <see cref="AuditLogOptions.DefaultCapBytes"/> / <see cref="AuditLogOptions.ErrorCapBytes"/>
|
||||
/// caps. Targets are identified by the script-facing external-system /
|
||||
/// database / notification-list / inbound-API-key name.
|
||||
/// </summary>
|
||||
public sealed class PerTargetRedactionOverride
|
||||
{
|
||||
/// <summary>Optional payload cap override (bytes); null inherits the global cap.</summary>
|
||||
public int? CapBytes { get; set; }
|
||||
|
||||
/// <summary>Additional body redactor regex patterns (appended to the global list).</summary>
|
||||
public List<string>? AdditionalBodyRedactors { get; set; }
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ScadaLink.AuditLog.Configuration;
|
||||
|
||||
namespace ScadaLink.AuditLog;
|
||||
|
||||
/// <summary>
|
||||
/// Composition root for the Audit Log (#23) component. M1 registers
|
||||
/// <see cref="AuditLogOptions"/>; later milestones extend this method to wire
|
||||
/// up writers, telemetry actors, and the central ingest pipeline. Audit Log
|
||||
/// (#23) sits alongside Notification Outbox (#21) and Site Call Audit (#22).
|
||||
/// <see cref="AuditLogOptions"/> and its validator; later milestones extend
|
||||
/// this method to wire up writers, telemetry actors, and the central ingest
|
||||
/// pipeline. Audit Log (#23) sits alongside Notification Outbox (#21) and
|
||||
/// Site Call Audit (#22).
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
@@ -17,9 +19,13 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
/// <summary>
|
||||
/// Binds <see cref="AuditLogOptions"/> from the
|
||||
/// <see cref="ConfigSectionName"/> section of <paramref name="config"/>.
|
||||
/// M2+ will register writers, telemetry actors, and the central ingest
|
||||
/// pipeline here. <c>IAuditLogRepository</c> is registered by
|
||||
/// <see cref="ConfigSectionName"/> section of <paramref name="config"/>
|
||||
/// and registers <see cref="AuditLogOptionsValidator"/> so a misconfigured
|
||||
/// <c>AuditLog</c> section is rejected with a key-naming message when the
|
||||
/// options are first resolved (or at startup when consumers wire in
|
||||
/// <c>ValidateOnStart()</c>). M2+ will register writers, telemetry actors,
|
||||
/// and the central ingest pipeline here. <c>IAuditLogRepository</c> is
|
||||
/// registered by
|
||||
/// <c>ScadaLink.ConfigurationDatabase.ServiceCollectionExtensions.AddConfigurationDatabase</c>,
|
||||
/// so the caller (the Host on the central node) must also call that.
|
||||
/// </summary>
|
||||
@@ -29,7 +35,9 @@ public static class ServiceCollectionExtensions
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
|
||||
services.AddOptions<AuditLogOptions>()
|
||||
.Bind(config.GetSection(ConfigSectionName));
|
||||
.Bind(config.GetSection(ConfigSectionName))
|
||||
.ValidateOnStart();
|
||||
services.AddSingleton<IValidateOptions<AuditLogOptions>, AuditLogOptionsValidator>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ScadaLink.AuditLog.Configuration;
|
||||
|
||||
namespace ScadaLink.AuditLog.Tests.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Task 9 (Bundle E): <see cref="AuditLogOptions"/> binding + validator
|
||||
/// behavior. The validator enforces invariants used by M2+ writers
|
||||
/// (per <c>docs/plans/2026-05-20-auditlog-m1-foundation.md</c>):
|
||||
/// <c>DefaultCapBytes > 0</c>, <c>ErrorCapBytes >= DefaultCapBytes</c>,
|
||||
/// <c>RetentionDays in [30, 3650]</c>. Header-redact defaults match the
|
||||
/// design doc (alog.md §6): Authorization, X-Api-Key, Cookie, Set-Cookie.
|
||||
/// </summary>
|
||||
public class AuditLogOptionsTests
|
||||
{
|
||||
private static IOptions<AuditLogOptions> BuildOptions(Dictionary<string, string?> config)
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(config)
|
||||
.Build();
|
||||
var services = new ServiceCollection();
|
||||
services.AddAuditLog(configuration);
|
||||
return services.BuildServiceProvider().GetRequiredService<IOptions<AuditLogOptions>>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidBinding_PopulatesAllScalarFields()
|
||||
{
|
||||
var opts = BuildOptions(new Dictionary<string, string?>
|
||||
{
|
||||
["AuditLog:DefaultCapBytes"] = "4096",
|
||||
["AuditLog:ErrorCapBytes"] = "32768",
|
||||
["AuditLog:RetentionDays"] = "180",
|
||||
}).Value;
|
||||
|
||||
Assert.Equal(4096, opts.DefaultCapBytes);
|
||||
Assert.Equal(32768, opts.ErrorCapBytes);
|
||||
Assert.Equal(180, opts.RetentionDays);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultsAreReasonable_WhenSectionEmpty()
|
||||
{
|
||||
var opts = BuildOptions(new Dictionary<string, string?>()).Value;
|
||||
|
||||
Assert.Equal(8192, opts.DefaultCapBytes);
|
||||
Assert.Equal(65536, opts.ErrorCapBytes);
|
||||
Assert.Equal(365, opts.RetentionDays);
|
||||
Assert.Contains("Authorization", opts.HeaderRedactList);
|
||||
Assert.Contains("X-Api-Key", opts.HeaderRedactList);
|
||||
Assert.Contains("Cookie", opts.HeaderRedactList);
|
||||
Assert.Contains("Set-Cookie", opts.HeaderRedactList);
|
||||
Assert.Empty(opts.GlobalBodyRedactors);
|
||||
Assert.Empty(opts.PerTargetOverrides);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HeaderRedactList_BindsFromConfig_AppendsToDefaults()
|
||||
{
|
||||
// Microsoft.Extensions.Configuration's collection binder appends to a
|
||||
// defaulted list (it does not replace it), so config-supplied entries
|
||||
// augment the built-in redact list rather than overriding it. The
|
||||
// built-in entries are the safety-net defaults documented on
|
||||
// AuditLogOptions; supplying additional headers is the supported
|
||||
// extension point.
|
||||
var opts = BuildOptions(new Dictionary<string, string?>
|
||||
{
|
||||
["AuditLog:HeaderRedactList:0"] = "X-Custom-Auth",
|
||||
["AuditLog:HeaderRedactList:1"] = "X-Tenant-Id",
|
||||
}).Value;
|
||||
|
||||
Assert.Contains("X-Custom-Auth", opts.HeaderRedactList);
|
||||
Assert.Contains("X-Tenant-Id", opts.HeaderRedactList);
|
||||
Assert.Contains("Authorization", opts.HeaderRedactList);
|
||||
Assert.Contains("X-Api-Key", opts.HeaderRedactList);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerTargetOverrides_BindsFromConfig()
|
||||
{
|
||||
var opts = BuildOptions(new Dictionary<string, string?>
|
||||
{
|
||||
["AuditLog:PerTargetOverrides:CRM:CapBytes"] = "16384",
|
||||
["AuditLog:PerTargetOverrides:CRM:AdditionalBodyRedactors:0"] = @"\d{16}",
|
||||
}).Value;
|
||||
|
||||
Assert.True(opts.PerTargetOverrides.ContainsKey("CRM"));
|
||||
var crm = opts.PerTargetOverrides["CRM"];
|
||||
Assert.Equal(16384, crm.CapBytes);
|
||||
Assert.NotNull(crm.AdditionalBodyRedactors);
|
||||
Assert.Contains(@"\d{16}", crm.AdditionalBodyRedactors!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidDefaultCapBytes_FailsValidation()
|
||||
{
|
||||
var result = new AuditLogOptionsValidator().Validate(
|
||||
Options.DefaultName,
|
||||
new AuditLogOptions { DefaultCapBytes = 0 });
|
||||
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("DefaultCapBytes", result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidErrorCapBytes_FailsValidation()
|
||||
{
|
||||
var result = new AuditLogOptionsValidator().Validate(
|
||||
Options.DefaultName,
|
||||
new AuditLogOptions { DefaultCapBytes = 1000, ErrorCapBytes = 100 });
|
||||
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("ErrorCapBytes", result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetentionDaysBelowMinimum_FailsValidation()
|
||||
{
|
||||
var result = new AuditLogOptionsValidator().Validate(
|
||||
Options.DefaultName,
|
||||
new AuditLogOptions { RetentionDays = 0 });
|
||||
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("RetentionDays", result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetentionDaysAboveMaximum_FailsValidation()
|
||||
{
|
||||
var result = new AuditLogOptionsValidator().Validate(
|
||||
Options.DefaultName,
|
||||
new AuditLogOptions { RetentionDays = 3651 });
|
||||
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("RetentionDays", result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultOptions_PassValidation()
|
||||
{
|
||||
var result = new AuditLogOptionsValidator().Validate(
|
||||
Options.DefaultName,
|
||||
new AuditLogOptions());
|
||||
|
||||
Assert.True(result.Succeeded, result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidRetention_BoundViaConfig_RejectedOnValueAccess()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["AuditLog:RetentionDays"] = "0",
|
||||
})
|
||||
.Build();
|
||||
var services = new ServiceCollection();
|
||||
services.AddAuditLog(configuration);
|
||||
var provider = services.BuildServiceProvider();
|
||||
var opts = provider.GetRequiredService<IOptions<AuditLogOptions>>();
|
||||
|
||||
var ex = Assert.Throws<OptionsValidationException>(() => _ = opts.Value);
|
||||
Assert.Contains("RetentionDays", ex.Message);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user