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
79 changes: 79 additions & 0 deletions build/pack-local.sh
Original file line number Diff line number Diff line change
@@ -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/<you>/…/aether/.local-feed -n aether-local \
# --configfile <consumer>/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/<id>/<version>/ 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/^/ /'
1 change: 1 addition & 0 deletions framework/BBT.Aether.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
<Project Path="src/BBT.Aether.TestBase/BBT.Aether.TestBase.csproj" />
</Folder>
<Folder Name="/test/">
<Project Path="test/BBT.Aether.AspNetCore.Tests/BBT.Aether.AspNetCore.Tests.csproj" />
<Project Path="test/BBT.Aether.Infrastructure.Tests/BBT.Aether.Infrastructure.Tests.csproj" />
<Project Path="test/BBT.Aether.Postgres.Tests/BBT.Aether.Postgres.Tests.csproj" />
<Project Path="test/BBT.Aether.SqlServer.Tests/BBT.Aether.SqlServer.Tests.csproj" />
Expand Down
2 changes: 2 additions & 0 deletions framework/docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -107,6 +108,7 @@ BBT.Aether.Core (Base)
| Inbox/Outbox | ✓ | ✓ | ✓ | - | - |
| Cache/Lock | ✓ | - | ✓ | - | - |
| Background Jobs | ✓ | ✓ | ✓ | ✓ | - |
| Current User | ✓ | - | - | ✓ | - |
| Telemetry | - | - | - | ✓ | ✓ |
| Tracing/Logging/Metrics | - | - | - | - | ✓ |

Expand Down
160 changes: 160 additions & 0 deletions framework/docs/current-user/README.md
Original file line number Diff line number Diff line change
@@ -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<OrderDto> 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<ICurrentUserResolver, MyJwtCurrentUserResolver>());
```

`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<string, string?>`, 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
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using System.Linq;
using BBT.Aether.Users;
using Microsoft.AspNetCore.Http;

Expand All @@ -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)
);
}
}
}
Loading
Loading