Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
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
41 changes: 41 additions & 0 deletions src/ServiceControl.Infrastructure.Tests/Auth/AuditHeadersTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#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_and_name_headers()
{
var options = new SendOptions();
AuditHeaders.Stamp(options, new AuditUser("alice-sub", "Alice"));

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

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

Assert.That(AuditHeaders.Read(headers), Is.EqualTo(new AuditUser("alice-sub", "Alice")));
}

[Test]
public void Read_returns_anonymous_when_headers_absent()
{
Assert.That(AuditHeaders.Read(new Dictionary<string, string>()), Is.EqualTo(AuditUser.Anonymous));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#nullable enable
namespace ServiceControl.Infrastructure.Tests.Auth;

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using NUnit.Framework;
using ServiceControl.Configuration;
using ServiceControl.Hosting.Auth;
using ServiceControl.Infrastructure;
using ServiceControl.Infrastructure.Auth;

[TestFixture]
public class AuditServiceRegistrationTests
{
[Test]
public void Registers_message_action_audit_and_user_accessor()
{
var builder = Host.CreateApplicationBuilder();
builder.Services.AddSingleton<ILoggerFactory>(LoggerFactory.Create(_ => { }));
var settings = new OpenIdConnectSettings(new SettingsRootNamespace("ServiceControl"), validateConfiguration: false, requireServicePulseSettings: false);

builder.AddServiceControlAuthorization(settings);

using var provider = builder.Services.BuildServiceProvider();
Assert.That(provider.GetService<IMessageActionAuditLog>(), Is.TypeOf<MessageActionAuditLog>());
Assert.That(provider.GetService<ICurrentUserAccessor>(), Is.TypeOf<CurrentUserAccessor>());
}
}
17 changes: 17 additions & 0 deletions src/ServiceControl.Infrastructure.Tests/Auth/AuditUserTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#nullable enable
namespace ServiceControl.Infrastructure.Tests.Auth;

using NUnit.Framework;
using ServiceControl.Infrastructure.Auth;

[TestFixture]
public class AuditUserTests
{
[Test]
public void Anonymous_has_sentinel_id_and_name()
{
Assert.That(AuditUser.Anonymous.Id, Is.EqualTo("anonymous"));
Assert.That(AuditUser.Anonymous.Name, Is.EqualTo("anonymous"));
Assert.That(AuditUser.AnonymousValue, Is.EqualTo("anonymous"));
}
}
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,98 @@
#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"));
}

[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 @@ -105,4 +105,14 @@ public void Audit_decisions_render_as_valid_structured_json()
Assert.That(deny.GetProperty("servicecontrol").GetProperty("resource").ValueKind, Is.EqualTo(JsonValueKind.Null), "absent resource should be JSON 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
34 changes: 34 additions & 0 deletions src/ServiceControl.Infrastructure/Auth/AuditHeaders.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#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 static void Stamp(SendOptions options, AuditUser user)
{
options.SetHeader(SubjectId, user.Id);
options.SetHeader(SubjectName, user.Name);
}

public static AuditUser Read(IReadOnlyDictionary<string, string> headers)
{
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);
}

return AuditUser.Anonymous;
}
}
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);
}
29 changes: 29 additions & 0 deletions src/ServiceControl.Infrastructure/Auth/CurrentUserAccessor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#nullable enable
namespace ServiceControl.Infrastructure.Auth;

using System.Security.Claims;

/// <summary>
/// Reads the subject id/name from the configured OIDC claim keys (the same keys
/// <c>PermissionVerbHandler</c> uses). Falls back to <see cref="AuditUser.Anonymous"/> rather than
/// throwing, so the action trail is still recorded when authentication is disabled.
/// </summary>
public sealed class CurrentUserAccessor(OpenIdConnectSettings oidcSettings) : ICurrentUserAccessor
{
public AuditUser Resolve(ClaimsPrincipal? principal)
{
if (principal?.Identity?.IsAuthenticated != true)
{
return AuditUser.Anonymous;
}

var id = principal.FindFirst(oidcSettings.SubjectIdClaim)?.Value;
if (string.IsNullOrEmpty(id))
{
return AuditUser.Anonymous;
}

var name = principal.FindFirst(oidcSettings.SubjectNameClaim)?.Value;
return new AuditUser(id, string.IsNullOrEmpty(name) ? id : name);
}
}
11 changes: 11 additions & 0 deletions src/ServiceControl.Infrastructure/Auth/ICurrentUserAccessor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#nullable enable
namespace ServiceControl.Infrastructure.Auth;

using System.Security.Claims;

/// <summary>Resolves the audited <see cref="AuditUser"/> from the current request principal.</summary>
public interface ICurrentUserAccessor
{
/// <summary>Returns the principal's subject id/name, or <see cref="AuditUser.Anonymous"/> when there is no identified principal.</summary>
AuditUser Resolve(ClaimsPrincipal? principal);
}
17 changes: 17 additions & 0 deletions src/ServiceControl.Infrastructure/Auth/IMessageActionAuditLog.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#nullable enable
namespace ServiceControl.Infrastructure.Auth;

/// <summary>
/// Records user-initiated recoverability message actions (retry / archive / unarchive) as structured
/// audit entries. Operation-level entries answer "who did what to which resource"; per-message entries
/// record each affected message. Both are emitted on the stable <c>ServiceControl.Audit</c> category
/// family so SIEM sinks can collect them without coupling to the concrete type name.
/// </summary>
public interface IMessageActionAuditLog
{
/// <summary>Records one user operation (a single click / API call), whatever its fan-out.</summary>
void Operation(AuditUser user, MessageActionKind kind, string permission, MessageActionScope scope, string? resource, int? count, string operationId, bool success = true);

/// <summary>Records one affected message, correlated to its operation via <paramref name="operationId"/>.</summary>
void MessageAction(AuditUser user, MessageActionKind kind, string permission, MessageActionScope scope, string messageId, string operationId, bool success = true);
}
Loading
Loading