Skip to content

Commit a338583

Browse files
robgrameCopilot
andcommitted
feat(audit): Log Analytics audit mirror decorator (Phase 4)
Implements the "Audit log mirror su Log Analytics via Diagnostic Settings" item from docs/ENCRYPTION-BRIEFING.md. The canonical AuditService keeps full ownership of the audit trail (DB write + Serilog file mirror); a new LogAnalyticsAuditMirror decorator wraps it and ADDITIONALLY pushes each event to an Azure Monitor custom table via a Data Collection Rule + Data Collection Endpoint. Changes - Core/Services/LogAnalyticsAuditMirror.cs: new decorator + ILogAnalyticsAuditSink test seam + LogAnalyticsAuditSink real impl using Azure.Monitor.Ingestion's LogsIngestionClient. AuditLogRecord shape matches the DCR-provisioned custom table schema (BitLockerKeyMonitorAudit_CL). - Core.csproj: add Azure.Monitor.Ingestion 1.1.2 (small, focused SDK; Core already references Azure.Identity for Graph). - Worker/Program.cs: register AuditService as a concrete singleton, then conditionally wrap it with LogAnalyticsAuditMirror when Audit:LogAnalytics:{Endpoint,DcrImmutableId,StreamName} are all configured. When unconfigured the decorator is NOT instantiated and behavior is byte-identical to the prior release. - Core.Tests/Services/LogAnalyticsAuditMirrorTests.cs: 6 new tests covering ordering (inner FIRST then sink), sink-failure swallowing, inner-failure propagation, cancellation, ctor null checks, ProcessIdentity delegation. Failure semantics (preserved): the mirror NEVER throws out of RecordAsync. A Log Analytics ingestion failure is logged at Warning and the call still returns successfully — the canonical event remains in DB and file log, so the audit trail is never lost just because Log Analytics is unreachable. Verification - dotnet build BitLockerKeyMonitor.slnx: 0 errors, 0 warnings - dotnet test BitLockerKeyMonitor.slnx: 70 passed (was 64; +6 new) - dotnet build BitLockerKeyMonitor.Azure.slnx: 0 errors, 0 warnings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 1204cee commit a338583

4 files changed

Lines changed: 320 additions & 1 deletion

File tree

src/BitLockerKeyMonitor.Core/BitLockerKeyMonitor.Core.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
<ItemGroup>
1515
<PackageReference Include="Azure.Identity" Version="1.21.0" />
16+
<PackageReference Include="Azure.Monitor.Ingestion" Version="1.1.2" />
1617
<PackageReference Include="CsvHelper" Version="33.1.0" />
1718
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
1819
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
using Azure.Core;
2+
using Azure.Monitor.Ingestion;
3+
using BitLockerKeyMonitor.Core.Services.Interfaces;
4+
using Microsoft.Extensions.Logging;
5+
6+
namespace BitLockerKeyMonitor.Core.Services;
7+
8+
/// <summary>
9+
/// Decorator that wraps an inner <see cref="IAuditService"/> and mirrors every recorded event
10+
/// to Azure Monitor Log Analytics via a Data Collection Endpoint (DCE) + Data Collection Rule
11+
/// (DCR). The inner audit service still owns the canonical DB write and the Serilog "AUDIT:"
12+
/// file mirror; this decorator adds a third destination so KQL queries can correlate audit
13+
/// activity across machines/regions when the BitLockerKeyMonitor solution runs on or close to
14+
/// Azure (Azure Arc, Hybrid Worker, Functions, etc.).
15+
/// </summary>
16+
/// <remarks>
17+
/// <para>Failure semantics: identical to <see cref="AuditService"/>. The mirror NEVER throws
18+
/// out of <see cref="RecordAsync"/>. A Log Analytics ingestion failure is logged at Warning and
19+
/// the call still returns successfully — the canonical event remains in the DB and the file
20+
/// log, so the audit trail is never lost just because Log Analytics is unreachable.</para>
21+
///
22+
/// <para>Order: the inner service is awaited FIRST so the DB write completes before we attempt
23+
/// the network call. This keeps the audit-trail consistency guarantee identical to non-decorated
24+
/// mode (caller-visible behavior matches whether or not the mirror is enabled).</para>
25+
/// </remarks>
26+
public sealed class LogAnalyticsAuditMirror : IAuditService
27+
{
28+
private readonly IAuditService _inner;
29+
private readonly ILogAnalyticsAuditSink _sink;
30+
private readonly ILogger<LogAnalyticsAuditMirror> _logger;
31+
32+
public LogAnalyticsAuditMirror(
33+
IAuditService inner,
34+
ILogAnalyticsAuditSink sink,
35+
ILogger<LogAnalyticsAuditMirror> logger)
36+
{
37+
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
38+
_sink = sink ?? throw new ArgumentNullException(nameof(sink));
39+
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
40+
}
41+
42+
public string ProcessIdentity => _inner.ProcessIdentity;
43+
44+
public async Task RecordAsync(
45+
string action,
46+
string user,
47+
string outcome,
48+
string? subject = null,
49+
string? details = null,
50+
CancellationToken ct = default)
51+
{
52+
// 1) Canonical record: DB + Serilog file mirror. Identical semantics to non-decorated.
53+
await _inner.RecordAsync(action, user, outcome, subject, details, ct).ConfigureAwait(false);
54+
55+
// 2) Mirror to Log Analytics. Failures must NEVER propagate; the caller already got
56+
// the canonical write back. Use the same swallow-and-warn pattern as AuditService
57+
// so the operational behavior is uniform.
58+
var record = new AuditLogRecord(
59+
TimeGenerated: DateTime.UtcNow,
60+
Action: action,
61+
User: user,
62+
Outcome: outcome,
63+
Subject: subject,
64+
Details: details,
65+
ProcessIdentity: _inner.ProcessIdentity,
66+
MachineName: Environment.MachineName);
67+
68+
try
69+
{
70+
await _sink.SendAsync(record, ct).ConfigureAwait(false);
71+
}
72+
catch (OperationCanceledException) when (ct.IsCancellationRequested)
73+
{
74+
_logger.LogWarning(
75+
"AUDIT LA mirror cancelled for {Action} by {User} — DB+file mirror retained",
76+
action, user);
77+
}
78+
catch (Exception ex)
79+
{
80+
_logger.LogWarning(ex,
81+
"AUDIT LA mirror FAILED for {Action} by {User} — DB+file mirror retained",
82+
action, user);
83+
}
84+
}
85+
}
86+
87+
/// <summary>
88+
/// One audit event in the shape sent to Log Analytics. Property names match the columns of the
89+
/// custom table provisioned by the DCR (typically <c>BitLockerKeyMonitorAudit_CL</c>); changing
90+
/// them requires updating the DCR's transform/schema.
91+
/// </summary>
92+
public sealed record AuditLogRecord(
93+
DateTime TimeGenerated,
94+
string Action,
95+
string User,
96+
string Outcome,
97+
string? Subject,
98+
string? Details,
99+
string ProcessIdentity,
100+
string MachineName);
101+
102+
/// <summary>
103+
/// Sends a batch of audit records to Log Analytics. Exists as an interface so unit tests can
104+
/// substitute an in-memory sink and exercise the decorator's failure semantics without touching
105+
/// the network. The real implementation is <see cref="LogAnalyticsAuditSink"/>.
106+
/// </summary>
107+
public interface ILogAnalyticsAuditSink
108+
{
109+
Task SendAsync(AuditLogRecord record, CancellationToken ct);
110+
}
111+
112+
/// <summary>
113+
/// Real Log Analytics sink backed by <see cref="LogsIngestionClient"/>. Authentication is
114+
/// delegated to the supplied <see cref="TokenCredential"/> (typically <c>DefaultAzureCredential</c>
115+
/// so a Managed Identity, a workload-identity-federated principal, or a developer VS Code login
116+
/// all just work).
117+
/// </summary>
118+
public sealed class LogAnalyticsAuditSink : ILogAnalyticsAuditSink
119+
{
120+
private readonly LogsIngestionClient _client;
121+
private readonly string _ruleId;
122+
private readonly string _streamName;
123+
124+
public LogAnalyticsAuditSink(Uri dceEndpoint, string ruleId, string streamName, TokenCredential credential)
125+
{
126+
if (dceEndpoint is null) throw new ArgumentNullException(nameof(dceEndpoint));
127+
ArgumentException.ThrowIfNullOrEmpty(ruleId);
128+
ArgumentException.ThrowIfNullOrEmpty(streamName);
129+
if (credential is null) throw new ArgumentNullException(nameof(credential));
130+
_ruleId = ruleId;
131+
_streamName = streamName;
132+
_client = new LogsIngestionClient(dceEndpoint, credential);
133+
}
134+
135+
public async Task SendAsync(AuditLogRecord record, CancellationToken ct)
136+
{
137+
// LogsIngestionClient.UploadAsync wants an enumerable of objects that serialize to the
138+
// DCR's expected schema. We pass a single-element array — batching across audit events
139+
// is intentionally not done here because (a) audit events are infrequent and
140+
// (b) we want LA-side visibility to be near-real-time, not batched up.
141+
var payload = new[] { record };
142+
await _client.UploadAsync(_ruleId, _streamName, payload, cancellationToken: ct)
143+
.ConfigureAwait(false);
144+
}
145+
}

src/BitLockerKeyMonitor.Worker/Program.cs

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,42 @@
6464
// Audit service — Worker-emitted events are attributed to "Worker" and use the
6565
// service's own Windows identity as the User. Singleton because it's stateless
6666
// and holds only the DbContextFactory (which is itself a singleton/scoped-safe).
67-
builder.Services.AddSingleton<BitLockerKeyMonitor.Core.Services.Interfaces.IAuditService>(sp =>
67+
// When Audit:LogAnalytics:Endpoint + DcrImmutableId + StreamName are configured the
68+
// canonical AuditService is wrapped by LogAnalyticsAuditMirror so events are ALSO
69+
// pushed to a Log Analytics custom table for cross-machine KQL correlation. The
70+
// mirror is best-effort: ingestion failures do not break the canonical DB+file write.
71+
builder.Services.AddSingleton<BitLockerKeyMonitor.Core.Services.AuditService>(sp =>
6872
new BitLockerKeyMonitor.Core.Services.AuditService(
6973
sp.GetRequiredService<IDbContextFactory<MonitorDbContext>>(),
7074
sp.GetRequiredService<ILogger<BitLockerKeyMonitor.Core.Services.AuditService>>(),
7175
processName: "Worker"));
7276

77+
var laEndpoint = builder.Configuration["Audit:LogAnalytics:Endpoint"];
78+
var laRuleId = builder.Configuration["Audit:LogAnalytics:DcrImmutableId"];
79+
var laStream = builder.Configuration["Audit:LogAnalytics:StreamName"];
80+
if (!string.IsNullOrWhiteSpace(laEndpoint)
81+
&& !string.IsNullOrWhiteSpace(laRuleId)
82+
&& !string.IsNullOrWhiteSpace(laStream))
83+
{
84+
builder.Services.AddSingleton<BitLockerKeyMonitor.Core.Services.ILogAnalyticsAuditSink>(_ =>
85+
new BitLockerKeyMonitor.Core.Services.LogAnalyticsAuditSink(
86+
new Uri(laEndpoint),
87+
laRuleId,
88+
laStream,
89+
new Azure.Identity.DefaultAzureCredential()));
90+
91+
builder.Services.AddSingleton<BitLockerKeyMonitor.Core.Services.Interfaces.IAuditService>(sp =>
92+
new BitLockerKeyMonitor.Core.Services.LogAnalyticsAuditMirror(
93+
sp.GetRequiredService<BitLockerKeyMonitor.Core.Services.AuditService>(),
94+
sp.GetRequiredService<BitLockerKeyMonitor.Core.Services.ILogAnalyticsAuditSink>(),
95+
sp.GetRequiredService<ILogger<BitLockerKeyMonitor.Core.Services.LogAnalyticsAuditMirror>>()));
96+
}
97+
else
98+
{
99+
builder.Services.AddSingleton<BitLockerKeyMonitor.Core.Services.Interfaces.IAuditService>(sp =>
100+
sp.GetRequiredService<BitLockerKeyMonitor.Core.Services.AuditService>());
101+
}
102+
73103
// DB at-rest encryption service (KEK/DEK + BIP39 recovery code).
74104
// Singleton because it holds the unwrapped DEK in memory after unlock and
75105
// serializes all lifecycle transitions. No production code path consumes
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
using BitLockerKeyMonitor.Core.Services;
2+
using BitLockerKeyMonitor.Core.Services.Interfaces;
3+
using Microsoft.Extensions.Logging.Abstractions;
4+
using Xunit;
5+
6+
namespace BitLockerKeyMonitor.Core.Tests.Services;
7+
8+
public class LogAnalyticsAuditMirrorTests
9+
{
10+
[Fact]
11+
public async Task Record_InvokesInnerThenSink_InOrder()
12+
{
13+
var sequence = new List<string>();
14+
var inner = new TrackingAudit(sequence);
15+
var sink = new TrackingSink(sequence);
16+
var mirror = new LogAnalyticsAuditMirror(inner, sink, NullLogger<LogAnalyticsAuditMirror>.Instance);
17+
18+
await mirror.RecordAsync("Action.X", "DOMAIN\\u", "Success", "subject", "details");
19+
20+
// Inner must complete BEFORE the sink — the canonical DB+file write owns the audit
21+
// guarantee; the LA mirror is best-effort and must not be observed by the caller.
22+
Assert.Equal(new[] { "inner", "sink" }, sequence);
23+
Assert.Single(sink.Records);
24+
Assert.Equal("Action.X", sink.Records[0].Action);
25+
Assert.Equal("DOMAIN\\u", sink.Records[0].User);
26+
Assert.Equal("Success", sink.Records[0].Outcome);
27+
Assert.Equal("subject", sink.Records[0].Subject);
28+
Assert.Equal("details", sink.Records[0].Details);
29+
Assert.Equal(Environment.MachineName, sink.Records[0].MachineName);
30+
}
31+
32+
[Fact]
33+
public async Task Record_SinkFailure_DoesNotPropagate()
34+
{
35+
// Mirror failures (network down, DCR misconfigured, throttling) MUST be swallowed so
36+
// the audit trail integrity invariant is preserved: the caller never observes an
37+
// exception just because the optional LA mirror is broken.
38+
var inner = new TrackingAudit(new List<string>());
39+
var sink = new ThrowingSink(new InvalidOperationException("ingestion down"));
40+
var mirror = new LogAnalyticsAuditMirror(inner, sink, NullLogger<LogAnalyticsAuditMirror>.Instance);
41+
42+
var ex = await Record.ExceptionAsync(() =>
43+
mirror.RecordAsync("Action.Y", "user", "Success"));
44+
45+
Assert.Null(ex);
46+
Assert.Equal(1, inner.Count);
47+
}
48+
49+
[Fact]
50+
public async Task Record_InnerFailure_Propagates_AndSinkNotCalled()
51+
{
52+
// Conversely: if the inner audit service throws (which AuditService is documented to
53+
// never do, but a custom replacement might), the mirror MUST NOT swallow it — the
54+
// canonical audit trail is what the system is guaranteeing, not the LA mirror.
55+
var inner = new ThrowingAudit(new InvalidOperationException("DB write blew up"));
56+
var sink = new TrackingSink(new List<string>());
57+
var mirror = new LogAnalyticsAuditMirror(inner, sink, NullLogger<LogAnalyticsAuditMirror>.Instance);
58+
59+
await Assert.ThrowsAsync<InvalidOperationException>(() =>
60+
mirror.RecordAsync("Action.Z", "user", "Failure"));
61+
62+
Assert.Empty(sink.Records);
63+
}
64+
65+
[Fact]
66+
public async Task Record_Cancelled_BeforeSink_DoesNotThrow()
67+
{
68+
var inner = new TrackingAudit(new List<string>());
69+
var sink = new ThrowingSink(new OperationCanceledException());
70+
var mirror = new LogAnalyticsAuditMirror(inner, sink, NullLogger<LogAnalyticsAuditMirror>.Instance);
71+
using var cts = new CancellationTokenSource();
72+
cts.Cancel();
73+
74+
var ex = await Record.ExceptionAsync(() =>
75+
mirror.RecordAsync("Action.W", "u", "Success", ct: cts.Token));
76+
Assert.Null(ex);
77+
}
78+
79+
[Fact]
80+
public void Ctor_RejectsNulls()
81+
{
82+
var inner = new TrackingAudit(new List<string>());
83+
var sink = new TrackingSink(new List<string>());
84+
Assert.Throws<ArgumentNullException>(() => new LogAnalyticsAuditMirror(null!, sink, NullLogger<LogAnalyticsAuditMirror>.Instance));
85+
Assert.Throws<ArgumentNullException>(() => new LogAnalyticsAuditMirror(inner, null!, NullLogger<LogAnalyticsAuditMirror>.Instance));
86+
Assert.Throws<ArgumentNullException>(() => new LogAnalyticsAuditMirror(inner, sink, null!));
87+
}
88+
89+
[Fact]
90+
public void ProcessIdentity_DelegatesToInner()
91+
{
92+
var inner = new TrackingAudit(new List<string>()) { ProcessIdentityValue = "DOMAIN\\svc$" };
93+
var sink = new TrackingSink(new List<string>());
94+
var mirror = new LogAnalyticsAuditMirror(inner, sink, NullLogger<LogAnalyticsAuditMirror>.Instance);
95+
Assert.Equal("DOMAIN\\svc$", mirror.ProcessIdentity);
96+
}
97+
98+
// ---- fakes ----
99+
100+
private sealed class TrackingAudit : IAuditService
101+
{
102+
private readonly List<string> _seq;
103+
public string ProcessIdentityValue { get; set; } = "DOMAIN\\user";
104+
public int Count { get; private set; }
105+
public TrackingAudit(List<string> seq) { _seq = seq; }
106+
public string ProcessIdentity => ProcessIdentityValue;
107+
public Task RecordAsync(string action, string user, string outcome, string? subject = null, string? details = null, CancellationToken ct = default)
108+
{
109+
_seq.Add("inner");
110+
Count++;
111+
return Task.CompletedTask;
112+
}
113+
}
114+
115+
private sealed class ThrowingAudit : IAuditService
116+
{
117+
private readonly Exception _ex;
118+
public ThrowingAudit(Exception ex) { _ex = ex; }
119+
public string ProcessIdentity => "DOMAIN\\u";
120+
public Task RecordAsync(string action, string user, string outcome, string? subject = null, string? details = null, CancellationToken ct = default)
121+
=> Task.FromException(_ex);
122+
}
123+
124+
private sealed class TrackingSink : ILogAnalyticsAuditSink
125+
{
126+
private readonly List<string> _seq;
127+
public List<AuditLogRecord> Records { get; } = new();
128+
public TrackingSink(List<string> seq) { _seq = seq; }
129+
public Task SendAsync(AuditLogRecord record, CancellationToken ct)
130+
{
131+
_seq.Add("sink");
132+
Records.Add(record);
133+
return Task.CompletedTask;
134+
}
135+
}
136+
137+
private sealed class ThrowingSink : ILogAnalyticsAuditSink
138+
{
139+
private readonly Exception _ex;
140+
public ThrowingSink(Exception ex) { _ex = ex; }
141+
public Task SendAsync(AuditLogRecord record, CancellationToken ct) => Task.FromException(_ex);
142+
}
143+
}

0 commit comments

Comments
 (0)