diff --git a/build/pack-local.sh b/build/pack-local.sh new file mode 100755 index 0000000..a7cedc7 --- /dev/null +++ b/build/pack-local.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# +# Packs every BBT.Aether.* library in framework/src into the repo-local NuGet feed +# at .local-feed, so a consumer (vnext) can build against unreleased framework work +# without publishing to nuget.org. +# +# ./build/pack-local.sh # packs 1.0.40-local +# ./build/pack-local.sh 1.0.41-local # packs an explicit version +# +# Wiring it up on the consumer side is a one-off: +# +# dotnet nuget add source /Users//…/aether/.local-feed -n aether-local \ +# --configfile /nuget.config +# # then set the consumer's Aether version property to the packed version +# +# WHY THE CACHE PURGE BELOW MATTERS +# --------------------------------- +# NuGet extracts a package into ~/.nuget/packages/// the first time it +# restores it, and from then on that directory wins — the feed is not consulted again +# for a version it already has. Re-packing the SAME version therefore appears to do +# nothing: the build keeps compiling against the previous contents, and the mismatch +# shows up as "the member I just added does not exist". Purging the version from the +# global cache before each pack is what makes iterating on a local feed reliable. +# The alternative — bumping the version on every single pack — is worse: it leaves +# the consumer's version property stale and litters the cache. + +set -euo pipefail + +VERSION="${1:-1.0.40-local}" + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SRC_DIR="$REPO_ROOT/framework/src" +FEED_DIR="$REPO_ROOT/.local-feed" +CACHE_DIR="${NUGET_PACKAGES:-$HOME/.nuget/packages}" + +if [[ ! "$VERSION" == *-local ]]; then + echo "refusing to pack '$VERSION': local-feed versions must end in -local so they can" >&2 + echo "never be mistaken for, or restored in place of, a published release." >&2 + exit 1 +fi + +echo "==> packing BBT.Aether.* $VERSION into ${FEED_DIR/#$HOME/~}" +mkdir -p "$FEED_DIR" + +# Drop any earlier build of THIS version, from both the feed and the global cache. +rm -f "$FEED_DIR"/*."$VERSION".nupkg "$FEED_DIR"/*."$VERSION".snupkg 2>/dev/null || true + +purged=0 +for package_dir in "$CACHE_DIR"/bbt.aether.*/; do + [[ -d "$package_dir$VERSION" ]] || continue + rm -rf "${package_dir:?}$VERSION" + purged=$((purged + 1)) +done +[[ $purged -gt 0 ]] && echo " purged $purged stale entr$([[ $purged -eq 1 ]] && echo y || echo ies) from the global package cache" + +packed=0 +for project in "$SRC_DIR"/*/*.csproj; do + name="$(basename "$project" .csproj)" + printf ' %-32s' "$name" + if dotnet pack "$project" \ + --configuration Release \ + --output "$FEED_DIR" \ + -p:Version="$VERSION" \ + --verbosity quiet --nologo > /dev/null 2>&1; then + echo "ok" + packed=$((packed + 1)) + else + echo "FAILED" + echo + echo "re-running to show the error:" >&2 + dotnet pack "$project" --configuration Release --output "$FEED_DIR" \ + -p:Version="$VERSION" --nologo 2>&1 | tail -25 >&2 + exit 1 + fi +done + +echo +echo "==> $packed packages at $VERSION" +ls "$FEED_DIR" | grep -F "$VERSION" | grep '\.nupkg$' | sed 's/^/ /' diff --git a/framework/BBT.Aether.slnx b/framework/BBT.Aether.slnx index 1af2f4a..1eee847 100644 --- a/framework/BBT.Aether.slnx +++ b/framework/BBT.Aether.slnx @@ -16,6 +16,7 @@ + diff --git a/framework/docs/README.md b/framework/docs/README.md index d738305..01acaea 100644 --- a/framework/docs/README.md +++ b/framework/docs/README.md @@ -75,6 +75,7 @@ app.Run(); | Feature | Description | |---------|-------------| +| [Current User](current-user/README.md) | Ambient caller identity, roles, position, header forwarding | | [Object Mapping](mapper/README.md) | AutoMapper integration | | [GUID Generation](guid-generation/README.md) | Sequential and simple GUID strategies | | [Telemetry](telemetry/README.md) | OpenTelemetry integration | @@ -107,6 +108,7 @@ BBT.Aether.Core (Base) | Inbox/Outbox | ✓ | ✓ | ✓ | - | - | | Cache/Lock | ✓ | - | ✓ | - | - | | Background Jobs | ✓ | ✓ | ✓ | ✓ | - | +| Current User | ✓ | - | - | ✓ | - | | Telemetry | - | - | - | ✓ | ✓ | | Tracing/Logging/Metrics | - | - | - | - | ✓ | diff --git a/framework/docs/current-user/README.md b/framework/docs/current-user/README.md new file mode 100644 index 0000000..20a2ece --- /dev/null +++ b/framework/docs/current-user/README.md @@ -0,0 +1,160 @@ +# Current User + +`ICurrentUser` is the ambient identity of the caller: who made the request, on whose behalf, with which +roles and position. It is resolved once per request from HTTP headers and stays available anywhere in the +call stack — application services, repositories, the audit interceptor — without threading a parameter +through every method. + +## Properties + +| Property | Header / claim | Notes | +|----------|----------------|-------| +| `Id` | `userId` | Internal user identifier | +| `UserName` | `sub` | Subject — the identity number | +| `Name` | `given_name` | | +| `Surname` | `family_name` | | +| `Roles` | `role` | Comma **or** space separated; parsed into an array | +| `Role` | `role` | Computed: the first entry of `Roles` | +| `Position` | `position` | Organizational posting of the caller; null when absent | +| `ActorUserId` | `act_uid` | Delegation — the acting user's id | +| `ActorUserName` | `act_sub` | Delegation — the acting user's subject | +| `ConsentId` | `consent_id` | | +| `IsAuthenticated` | — | True when `UserName` is not empty | + +Header names come from `AetherClaimTypes` and are settable, so a host that speaks a different header +contract can rename them at startup: + +```csharp +AetherClaimTypes.Position = "x-user-position"; +``` + +### `Role` vs `Roles` + +`Roles` is the full set. `Role` is the first entry — a convenience for legacy systems that carry a single +`role` claim, where the header holds exactly one value: + +```csharp +// Legacy: role: maker +CurrentUser.Role; // "maker" +CurrentUser.Roles; // ["maker"] + +// Modern: role: maker,checker +CurrentUser.Role; // "maker" ← only the first +CurrentUser.Roles; // ["maker", "checker"] +CurrentUser.IsInRole("checker"); // true +``` + +Never make an authorization decision off `Role` when a caller may hold several roles — use `Roles` or +`IsInRole`. + +## Reading the current user + +`ICurrentUser` is registered by `AddAetherCore()` and exposed as a `CurrentUser` property on +`ApplicationService` and `AetherControllerBase`: + +```csharp +public class OrderAppService : ApplicationService +{ + public async Task ApproveAsync(Guid id) + { + if (!CurrentUser.IsInRole("checker")) + throw new BusinessException("Approval requires the checker role."); + + _logger.LogInformation( + "Order {Id} approved by {User} at {Position}", + id, CurrentUser.UserName, CurrentUser.Position); + // ... + } +} +``` + +## Resolution pipeline + +``` +HTTP request headers + → HeaderCurrentUserResolver (ICurrentUserResolver) + → BasicUserInfo + → AetherCurrentUserMiddleware → ICurrentUser.Change(basicUserInfo) + → AsyncLocalCurrentUserAccessor (ambient for the rest of the request) +``` + +`AddAetherAspNetCore()` registers `HeaderCurrentUserResolver` and the middleware. To resolve the user from +somewhere other than headers — a JWT already validated by the host, a gateway-specific envelope — +implement `ICurrentUserResolver` and replace the registration **after** calling `AddAetherAspNetCore()`: + +```csharp +services.AddAetherAspNetCore(); +services.Replace(ServiceDescriptor.Transient()); +``` + +`AddAetherAspNetCore()` registers `HeaderCurrentUserResolver` with a plain `AddTransient`, so registering +yours beforehand would leave both descriptors in place and Aether's — registered last — would win. + +## Setting the user outside an HTTP request + +Background jobs, message consumers and resumed workflows have no ambient request, so nothing populated +`ICurrentUser`. Capture the claim headers when the work is enqueued and restore them when it runs. + +Capture (inside the request): + +```csharp +var payload = new ReportJobPayload( + ReportId: reportId, + ClaimHeaders: httpContext.Request.GetCurrentUserHeaders()); // BBT.Aether.AspNetCore + +await _backgroundJobService.EnqueueAsync( + handlerName: "report-generator", + jobName: $"report-{reportId}", + payload: payload, + schedule: DateTimeOffset.UtcNow.AddMinutes(1).ToString("O")); +``` + +Restore (inside the job): + +```csharp +using (_currentUser.ChangeFromHeaders(payload.ClaimHeaders)) // BBT.Aether.Core +{ + // CurrentUser.UserName, .Role, .Position are the original caller's here + await _reportService.GenerateAsync(payload.ReportId); +} +// previous user restored +``` + +`ChangeFromHeaders` is a no-op when `headers` is null or empty — the ambient user, if any, stays in place. +Both helpers live in `CurrentUserHeaderExtensions` and work over a plain +`IReadOnlyDictionary`, so they carry no ASP.NET dependency. + +`Change` also has a `BasicUserInfo` overload; prefer it over the positional one so call sites survive new +fields being added to the user model: + +```csharp +using (_currentUser.Change(new BasicUserInfo( + id: "42", userName: "12345678901", roles: ["maker"], position: "branch-teller"))) +{ + // ... +} +``` + +## Forwarding the user to another service + +`ToForwardHeaders()` turns the current user back into claim headers, so a downstream service resolves the +same caller. Empty values are omitted and `Roles` is joined with commas: + +```csharp +var request = new HttpRequestMessage(HttpMethod.Post, "/api/orders"); +foreach (var (key, value) in _currentUser.ToForwardHeaders()) +{ + request.Headers.TryAddWithoutValidation(key, value); +} +``` + +The dictionary is round-trip compatible with `ChangeFromHeaders`, so the same pair works for both +in-process handoff and cross-service calls. + +> **Trust boundary.** These headers are the identity contract *inside* your trust boundary — the gateway is +> what authenticates the token and stamps them. Never accept them straight off the public internet. + +## Related + +- [Telemetry](../telemetry/README.md) — enriching traces and logs with claim headers +- [Application Services](../application-services/README.md) — the `CurrentUser` property on service bases diff --git a/framework/src/BBT.Aether.AspNetCore/BBT/Aether/AspNetCore/Security/AetherCurrentUserMiddleware.cs b/framework/src/BBT.Aether.AspNetCore/BBT/Aether/AspNetCore/Security/AetherCurrentUserMiddleware.cs index a4a7baa..8eea8a0 100644 --- a/framework/src/BBT.Aether.AspNetCore/BBT/Aether/AspNetCore/Security/AetherCurrentUserMiddleware.cs +++ b/framework/src/BBT.Aether.AspNetCore/BBT/Aether/AspNetCore/Security/AetherCurrentUserMiddleware.cs @@ -16,17 +16,9 @@ public async Task InvokeAsync(HttpContext context, RequestDelegate next) return; } - using (currentUser.Change( - basicUserInfo.Id, - basicUserInfo.UserName, - basicUserInfo.Name, - basicUserInfo.Surname, - basicUserInfo.Roles, - basicUserInfo.ActorUserId, - basicUserInfo.ActorUserName, - basicUserInfo.ConsentId)) + using (currentUser.Change(basicUserInfo)) { await next(context); } } -} \ No newline at end of file +} diff --git a/framework/src/BBT.Aether.AspNetCore/BBT/Aether/AspNetCore/Security/HeaderCurrentUserResolver.cs b/framework/src/BBT.Aether.AspNetCore/BBT/Aether/AspNetCore/Security/HeaderCurrentUserResolver.cs index 5047b27..c45038b 100644 --- a/framework/src/BBT.Aether.AspNetCore/BBT/Aether/AspNetCore/Security/HeaderCurrentUserResolver.cs +++ b/framework/src/BBT.Aether.AspNetCore/BBT/Aether/AspNetCore/Security/HeaderCurrentUserResolver.cs @@ -1,4 +1,3 @@ -using System.Linq; using BBT.Aether.Users; using Microsoft.AspNetCore.Http; @@ -17,25 +16,21 @@ public class HeaderCurrentUserResolver(IHttpContextAccessor httpContextAccessor) return null; } - var userId = context.Request.Headers[AetherClaimTypes.UserId].FirstOrDefault() ?? string.Empty; - var userName = context.Request.Headers[AetherClaimTypes.UserName].FirstOrDefault() ?? string.Empty; - var name = context.Request.Headers[AetherClaimTypes.Name].FirstOrDefault() ?? string.Empty; - var surname = context.Request.Headers[AetherClaimTypes.SurName].FirstOrDefault() ?? string.Empty; - var rolesHeader = context.Request.Headers[AetherClaimTypes.Role].FirstOrDefault(); - var roles = rolesHeader != null ? rolesHeader.Split(',') : []; - var actorUserName = context.Request.Headers[AetherClaimTypes.ActorSub].FirstOrDefault() ?? string.Empty; - var consentId = context.Request.Headers[AetherClaimTypes.ConsentId].FirstOrDefault() ?? string.Empty; - var actorUserId = context.Request.Headers[AetherClaimTypes.ActorUserId].FirstOrDefault() ?? string.Empty; - + var request = context.Request; + return new BasicUserInfo( - userId, - userName, - name, - surname, - roles, - actorUserId, - actorUserName, - consentId + request.GetClaimHeader(AetherClaimTypes.UserId) ?? string.Empty, + request.GetClaimHeader(AetherClaimTypes.UserName) ?? string.Empty, + request.GetClaimHeader(AetherClaimTypes.Name) ?? string.Empty, + request.GetClaimHeader(AetherClaimTypes.SurName) ?? string.Empty, + CurrentUserHeaderExtensions.ParseRolesFromHeader(request.GetClaimHeader(AetherClaimTypes.Role)) ?? [], + request.GetClaimHeader(AetherClaimTypes.ActorUserId) ?? string.Empty, + request.GetClaimHeader(AetherClaimTypes.ActorSub) ?? string.Empty, + request.GetClaimHeader(AetherClaimTypes.ConsentId) ?? string.Empty, + // Position stays null when the request carries none, unlike the fields above: it is a newer + // claim with no empty-string callers to keep working, and null lets consumers fall through + // with `?? fallback` instead of having to test for empty. + request.GetClaimHeader(AetherClaimTypes.Position) ); } -} \ No newline at end of file +} diff --git a/framework/src/BBT.Aether.AspNetCore/BBT/Aether/AspNetCore/Security/HttpRequestCurrentUserExtensions.cs b/framework/src/BBT.Aether.AspNetCore/BBT/Aether/AspNetCore/Security/HttpRequestCurrentUserExtensions.cs new file mode 100644 index 0000000..80b0e85 --- /dev/null +++ b/framework/src/BBT.Aether.AspNetCore/BBT/Aether/AspNetCore/Security/HttpRequestCurrentUserExtensions.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using BBT.Aether.Users; +using Microsoft.AspNetCore.Http; + +namespace BBT.Aether.AspNetCore.Security; + +/// +/// Reads Aether claim headers off an . +/// +/// The counterpart of , which works over a plain dictionary: +/// capture the claim headers here on the way in, then restore the user later — in a background job or a +/// resumed workflow — with ICurrentUser.ChangeFromHeaders. +/// +/// +public static class HttpRequestCurrentUserExtensions +{ + /// + /// Gets a single claim header value, or null when the request does not carry it. + /// + /// The HTTP request. + /// The header name — use an value. + /// The header value, or null when absent or empty. + public static string? GetClaimHeader(this HttpRequest request, string claimType) + { + Check.NotNull(request, nameof(request)); + Check.NotNullOrWhiteSpace(claimType, nameof(claimType)); + + var value = request.Headers[claimType].FirstOrDefault(); + return string.IsNullOrEmpty(value) ? null : value; + } + + /// + /// Collects the Aether claim headers the request carries into a dictionary shaped for + /// ICurrentUser.ChangeFromHeaders. Absent headers are omitted. + /// + /// The HTTP request. + /// A case-insensitive dictionary of the claim headers present on the request. + public static Dictionary GetCurrentUserHeaders(this HttpRequest request) + { + Check.NotNull(request, nameof(request)); + + // Read the claim type names on every call: AetherClaimTypes members are settable, so a host may + // rename a header at startup and a cached list would keep the old name. + string[] claimTypes = + [ + AetherClaimTypes.UserId, + AetherClaimTypes.UserName, + AetherClaimTypes.Name, + AetherClaimTypes.SurName, + AetherClaimTypes.Role, + AetherClaimTypes.Position, + AetherClaimTypes.ActorUserId, + AetherClaimTypes.ActorSub, + AetherClaimTypes.ConsentId + ]; + + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var claimType in claimTypes) + { + var value = request.GetClaimHeader(claimType); + if (value != null) + { + headers[claimType] = value; + } + } + + return headers; + } +} diff --git a/framework/src/BBT.Aether.Core/BBT/Aether/Users/AetherClaimTypes.cs b/framework/src/BBT.Aether.Core/BBT/Aether/Users/AetherClaimTypes.cs index a69b7e6..3267d4a 100644 --- a/framework/src/BBT.Aether.Core/BBT/Aether/Users/AetherClaimTypes.cs +++ b/framework/src/BBT.Aether.Core/BBT/Aether/Users/AetherClaimTypes.cs @@ -31,6 +31,12 @@ public static class AetherClaimTypes /// public static string Role { get; set; } = "role"; + /// + /// Default: position + /// (Organizational posting of the user) + /// + public static string Position { get; set; } = "position"; + /// /// Default: email /// diff --git a/framework/src/BBT.Aether.Core/BBT/Aether/Users/BasicUserInfo.cs b/framework/src/BBT.Aether.Core/BBT/Aether/Users/BasicUserInfo.cs index 7a9ceaa..7d8587d 100644 --- a/framework/src/BBT.Aether.Core/BBT/Aether/Users/BasicUserInfo.cs +++ b/framework/src/BBT.Aether.Core/BBT/Aether/Users/BasicUserInfo.cs @@ -11,7 +11,8 @@ public class BasicUserInfo( string[]? roles = null, string? actorUserId = null, string? actorUserName = null, - string? consentId = null) + string? consentId = null, + string? position = null) { /// /// Gets or sets the user's ID. @@ -45,4 +46,8 @@ public class BasicUserInfo( /// Gets or sets the consent ID. /// public string? ConsentId { get; set; } = consentId; + /// + /// Gets or sets the user's position (organizational posting). + /// + public string? Position { get; set; } = position; } \ No newline at end of file diff --git a/framework/src/BBT.Aether.Core/BBT/Aether/Users/CurrentUser.cs b/framework/src/BBT.Aether.Core/BBT/Aether/Users/CurrentUser.cs index 189c39d..999e391 100644 --- a/framework/src/BBT.Aether.Core/BBT/Aether/Users/CurrentUser.cs +++ b/framework/src/BBT.Aether.Core/BBT/Aether/Users/CurrentUser.cs @@ -21,6 +21,10 @@ public class CurrentUser(ICurrentUserAccessor currentUserAccessor) : ICurrentUse /// public string[]? Roles => currentUserAccessor.Current?.Roles; /// + public string? Role => Roles?.FirstOrDefault(); + /// + public string? Position => currentUserAccessor.Current?.Position; + /// public string? ActorUserId => currentUserAccessor.Current?.ActorUserId; /// public string? ActorUserName => currentUserAccessor.Current?.ActorUserName; @@ -43,25 +47,29 @@ public IDisposable Change( string[]? roles = null, string? actorUserId = null, string? actorUserName = null, - string? consentId = null + string? consentId = null, + string? position = null ) { - return SetCurrent(id, userName, name, surname, roles, actorUserId, actorUserName, consentId); + return Change(new BasicUserInfo( + id, + userName, + name, + surname, + roles, + actorUserId, + actorUserName, + consentId, + position)); } - private IDisposable SetCurrent( - string? id, - string? userName = null, - string? name = null, - string? surname = null, - string[]? roles = null, - string? actorUserId = null, - string? actorUserName = null, - string? consentId = null - ) + /// + public IDisposable Change(BasicUserInfo user) { + Check.NotNull(user, nameof(user)); + var parentScope = currentUserAccessor.Current; - currentUserAccessor.Current = new BasicUserInfo(id, userName, name, surname, roles, actorUserId, actorUserName, consentId); + currentUserAccessor.Current = user; return new DisposeAction(() => { currentUserAccessor.Current = parentScope; }); } -} \ No newline at end of file +} diff --git a/framework/src/BBT.Aether.Core/BBT/Aether/Users/CurrentUserHeaderExtensions.cs b/framework/src/BBT.Aether.Core/BBT/Aether/Users/CurrentUserHeaderExtensions.cs new file mode 100644 index 0000000..e3c9ac9 --- /dev/null +++ b/framework/src/BBT.Aether.Core/BBT/Aether/Users/CurrentUserHeaderExtensions.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace BBT.Aether.Users; + +/// +/// Header/claim dictionary helpers for . +/// +/// These work over a plain rather than an +/// HttpContext, so they are usable from scopes with no ambient HTTP request — background jobs, +/// message consumers, workflow execution resumed out of band. The keys are the ones in +/// , which is also what +/// BBT.Aether.AspNetCore.Security.HeaderCurrentUserResolver reads from the request, so a user +/// captured on the way in can be restored later, or forwarded to a downstream service, unchanged. +/// +/// +public static class CurrentUserHeaderExtensions +{ + /// + /// Makes the user described by current for the lifetime of the returned + /// disposable; the previous user is restored on dispose. + /// + /// The current user service. + /// + /// Claim headers keyed by values. When null or empty, nothing changes + /// and a no-op disposable is returned — the ambient user, if any, stays in place. + /// + /// An IDisposable that restores the previous user when disposed. + public static IDisposable ChangeFromHeaders( + this ICurrentUser currentUser, + IReadOnlyDictionary? headers) + { + Check.NotNull(currentUser, nameof(currentUser)); + + if (headers is null || headers.Count == 0) + { + return NullDisposable.Instance; + } + + return currentUser.Change(new BasicUserInfo( + headers.GetValueOrDefault(AetherClaimTypes.UserId), + headers.GetValueOrDefault(AetherClaimTypes.UserName), + headers.GetValueOrDefault(AetherClaimTypes.Name), + headers.GetValueOrDefault(AetherClaimTypes.SurName), + ParseRolesFromHeader(headers.GetValueOrDefault(AetherClaimTypes.Role)), + headers.GetValueOrDefault(AetherClaimTypes.ActorUserId), + headers.GetValueOrDefault(AetherClaimTypes.ActorSub), + headers.GetValueOrDefault(AetherClaimTypes.ConsentId), + headers.GetValueOrDefault(AetherClaimTypes.Position))); + } + + /// + /// Builds the claim header dictionary for the current user, to be merged into an outbound request so a + /// downstream service resolves the same user. Empty values are omitted; + /// is joined with commas. + /// + /// The current user. + /// A case-insensitive dictionary of claim headers. + public static Dictionary ToForwardHeaders(this ICurrentUser currentUser) + { + Check.NotNull(currentUser, nameof(currentUser)); + + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + Add(headers, AetherClaimTypes.UserId, currentUser.Id); + Add(headers, AetherClaimTypes.UserName, currentUser.UserName); + Add(headers, AetherClaimTypes.Name, currentUser.Name); + Add(headers, AetherClaimTypes.SurName, currentUser.Surname); + if (currentUser.Roles is { Length: > 0 } roles) + { + headers[AetherClaimTypes.Role] = string.Join(",", roles); + } + + Add(headers, AetherClaimTypes.ActorUserId, currentUser.ActorUserId); + Add(headers, AetherClaimTypes.ActorSub, currentUser.ActorUserName); + Add(headers, AetherClaimTypes.ConsentId, currentUser.ConsentId); + Add(headers, AetherClaimTypes.Position, currentUser.Position); + return headers; + } + + /// + /// Parses a role header value into role names. Comma and space are both accepted as separators, + /// so "maker,checker", "maker checker" and "maker , checker" all yield the same two + /// roles. Returns null when the value carries no role. + /// + /// The raw header value. + /// The role names, or null when there are none. + public static string[]? ParseRolesFromHeader(string? roleHeaderValue) + { + if (string.IsNullOrWhiteSpace(roleHeaderValue)) + { + return null; + } + + var roles = roleHeaderValue + .Split([',', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(s => s.Length > 0) + .ToArray(); + + return roles.Length == 0 ? null : roles; + } + + private static void Add(Dictionary headers, string key, string? value) + { + if (!string.IsNullOrEmpty(value)) + { + headers[key] = value; + } + } +} diff --git a/framework/src/BBT.Aether.Core/BBT/Aether/Users/ICurrentUser.cs b/framework/src/BBT.Aether.Core/BBT/Aether/Users/ICurrentUser.cs index caba37b..ba21ebf 100644 --- a/framework/src/BBT.Aether.Core/BBT/Aether/Users/ICurrentUser.cs +++ b/framework/src/BBT.Aether.Core/BBT/Aether/Users/ICurrentUser.cs @@ -37,6 +37,23 @@ public interface ICurrentUser /// string[]? Roles { get; } + /// + /// Gets the user's primary role — the first entry of . + /// + /// Provided for legacy systems that carry a single role claim: there the header holds one + /// value, so this returns exactly that value. When a caller may carry several roles, use + /// — this property only ever reflects the first one. + /// + /// + string? Role { get; } + + /// + /// Gets the user's position — the organizational posting that, together with the actor + /// (act_sub) and subject (sub) identities, identifies the caller at an external + /// identity provider. + /// + string? Position { get; } + /// /// Gets the actor user's ID (in case of delegation). /// @@ -70,6 +87,7 @@ public interface ICurrentUser /// The actor user's ID. /// The actor user's username. /// The consent ID. + /// The user's position (organizational posting). /// An IDisposable that reverts the changes when disposed. IDisposable Change( string? id, @@ -79,5 +97,17 @@ IDisposable Change( string[]? roles = null, string? actorUserId = null, string? actorUserName = null, - string? consentId = null); + string? consentId = null, + string? position = null); + + /// + /// Changes the current user's information within a disposable scope. + /// + /// Prefer this overload: it carries every field of , so a call site does + /// not have to be revisited when a new field is added to the user model. + /// + /// + /// The user information to make current. + /// An IDisposable that reverts the change when disposed. + IDisposable Change(BasicUserInfo user); } \ No newline at end of file diff --git a/framework/test/BBT.Aether.AspNetCore.Tests/BBT.Aether.AspNetCore.Tests.csproj b/framework/test/BBT.Aether.AspNetCore.Tests/BBT.Aether.AspNetCore.Tests.csproj new file mode 100644 index 0000000..3c4b00d --- /dev/null +++ b/framework/test/BBT.Aether.AspNetCore.Tests/BBT.Aether.AspNetCore.Tests.csproj @@ -0,0 +1,36 @@ + + + + net10.0 + latest + enable + false + true + + $(NoWarn);CS1591;DAPR_JOBS;DAPR_DISTRIBUTEDLOCK + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + + + diff --git a/framework/test/BBT.Aether.AspNetCore.Tests/BBT/Aether/AspNetCore/Security/AetherCurrentUserMiddlewareTests.cs b/framework/test/BBT.Aether.AspNetCore.Tests/BBT/Aether/AspNetCore/Security/AetherCurrentUserMiddlewareTests.cs new file mode 100644 index 0000000..4744c86 --- /dev/null +++ b/framework/test/BBT.Aether.AspNetCore.Tests/BBT/Aether/AspNetCore/Security/AetherCurrentUserMiddlewareTests.cs @@ -0,0 +1,62 @@ +using System.Threading.Tasks; +using BBT.Aether.AspNetCore.Security; +using BBT.Aether.Users; +using Microsoft.AspNetCore.Http; +using NSubstitute; +using Shouldly; +using Xunit; + +namespace BBT.Aether.AspNetCore.Tests.BBT.Aether.AspNetCore.Security; + +public class AetherCurrentUserMiddlewareTests +{ + [Fact] + public async Task InvokeAsync_WhenResolverReturnsNull_DoesNotChangeUserButStillCallsNext() + { + var currentUser = Substitute.For(); + var resolver = Substitute.For(); + resolver.GetCurrentUser().Returns((BasicUserInfo?)null); + var nextCalled = false; + + await new AetherCurrentUserMiddleware(currentUser, resolver) + .InvokeAsync(new DefaultHttpContext(), _ => + { + nextCalled = true; + return Task.CompletedTask; + }); + + nextCalled.ShouldBeTrue(); + currentUser.DidNotReceiveWithAnyArgs().Change(Arg.Any()); + } + + [Fact] + public async Task InvokeAsync_MakesTheResolvedUserCurrentForTheRequestOnly() + { + var currentUser = new CurrentUser(new PerInstanceCurrentUserAccessor()); + var resolver = Substitute.For(); + resolver.GetCurrentUser().Returns(new BasicUserInfo( + "42", "12345678901", "Ada", "Lovelace", + ["maker", "checker"], "99", "10987654321", "consent-1", "branch-teller")); + + string? positionDuringRequest = null; + string? roleDuringRequest = null; + + await new AetherCurrentUserMiddleware(currentUser, resolver) + .InvokeAsync(new DefaultHttpContext(), _ => + { + positionDuringRequest = currentUser.Position; + roleDuringRequest = currentUser.Role; + return Task.CompletedTask; + }); + + positionDuringRequest.ShouldBe("branch-teller"); + roleDuringRequest.ShouldBe("maker"); + currentUser.Position.ShouldBeNull(); + currentUser.IsAuthenticated.ShouldBeFalse(); + } + + private sealed class PerInstanceCurrentUserAccessor : ICurrentUserAccessor + { + public BasicUserInfo? Current { get; set; } + } +} diff --git a/framework/test/BBT.Aether.AspNetCore.Tests/BBT/Aether/AspNetCore/Security/HeaderCurrentUserResolverTests.cs b/framework/test/BBT.Aether.AspNetCore.Tests/BBT/Aether/AspNetCore/Security/HeaderCurrentUserResolverTests.cs new file mode 100644 index 0000000..020c574 --- /dev/null +++ b/framework/test/BBT.Aether.AspNetCore.Tests/BBT/Aether/AspNetCore/Security/HeaderCurrentUserResolverTests.cs @@ -0,0 +1,100 @@ +using System.Collections.Generic; +using BBT.Aether.AspNetCore.Security; +using BBT.Aether.Users; +using Microsoft.AspNetCore.Http; +using NSubstitute; +using Shouldly; +using Xunit; + +namespace BBT.Aether.AspNetCore.Tests.BBT.Aether.AspNetCore.Security; + +public class HeaderCurrentUserResolverTests +{ + [Fact] + public void GetCurrentUser_WhenNoHttpContext_ReturnsNull() + { + var accessor = Substitute.For(); + accessor.HttpContext.Returns((HttpContext?)null); + + new HeaderCurrentUserResolver(accessor).GetCurrentUser().ShouldBeNull(); + } + + [Fact] + public void GetCurrentUser_MapsEveryClaimHeader() + { + var resolver = ResolverFor(new Dictionary + { + [AetherClaimTypes.UserId] = "42", + [AetherClaimTypes.UserName] = "12345678901", + [AetherClaimTypes.Name] = "Ada", + [AetherClaimTypes.SurName] = "Lovelace", + [AetherClaimTypes.Role] = "maker,checker", + [AetherClaimTypes.ActorUserId] = "99", + [AetherClaimTypes.ActorSub] = "10987654321", + [AetherClaimTypes.ConsentId] = "consent-1", + [AetherClaimTypes.Position] = "branch-teller" + }); + + var user = resolver.GetCurrentUser(); + + user.ShouldNotBeNull(); + user.Id.ShouldBe("42"); + user.UserName.ShouldBe("12345678901"); + user.Name.ShouldBe("Ada"); + user.Surname.ShouldBe("Lovelace"); + user.Roles.ShouldBe(new[] { "maker", "checker" }); + user.ActorUserId.ShouldBe("99"); + user.ActorUserName.ShouldBe("10987654321"); + user.ConsentId.ShouldBe("consent-1"); + user.Position.ShouldBe("branch-teller"); + } + + [Fact] + public void GetCurrentUser_WhenRoleHeaderIsSpaceSeparated_SplitsIntoRoles() + { + var user = ResolverFor(new Dictionary + { + [AetherClaimTypes.UserName] = "12345678901", + [AetherClaimTypes.Role] = "maker checker" + }).GetCurrentUser(); + + user!.Roles.ShouldBe(new[] { "maker", "checker" }); + } + + [Fact] + public void GetCurrentUser_WhenSingleLegacyRoleHeader_YieldsThatOneRole() + { + var user = ResolverFor(new Dictionary + { + [AetherClaimTypes.UserName] = "12345678901", + [AetherClaimTypes.Role] = " maker " + }).GetCurrentUser(); + + user!.Roles.ShouldBe(new[] { "maker" }); + } + + [Fact] + public void GetCurrentUser_WhenNoRoleHeader_YieldsEmptyRoles() + { + var user = ResolverFor(new Dictionary + { + [AetherClaimTypes.UserName] = "12345678901" + }).GetCurrentUser(); + + user!.Roles.ShouldBeEmpty(); + user.Position.ShouldBeNull(); + } + + private static HeaderCurrentUserResolver ResolverFor(Dictionary headers) + { + var context = new DefaultHttpContext(); + foreach (var (key, value) in headers) + { + context.Request.Headers[key] = value; + } + + var accessor = Substitute.For(); + accessor.HttpContext.Returns(context); + return new HeaderCurrentUserResolver(accessor); + } +} diff --git a/framework/test/BBT.Aether.AspNetCore.Tests/BBT/Aether/AspNetCore/Security/HttpRequestCurrentUserExtensionsTests.cs b/framework/test/BBT.Aether.AspNetCore.Tests/BBT/Aether/AspNetCore/Security/HttpRequestCurrentUserExtensionsTests.cs new file mode 100644 index 0000000..2a433eb --- /dev/null +++ b/framework/test/BBT.Aether.AspNetCore.Tests/BBT/Aether/AspNetCore/Security/HttpRequestCurrentUserExtensionsTests.cs @@ -0,0 +1,91 @@ +using System.Collections.Generic; +using BBT.Aether.AspNetCore.Security; +using BBT.Aether.Users; +using Microsoft.AspNetCore.Http; +using Shouldly; +using Xunit; + +namespace BBT.Aether.AspNetCore.Tests.BBT.Aether.AspNetCore.Security; + +public class HttpRequestCurrentUserExtensionsTests +{ + [Fact] + public void GetClaimHeader_WhenAbsentOrEmpty_ReturnsNull() + { + var request = RequestWith(new Dictionary + { + [AetherClaimTypes.Position] = string.Empty + }); + + request.GetClaimHeader(AetherClaimTypes.Position).ShouldBeNull(); + request.GetClaimHeader(AetherClaimTypes.UserName).ShouldBeNull(); + } + + [Fact] + public void GetClaimHeader_ReturnsTheHeaderValue() + { + var request = RequestWith(new Dictionary + { + [AetherClaimTypes.Position] = "branch-teller" + }); + + request.GetClaimHeader(AetherClaimTypes.Position).ShouldBe("branch-teller"); + } + + [Fact] + public void GetCurrentUserHeaders_CollectsOnlyThePresentClaimHeaders() + { + var request = RequestWith(new Dictionary + { + [AetherClaimTypes.UserName] = "12345678901", + [AetherClaimTypes.Role] = "maker,checker", + [AetherClaimTypes.Position] = "branch-teller", + ["X-Unrelated"] = "ignored" + }); + + var headers = request.GetCurrentUserHeaders(); + + headers.Keys.ShouldBe( + [AetherClaimTypes.UserName, AetherClaimTypes.Role, AetherClaimTypes.Position], + ignoreOrder: true); + headers[AetherClaimTypes.Position].ShouldBe("branch-teller"); + } + + [Fact] + public void GetCurrentUserHeaders_FeedsChangeFromHeaders() + { + var request = RequestWith(new Dictionary + { + [AetherClaimTypes.UserName] = "12345678901", + [AetherClaimTypes.Role] = "maker checker", + [AetherClaimTypes.Position] = "branch-teller" + }); + var currentUser = new CurrentUser(new PerInstanceCurrentUserAccessor()); + + using (currentUser.ChangeFromHeaders(request.GetCurrentUserHeaders())) + { + currentUser.UserName.ShouldBe("12345678901"); + currentUser.Role.ShouldBe("maker"); + currentUser.Roles.ShouldBe(new[] { "maker", "checker" }); + currentUser.Position.ShouldBe("branch-teller"); + } + + currentUser.Position.ShouldBeNull(); + } + + private static HttpRequest RequestWith(Dictionary headers) + { + var context = new DefaultHttpContext(); + foreach (var (key, value) in headers) + { + context.Request.Headers[key] = value; + } + + return context.Request; + } + + private sealed class PerInstanceCurrentUserAccessor : ICurrentUserAccessor + { + public BasicUserInfo? Current { get; set; } + } +} diff --git a/framework/test/BBT.Aether.Infrastructure.Tests/BBT/Aether/Users/CurrentUserHeaderExtensionsTests.cs b/framework/test/BBT.Aether.Infrastructure.Tests/BBT/Aether/Users/CurrentUserHeaderExtensionsTests.cs new file mode 100644 index 0000000..b4a914a --- /dev/null +++ b/framework/test/BBT.Aether.Infrastructure.Tests/BBT/Aether/Users/CurrentUserHeaderExtensionsTests.cs @@ -0,0 +1,208 @@ +using System.Collections.Generic; +using BBT.Aether.TestSupport; +using BBT.Aether.Users; +using NSubstitute; +using Shouldly; +using Xunit; + +namespace BBT.Aether.Infrastructure.Tests.BBT.Aether.Users; + +public class CurrentUserHeaderExtensionsTests +{ + [Fact] + public void ChangeFromHeaders_WhenHeadersNull_DoesNotChangeUser() + { + var currentUser = Substitute.For(); + + using (currentUser.ChangeFromHeaders(null)) + { + } + + currentUser.DidNotReceiveWithAnyArgs().Change(Arg.Any()); + } + + [Fact] + public void ChangeFromHeaders_WhenHeadersEmpty_DoesNotChangeUser() + { + var currentUser = Substitute.For(); + + using (currentUser.ChangeFromHeaders(new Dictionary())) + { + } + + currentUser.DidNotReceiveWithAnyArgs().Change(Arg.Any()); + } + + [Fact] + public void ChangeFromHeaders_MapsEveryClaimHeader() + { + var user = CreateCurrentUser(); + var headers = new Dictionary + { + [AetherClaimTypes.UserId] = "42", + [AetherClaimTypes.UserName] = "12345678901", + [AetherClaimTypes.Name] = "Ada", + [AetherClaimTypes.SurName] = "Lovelace", + [AetherClaimTypes.Role] = "maker,checker", + [AetherClaimTypes.ActorUserId] = "99", + [AetherClaimTypes.ActorSub] = "10987654321", + [AetherClaimTypes.ConsentId] = "consent-1", + [AetherClaimTypes.Position] = "branch-teller" + }; + + using (user.ChangeFromHeaders(headers)) + { + user.Id.ShouldBe("42"); + user.UserName.ShouldBe("12345678901"); + user.Name.ShouldBe("Ada"); + user.Surname.ShouldBe("Lovelace"); + user.Roles.ShouldBe(new[] { "maker", "checker" }); + user.Role.ShouldBe("maker"); + user.ActorUserId.ShouldBe("99"); + user.ActorUserName.ShouldBe("10987654321"); + user.ConsentId.ShouldBe("consent-1"); + user.Position.ShouldBe("branch-teller"); + } + + user.UserName.ShouldBeNull(); + } + + [Fact] + public void ChangeFromHeaders_WhenOnlySubAndActSubPresent_LeavesTheRestNull() + { + var user = CreateCurrentUser(); + var headers = new Dictionary + { + [AetherClaimTypes.UserName] = "12345678901", + [AetherClaimTypes.ActorSub] = "10987654321" + }; + + using (user.ChangeFromHeaders(headers)) + { + user.UserName.ShouldBe("12345678901"); + user.ActorUserName.ShouldBe("10987654321"); + user.Id.ShouldBeNull(); + user.Roles.ShouldBeNull(); + user.Role.ShouldBeNull(); + user.Position.ShouldBeNull(); + } + } + + [Fact] + public void ChangeFromHeaders_WhenRoleHeaderEmpty_LeavesRolesNull() + { + var user = CreateCurrentUser(); + var headers = new Dictionary + { + [AetherClaimTypes.UserName] = "12345678901", + [AetherClaimTypes.Role] = " " + }; + + using (user.ChangeFromHeaders(headers)) + { + user.Roles.ShouldBeNull(); + user.Role.ShouldBeNull(); + } + } + + [Fact] + public void ToForwardHeaders_IncludesPositionAndJoinsRoles() + { + var user = CreateCurrentUser(); + + using (user.Change(new BasicUserInfo( + "42", "12345678901", "Ada", "Lovelace", + ["maker", "checker"], "99", "10987654321", "consent-1", "branch-teller"))) + { + var headers = user.ToForwardHeaders(); + + headers[AetherClaimTypes.UserId].ShouldBe("42"); + headers[AetherClaimTypes.UserName].ShouldBe("12345678901"); + headers[AetherClaimTypes.Name].ShouldBe("Ada"); + headers[AetherClaimTypes.SurName].ShouldBe("Lovelace"); + headers[AetherClaimTypes.Role].ShouldBe("maker,checker"); + headers[AetherClaimTypes.ActorUserId].ShouldBe("99"); + headers[AetherClaimTypes.ActorSub].ShouldBe("10987654321"); + headers[AetherClaimTypes.ConsentId].ShouldBe("consent-1"); + headers[AetherClaimTypes.Position].ShouldBe("branch-teller"); + } + } + + [Fact] + public void ToForwardHeaders_OmitsEmptyValues() + { + var user = CreateCurrentUser(); + + using (user.Change("42", "12345678901", roles: [])) + { + var headers = user.ToForwardHeaders(); + + headers.Keys.ShouldBe([AetherClaimTypes.UserId, AetherClaimTypes.UserName], ignoreOrder: true); + headers.ContainsKey(AetherClaimTypes.Position).ShouldBeFalse(); + headers.ContainsKey(AetherClaimTypes.Role).ShouldBeFalse(); + } + } + + [Fact] + public void ToForwardHeaders_IsCaseInsensitive() + { + var user = CreateCurrentUser(); + + using (user.Change("42", "12345678901", position: "branch-teller")) + { + var headers = user.ToForwardHeaders(); + + headers["POSITION"].ShouldBe("branch-teller"); + } + } + + [Fact] + public void ToForwardHeaders_RoundTripsThroughChangeFromHeaders() + { + var source = CreateCurrentUser(); + var target = CreateCurrentUser(); + + using (source.Change(new BasicUserInfo( + "42", "12345678901", "Ada", "Lovelace", + ["maker", "checker"], "99", "10987654321", "consent-1", "branch-teller"))) + { + var headers = source.ToForwardHeaders(); + + using (target.ChangeFromHeaders(headers)) + { + target.Id.ShouldBe(source.Id); + target.UserName.ShouldBe(source.UserName); + target.Name.ShouldBe(source.Name); + target.Surname.ShouldBe(source.Surname); + target.Roles.ShouldBe(source.Roles); + target.ActorUserId.ShouldBe(source.ActorUserId); + target.ActorUserName.ShouldBe(source.ActorUserName); + target.ConsentId.ShouldBe(source.ConsentId); + target.Position.ShouldBe(source.Position); + } + } + } + + [Theory] + [InlineData("maker,checker")] + [InlineData("maker checker")] + [InlineData("maker , checker")] + [InlineData(" maker checker ")] + public void ParseRolesFromHeader_SplitsOnCommaAndSpaceAndTrims(string value) + { + CurrentUserHeaderExtensions.ParseRolesFromHeader(value) + .ShouldBe(new[] { "maker", "checker" }); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData(",")] + public void ParseRolesFromHeader_WhenNoRole_ReturnsNull(string? value) + { + CurrentUserHeaderExtensions.ParseRolesFromHeader(value).ShouldBeNull(); + } + + private static ICurrentUser CreateCurrentUser() => TestCurrentUserAccessor.NewCurrentUser(); +} diff --git a/framework/test/BBT.Aether.Infrastructure.Tests/BBT/Aether/Users/CurrentUserTests.cs b/framework/test/BBT.Aether.Infrastructure.Tests/BBT/Aether/Users/CurrentUserTests.cs new file mode 100644 index 0000000..08045a9 --- /dev/null +++ b/framework/test/BBT.Aether.Infrastructure.Tests/BBT/Aether/Users/CurrentUserTests.cs @@ -0,0 +1,138 @@ +using System; +using BBT.Aether.TestSupport; +using BBT.Aether.Users; +using Shouldly; +using Xunit; + +namespace BBT.Aether.Infrastructure.Tests.BBT.Aether.Users; + +public class CurrentUserTests +{ + private static ICurrentUser Create() => TestCurrentUserAccessor.NewCurrentUser(); + + [Fact] + public void Role_WhenNoUser_IsNull() + { + var user = Create(); + + user.Role.ShouldBeNull(); + user.Position.ShouldBeNull(); + user.IsAuthenticated.ShouldBeFalse(); + } + + [Fact] + public void Role_WhenSingleRole_ReturnsThatRole() + { + var user = Create(); + + using (user.Change("1", "12345678901", roles: ["maker"])) + { + user.Role.ShouldBe("maker"); + user.Roles.ShouldBe(new[] { "maker" }); + } + } + + [Fact] + public void Role_WhenMultipleRoles_ReturnsFirst() + { + var user = Create(); + + using (user.Change("1", "12345678901", roles: ["maker", "checker"])) + { + user.Role.ShouldBe("maker"); + } + } + + [Fact] + public void Role_WhenRolesEmptyOrNull_IsNull() + { + var user = Create(); + + using (user.Change("1", "12345678901", roles: [])) + { + user.Role.ShouldBeNull(); + } + + using (user.Change("1", "12345678901")) + { + user.Role.ShouldBeNull(); + } + } + + [Fact] + public void Position_IsReadFromCurrentUserInfo() + { + var user = Create(); + + using (user.Change("1", "12345678901", position: "branch-teller")) + { + user.Position.ShouldBe("branch-teller"); + } + + user.Position.ShouldBeNull(); + } + + [Fact] + public void Change_WithBasicUserInfo_MakesEveryFieldCurrent() + { + var user = Create(); + var info = new BasicUserInfo( + "42", + "12345678901", + "Ada", + "Lovelace", + ["maker", "checker"], + "99", + "10987654321", + "consent-1", + "branch-teller"); + + using (user.Change(info)) + { + user.IsAuthenticated.ShouldBeTrue(); + user.Id.ShouldBe("42"); + user.UserName.ShouldBe("12345678901"); + user.Name.ShouldBe("Ada"); + user.Surname.ShouldBe("Lovelace"); + user.Roles.ShouldBe(new[] { "maker", "checker" }); + user.Role.ShouldBe("maker"); + user.ActorUserId.ShouldBe("99"); + user.ActorUserName.ShouldBe("10987654321"); + user.ConsentId.ShouldBe("consent-1"); + user.Position.ShouldBe("branch-teller"); + user.IsInRole("checker").ShouldBeTrue(); + user.IsInRole("approver").ShouldBeFalse(); + } + + user.Id.ShouldBeNull(); + user.IsAuthenticated.ShouldBeFalse(); + } + + [Fact] + public void Change_WhenNested_RestoresOuterUserOnDispose() + { + var user = Create(); + + using (user.Change("outer", "111", roles: ["maker"], position: "hq")) + { + using (user.Change("inner", "222", roles: ["checker"], position: "branch")) + { + user.Id.ShouldBe("inner"); + user.Role.ShouldBe("checker"); + user.Position.ShouldBe("branch"); + } + + user.Id.ShouldBe("outer"); + user.Role.ShouldBe("maker"); + user.Position.ShouldBe("hq"); + } + } + + [Fact] + public void Change_WithNullUserInfo_Throws() + { + var user = Create(); + + Should.Throw(() => user.Change(null!)); + } +} diff --git a/framework/test/BBT.Aether.Infrastructure.Tests/TestSupport/TestCurrentUserAccessor.cs b/framework/test/BBT.Aether.Infrastructure.Tests/TestSupport/TestCurrentUserAccessor.cs new file mode 100644 index 0000000..13baa79 --- /dev/null +++ b/framework/test/BBT.Aether.Infrastructure.Tests/TestSupport/TestCurrentUserAccessor.cs @@ -0,0 +1,19 @@ +using BBT.Aether.Users; + +namespace BBT.Aether.TestSupport; + +/// +/// A per-instance for unit tests. The production +/// is a singleton whose AsyncLocal value leaks between tests +/// that share a thread pool thread, which makes assertions on the ambient user order-dependent. +/// +public sealed class TestCurrentUserAccessor : ICurrentUserAccessor +{ + /// + public BasicUserInfo? Current { get; set; } + + /// + /// Creates an backed by a fresh accessor. + /// + public static ICurrentUser NewCurrentUser() => new CurrentUser(new TestCurrentUserAccessor()); +}