Skip to content
Open
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
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
@@ -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
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 @@ -97,7 +97,7 @@
});

var deny = JsonDocument.Parse(captured.Logs[1]).RootElement;
Assert.Multiple(() =>

Check failure on line 100 in src/ServiceControl.Infrastructure.Tests/LoggingConfiguratorTests.cs

View workflow job for this annotation

GitHub Actions / Windows-Default

Audit_decisions_render_as_valid_structured_json

System.Collections.Generic.KeyNotFoundException : The given key was not present in the dictionary.

Check failure on line 100 in src/ServiceControl.Infrastructure.Tests/LoggingConfiguratorTests.cs

View workflow job for this annotation

GitHub Actions / Linux-Default

Audit_decisions_render_as_valid_structured_json

System.Collections.Generic.KeyNotFoundException : The given key was not present in the dictionary.
{
Assert.That(deny.GetProperty("event").GetProperty("type")[0].GetString(), Is.EqualTo("denied"));
Assert.That(deny.GetProperty("event").GetProperty("outcome").GetString(), Is.EqualTo("failure"));
Expand All @@ -105,4 +105,14 @@
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
Loading
Loading