Skip to content
Open
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
92db56c
✨ Add AuditUser and message-action enums
ramonsmits Jul 1, 2026
c5a031c
✨ Add MessageActionAuditLog ECS emitter
ramonsmits Jul 1, 2026
757b737
✨ Add ICurrentUserAccessor for audit identity resolution
ramonsmits Jul 1, 2026
8aa8747
✨ Add AuditHeaders identity stamp/read seam
ramonsmits Jul 1, 2026
9280bec
✨ Register message-action audit services
ramonsmits Jul 1, 2026
fb04b91
✅ Guard that ServiceControl.Audit.Messages routes to the audit sink
ramonsmits Jul 1, 2026
3bdbb51
✨ Audit group-retry operations
ramonsmits Jul 1, 2026
c07fd56
✨ Audit group-archive and group-unarchive operations
ramonsmits Jul 1, 2026
70a6e2c
✨ Audit direct retry operations
ramonsmits Jul 1, 2026
44fe317
✨ Audit archive, unarchive, and pending-retry operations
ramonsmits Jul 1, 2026
a9b6151
✅ Strengthen batch-audit test assertions (count/resource)
ramonsmits Jul 1, 2026
c4cd4bd
✨ Audit per-message entries for batch id operations
ramonsmits Jul 1, 2026
68a5706
✅ File-scoped namespaces and per-message coverage for audit tests
ramonsmits Jul 1, 2026
7f5f2ca
✨ Audit each retried/archived message at execution time
ramonsmits Jul 3, 2026
7cbeb5c
♻️ Omit null-valued properties from audit ECS documents
ramonsmits Jul 3, 2026
6b8c062
⚜️ Use file-scoped namespaces for new audit files
ramonsmits Jul 3, 2026
b869ba1
Audit edit operations
WilliamBZA Jul 6, 2026
c7d5f50
Update assertions
WilliamBZA Jul 6, 2026
92d6f94
Remove some unneeded white box tests
ramonsmits Jul 6, 2026
24bac90
Use File-scoped namespaces
ramonsmits Jul 6, 2026
f0b1547
Trying to fix format
dvdstelt Jul 6, 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
2 changes: 1 addition & 1 deletion src/ServiceControl.Audit/Infrastructure/WebApi/Cors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public static CorsPolicy GetDefaultPolicy(CorsSettings settings)
}

// Headers exposed to the client in the response (accessible via JavaScript)
builder.WithExposedHeaders(["ETag", "Last-Modified", "Link", "Total-Count", "X-Particular-Version"]);
builder.WithExposedHeaders(["ETag", "Last-Modified", "Link", "Total-Count", "X-Particular-Version", "Request-Id"]);
// Headers allowed in the request from the client
builder.WithHeaders(["Origin", "X-Requested-With", "Content-Type", "Accept", "Authorization"]);
// HTTP methods allowed for cross-origin requests
Expand Down
16 changes: 16 additions & 0 deletions src/ServiceControl.Audit/WebApplicationExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
namespace ServiceControl.Audit;

using System.Threading.Tasks;
using Infrastructure.WebApi;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using ServiceControl.Hosting.ForwardedHeaders;
using ServiceControl.Hosting.Https;
using ServiceControl.Infrastructure;
Expand All @@ -10,6 +12,20 @@ public static class WebApplicationExtensions
{
public static void UseServiceControlAudit(this WebApplication app, ForwardedHeadersSettings forwardedHeadersSettings, HttpsSettings httpsSettings)
{
// Surface the per-request id so callers can correlate and quote it. TraceIdentifier is stable
// for the request; OnStarting sets it before the response flushes.
app.Use((context, next) =>
{
context.Response.OnStarting(static state =>
{
var httpContext = (HttpContext)state;
httpContext.Response.Headers["Request-Id"] = httpContext.TraceIdentifier;
return Task.CompletedTask;
}, context);

return next(context);
});

app.UseServiceControlForwardedHeaders(forwardedHeadersSettings);
app.UseServiceControlHttps(httpsSettings);
app.UseResponseCompression();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ public static void AddServiceControlAuthorization(this IHostApplicationBuilder h
services.AddSingleton<IAuthorizationAuditLog, AuthorizationAuditLog>();
services.AddSingleton<IAuthorizationHandler, PermissionVerbHandler>();

// Message-action audit trail. Registered unconditionally (independent of OIDC being enabled) so
// the action trail is recorded even without authentication, attributed to AuditUser.Anonymous.
services.AddSingleton<IMessageActionAuditLog, MessageActionAuditLog>();
services.AddSingleton<ICurrentUserAccessor, CurrentUserAccessor>();

// Backs the my/routes manifest: a singleton table projected from the wired endpoints. Reuses
// the EndpointDataSource the framework registers, so it sees exactly the routes that are served.
services.AddSingleton<RouteAuthorizationTable>();
Expand Down
47 changes: 47 additions & 0 deletions src/ServiceControl.Infrastructure.Tests/Auth/AuditHeadersTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#nullable enable
namespace ServiceControl.Infrastructure.Tests.Auth;

using System.Collections.Generic;
using NServiceBus;
using NServiceBus.Testing;
using NUnit.Framework;
using ServiceControl.Infrastructure.Auth;

[TestFixture]
public class AuditHeadersTests
{
[Test]
public void Stamp_writes_id_name_and_operation_headers()
{
var options = new SendOptions();
AuditHeaders.Stamp(options, new AuditUser("alice-sub", "Alice"), "op-123");

var headers = options.GetHeaders();
Assert.That(headers[AuditHeaders.SubjectId], Is.EqualTo("alice-sub"));
Assert.That(headers[AuditHeaders.SubjectName], Is.EqualTo("Alice"));
Assert.That(headers[AuditHeaders.OperationId], Is.EqualTo("op-123"));
}

[Test]
public void Read_round_trips_stamped_identity_and_operation()
{
var headers = new Dictionary<string, string>
{
[AuditHeaders.SubjectId] = "alice-sub",
[AuditHeaders.SubjectName] = "Alice",
[AuditHeaders.OperationId] = "op-123"
};

var (user, operationId) = AuditHeaders.Read(headers);
Assert.That(user, Is.EqualTo(new AuditUser("alice-sub", "Alice")));
Assert.That(operationId, Is.EqualTo("op-123"));
}

[Test]
public void Read_returns_anonymous_when_headers_absent()
{
var (user, operationId) = AuditHeaders.Read(new Dictionary<string, string>());
Assert.That(user, Is.EqualTo(AuditUser.Anonymous));
Assert.That(operationId, Is.Null);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ public void Decision_deny_emits_one_entry_on_audit_category()
Assert.That(ecs.GetProperty("event").GetProperty("type")[0].GetString(), Is.EqualTo("denied"));
Assert.That(ecs.GetProperty("event").GetProperty("outcome").GetString(), Is.EqualTo("failure"));
Assert.That(ecs.GetProperty("user").GetProperty("id").GetString(), Is.EqualTo("bob-sub-002"));
Assert.That(ecs.GetProperty("servicecontrol").GetProperty("resource").ValueKind, Is.EqualTo(JsonValueKind.Null));
Assert.That(ecs.GetProperty("servicecontrol").TryGetProperty("resource", out _), Is.False, "null resource should be omitted");
Assert.That(entries[0].Level, Is.EqualTo(LogLevel.Warning));
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
#nullable enable
namespace ServiceControl.Infrastructure.Tests.Auth;

using System.Security.Claims;
using NUnit.Framework;
using ServiceControl.Configuration;
using ServiceControl.Infrastructure;
using ServiceControl.Infrastructure.Auth;

[TestFixture]
public class CurrentUserAccessorTests
{
static CurrentUserAccessor Create()
{
// Default claim keys: SubjectIdClaim = "sub", SubjectNameClaim = "preferred_username".
var settings = new OpenIdConnectSettings(new SettingsRootNamespace("ServiceControl"), validateConfiguration: false, requireServicePulseSettings: false);
return new CurrentUserAccessor(settings);
}

static ClaimsPrincipal Authenticated(params Claim[] claims) =>
new(new ClaimsIdentity(claims, authenticationType: "test"));

[Test]
public void Resolves_id_and_name_from_configured_claims()
{
var user = Create().Resolve(Authenticated(new Claim("sub", "alice-sub"), new Claim("preferred_username", "Alice")));
Assert.That(user.Id, Is.EqualTo("alice-sub"));
Assert.That(user.Name, Is.EqualTo("Alice"));
}

[Test]
public void Falls_back_to_id_when_name_claim_missing()
{
var user = Create().Resolve(Authenticated(new Claim("sub", "alice-sub")));
Assert.That(user.Name, Is.EqualTo("alice-sub"));
}

[Test]
public void Anonymous_when_principal_is_null()
{
Assert.That(Create().Resolve(null), Is.EqualTo(AuditUser.Anonymous));
}

[Test]
public void Anonymous_when_not_authenticated()
{
Assert.That(Create().Resolve(new ClaimsPrincipal(new ClaimsIdentity())), Is.EqualTo(AuditUser.Anonymous));
}

[Test]
public void Anonymous_when_subject_claim_absent()
{
Assert.That(Create().Resolve(Authenticated(new Claim("preferred_username", "Alice"))), Is.EqualTo(AuditUser.Anonymous));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
#nullable enable
namespace ServiceControl.Infrastructure.Tests.Auth;

using System.Text.Json;
using Microsoft.Extensions.Logging;
using NUnit.Framework;
using ServiceControl.Infrastructure.Auth;

[TestFixture]
public class MessageActionAuditLogTests
{
static (RecordingLoggerProvider provider, MessageActionAuditLog log) Create()
{
var provider = new RecordingLoggerProvider();
var factory = LoggerFactory.Create(b => b.AddProvider(provider));
return (provider, new MessageActionAuditLog(factory));
}

[Test]
public void Operation_emits_one_entry_on_operation_category()
{
var (provider, log) = Create();

log.Operation(new AuditUser("alice-sub", "Alice"), MessageActionKind.Retry,
"error:recoverabilitygroups:retry", MessageActionScope.Group, resource: "group-1", count: 42, operationId: "op-1");

var entries = provider.EntriesFor("ServiceControl.Audit");
Assert.That(entries, Has.Count.EqualTo(1));
Assert.That(entries[0].Level, Is.EqualTo(LogLevel.Information));
var ecs = JsonDocument.Parse(entries[0].Message).RootElement;
Assert.That(ecs.GetProperty("event").GetProperty("category")[0].GetString(), Is.EqualTo("configuration"));
Assert.That(ecs.GetProperty("event").GetProperty("type")[0].GetString(), Is.EqualTo("change"));
Assert.That(ecs.GetProperty("event").GetProperty("action").GetString(), Is.EqualTo("error:recoverabilitygroups:retry"));
Assert.That(ecs.GetProperty("event").GetProperty("outcome").GetString(), Is.EqualTo("success"));
Assert.That(ecs.GetProperty("user").GetProperty("id").GetString(), Is.EqualTo("alice-sub"));
Assert.That(ecs.GetProperty("servicecontrol").GetProperty("scope").GetString(), Is.EqualTo("group"));
Assert.That(ecs.GetProperty("servicecontrol").GetProperty("resource").GetString(), Is.EqualTo("group-1"));
Assert.That(ecs.GetProperty("servicecontrol").GetProperty("count").GetInt32(), Is.EqualTo(42));
Assert.That(ecs.GetProperty("servicecontrol").GetProperty("operation").GetProperty("id").GetString(), Is.EqualTo("op-1"));
}

[Test]
public void Archive_maps_to_deletion_event_type()
{
var (provider, log) = Create();

log.Operation(AuditUser.Anonymous, MessageActionKind.Archive,
"error:messages:archive", MessageActionScope.Single, resource: "m-1", count: 1, operationId: "op-2");

var ecs = JsonDocument.Parse(provider.EntriesFor("ServiceControl.Audit")[0].Message).RootElement;
Assert.That(ecs.GetProperty("event").GetProperty("type")[0].GetString(), Is.EqualTo("deletion"));
Assert.That(ecs.GetProperty("user").GetProperty("id").GetString(), Is.EqualTo("anonymous"));
}

[Test]
public void MessageAction_emits_on_messages_subcategory_with_event_id_2002()
{
var (provider, log) = Create();

log.MessageAction(new AuditUser("bob-sub", "Bob"), MessageActionKind.Unarchive,
"error:messages:unarchive", MessageActionScope.Batch, messageId: "m-9", operationId: "op-3");

Assert.That(provider.EntriesFor("ServiceControl.Audit"), Is.Empty);
var entries = provider.EntriesFor("ServiceControl.Audit.Messages");
Assert.That(entries, Has.Count.EqualTo(1));
Assert.That(entries[0].EventId.Id, Is.EqualTo(2002));
var ecs = JsonDocument.Parse(entries[0].Message).RootElement;
Assert.That(ecs.GetProperty("servicecontrol").GetProperty("message").GetProperty("id").GetString(), Is.EqualTo("m-9"));
Assert.That(ecs.GetProperty("event").GetProperty("type")[0].GetString(), Is.EqualTo("change"));
}

[Test]
public void Operation_failure_logs_as_warning()
{
var (provider, log) = Create();

log.Operation(new AuditUser("a", "a"), MessageActionKind.Retry, "error:messages:retry",
MessageActionScope.All, resource: null, count: null, operationId: "op-4", success: false);

var entry = provider.EntriesFor("ServiceControl.Audit")[0];
Assert.That(entry.Level, Is.EqualTo(LogLevel.Warning));
Assert.That(entry.EventId.Id, Is.EqualTo(2001));
var ecs = JsonDocument.Parse(entry.Message).RootElement;
Assert.That(ecs.GetProperty("event").GetProperty("outcome").GetString(), Is.EqualTo("failure"));
}

[Test]
public void Null_valued_fields_are_omitted()
{
var (provider, log) = Create();

log.Operation(new AuditUser("a", "a"), MessageActionKind.Retry, "error:messages:retry",
MessageActionScope.All, resource: null, count: null, operationId: "op-5");

var sc = JsonDocument.Parse(provider.EntriesFor("ServiceControl.Audit")[0].Message).RootElement.GetProperty("servicecontrol");
using (Assert.EnterMultipleScope())
{
Assert.That(sc.TryGetProperty("resource", out _), Is.False);
Assert.That(sc.TryGetProperty("count", out _), Is.False);
Assert.That(sc.TryGetProperty("message", out _), Is.False);
Assert.That(sc.GetProperty("operation").GetProperty("id").GetString(), Is.EqualTo("op-5"));
}
}

[TestCase(null, "op")]
[TestCase("", "op")]
[TestCase("error:messages:retry", null)]
[TestCase("error:messages:retry", "")]
public void Operation_throws_when_permission_or_operationId_missing(string? permission, string? operationId)
{
var (_, log) = Create();
Assert.That(
() => log.Operation(AuditUser.Anonymous, MessageActionKind.Retry, permission!, MessageActionScope.All, null, null, operationId!),
Throws.InstanceOf<System.ArgumentException>());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,17 @@ public void Audit_decisions_render_as_valid_structured_json()
Assert.That(deny.GetProperty("event").GetProperty("type")[0].GetString(), Is.EqualTo("denied"));
Assert.That(deny.GetProperty("event").GetProperty("outcome").GetString(), Is.EqualTo("failure"));
Assert.That(deny.GetProperty("user").GetProperty("id").GetString(), Is.EqualTo("bob-sub-002"));
Assert.That(deny.GetProperty("servicecontrol").GetProperty("resource").ValueKind, Is.EqualTo(JsonValueKind.Null), "absent resource should be JSON null");
Assert.That(deny.GetProperty("servicecontrol").TryGetProperty("resource", out _), Is.False, "absent resource should be omitted from the JSON entirely, not present as null");
});
}

[Test]
public void Message_action_subcategory_is_captured_by_the_audit_rule()
{
var config = BuildConfig();
var auditRule = config.LoggingRules.Single(r => r.LoggerNamePattern == AuditPattern);

Assert.That(auditRule.NameMatches(ServiceControl.Infrastructure.Auth.MessageActionAuditLog.MessageCategory), Is.True);
Assert.That(auditRule.NameMatches(ServiceControl.Infrastructure.Auth.MessageActionAuditLog.OperationCategory), Is.True);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@
<ItemGroup>
<ProjectReference Include="..\ServiceControl.Infrastructure\ServiceControl.Infrastructure.csproj" />
<ProjectReference Include="..\TestHelper\TestHelper.csproj" />
<ProjectReference Include="..\ServiceControl.Hosting\ServiceControl.Hosting.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="GitHubActionsTestLogger" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NServiceBus.Testing" />
<PackageReference Include="NUnit" />
<PackageReference Include="NUnit.Analyzers" />
<PackageReference Include="NUnit3TestAdapter" />
Expand Down
41 changes: 41 additions & 0 deletions src/ServiceControl.Infrastructure/Auth/AuditHeaders.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#nullable enable
namespace ServiceControl.Infrastructure.Auth;

using System.Collections.Generic;
using NServiceBus;

/// <summary>
/// Carries the initiating principal on ServiceControl's own internal command messages so asynchronous
/// handlers can attribute per-message actions. Trusted as-is (trusted-subsystem model): the integrity
/// rests on transport access control, consistent with how the command itself is already trusted. This
/// type is the single stamp/read choke point — cryptographic signing would be added here.
/// </summary>
public static class AuditHeaders
{
public const string SubjectId = "ServiceControl.Audit.InitiatedBy.Id";
public const string SubjectName = "ServiceControl.Audit.InitiatedBy.Name";
public const string OperationId = "ServiceControl.Audit.OperationId";

public static void Stamp(SendOptions options, AuditUser user, string operationId)
{
options.SetHeader(SubjectId, user.Id);
options.SetHeader(SubjectName, user.Name);
if (!string.IsNullOrEmpty(operationId))
{
options.SetHeader(OperationId, operationId);
}
}

public static (AuditUser User, string? OperationId) Read(IReadOnlyDictionary<string, string> headers)
{
headers.TryGetValue(OperationId, out var operationId);

if (headers.TryGetValue(SubjectId, out var id) && !string.IsNullOrEmpty(id))
{
headers.TryGetValue(SubjectName, out var name);
return (new AuditUser(id, string.IsNullOrEmpty(name) ? id : name), operationId);
}

return (AuditUser.Anonymous, operationId);
}
}
13 changes: 13 additions & 0 deletions src/ServiceControl.Infrastructure/Auth/AuditUser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#nullable enable
namespace ServiceControl.Infrastructure.Auth;

/// <summary>
/// The principal an audited action is attributed to. <see cref="Anonymous"/> is recorded when
/// authentication is disabled or no identified principal is present.
/// </summary>
public readonly record struct AuditUser(string Id, string Name)
{
public const string AnonymousValue = "anonymous";

public static readonly AuditUser Anonymous = new(AnonymousValue, AnonymousValue);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ namespace ServiceControl.Infrastructure.Auth;
using System.Collections.Generic;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;

/// <summary>
Expand All @@ -19,7 +20,7 @@ public sealed partial class AuthorizationAuditLog(ILoggerFactory loggerFactory)

// Relaxed escaping keeps the JSON readable for log sinks (no \uXXXX for '+', '<', accented names, …);
// the HTML-safe default only matters in a browser context, which an audit log is not.
static readonly JsonSerializerOptions EcsJsonOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
static readonly JsonSerializerOptions EcsJsonOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull };

public void Decision(string subjectId, string subjectName, string permission, string? resource, bool allowed, string reason)
{
Expand Down
Loading
Loading