Skip to content
Merged
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
28 changes: 28 additions & 0 deletions docs/guide/http/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,31 @@ public void RequireAuthorizeOnAll()
```
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Http/Wolverine.Http/WolverineHttpOptions.cs#L240-L250' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_RequireAuthorizeOnAll' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

## Tracking User Name

Wolverine can automatically propagate the authenticated user's identity from the HTTP `ClaimsPrincipal` through the messaging infrastructure. When enabled, the `ClaimsPrincipal.Identity.Name` is:

1. Set on `IMessageContext.UserName` for the current request
2. Propagated to all outgoing message envelopes via `Envelope.UserName`
3. Automatically set as `IDocumentSession.LastModifiedBy` when using the Wolverine + Marten integration
4. Added as an OpenTelemetry tag (`enduser.id`) on the current activity

To enable this feature, set `EnableRelayOfUserName` in your Wolverine configuration:

```csharp
builder.Host.UseWolverine(opts =>
{
// Automatically relay the authenticated user name from HTTP
// through the messaging infrastructure
opts.EnableRelayOfUserName = true;
});
```

When this option is enabled, Wolverine will automatically apply middleware to any HTTP endpoint that uses `IMessageContext` or `IMessageBus`. The middleware reads `HttpContext.User?.Identity?.Name` and sets it on the message context before your endpoint code executes.

The user name is carried on outgoing envelopes, so downstream message handlers will also have access to the original user name via `IMessageContext.UserName` or `Envelope.UserName`. This is particularly useful for auditing and tracking who initiated a chain of messages.

### Marten Integration

When using the Wolverine + Marten integration, the user name is automatically applied to `IDocumentSession.LastModifiedBy`. This means Marten's built-in `mt_last_modified_by` metadata column will be populated with the authenticated user's name for any documents stored during message handling -- even for cascading messages downstream from the original HTTP request.
40 changes: 40 additions & 0 deletions src/Http/Wolverine.Http.Tests/user_name_relay_from_http.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using System.Security.Claims;
using Shouldly;
using WolverineWebApi;

namespace Wolverine.Http.Tests;

public class user_name_relay_from_http : IntegrationContext
{
public user_name_relay_from_http(AppFixture fixture) : base(fixture)
{
}

[Fact]
public async Task user_name_is_set_from_claims_principal()
{
var identity = new ClaimsIdentity(
[new Claim(ClaimTypes.Name, "testuser@example.com")],
"TestAuth");
var principal = new ClaimsPrincipal(identity);

var result = await Scenario(x =>
{
x.ConfigureHttpContext(c => c.User = principal);
x.Get.Url("/user/name");
});

result.ReadAsText().ShouldBe("testuser@example.com");
}

[Fact]
public async Task user_name_is_none_when_not_authenticated()
{
var result = await Scenario(x =>
{
x.Get.Url("/user/name");
});

result.ReadAsText().ShouldBe("NONE");
}
}
41 changes: 41 additions & 0 deletions src/Http/Wolverine.Http/Runtime/UserNameMiddleware.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using System.Diagnostics;
using JasperFx;
using JasperFx.CodeGeneration;
using JasperFx.CodeGeneration.Frames;
using JasperFx.Core.Reflection;
using Microsoft.AspNetCore.Http;

namespace Wolverine.Http.Runtime;

public static class UserNameMiddleware
{
public static void Apply(HttpContext httpContext, IMessageContext messaging)
{
var userName = httpContext.User?.Identity?.Name;
if (userName is not null)
{
messaging.UserName = userName;
Activity.Current?.SetTag("enduser.id", userName);
}
}
}

internal class UserNamePolicy : IHttpPolicy
{
public void Apply(IReadOnlyList<HttpChain> chains, GenerationRules rules, IServiceContainer container)
{
var options = container.GetInstance<WolverineOptions>();
if (!options.EnableRelayOfUserName) return;

foreach (var chain in chains)
{
var serviceDependencies = chain.ServiceDependencies(container, Type.EmptyTypes).ToArray();
if (serviceDependencies.Contains(typeof(IMessageContext)) ||
serviceDependencies.Contains(typeof(IMessageBus)))
{
chain.Middleware.Insert(0,
new MethodCall(typeof(UserNameMiddleware), nameof(UserNameMiddleware.Apply)));
}
}
}
}
1 change: 1 addition & 0 deletions src/Http/Wolverine.Http/WolverineHttpOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ public WolverineHttpOptions()
{
Policies.Add(new HttpAwarePolicy());
Policies.Add(new RequestIdPolicy());
Policies.Add(new UserNamePolicy());
Policies.Add(new RequiredEntityPolicy());
Policies.Add(new HttpChainResponseCacheHeaderPolicy());

Expand Down
2 changes: 2 additions & 0 deletions src/Http/WolverineWebApi/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ public static async Task<int> Main(string[] args)

opts.Durability.Mode = DurabilityMode.Solo;

opts.EnableRelayOfUserName = true;

// Other Wolverine configuration...
opts.Policies.AutoApplyTransactions();
opts.Policies.UseDurableLocalQueues();
Expand Down
13 changes: 13 additions & 0 deletions src/Http/WolverineWebApi/UserNameEndpoint.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using Wolverine;
using Wolverine.Http;

namespace WolverineWebApi;

public static class UserNameEndpoint
{
[WolverineGet("/user/name")]
public static string GetUserName(IMessageContext context)
{
return context.UserName ?? "NONE";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,15 @@ private void configureSession(MessageContext context, IDocumentSession session)

session.CorrelationId = context.CorrelationId;

if (context.Envelope?.UserName is not null)
{
session.LastModifiedBy = context.Envelope.UserName;
}
else if (context.UserName is not null)
{
session.LastModifiedBy = context.UserName;
}

var transaction = new MartenEnvelopeTransaction(session, context);
context.EnlistInOutbox(transaction);

Expand Down
55 changes: 55 additions & 0 deletions src/Testing/CoreTests/Runtime/MessageContextTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,61 @@ public void track_envelope_correlation()
theEnvelope.ParentId.ShouldBe(activity.Id);
}

[Fact]
public void track_envelope_correlation_relays_user_name_when_enabled()
{
theRuntime.Options.EnableRelayOfUserName = true;
theContext.UserName = "testuser";

using var activity = new Activity("DoWork");
activity.Start();

theContext.TrackEnvelopeCorrelation(theEnvelope, activity);

theEnvelope.UserName.ShouldBe("testuser");
}

[Fact]
public void track_envelope_correlation_does_not_relay_user_name_when_disabled()
{
theRuntime.Options.EnableRelayOfUserName = false;
theContext.UserName = "testuser";

using var activity = new Activity("DoWork");
activity.Start();

theContext.TrackEnvelopeCorrelation(theEnvelope, activity);

theEnvelope.UserName.ShouldBeNull();
}

[Fact]
public void track_envelope_correlation_does_not_override_existing_user_name()
{
theRuntime.Options.EnableRelayOfUserName = true;
theContext.UserName = "contextuser";
theEnvelope.UserName = "envelopeuser";

using var activity = new Activity("DoWork");
activity.Start();

theContext.TrackEnvelopeCorrelation(theEnvelope, activity);

theEnvelope.UserName.ShouldBe("envelopeuser");
}

[Fact]
public void reads_user_name_from_envelope()
{
var original = ObjectMother.Envelope();
original.UserName = "fromenvelope";

var context = new MessageContext(theRuntime);
context.ReadEnvelope(original, InvocationCallback.Instance);

context.UserName.ShouldBe("fromenvelope");
}

[Fact]
public void reads_tenant_id_from_envelope()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,20 @@ public void topic_name_is_round_tripped()
incoming.TopicName.ShouldBe(outgoing.TopicName);
}

[Fact]
public void user_name_is_round_tripped()
{
outgoing.UserName = "testuser@example.com";
incoming.UserName.ShouldBe(outgoing.UserName);
}

[Fact]
public void user_name_null_is_round_tripped()
{
outgoing.UserName = null;
incoming.UserName.ShouldBeNull();
}

[Fact]
public void accepted_content_types_positive()
{
Expand Down
5 changes: 5 additions & 0 deletions src/Wolverine/Envelope.cs
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@
catch (Exception e)
{
throw new WolverineSerializationException(
$"Error trying to serialize message of type {Message.GetType().FullNameInCode()} with serializer {Serializer}", e);

Check warning on line 150 in src/Wolverine/Envelope.cs

View workflow job for this annotation

GitHub Actions / test

Dereference of a possibly null reference.
}
}

Expand Down Expand Up @@ -176,7 +176,7 @@
return _data;
}

throw new WolverineSerializationException($"No data or writer is known for this envelope of message type {_message.GetType().FullNameInCode()}");

Check warning on line 179 in src/Wolverine/Envelope.cs

View workflow job for this annotation

GitHub Actions / test

Dereference of a possibly null reference.
}

try
Expand All @@ -186,7 +186,7 @@
catch (Exception e)
{
throw new WolverineSerializationException(
$"Error trying to serialize message of type {Message.GetType().FullNameInCode()} with serializer {Serializer}", e);

Check warning on line 189 in src/Wolverine/Envelope.cs

View workflow job for this annotation

GitHub Actions / test

Dereference of a possibly null reference.
}

return _data;
Expand Down Expand Up @@ -305,6 +305,11 @@
/// </summary>
public string? TenantId { get; set; }

/// <summary>
/// The authenticated user name for tracking and auditing purposes
/// </summary>
public string? UserName { get; set; }

/// <summary>
/// Specifies the accepted content types for the requested reply
/// </summary>
Expand Down
1 change: 1 addition & 0 deletions src/Wolverine/EnvelopeConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,5 @@ public static class EnvelopeConstants
public const string PartitionKey = "partition-key";
public const string TopicNameKey = "topic-name";
public const string KeepUntilKey = "keep-until";
public const string UserNameKey = "user-name";
}
7 changes: 7 additions & 0 deletions src/Wolverine/IMessageContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ public interface IMessageContext : IMessageBus
/// </summary>
string? CorrelationId { get; set; }

/// <summary>
/// The authenticated user name for tracking and auditing purposes.
/// When EnableRelayOfUserName is true, this is automatically propagated
/// to outgoing messages and Marten's LastModifiedBy.
/// </summary>
string? UserName { get; set; }

/// <summary>
/// The envelope being currently handled. This will only be non-null during
/// the handling of a message
Expand Down
7 changes: 7 additions & 0 deletions src/Wolverine/Runtime/MessageBus.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ private void assertNotMediatorOnly()
}

public string? CorrelationId { get; set; }
public string? UserName { get; set; }
public Envelope? Envelope { get; protected set; }
public virtual ValueTask RespondToSenderAsync(object response)
{
Expand Down Expand Up @@ -305,6 +306,12 @@ internal virtual void TrackEnvelopeCorrelation(Envelope outbound, Activity? acti
outbound.CorrelationId = CorrelationId;
outbound.ConversationId = outbound.Id; // the message chain originates here
outbound.TenantId ??= TenantId; // don't override a tenant id that's specifically set on the envelope itself

if (Runtime.Options.EnableRelayOfUserName)
{
outbound.UserName ??= UserName;
}

outbound.ParentId = activity?.Id;
outbound.Store = Storage;
}
Expand Down
1 change: 1 addition & 0 deletions src/Wolverine/Runtime/MessageContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,7 @@ internal void ReadEnvelope(Envelope? originalEnvelope, IChannelCallback channel)
_channel = channel;
_sagaId = originalEnvelope.SagaId;
TenantId = originalEnvelope.TenantId;
UserName = originalEnvelope.UserName;

Transaction = this;

Expand Down
8 changes: 6 additions & 2 deletions src/Wolverine/Runtime/Serialization/EnvelopeSerializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,11 @@ public static void ReadDataElement(Envelope env, string key, string value)
case EnvelopeConstants.TopicNameKey:
env.TopicName = value;
break;


case EnvelopeConstants.UserNameKey:
env.UserName = value;
break;

case EnvelopeConstants.PartitionKey:
env.PartitionKey = value;
break;
Expand Down Expand Up @@ -255,7 +259,7 @@ private static int writeHeaders(BinaryWriter writer, Envelope env)
writer.WriteProp(ref count, EnvelopeConstants.ParentIdKey, env.ParentId);
writer.WriteProp(ref count, EnvelopeConstants.TenantIdKey, env.TenantId);
writer.WriteProp(ref count, EnvelopeConstants.TopicNameKey, env.TopicName);

writer.WriteProp(ref count, EnvelopeConstants.UserNameKey, env.UserName);

if (env.AcceptedContentTypes.Length != 0)
{
Expand Down
1 change: 1 addition & 0 deletions src/Wolverine/TestMessageContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@
/// <param name="response"></param>
public void RespondWith<TResponse>(TResponse response)
{
var expectation = new ExpectedResponse<T>(_match, response, _destination, _endpointName);

Check warning on line 92 in src/Wolverine/TestMessageContext.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference argument for parameter 'response' in 'ExpectedResponse<T>.ExpectedResponse(Func<T, bool> match, object response, Uri? destination, string? endpointName)'.
_parent.Expectations.Add(expectation);
}
}
Expand Down Expand Up @@ -179,6 +179,7 @@
}

public string? CorrelationId { get; set; }
public string? UserName { get; set; }
public Envelope? Envelope { get; }

Task ICommandBus.InvokeAsync(object message, CancellationToken cancellation, TimeSpan? timeout)
Expand Down
7 changes: 7 additions & 0 deletions src/Wolverine/WolverineOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@

}

public WolverineOptions(string? assemblyName)

Check warning on line 108 in src/Wolverine/WolverineOptions.cs

View workflow job for this annotation

GitHub Actions / test

Non-nullable property 'ServiceName' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.

Check warning on line 108 in src/Wolverine/WolverineOptions.cs

View workflow job for this annotation

GitHub Actions / test

Non-nullable property 'ServiceName' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.
{
Transports = new TransportCollection();

Expand Down Expand Up @@ -167,6 +167,13 @@
/// </summary>
public SendingFailurePolicies SendingFailure { get; } = new();

/// <summary>
/// When enabled, Wolverine will automatically relay the authenticated user name
/// from HTTP ClaimsPrincipal through the messaging infrastructure, propagating it
/// on outgoing envelopes and into Marten's IDocumentSession.LastModifiedBy
/// </summary>
public bool EnableRelayOfUserName { get; set; }

/// <summary>
/// What is the policy within this application for whether or not it is valid to allow Service Location within
/// the generated code for message handlers or HTTP endpoints. Default is AllowedByWarn. Just keep in mind that
Expand Down Expand Up @@ -234,7 +241,7 @@
public void AddMessageHandler<T>(MessageHandler<T> handler)
{
AddMessageHandler(typeof(T), handler);
handler.ConfigureChain(handler.Chain); // Yeah, this is 100% a tell, don't ask violation

Check warning on line 244 in src/Wolverine/WolverineOptions.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference argument for parameter 'chain' in 'void MessageHandler<T>.ConfigureChain(HandlerChain chain)'.
}

[IgnoreDescription]
Expand Down
Loading