Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
aa99fa8
Add audit trail logging design doc
jagudelo-gap Jul 28, 2026
324351b
Add audit trail logging implementation plan
jagudelo-gap Jul 28, 2026
c47b5ee
feat: add audit trail data model and configuration types
jagudelo-gap Jul 28, 2026
9f20250
feat: add bounded audit event channel and recorder
jagudelo-gap Jul 28, 2026
59bd8a0
fix: make AuditEventRecorder.Record fail-open on connection-string re…
jagudelo-gap Jul 28, 2026
d584c08
feat: add audit log background writer with retry and fallback logging
jagudelo-gap Jul 28, 2026
308eca9
fix: handle cancellation during audit log retry delay as fallback
jagudelo-gap Jul 28, 2026
6b434c9
feat: add audit action-event capture middleware
jagudelo-gap Jul 28, 2026
31b23cb
fix: record audit action event even when downstream middleware throws
jagudelo-gap Jul 28, 2026
582d266
feat: record authentication audit events at token issuance and challenge
jagudelo-gap Jul 28, 2026
1cdceee
feat: add AuditLogs table and writer for Admin API V2
jagudelo-gap Jul 29, 2026
1d4edf8
feat: add AuditLogs table and writer for Admin API V3
jagudelo-gap Jul 29, 2026
0038aa0
feat: wire up audit logging services, middleware, and configuration
jagudelo-gap Jul 29, 2026
9837392
fix: record accurate status codes for auth challenge and action-excep…
jagudelo-gap Jul 29, 2026
1d03d7d
docs: document audit trail logging configuration and captured events
jagudelo-gap Jul 29, 2026
ad9d368
fix: widen AuditLogs.ClientId column, add auth-hook tests, document k…
jagudelo-gap Jul 29, 2026
3e9b84c
feat: add containerized DB test runner for Admin API DBTests
jagudelo-gap Jul 29, 2026
982f08c
fix: update adminApiMode requirement to support v2 and v3 for dispatc…
jagudelo-gap Jul 29, 2026
430adcb
Merge remote-tracking branch 'origin/main' into ADMINAPI-1479
jagudelo-gap Jul 29, 2026
0d5b742
chore(logging): remove log4net migration plan document and related re…
jagudelo-gap Jul 29, 2026
4a72f49
feat(audit): enhance logging for dropped audit events and fallback lo…
jagudelo-gap Jul 29, 2026
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// SPDX-License-Identifier: Apache-2.0
// Licensed to the Ed-Fi Alliance under one or more agreements.
// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
// See the LICENSE and NOTICES files in the project root for more information.

using System.Security.Claims;
using EdFi.Ods.AdminApi.Common.Infrastructure.Audit;
using FakeItEasy;
using Microsoft.AspNetCore.Http;
using NUnit.Framework;

namespace EdFi.Ods.AdminApi.Common.UnitTests.Infrastructure.Audit;

[TestFixture]
public class AuditActionLoggingMiddlewareTests
{
private static DefaultHttpContext BuildContext(string method, string path, string? clientId, int statusCode)
{
var context = new DefaultHttpContext();
context.Request.Method = method;
context.Request.Path = path;
context.Response.Body = new MemoryStream();
context.Response.StatusCode = statusCode;
if (clientId != null)
{
context.User = new ClaimsPrincipal(new ClaimsIdentity(
[new Claim("client_id", clientId)], "test"));
}
return context;
}

[Test]
public async Task InvokeAsync_ForPostRequest_RecordsActionEvent()
{
var recorder = A.Fake<IAuditEventRecorder>();
var middleware = new AuditActionLoggingMiddleware(_ => Task.CompletedTask, recorder);
var context = BuildContext("POST", "/v3/apiClients", "client-1", 201);

await middleware.InvokeAsync(context);

A.CallTo(() => recorder.Record(
AuditEventType.Action, "client-1", A<string?>._, "POST", "/v3/apiClients", 201))
.MustHaveHappenedOnceExactly();
}

[Test]
public async Task InvokeAsync_ForGetRequest_DoesNotRecordEvent()
{
var recorder = A.Fake<IAuditEventRecorder>();
var middleware = new AuditActionLoggingMiddleware(_ => Task.CompletedTask, recorder);
var context = BuildContext("GET", "/v3/apiClients", "client-1", 200);

await middleware.InvokeAsync(context);

A.CallTo(() => recorder.Record(
A<AuditEventType>._, A<string?>._, A<string?>._, A<string?>._, A<string?>._, A<int?>._))
.MustNotHaveHappened();
}

[Test]
public async Task InvokeAsync_WhenNoClientIdClaim_RecordsNullClientId()
{
var recorder = A.Fake<IAuditEventRecorder>();
var middleware = new AuditActionLoggingMiddleware(_ => Task.CompletedTask, recorder);
var context = BuildContext("DELETE", "/v3/apiClients/1", null, 204);

await middleware.InvokeAsync(context);

A.CallTo(() => recorder.Record(
AuditEventType.Action, null, A<string?>._, "DELETE", "/v3/apiClients/1", 204))
.MustHaveHappenedOnceExactly();
}

[Test]
public void InvokeAsync_WhenNextThrows_StillRecordsActionEventAndPropagatesException()
{
var recorder = A.Fake<IAuditEventRecorder>();
var middleware = new AuditActionLoggingMiddleware(
_ => throw new InvalidOperationException("downstream failure"), recorder);
var context = BuildContext("POST", "/v3/apiClients", "client-1", 200);

Assert.ThrowsAsync<InvalidOperationException>(() => middleware.InvokeAsync(context));

A.CallTo(() => recorder.Record(
AuditEventType.Action, "client-1", A<string?>._, "POST", "/v3/apiClients", StatusCodes.Status500InternalServerError))
.MustHaveHappenedOnceExactly();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// SPDX-License-Identifier: Apache-2.0
// Licensed to the Ed-Fi Alliance under one or more agreements.
// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
// See the LICENSE and NOTICES files in the project root for more information.

using EdFi.Ods.AdminApi.Common.Infrastructure.Audit;
using EdFi.Ods.AdminApi.Common.Infrastructure.Context;
using EdFi.Ods.AdminApi.Common.Infrastructure.MultiTenancy;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
using NUnit.Framework;
using Shouldly;

namespace EdFi.Ods.AdminApi.Common.UnitTests.Infrastructure.Audit;

[TestFixture]
public class AuditEventRecorderTests
{
private static IConfiguration BuildConfiguration() =>
new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:EdFi_Admin"] = "fallback-connection-string"
})
.Build();

[Test]
public void Record_WhenAuditLoggingDisabled_DoesNotEnqueueEvent()
{
var channel = new AuditLogChannel();
var tenantContext = new ContextProvider<TenantConfiguration>(new AsyncLocalContextStorage());
var recorder = new AuditEventRecorder(
channel,
Options.Create(new AuditLoggingSettings { Enabled = false }),
tenantContext,
BuildConfiguration());

recorder.Record(AuditEventType.Action, "client-1", "127.0.0.1", "POST", "/v3/apiClients", 201);

channel.Reader.TryRead(out _).ShouldBeFalse();
}

[Test]
public void Record_WhenAuditLoggingEnabledAndNoTenantContext_EnqueuesEventWithFallbackConnectionString()
{
var channel = new AuditLogChannel();
var tenantContext = new ContextProvider<TenantConfiguration>(new AsyncLocalContextStorage());
var recorder = new AuditEventRecorder(
channel,
Options.Create(new AuditLoggingSettings { Enabled = true }),
tenantContext,
BuildConfiguration());

recorder.Record(AuditEventType.Action, "client-1", "127.0.0.1", "POST", "/v3/apiClients", 201);

channel.Reader.TryRead(out var auditEvent).ShouldBeTrue();
auditEvent!.AdminConnectionString.ShouldBe("fallback-connection-string");
auditEvent.EventType.ShouldBe(AuditEventType.Action);
auditEvent.ClientId.ShouldBe("client-1");
auditEvent.SourceIpAddress.ShouldBe("127.0.0.1");
auditEvent.HttpVerb.ShouldBe("POST");
auditEvent.HttpUrl.ShouldBe("/v3/apiClients");
auditEvent.StatusCode.ShouldBe(201);
}

[Test]
public void Record_WhenTenantContextIsSet_EnqueuesEventWithTenantConnectionString()
{
var channel = new AuditLogChannel();
var tenantContext = new ContextProvider<TenantConfiguration>(new AsyncLocalContextStorage());
tenantContext.Set(new TenantConfiguration { AdminConnectionString = "tenant-connection-string" });
var recorder = new AuditEventRecorder(
channel,
Options.Create(new AuditLoggingSettings { Enabled = true }),
tenantContext,
BuildConfiguration());

recorder.Record(AuditEventType.AuthenticationFailure, null, "10.0.0.5", null, null, 401);

channel.Reader.TryRead(out var auditEvent).ShouldBeTrue();
auditEvent!.AdminConnectionString.ShouldBe("tenant-connection-string");
auditEvent.ClientId.ShouldBeNull();
}

[Test]
public void Record_WhenConnectionStringResolutionThrows_DoesNotThrowAndDoesNotEnqueueEvent()
{
var channel = new AuditLogChannel();
var tenantContext = new ContextProvider<TenantConfiguration>(new AsyncLocalContextStorage());
var configurationWithNoConnectionString = new ConfigurationBuilder().Build();
var recorder = new AuditEventRecorder(
channel,
Options.Create(new AuditLoggingSettings { Enabled = true }),
tenantContext,
configurationWithNoConnectionString);

Should.NotThrow(() =>
recorder.Record(AuditEventType.Action, "client-1", "127.0.0.1", "POST", "/v3/apiClients", 201));

channel.Reader.TryRead(out _).ShouldBeFalse();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// SPDX-License-Identifier: Apache-2.0
// Licensed to the Ed-Fi Alliance under one or more agreements.
// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
// See the LICENSE and NOTICES files in the project root for more information.

using EdFi.Ods.AdminApi.Common.Infrastructure.Audit;
using FakeItEasy;
using NUnit.Framework;
using Shouldly;

namespace EdFi.Ods.AdminApi.Common.UnitTests.Infrastructure.Audit;

[TestFixture]
public class AuditLogBackgroundServiceTests
{
private static AuditEvent SampleEvent() => new()
{
AdminConnectionString = "conn",
EventType = AuditEventType.Action,
Timestamp = DateTime.UtcNow,
HttpVerb = "DELETE",
HttpUrl = "/v3/apiClients/1",
StatusCode = 204
};

[Test]
public async Task ProcessEventAsync_WhenWriterSucceedsFirstTry_WritesOnceAndDoesNotFallBack()
{
var writer = A.Fake<IAuditLogWriter>();
var service = new AuditLogBackgroundService(new AuditLogChannel(), writer);

var fellBackToLogging = await service.ProcessEventAsync(SampleEvent(), writer, CancellationToken.None);

fellBackToLogging.ShouldBeFalse();
A.CallTo(() => writer.WriteAsync(A<AuditEvent>._, A<CancellationToken>._))
.MustHaveHappenedOnceExactly();
}

[Test]
public async Task ProcessEventAsync_WhenWriterFailsTwiceThenSucceeds_RetriesAndDoesNotFallBack()
{
var writer = A.Fake<IAuditLogWriter>();
var callCount = 0;
A.CallTo(() => writer.WriteAsync(A<AuditEvent>._, A<CancellationToken>._))
.Invokes(() => callCount++)
.ReturnsLazily(() =>
{
if (callCount < 3)
{
throw new InvalidOperationException("transient failure");
}
return Task.CompletedTask;
});
var service = new AuditLogBackgroundService(new AuditLogChannel(), writer);

var fellBackToLogging = await service.ProcessEventAsync(SampleEvent(), writer, CancellationToken.None);

fellBackToLogging.ShouldBeFalse();
callCount.ShouldBe(3);
}

[Test]
public async Task ProcessEventAsync_WhenWriterAlwaysFails_FallsBackAfterExhaustingRetries()
{
var writer = A.Fake<IAuditLogWriter>();
A.CallTo(() => writer.WriteAsync(A<AuditEvent>._, A<CancellationToken>._))
.Throws(new InvalidOperationException("permanent failure"));
var service = new AuditLogBackgroundService(new AuditLogChannel(), writer);

var fellBackToLogging = await service.ProcessEventAsync(SampleEvent(), writer, CancellationToken.None);

fellBackToLogging.ShouldBeTrue();
A.CallTo(() => writer.WriteAsync(A<AuditEvent>._, A<CancellationToken>._))
.MustHaveHappened(3, Times.Exactly);
}

[Test]
public async Task ProcessEventAsync_WhenCancelledDuringRetryDelay_FallsBackInsteadOfThrowing()
{
var writer = A.Fake<IAuditLogWriter>();
A.CallTo(() => writer.WriteAsync(A<AuditEvent>._, A<CancellationToken>._))
.Throws(new InvalidOperationException("transient failure"));
var service = new AuditLogBackgroundService(new AuditLogChannel(), writer);
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(50));

var fellBackToLogging = await service.ProcessEventAsync(SampleEvent(), writer, cts.Token);

fellBackToLogging.ShouldBeTrue();
}
}
8 changes: 8 additions & 0 deletions Application/EdFi.Ods.AdminApi.Common/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// SPDX-License-Identifier: Apache-2.0
// Licensed to the Ed-Fi Alliance under one or more agreements.
// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
// See the LICENSE and NOTICES files in the project root for more information.

using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("EdFi.Ods.AdminApi.Common.UnitTests")]
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// SPDX-License-Identifier: Apache-2.0
// Licensed to the Ed-Fi Alliance under one or more agreements.
// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
// See the LICENSE and NOTICES files in the project root for more information.

using Microsoft.AspNetCore.Http;

namespace EdFi.Ods.AdminApi.Common.Infrastructure.Audit;

public class AuditActionLoggingMiddleware(RequestDelegate next, IAuditEventRecorder recorder)
{
private static readonly HashSet<string> _mutatingVerbs = new(StringComparer.OrdinalIgnoreCase)
{
"POST", "PUT", "PATCH", "DELETE"
};

public async Task InvokeAsync(HttpContext context)
{
if (!_mutatingVerbs.Contains(context.Request.Method))
{
await next(context);
return;
}

var clientId = context.User.FindFirst("client_id")?.Value;
var sourceIpAddress = context.Connection.RemoteIpAddress?.ToString();
var httpVerb = context.Request.Method;
var httpUrl = context.Request.Path.Value;

try
{
await next(context);
}
catch
{
recorder.Record(
AuditEventType.Action,
clientId,
sourceIpAddress,
httpVerb,
httpUrl,
StatusCodes.Status500InternalServerError);
throw;
}

recorder.Record(AuditEventType.Action, clientId, sourceIpAddress, httpVerb, httpUrl, context.Response.StatusCode);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// SPDX-License-Identifier: Apache-2.0
// Licensed to the Ed-Fi Alliance under one or more agreements.
// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
// See the LICENSE and NOTICES files in the project root for more information.

namespace EdFi.Ods.AdminApi.Common.Infrastructure.Audit;

public class AuditEvent
{
public required string AdminConnectionString { get; init; }
public required AuditEventType EventType { get; init; }
public required DateTime Timestamp { get; init; }
public string? ClientId { get; init; }
public string? SourceIpAddress { get; init; }
public string? HttpVerb { get; init; }
public string? HttpUrl { get; init; }
public int? StatusCode { get; init; }
}
Loading
Loading