From 7723bfb712ab3658620d77db9a24b569eddc9fc8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 20 May 2026 11:17:46 -0400 Subject: [PATCH] feat(auditlog): add AuditLogOptions + validator (#23) --- .../Configuration/AuditLogOptions.cs | 32 +++- .../Configuration/AuditLogOptionsValidator.cs | 57 ++++++ .../PerTargetRedactionOverride.cs | 17 ++ .../ServiceCollectionExtensions.cs | 22 ++- .../Configuration/AuditLogOptionsTests.cs | 167 ++++++++++++++++++ 5 files changed, 285 insertions(+), 10 deletions(-) create mode 100644 src/ScadaLink.AuditLog/Configuration/AuditLogOptionsValidator.cs create mode 100644 src/ScadaLink.AuditLog/Configuration/PerTargetRedactionOverride.cs create mode 100644 tests/ScadaLink.AuditLog.Tests/Configuration/AuditLogOptionsTests.cs diff --git a/src/ScadaLink.AuditLog/Configuration/AuditLogOptions.cs b/src/ScadaLink.AuditLog/Configuration/AuditLogOptions.cs index 757977c..89cfe9b 100644 --- a/src/ScadaLink.AuditLog/Configuration/AuditLogOptions.cs +++ b/src/ScadaLink.AuditLog/Configuration/AuditLogOptions.cs @@ -1,10 +1,36 @@ namespace ScadaLink.AuditLog.Configuration; /// -/// Configuration for Audit Log (#23). Bound from the "AuditLog" section of -/// appsettings.json. 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 AuditLog section of +/// appsettings.json. 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. /// public sealed class AuditLogOptions { + /// Default payload-summary cap in bytes (default 8 KiB). + public int DefaultCapBytes { get; set; } = 8192; + + /// Payload-summary cap on error rows in bytes (default 64 KiB). + public int ErrorCapBytes { get; set; } = 65536; + + /// HTTP headers redacted by default before persistence. + public List HeaderRedactList { get; set; } = new() + { + "Authorization", + "X-Api-Key", + "Cookie", + "Set-Cookie", + }; + + /// Body-content redactors applied globally (regex patterns). + public List GlobalBodyRedactors { get; set; } = new(); + + /// Per-target redaction overrides keyed by target identifier. + public Dictionary PerTargetOverrides { get; set; } = new(); + + /// Central retention window in days (default 365, range [30, 3650]). + public int RetentionDays { get; set; } = 365; } diff --git a/src/ScadaLink.AuditLog/Configuration/AuditLogOptionsValidator.cs b/src/ScadaLink.AuditLog/Configuration/AuditLogOptionsValidator.cs new file mode 100644 index 0000000..59785c3 --- /dev/null +++ b/src/ScadaLink.AuditLog/Configuration/AuditLogOptionsValidator.cs @@ -0,0 +1,57 @@ +using Microsoft.Extensions.Options; + +namespace ScadaLink.AuditLog.Configuration; + +/// +/// Validates on startup. The caps drive payload +/// truncation in the M2+ writers, so an unset/zero cap would let arbitrarily +/// large blobs into the central AuditLog table. +/// must be at least as large as +/// because the error cap is meant to capture more detail than the +/// happy-path summary, not less. is +/// bounded to [30, 3650] to keep purge windows sane: too short would +/// drop in-flight investigations, too long would defeat the partition-switch +/// purge's purpose. +/// +public sealed class AuditLogOptionsValidator : IValidateOptions +{ + /// Inclusive lower bound for . + public const int MinRetentionDays = 30; + + /// Inclusive upper bound for . + public const int MaxRetentionDays = 3650; + + /// + public ValidateOptionsResult Validate(string? name, AuditLogOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + var failures = new List(); + + 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); + } +} diff --git a/src/ScadaLink.AuditLog/Configuration/PerTargetRedactionOverride.cs b/src/ScadaLink.AuditLog/Configuration/PerTargetRedactionOverride.cs new file mode 100644 index 0000000..739ee07 --- /dev/null +++ b/src/ScadaLink.AuditLog/Configuration/PerTargetRedactionOverride.cs @@ -0,0 +1,17 @@ +namespace ScadaLink.AuditLog.Configuration; + +/// +/// Per-target redaction override applied additively on top of +/// and the +/// / +/// caps. Targets are identified by the script-facing external-system / +/// database / notification-list / inbound-API-key name. +/// +public sealed class PerTargetRedactionOverride +{ + /// Optional payload cap override (bytes); null inherits the global cap. + public int? CapBytes { get; set; } + + /// Additional body redactor regex patterns (appended to the global list). + public List? AdditionalBodyRedactors { get; set; } +} diff --git a/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs b/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs index 7c93919..7e010e6 100644 --- a/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs +++ b/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs @@ -1,14 +1,16 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using ScadaLink.AuditLog.Configuration; namespace ScadaLink.AuditLog; /// /// Composition root for the Audit Log (#23) component. M1 registers -/// ; 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). +/// 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). /// public static class ServiceCollectionExtensions { @@ -17,9 +19,13 @@ public static class ServiceCollectionExtensions /// /// Binds from the - /// section of . - /// M2+ will register writers, telemetry actors, and the central ingest - /// pipeline here. IAuditLogRepository is registered by + /// section of + /// and registers so a misconfigured + /// AuditLog section is rejected with a key-naming message when the + /// options are first resolved (or at startup when consumers wire in + /// ValidateOnStart()). M2+ will register writers, telemetry actors, + /// and the central ingest pipeline here. IAuditLogRepository is + /// registered by /// ScadaLink.ConfigurationDatabase.ServiceCollectionExtensions.AddConfigurationDatabase, /// so the caller (the Host on the central node) must also call that. /// @@ -29,7 +35,9 @@ public static class ServiceCollectionExtensions ArgumentNullException.ThrowIfNull(config); services.AddOptions() - .Bind(config.GetSection(ConfigSectionName)); + .Bind(config.GetSection(ConfigSectionName)) + .ValidateOnStart(); + services.AddSingleton, AuditLogOptionsValidator>(); return services; } diff --git a/tests/ScadaLink.AuditLog.Tests/Configuration/AuditLogOptionsTests.cs b/tests/ScadaLink.AuditLog.Tests/Configuration/AuditLogOptionsTests.cs new file mode 100644 index 0000000..4e68e41 --- /dev/null +++ b/tests/ScadaLink.AuditLog.Tests/Configuration/AuditLogOptionsTests.cs @@ -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; + +/// +/// Task 9 (Bundle E): binding + validator +/// behavior. The validator enforces invariants used by M2+ writers +/// (per docs/plans/2026-05-20-auditlog-m1-foundation.md): +/// DefaultCapBytes > 0, ErrorCapBytes >= DefaultCapBytes, +/// RetentionDays in [30, 3650]. Header-redact defaults match the +/// design doc (alog.md §6): Authorization, X-Api-Key, Cookie, Set-Cookie. +/// +public class AuditLogOptionsTests +{ + private static IOptions BuildOptions(Dictionary config) + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(config) + .Build(); + var services = new ServiceCollection(); + services.AddAuditLog(configuration); + return services.BuildServiceProvider().GetRequiredService>(); + } + + [Fact] + public void ValidBinding_PopulatesAllScalarFields() + { + var opts = BuildOptions(new Dictionary + { + ["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()).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 + { + ["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 + { + ["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 + { + ["AuditLog:RetentionDays"] = "0", + }) + .Build(); + var services = new ServiceCollection(); + services.AddAuditLog(configuration); + var provider = services.BuildServiceProvider(); + var opts = provider.GetRequiredService>(); + + var ex = Assert.Throws(() => _ = opts.Value); + Assert.Contains("RetentionDays", ex.Message); + } +}