The Identity Abstraction Nobody Injected
Trill's Stories service ships a complete caller-identity abstraction - context, factory, claims, an IsAdmin flag - registers it in the container, and injects it into nothing, while every endpoint reads the user id out of the request body instead.
There is a particular kind of dead code that is more dangerous than the ordinary kind. Not a leftover helper nobody calls, but a well-designed abstraction, sitting in the right folder, registered in the container, named exactly what a reviewer would look for — and wired to nothing. It answers the audit question before the audit asks it. Part 8 showed the Users service unable to find its own accounts. This part shows the Stories service unable to find out who is calling, in the presence of six classes whose entire job is to tell it.
What exists
Six files, about 120 lines, spread across two projects:
Application/IAppContext.cs
Application/IIdentityContext.cs
Infrastructure/Contexts/AppContext.cs
Infrastructure/Contexts/AppContextFactory.cs
Infrastructure/Contexts/IAppContextFactory.cs
Infrastructure/Contexts/IdentityContext.cs
The contracts sit in Application — the layer that would consume them — and the implementations in Infrastructure, which is exactly right. IAppContext is two properties:
public interface IAppContext
{
string RequestId { get; }
IIdentityContext Identity { get; }
}
And IIdentityContext is the thing a handler would actually want:
public interface IIdentityContext
{
Guid Id { get; }
string Role { get; }
bool IsAuthenticated { get; }
bool IsAdmin { get; }
IDictionary<string, string> Claims { get; }
}
IdentityContext's constructor does the parsing carefully — Guid.TryParse with Guid.Empty as the fallback, a null-coalesced claims dictionary, and IsAdmin derived with an explicit StringComparison.InvariantCultureIgnoreCase rather than a naive ==.
AppContextFactory is the piece that makes it work across transports:
public IAppContext Create()
{
if (_contextAccessor.CorrelationContext is { })
{
var payload = JsonSerializer.Serialize(_contextAccessor.CorrelationContext, SerializerOptions);
return string.IsNullOrWhiteSpace(payload)
? AppContext.Empty
: new AppContext(JsonSerializer.Deserialize<CorrelationContext>(payload, SerializerOptions));
}
var context = _httpContextAccessor.GetCorrelationContext();
return context is null ? AppContext.Empty : new AppContext(context);
}
Two sources, one shape. If the command arrived over RabbitMQ, the caller's identity comes from Convey's ICorrelationContextAccessor — the correlation envelope the message broker propagates. If it arrived over HTTP, it comes from the Correlation-Context header, deserialised into the same CorrelationContext type. A handler injecting IAppContext would learn who the caller is without knowing or caring which transport delivered the request. That is the hard half of identity propagation in a microservice estate, and it is built.
What uses it
Grepping the whole src tree for IAppContext and IIdentityContext returns their own definitions, their own implementations, and two lines in the composition root:
.AddTransient<IAppContextFactory, AppContextFactory>()
.AddTransient(ctx => ctx.GetRequiredService<IAppContextFactory>().Create())
That is all. Not one command handler, event handler, query handler, controller or gRPC method takes IAppContext in its constructor. The registration is the only consumer of the factory, and nothing is a consumer of the registration.
The same is true of the security wiring around it. Infrastructure/Extensions.cs calls AddCertificateAuthentication() and AddSecurity(), and UseInfrastructure() calls UseCertificateAuthentication(); appsettings.json configures security.certificate.header: "Certificate", meaning the service expects the API gateway to forward a client certificate. The resulting principal is consumed by nothing. Grep for Authorize or RequireAuthorization across Trill.Services.Stories/src and Trill.Services.Users/src and the result is empty — no attribute, no policy, no auth: true on any dispatcher endpoint.
What the handlers use instead
Application/Commands/SendStory.cs:
public class SendStory : ICommand
{
public Guid Id { get; } = Guid.NewGuid();
public long StoryId { get; }
public Guid UserId { get; }
public string Title { get; }
...
}
UserId is a property of the command, deserialised from the JSON body. RateStory is the same. The .rest fixture is unambiguous about how it is populated:
POST {{url}}/stories
{
"userId": "{{userId}}",
"title": "Test 1",
"text": "Lorem ipsum text",
"tags": ["dotnet", "csharp"],
"visibleFrom": null,
"visibleTo": null,
"highlighted": false
}
The caller states who they are. SendStoryHandler loads that user, runs IStoryAuthorPolicy.CanCreate against them, and writes a story attributed to them. RateStoryHandler records a vote as them. Every write in the Stories service is attributed to a value the writer chose, and the class that could have supplied the real one is one constructor parameter away in the same assembly.
The Users service goes further, because CreateUser carries not just an identity but a privilege level:
public class CreateUser : ICommand
{
public Guid UserId { get; }
public string Email { get; }
public string Name { get; }
public string Password { get; }
public string Role { get; }
public IEnumerable<string> Permissions { get; }
}
And CreateUserHandler accepts it:
var role = string.IsNullOrWhiteSpace(command.Role) ? "user" : command.Role.ToLowerInvariant();
Role.IsValid accepts exactly two values, "user" and "admin". POST /sign-up with "role": "admin" mints an administrator, and SignInHandler copies user.Permissions verbatim into the JWT's claims. The .rest fixture sends "role": "user", which documents that the field is caller-supplied rather than server-assigned.
What the envelope was going to carry
The shape of the thing that was almost built is legible in one more class. Infrastructure/Contexts/CorrelationContext.cs is the payload AppContextFactory deserialises, and it is not Trill's invention — it is the correlation envelope Convey propagates across the bus and the Correlation-Context HTTP header:
internal sealed class CorrelationContext
{
public string CorrelationId { get; set; }
public string SpanContext { get; set; }
public UserContext User { get; set; }
public string ResourceId { get; set; }
public string TraceId { get; set; }
public string ConnectionId { get; set; }
public string Name { get; set; }
public DateTime CreatedAt { get; set; }
public class UserContext
{
public string Id { get; set; }
public bool IsAuthenticated { get; set; }
public string Role { get; set; }
public IDictionary<string, string> Claims { get; set; }
}
}
UserContext is the whole point. In the intended topology the API gateway validates the JWT, projects the principal into this envelope, and every downstream service — over HTTP or over RabbitMQ, first hop or fifth — reads the caller's identity from the same structure without re-validating a token it may not even have. That is a coherent, well-known pattern, and it is exactly the pattern IdentityContext's three-argument constructor was written to consume:
internal IdentityContext(CorrelationContext.UserContext context)
: this(context.Id, context.Role, context.IsAuthenticated, context.Claims)
{
}
And the producer exists too, one repository over. Trill.APIGateway/Framework/CorrelationContextBuilder.cs fills that exact structure from the validated principal:
User = new CorrelationContext.UserContext
{
Id = context.User.Identity.Name,
IsAuthenticated = context.User.Identity.IsAuthenticated,
Role = context.User.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Role)?.Value,
Claims = context.User.Claims.ToDictionary(c => c.Type, c => c.Value)
}
Every piece of it is in the estate. The gateway populates a correlation envelope with the caller's identity; the factory knows how to read one from two transports; the context knows how to project one into a typed identity; and no handler asks for the result. The failure is not that the design is missing — it is that the last consumer was never written, and everything upstream of it looks finished.
The gateway does try to close the gap, by a route the services never see. UserMiddleware intercepts POST, PUT and PATCH, authenticates, and then rewrites the request body:
payload["userId"] = Guid.Parse(context.User.Identity.Name);
var json = JsonSerializer.Serialize(payload);
await using var memoryStream = new MemoryStream(Encoding.UTF8.GetBytes(json));
context.Request.Body = memoryStream;
The token is turned into a body field, so a service that trusts command.UserId is, on that path, trusting the gateway's validated principal after all. It is a genuinely ingenious workaround for services that never learned to read the envelope — and it is also a third mechanism for a problem that already had two, with its own failure modes that the companion series on the estate's spine takes apart properly. What matters here is what it says about the services: the identity abstraction stayed unused for long enough that the platform grew a substitute for it.
The lifetime bug hiding in the registration
Even the two lines that do exist are subtly wrong, and it is a useful thing to notice:
.AddTransient(ctx => ctx.GetRequiredService<IAppContextFactory>().Create())
AddTransient on a factory delegate means every injection point gets its own IAppContext. When a correlation context is present that is harmless — two calls to Create() deserialise the same payload into equal objects. When it is absent, Create() returns AppContext.Empty, and AppContext's parameterless constructor is:
internal AppContext() : this(Guid.NewGuid().ToString("N"), IdentityContext.Empty)
A fresh RequestId per resolution. So a request with no correlation header, handled by a controller and a handler that both inject IAppContext, would produce two different request identifiers for one request — and RequestId is the field you would put in every log line to tie them together. AddScoped is the correct lifetime and would cost nothing. The bug is latent only because nobody resolves the service.
Why an unused abstraction is worse than a missing one
I want to be fair here, because the instinct behind these six files is right and the execution is good. This is a teaching estate; identity propagation across HTTP and AMQP is genuinely hard; and the shape on offer — a transport-agnostic IAppContext built from a correlation envelope — is the shape I would recommend. The Convey series covers the dispatcher-endpoint machinery this would plug into. Reading this code teaches you something correct.
The problem is what its presence signals. Someone auditing this service for “do we trust the client's claim of identity?” opens Infrastructure/Contexts/, finds IdentityContext with IsAuthenticated and IsAdmin on it, finds it registered, and reasonably concludes the answer is no. The grep that proves otherwise — searching for injection sites rather than definitions — is one step further than most audits go. Dead security code does not merely fail to protect you; it actively answers the question that would have found the gap.
Three habits fall out of this file, and they generalise past identity:
- Search for injection sites, not definitions. “Does this codebase have an identity abstraction?” is the wrong question. “How many constructors take one?” is the right one, and it is the same grep with a different pattern.
- A registration with no consumer is a defect, not neutral. A composition-root test that resolves every registered interface and asserts at least one consumer would have flagged
IAppContext,IUsersApiClientandIStoryRatingServicein this service alone — the last of which was part 4's subject. - If the caller can name themselves, the endpoint has no identity model, regardless of what the container knows.
UserIdon the command is the whole finding; everything else is context.
The cleanest fix here is also the smallest: delete UserId from SendStory and RateStory, inject IAppContext into the two handlers, and read _appContext.Identity.Id. The commands get shorter, the gateway becomes the only thing that can assert an identity, and six well-written files start doing the job they were written for.
Next, out of Stories and Users entirely, into the Timeline service and the single format string that breaks every read it performs: one format string, two meanings.