From 126e2b60b0676c8477b62ed9fe3c5d3bb083cce2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Oct 2025 23:16:36 +0000 Subject: [PATCH 1/3] Initial plan From f63ff6a57e4776930e0e04f14a4b6dd7968c6db3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Oct 2025 23:22:15 +0000 Subject: [PATCH 2/3] Add UserClaims enricher with tests and documentation Co-authored-by: mo-esmp <1659032+mo-esmp@users.noreply.github.com> --- README.md | 66 +++- .../Enrichers/UserClaimsEnricher.cs | 70 ++++ ...ClientInfoLoggerConfigurationExtensions.cs | 17 + .../UserClaimsEnricherTests.cs | 310 ++++++++++++++++++ 4 files changed, 462 insertions(+), 1 deletion(-) create mode 100644 src/Serilog.Enrichers.ClientInfo/Enrichers/UserClaimsEnricher.cs create mode 100644 test/Serilog.Enrichers.ClientInfo.Tests/UserClaimsEnricherTests.cs diff --git a/README.md b/README.md index e28fbe7..a0a5fe1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # serilog-enrichers-clientinfo [![NuGet](http://img.shields.io/nuget/v/Serilog.Enrichers.ClientInfo.svg?style=flat)](https://www.nuget.org/packages/Serilog.Enrichers.ClientInfo/) [![](https://img.shields.io/nuget/dt/Serilog.Enrichers.ClientInfo.svg?label=nuget%20downloads)](Serilog.Enrichers.ClientInfo) -Enrich logs with client IP, Correlation Id and HTTP request headers. +Enrich logs with client IP, Correlation Id, HTTP request headers, and user claims. Install the _Serilog.Enrichers.ClientInfo_ [NuGet package](https://www.nuget.org/packages/Serilog.Enrichers.ClientInfo/) @@ -19,6 +19,7 @@ Log.Logger = new LoggerConfiguration() .Enrich.WithClientIp() .Enrich.WithCorrelationId() .Enrich.WithRequestHeader("Header-Name1") + .Enrich.WithUserClaims(ClaimTypes.NameIdentifier, ClaimTypes.Email) // ...other configuration... .CreateLogger(); ``` @@ -35,6 +36,10 @@ or in `appsettings.json` file: { "Name": "WithRequestHeader", "Args": { "headerName": "User-Agent"} + }, + { + "Name": "WithUserClaims", + "Args": { "claimNames": ["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"] } } ], "WriteTo": [ @@ -178,6 +183,65 @@ Log.Logger = new LoggerConfiguration() .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss}] {Level:u3} {UserAgent} {Message:lj}{NewLine}{Exception}") ``` +### UserClaims +The `UserClaims` enricher allows you to log specific user claim values from authenticated users. This is useful for tracking user-specific information in your logs. + +#### Basic Usage +```csharp +using System.Security.Claims; + +Log.Logger = new LoggerConfiguration() + .Enrich.WithUserClaims(ClaimTypes.NameIdentifier, ClaimTypes.Email) + ... +``` + +or in `appsettings.json` file: +```json +{ + "Serilog": { + "MinimumLevel": "Debug", + "Using": [ "Serilog.Enrichers.ClientInfo" ], + "Enrich": [ + { + "Name": "WithUserClaims", + "Args": { + "claimNames": [ + "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier", + "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress" + ] + } + } + ] + } +} +``` + +#### Features +- **Configurable Claims**: Specify which claims to log by providing claim names as parameters. +- **Null-Safe**: If a claim doesn't exist, it will be logged as `null` instead of throwing an error. +- **Authentication-Aware**: Only logs claims when the user is authenticated. If the user is not authenticated, no claim properties are added to the log. +- **Performance-Optimized**: Claim values are cached per request for better performance. + +#### Example with Multiple Claims +```csharp +Log.Logger = new LoggerConfiguration() + .Enrich.WithUserClaims( + ClaimTypes.NameIdentifier, + ClaimTypes.Email, + ClaimTypes.Name, + ClaimTypes.Role) + .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss}] User: {http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier} {Message:lj}{NewLine}{Exception}") + ... +``` + +#### Custom Claims +You can also log custom claim types: +```csharp +Log.Logger = new LoggerConfiguration() + .Enrich.WithUserClaims("tenant_id", "organization_id") + ... +``` + ## Installing into an ASP.NET Core Web Application You need to register the `IHttpContextAccessor` singleton so the enrichers have access to the requests `HttpContext` to extract client IP and client agent. This is what your `Startup` class should contain in order for this enricher to work as expected: diff --git a/src/Serilog.Enrichers.ClientInfo/Enrichers/UserClaimsEnricher.cs b/src/Serilog.Enrichers.ClientInfo/Enrichers/UserClaimsEnricher.cs new file mode 100644 index 0000000..3bbf455 --- /dev/null +++ b/src/Serilog.Enrichers.ClientInfo/Enrichers/UserClaimsEnricher.cs @@ -0,0 +1,70 @@ +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Serilog.Core; +using Serilog.Events; + +namespace Serilog.Enrichers; + +/// +public class UserClaimsEnricher : ILogEventEnricher +{ + private readonly IHttpContextAccessor _contextAccessor; + private readonly string[] _claimNames; + private readonly Dictionary _claimItemKeys; + + /// + /// Initializes a new instance of the class. + /// + /// The names of the claims to log. + public UserClaimsEnricher(params string[] claimNames) + : this(new HttpContextAccessor(), claimNames) + { + } + + internal UserClaimsEnricher(IHttpContextAccessor contextAccessor, params string[] claimNames) + { + _contextAccessor = contextAccessor; + _claimNames = claimNames ?? []; + _claimItemKeys = new Dictionary(); + + // Pre-compute item keys for each claim + foreach (string claimName in _claimNames) + { + _claimItemKeys[claimName] = $"Serilog_UserClaim_{claimName}"; + } + } + + /// + public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) + { + HttpContext httpContext = _contextAccessor.HttpContext; + if (httpContext == null) return; + + ClaimsPrincipal user = httpContext.User; + if (user == null || !user.Identity?.IsAuthenticated == true) return; + + foreach (string claimName in _claimNames) + { + string itemKey = _claimItemKeys[claimName]; + + // Check if property already exists in HttpContext.Items + if (httpContext.Items.TryGetValue(itemKey, out object value) && + value is LogEventProperty logEventProperty) + { + logEvent.AddPropertyIfAbsent(logEventProperty); + continue; + } + + // Get claim value (null if not found) + string claimValue = user.FindFirst(claimName)?.Value; + + // Create log property with the claim name as the property name + LogEventProperty claimProperty = new(claimName, new ScalarValue(claimValue)); + httpContext.Items.Add(itemKey, claimProperty); + + logEvent.AddPropertyIfAbsent(claimProperty); + } + } +} diff --git a/src/Serilog.Enrichers.ClientInfo/Extensions/ClientInfoLoggerConfigurationExtensions.cs b/src/Serilog.Enrichers.ClientInfo/Extensions/ClientInfoLoggerConfigurationExtensions.cs index e943688..73522ca 100644 --- a/src/Serilog.Enrichers.ClientInfo/Extensions/ClientInfoLoggerConfigurationExtensions.cs +++ b/src/Serilog.Enrichers.ClientInfo/Extensions/ClientInfoLoggerConfigurationExtensions.cs @@ -85,4 +85,21 @@ public static LoggerConfiguration WithRequestHeader(this LoggerEnrichmentConfigu return enrichmentConfiguration.With(new ClientHeaderEnricher(headerName, propertyName)); } + + /// + /// Registers the user claims enricher to enrich logs with specified user claim values. + /// + /// The enrichment configuration. + /// The names of the claims to log. + /// enrichmentConfiguration + /// claimNames + /// The logger configuration so that multiple calls can be chained. + public static LoggerConfiguration WithUserClaims(this LoggerEnrichmentConfiguration enrichmentConfiguration, + params string[] claimNames) + { + ArgumentNullException.ThrowIfNull(enrichmentConfiguration, nameof(enrichmentConfiguration)); + ArgumentNullException.ThrowIfNull(claimNames, nameof(claimNames)); + + return enrichmentConfiguration.With(new UserClaimsEnricher(claimNames)); + } } \ No newline at end of file diff --git a/test/Serilog.Enrichers.ClientInfo.Tests/UserClaimsEnricherTests.cs b/test/Serilog.Enrichers.ClientInfo.Tests/UserClaimsEnricherTests.cs new file mode 100644 index 0000000..f681adb --- /dev/null +++ b/test/Serilog.Enrichers.ClientInfo.Tests/UserClaimsEnricherTests.cs @@ -0,0 +1,310 @@ +using Microsoft.AspNetCore.Http; +using NSubstitute; +using Serilog.Core; +using Serilog.Events; +using System; +using System.Security.Claims; +using Xunit; + +namespace Serilog.Enrichers.ClientInfo.Tests; + +public class UserClaimsEnricherTests +{ + private readonly IHttpContextAccessor _contextAccessor; + + public UserClaimsEnricherTests() + { + DefaultHttpContext httpContext = new(); + _contextAccessor = Substitute.For(); + _contextAccessor.HttpContext.Returns(httpContext); + } + + [Fact] + public void EnrichLogWithUserClaims_WhenUserIsAuthenticated_ShouldCreateClaimProperties() + { + // Arrange + string userId = "user123"; + string userEmail = "user@example.com"; + + ClaimsIdentity identity = new(new[] + { + new Claim(ClaimTypes.NameIdentifier, userId), + new Claim(ClaimTypes.Email, userEmail) + }, "TestAuth"); + + _contextAccessor.HttpContext!.User = new ClaimsPrincipal(identity); + + UserClaimsEnricher userClaimsEnricher = new(_contextAccessor, ClaimTypes.NameIdentifier, ClaimTypes.Email); + + LogEvent evt = null; + Logger log = new LoggerConfiguration() + .Enrich.With(userClaimsEnricher) + .WriteTo.Sink(new DelegatingSink(e => evt = e)) + .CreateLogger(); + + // Act + log.Information("Test log message."); + + // Assert + Assert.NotNull(evt); + Assert.True(evt.Properties.ContainsKey(ClaimTypes.NameIdentifier)); + Assert.Equal(userId, evt.Properties[ClaimTypes.NameIdentifier].LiteralValue().ToString()); + Assert.True(evt.Properties.ContainsKey(ClaimTypes.Email)); + Assert.Equal(userEmail, evt.Properties[ClaimTypes.Email].LiteralValue().ToString()); + } + + [Fact] + public void EnrichLogWithUserClaims_WhenClaimDoesNotExist_ShouldLogNullValue() + { + // Arrange + string userId = "user123"; + + ClaimsIdentity identity = new(new[] + { + new Claim(ClaimTypes.NameIdentifier, userId) + }, "TestAuth"); + + _contextAccessor.HttpContext!.User = new ClaimsPrincipal(identity); + + UserClaimsEnricher userClaimsEnricher = new(_contextAccessor, ClaimTypes.NameIdentifier, ClaimTypes.Email); + + LogEvent evt = null; + Logger log = new LoggerConfiguration() + .Enrich.With(userClaimsEnricher) + .WriteTo.Sink(new DelegatingSink(e => evt = e)) + .CreateLogger(); + + // Act + log.Information("Test log message."); + + // Assert + Assert.NotNull(evt); + Assert.True(evt.Properties.ContainsKey(ClaimTypes.NameIdentifier)); + Assert.Equal(userId, evt.Properties[ClaimTypes.NameIdentifier].LiteralValue().ToString()); + Assert.True(evt.Properties.ContainsKey(ClaimTypes.Email)); + Assert.Null(evt.Properties[ClaimTypes.Email].LiteralValue()); + } + + [Fact] + public void EnrichLogWithUserClaims_WhenUserIsNotAuthenticated_ShouldNotAddProperties() + { + // Arrange + ClaimsIdentity identity = new(); // Not authenticated + _contextAccessor.HttpContext!.User = new ClaimsPrincipal(identity); + + UserClaimsEnricher userClaimsEnricher = new(_contextAccessor, ClaimTypes.NameIdentifier); + + LogEvent evt = null; + Logger log = new LoggerConfiguration() + .Enrich.With(userClaimsEnricher) + .WriteTo.Sink(new DelegatingSink(e => evt = e)) + .CreateLogger(); + + // Act + log.Information("Test log message."); + + // Assert + Assert.NotNull(evt); + Assert.False(evt.Properties.ContainsKey(ClaimTypes.NameIdentifier)); + } + + [Fact] + public void EnrichLogWithUserClaims_WhenUserIsNull_ShouldNotAddProperties() + { + // Arrange + _contextAccessor.HttpContext!.User = null; + + UserClaimsEnricher userClaimsEnricher = new(_contextAccessor, ClaimTypes.NameIdentifier); + + LogEvent evt = null; + Logger log = new LoggerConfiguration() + .Enrich.With(userClaimsEnricher) + .WriteTo.Sink(new DelegatingSink(e => evt = e)) + .CreateLogger(); + + // Act + log.Information("Test log message."); + + // Assert + Assert.NotNull(evt); + Assert.False(evt.Properties.ContainsKey(ClaimTypes.NameIdentifier)); + } + + [Fact] + public void EnrichLogWithUserClaims_WhenHttpContextIsNull_ShouldNotThrow() + { + // Arrange + IHttpContextAccessor nullContextAccessor = Substitute.For(); + nullContextAccessor.HttpContext.Returns((HttpContext)null); + + UserClaimsEnricher userClaimsEnricher = new(nullContextAccessor, ClaimTypes.NameIdentifier); + + LogEvent evt = null; + Logger log = new LoggerConfiguration() + .Enrich.With(userClaimsEnricher) + .WriteTo.Sink(new DelegatingSink(e => evt = e)) + .CreateLogger(); + + // Act + Exception exception = Record.Exception(() => log.Information("Test log message.")); + + // Assert + Assert.Null(exception); + Assert.NotNull(evt); + } + + [Fact] + public void EnrichLogWithUserClaims_WhenCalledMultipleTimes_ShouldUseCachedValue() + { + // Arrange + string userId = "user123"; + + ClaimsIdentity identity = new(new[] + { + new Claim(ClaimTypes.NameIdentifier, userId) + }, "TestAuth"); + + _contextAccessor.HttpContext!.User = new ClaimsPrincipal(identity); + + UserClaimsEnricher userClaimsEnricher = new(_contextAccessor, ClaimTypes.NameIdentifier); + + LogEvent evt1 = null; + LogEvent evt2 = null; + Logger log = new LoggerConfiguration() + .Enrich.With(userClaimsEnricher) + .WriteTo.Sink(new DelegatingSink(e => { evt1 ??= e; evt2 = e; })) + .CreateLogger(); + + // Act + log.Information("First log message."); + log.Information("Second log message."); + + // Assert + Assert.NotNull(evt1); + Assert.NotNull(evt2); + Assert.True(evt1.Properties.ContainsKey(ClaimTypes.NameIdentifier)); + Assert.True(evt2.Properties.ContainsKey(ClaimTypes.NameIdentifier)); + Assert.Equal(userId, evt1.Properties[ClaimTypes.NameIdentifier].LiteralValue().ToString()); + Assert.Equal(userId, evt2.Properties[ClaimTypes.NameIdentifier].LiteralValue().ToString()); + } + + [Fact] + public void EnrichLogWithUserClaims_WithMultipleClaims_ShouldLogAllSpecifiedClaims() + { + // Arrange + string userId = "user123"; + string userEmail = "user@example.com"; + string userName = "John Doe"; + string userRole = "Admin"; + + ClaimsIdentity identity = new(new[] + { + new Claim(ClaimTypes.NameIdentifier, userId), + new Claim(ClaimTypes.Email, userEmail), + new Claim(ClaimTypes.Name, userName), + new Claim(ClaimTypes.Role, userRole) + }, "TestAuth"); + + _contextAccessor.HttpContext!.User = new ClaimsPrincipal(identity); + + UserClaimsEnricher userClaimsEnricher = new(_contextAccessor, + ClaimTypes.NameIdentifier, + ClaimTypes.Email, + ClaimTypes.Name, + ClaimTypes.Role); + + LogEvent evt = null; + Logger log = new LoggerConfiguration() + .Enrich.With(userClaimsEnricher) + .WriteTo.Sink(new DelegatingSink(e => evt = e)) + .CreateLogger(); + + // Act + log.Information("Test log message."); + + // Assert + Assert.NotNull(evt); + Assert.True(evt.Properties.ContainsKey(ClaimTypes.NameIdentifier)); + Assert.Equal(userId, evt.Properties[ClaimTypes.NameIdentifier].LiteralValue().ToString()); + Assert.True(evt.Properties.ContainsKey(ClaimTypes.Email)); + Assert.Equal(userEmail, evt.Properties[ClaimTypes.Email].LiteralValue().ToString()); + Assert.True(evt.Properties.ContainsKey(ClaimTypes.Name)); + Assert.Equal(userName, evt.Properties[ClaimTypes.Name].LiteralValue().ToString()); + Assert.True(evt.Properties.ContainsKey(ClaimTypes.Role)); + Assert.Equal(userRole, evt.Properties[ClaimTypes.Role].LiteralValue().ToString()); + } + + [Fact] + public void EnrichLogWithUserClaims_WithEmptyClaimArray_ShouldNotAddProperties() + { + // Arrange + ClaimsIdentity identity = new(new[] + { + new Claim(ClaimTypes.NameIdentifier, "user123") + }, "TestAuth"); + + _contextAccessor.HttpContext!.User = new ClaimsPrincipal(identity); + + UserClaimsEnricher userClaimsEnricher = new(_contextAccessor); + + LogEvent evt = null; + Logger log = new LoggerConfiguration() + .Enrich.With(userClaimsEnricher) + .WriteTo.Sink(new DelegatingSink(e => evt = e)) + .CreateLogger(); + + // Act + log.Information("Test log message."); + + // Assert + Assert.NotNull(evt); + Assert.DoesNotContain(ClaimTypes.NameIdentifier, evt.Properties.Keys); + } + + [Fact] + public void WithUserClaims_ThenLoggerIsCalled_ShouldNotThrowException() + { + // Arrange + Logger logger = new LoggerConfiguration() + .Enrich.WithUserClaims(ClaimTypes.NameIdentifier) + .WriteTo.Sink(new DelegatingSink(_ => { })) + .CreateLogger(); + + // Act + Exception exception = Record.Exception(() => logger.Information("LOG")); + + // Assert + Assert.Null(exception); + } + + [Fact] + public void EnrichLogWithUserClaims_WithCustomClaimType_ShouldLogCustomClaim() + { + // Arrange + string customClaimType = "custom_claim_type"; + string customClaimValue = "custom_value"; + + ClaimsIdentity identity = new(new[] + { + new Claim(customClaimType, customClaimValue) + }, "TestAuth"); + + _contextAccessor.HttpContext!.User = new ClaimsPrincipal(identity); + + UserClaimsEnricher userClaimsEnricher = new(_contextAccessor, customClaimType); + + LogEvent evt = null; + Logger log = new LoggerConfiguration() + .Enrich.With(userClaimsEnricher) + .WriteTo.Sink(new DelegatingSink(e => evt = e)) + .CreateLogger(); + + // Act + log.Information("Test log message."); + + // Assert + Assert.NotNull(evt); + Assert.True(evt.Properties.ContainsKey(customClaimType)); + Assert.Equal(customClaimValue, evt.Properties[customClaimType].LiteralValue().ToString()); + } +} From f34420992479b603403c78519174febea210b7f8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Oct 2025 23:23:24 +0000 Subject: [PATCH 3/3] Remove unused using statement Co-authored-by: mo-esmp <1659032+mo-esmp@users.noreply.github.com> --- src/Serilog.Enrichers.ClientInfo/Enrichers/UserClaimsEnricher.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Serilog.Enrichers.ClientInfo/Enrichers/UserClaimsEnricher.cs b/src/Serilog.Enrichers.ClientInfo/Enrichers/UserClaimsEnricher.cs index 3bbf455..6be1f23 100644 --- a/src/Serilog.Enrichers.ClientInfo/Enrichers/UserClaimsEnricher.cs +++ b/src/Serilog.Enrichers.ClientInfo/Enrichers/UserClaimsEnricher.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using System.Linq; using System.Security.Claims; using Microsoft.AspNetCore.Http; using Serilog.Core;