From bb56eb38b76a282bc1014842aac66adc32c69c26 Mon Sep 17 00:00:00 2001 From: Marcel Roozerkans Date: Sat, 23 May 2026 21:28:14 +0200 Subject: [PATCH 1/6] docs: add design for Mediator.Authorization resource-based context adoption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lights up IResourceSecurityContext (dormant contract shipped in ZeroAlloc.Authorization v2.1) by wrapping the resolved ISecurityContext in a per-dispatch ResourceSecurityContextAdapter before calling AuthorizerFor.EvaluateAsync. The dispatched request becomes the typed resource. Always-wrap (no opt-in surface), ~16 B per dispatch. Pure runtime additive behaviour — no PublicAPI change. Lands as a feat: minor bump for Mediator.Authorization. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...r-authorization-resource-context-design.md | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 docs/plans/2026-05-23-mediator-authorization-resource-context-design.md diff --git a/docs/plans/2026-05-23-mediator-authorization-resource-context-design.md b/docs/plans/2026-05-23-mediator-authorization-resource-context-design.md new file mode 100644 index 0000000..40ebea0 --- /dev/null +++ b/docs/plans/2026-05-23-mediator-authorization-resource-context-design.md @@ -0,0 +1,176 @@ +# Mediator.Authorization — resource-based context adoption + +**Date:** 2026-05-23 +**Scope:** Light up `IResourceSecurityContext` (the dormant contract shipped today in `ZeroAlloc.Authorization` v2.1) in `Mediator.Authorization`. The behaviour now exposes the dispatched request as the typed resource so resource-based authorization policies fire correctly through Mediator. + +## Background + +- `ZeroAlloc.Authorization` v2.1 (PR #28, merged today) shipped `IResourceSecurityContext` as a public interface plus the `OwnerOnlyPolicy`-style consumer pattern: policies type-check `ctx is IResourceSecurityContext rc` to access `rc.Resource`. +- The contract was deliberately documented as *dormant* in v2.1: the interface exists, but no host populates it. Consumer policies that type-check for it fall through to the `false` branch until a host opts in. +- `Mediator.Authorization`'s `AuthorizationBehavior.Handle` is the natural first host: it already resolves an `ISecurityContext` from DI per dispatch, then calls `AuthorizerFor.EvaluateAsync(ctx, ct)`. Replacing the plain `ctx` with an adapter that ALSO carries the dispatched `TRequest` as the resource is a small, fully-additive change. + +## Goal + +Make `IResourceSecurityContext` light up automatically for every Mediator-dispatched request, with no consumer code changes required beyond writing the resource-based policy. + +## Decisions + +### 1. The resource IS the dispatched request + +`TResource = TRequest` always. In CQRS-style Mediator usage the request IS the operation +plus its parameters — that's the natural "thing being acted upon." Policies that need a +sub-property (e.g., `request.Order` instead of the whole request) extract it inside the +policy body: `rc.Resource.Order.OwnerId`. No `IHasResource` marker, no extractor delegate. + +### 2. Always wrap — no opt-in + +Every dispatch through `AuthorizationBehavior` constructs a `ResourceSecurityContextAdapter` +wrapping the resolved `ISecurityContext` plus the `request` value. Policies that don't care about +the resource cast `ctx` as `ISecurityContext` and ignore the rest; the `is IResourceSecurityContext rc` +check inside the policy is the natural gate. + +Cost per dispatch: one ~16 B class allocation (object header + 2 reference fields). Negligible +next to the DI resolution + ValueTask state machine cost already paid per dispatch. An opt-in +marker interface was considered (zero-alloc preservation for non-resource policies) but rejected: +the failure mode of forgetting the marker is worse than the 16 B cost, and an "all dispatches +get the resource" mental model is simpler than a per-request opt-in surface. + +### 3. Adapter is `internal sealed class` + +Not part of the public API. Pure runtime implementation detail of `AuthorizationBehavior`. +PublicAPI.Shipped.txt and PublicAPI.Unshipped.txt see no changes. + +## Design + +### Adapter + +New file `src/ZeroAlloc.Mediator.Authorization/ResourceSecurityContextAdapter.cs`: + +```csharp +using System.Collections.Generic; +using ZeroAlloc.Authorization; + +namespace ZeroAlloc.Mediator.Authorization; + +/// +/// Per-dispatch adapter that exposes the dispatched request as the typed +/// while forwarding all +/// members to the inner caller-identity context. +/// +internal sealed class ResourceSecurityContextAdapter : IResourceSecurityContext +{ + private readonly ISecurityContext _inner; + + public ResourceSecurityContextAdapter(ISecurityContext inner, TResource resource) + { + _inner = inner; + Resource = resource; + } + + public TResource Resource { get; } + + // ISecurityContext forwarders. + public string Id => _inner.Id; + public IReadOnlySet Roles => _inner.Roles; + public IReadOnlyDictionary Claims => _inner.Claims; +} +``` + +### `AuthorizationBehavior.Handle` change + +In `src/ZeroAlloc.Mediator.Authorization/AuthorizationBehavior.cs`, immediately before the +`authorizer.EvaluateAsync(ctx, ct)` call, wrap `ctx`: + +```diff + var ctx = sp.GetService(); + if (ctx is null) + { + throw new InvalidOperationException( + "AuthorizationBehavior could not resolve ISecurityContext. ..."); + } + +-var result = await authorizer.EvaluateAsync(ctx, ct).ConfigureAwait(false); ++var resourceCtx = new ResourceSecurityContextAdapter(ctx, request); ++var result = await authorizer.EvaluateAsync(resourceCtx, ct).ConfigureAwait(false); +``` + +Existing fail-open paths (`sp is null`, `authorizer is null`) are unchanged — they short-circuit +before the wrap. + +### Consumer-side effect (no consumer changes required) + +A resource-based policy that was previously dormant (`ctx is IResourceSecurityContext<...> rc` +always returned false) now resolves correctly when dispatched through Mediator: + +```csharp +public sealed class OwnerOnlyDeletePolicy : IAuthorizationPolicy +{ + public ValueTask> EvaluateAsync( + ISecurityContext ctx, CancellationToken ct = default) + => new(ctx is IResourceSecurityContext rc && rc.Resource.UserId == ctx.Id + ? UnitResult.Success() + : new AuthorizationFailure("delete.not_owner")); +} + +[Policy("OwnerOnlyDelete")] +public sealed class OwnerOnlyDeletePolicyRegistration { /* class-level [Policy] marker; impl in OwnerOnlyDeletePolicy */ } + +[RequirePolicy("OwnerOnlyDelete")] +public sealed record DeleteUserCommand(string UserId) : IRequest; +``` + +A policy that type-checks for a DIFFERENT request type correctly falls through: + +```csharp +// Policy expects IResourceSecurityContext; dispatch is DeleteUserCommand. +// ctx is IResourceSecurityContext rc → false → policy returns failure. +``` + +This is the documented behaviour from Authorization v2.1's `resource-based-authorization.md` — +nothing new for the consumer; the adapter just makes the cast succeed when the types align. + +## Tests + +`tests/ZeroAlloc.Mediator.Authorization.Tests/ResourceSecurityContextTests.cs` (new): + +- **`Policy_Sees_DispatchedRequest_AsResource`** — dispatch a `DeleteUserCommand("alice")` through `IMediator.Send`, with an authenticated security context whose `Id == "alice"`. Assert: handler runs (policy passed). +- **`Policy_TypeChecking_WrongRequestType_FallsThrough`** — a policy that type-checks `IResourceSecurityContext` falls through to its failure branch when dispatched on a `DeleteUserCommand`. Assert: response is `AuthorizationDeniedException` (or `Result<,>.Failure(...)` if the request shape supports it). + +`tests/ZeroAlloc.Mediator.Authorization.Tests/AllocationBudgetTests.cs` (modify): + +- Existing `AuthorizationBehavior_Allow_Path_WithinBudget` test budget may need to widen by ~16 B to accommodate the adapter alloc. Measure first; document the new headroom in the test comment. + +No snapshot tests — `Mediator.Authorization` has no generator emit of its own; the behaviour is plain runtime code. + +## Out of scope + +- **AI.Sentinel adoption.** Tracked separately. Same shape: AI.Sentinel's existing tool-call dispatch behaviour can adopt the same adapter pattern to expose the tool call as the resource. +- **Sub-property as resource (`IHasResource`).** Considered, rejected as YAGNI. Sub-property access happens inside the policy body via `rc.Resource.X.Y`. +- **Opt-in / marker interface.** Considered, rejected — the 16 B per-dispatch cost is small enough that mandatory wrap is the better trade-off. +- **AOT compatibility audit.** The adapter is a sealed class with no reflection / no dynamic code. AOT-safe by construction; existing PackSmoke binary will continue to exercise `AuthorizationBehavior.Handle` and exercises the wrap path transitively. + +## Files touched + +Runtime (`src/ZeroAlloc.Mediator.Authorization/`): + +- NEW: `ResourceSecurityContextAdapter.cs` (the adapter class). +- MOD: `AuthorizationBehavior.cs` (wrap `ctx` before passing to authorizer). + +Tests (`tests/ZeroAlloc.Mediator.Authorization.Tests/`): + +- NEW: `ResourceSecurityContextTests.cs` (2 runtime tests). +- MOD: `AllocationBudgetTests.cs` (raise the dispatch budget by ~16 B if measurement requires; document the headroom in a comment). + +Docs (`docs/`): + +- MOD or NEW: a short section / page on resource-based policies under Mediator.Authorization, + linking out to `ZeroAlloc.Authorization`'s `resource-based-authorization.md`. Discover the existing convention before authoring. + +## Backward compatibility + +Strictly additive runtime behaviour. No PublicAPI change. No interface or signature change. +v2.0/v2.1 consumers of `Mediator.Authorization` that don't write resource-based policies see +no behaviour change beyond the ~16 B per-dispatch adapter allocation. + +No SemVer break. Lands as a `feat:` commit; release-please bumps Mediator.Authorization minor +(per the package's conventional-commits config). From 19601e3f6de544caa9147e95df5cb04cb745f49a Mon Sep 17 00:00:00 2001 From: Marcel Roozerkans Date: Sat, 23 May 2026 21:34:25 +0200 Subject: [PATCH 2/6] docs(plans): add implementation plan for Mediator.Authorization resource context Task-by-task plan covering adapter creation, AuthorizationBehavior wrap, 2 runtime tests, conditional allocation-budget bump, docs section, and push + admin-merge. Follows the precedent of the 2026-05-22 integration tests plan. --- ...mediator-authorization-resource-context.md | 609 ++++++++++++++++++ 1 file changed, 609 insertions(+) create mode 100644 docs/plans/2026-05-23-mediator-authorization-resource-context.md diff --git a/docs/plans/2026-05-23-mediator-authorization-resource-context.md b/docs/plans/2026-05-23-mediator-authorization-resource-context.md new file mode 100644 index 0000000..56ee4f7 --- /dev/null +++ b/docs/plans/2026-05-23-mediator-authorization-resource-context.md @@ -0,0 +1,609 @@ +# Mediator.Authorization resource-based context adoption — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Light up the dormant `IResourceSecurityContext` contract shipped in `ZeroAlloc.Authorization` v2.1 by having `AuthorizationBehavior.Handle` wrap the resolved `ISecurityContext` in a per-dispatch adapter that exposes the dispatched `TRequest` as `Resource`. Consumer policies that type-check `ctx is IResourceSecurityContext rc` will now resolve correctly through Mediator. + +**Architecture:** One new internal sealed class `ResourceSecurityContextAdapter` that implements `IResourceSecurityContext` and forwards `ISecurityContext` members to an inner context. One 2-line edit in `AuthorizationBehavior.Handle` to wrap `ctx` before passing to the authorizer. Two new runtime tests asserting the resource is exposed correctly + that wrong-type policies fall through. One small adjustment to the existing allocation budget to absorb the ~16 B adapter allocation. One short docs section linking out to `ZeroAlloc.Authorization`'s resource-based-authorization guide. No PublicAPI change (the adapter is internal). + +**Tech Stack:** .NET 10 / .NET 8 multi-targeted, xUnit 2.x, `ZeroAlloc.Authorization` v2.1 (carries `IResourceSecurityContext` and `ISecurityContext`), `ZeroAlloc.TestHelpers` (`AllocationGate`), existing `AuthorizationBehavior` pipeline behaviour (`Order = -1000`). + +**Design doc:** `docs/plans/2026-05-23-mediator-authorization-resource-context-design.md` (committed at `bb56eb3`). + +**Working branch:** `feat/mediator-authorization-resource-context` (already created off `main`; design doc committed at `bb56eb3`). + +**Key context to keep in mind while implementing:** + +- `IResourceSecurityContext : ISecurityContext` is *covariant* in `TResource`. It exposes a single `TResource Resource { get; }` property in addition to the three `ISecurityContext` members (`Id`, `Roles`, `Claims`). +- Consumer policies pattern is `ctx is IResourceSecurityContext rc && rc.Resource.UserId == ctx.Id`. The `is` check resolves only when the runtime `TResource` matches; type-checking against a different request type correctly falls through. +- `AuthorizationBehavior.Handle` already resolves `ISecurityContext` from DI per dispatch. The change wraps that context *after* the null-guard, *before* the `authorizer.EvaluateAsync(ctx, ct)` call. +- `AuthorizationBehavior.Handle` carries `[UnconditionalSuppressMessage("Trimming", "IL2091")]` — the existing trim-safety analysis already accepts the method shape. Adding a wrap allocation doesn't change trimming surface. +- Test fixtures live in `tests/ZeroAlloc.Mediator.Authorization.Tests/TestFixtures.cs` and existing tests already have `[Collection("non-parallel-authorization")]` because `AuthorizationBehaviorState.ServiceProvider` is a static field. +- The repo docs convention is flat under `docs/` (verified: `docs/authorization.md`, `docs/pipeline-behaviors.md`, etc., no subdirs for core concepts). The resource-based section goes into `docs/authorization.md`. + +--- + +## Task 1: Add `ResourceSecurityContextAdapter` + +**Files:** +- Create: `c:/Projects/Prive/ZeroAlloc/ZeroAlloc.Mediator/src/ZeroAlloc.Mediator.Authorization/ResourceSecurityContextAdapter.cs` + +**Step 1: Write the adapter** + +Create the file with this content verbatim: + +```csharp +using System.Collections.Generic; +using ZeroAlloc.Authorization; + +namespace ZeroAlloc.Mediator.Authorization; + +/// +/// Per-dispatch adapter that exposes the dispatched request as the typed +/// while forwarding all +/// members to the inner caller-identity context. +/// +/// +/// Constructed inside +/// once per dispatch. Cost is one ~16 B class allocation (object header + 2 reference +/// fields) — small enough to be absorbed by 's existing +/// allocation budget; see AllocationBudgetTests. +/// Internal-only by design: consumer policies type-check the public +/// interface, never the adapter type. +/// +internal sealed class ResourceSecurityContextAdapter : IResourceSecurityContext +{ + private readonly ISecurityContext _inner; + + public ResourceSecurityContextAdapter(ISecurityContext inner, TResource resource) + { + _inner = inner; + Resource = resource; + } + + public TResource Resource { get; } + + public string Id => _inner.Id; + public IReadOnlySet Roles => _inner.Roles; + public IReadOnlyDictionary Claims => _inner.Claims; +} +``` + +**Step 2: Verify the build** + +```bash +cd c:/Projects/Prive/ZeroAlloc/ZeroAlloc.Mediator +dotnet build src/ZeroAlloc.Mediator.Authorization/ZeroAlloc.Mediator.Authorization.csproj -c Release +``` + +Expected: SUCCEEDED, 0 errors, 0 warnings. No PublicAPI change required because the type is `internal`. + +If the build fails with `CS0246` on `IResourceSecurityContext`, confirm the `ZeroAlloc.Authorization` package reference is at v2.1+ (the interface was added in v2.1 per the design doc's background section). + +**Step 3: Commit** + +```bash +git add src/ZeroAlloc.Mediator.Authorization/ResourceSecurityContextAdapter.cs +git commit -m "feat(authorization): add internal ResourceSecurityContextAdapter + +Per-dispatch adapter that implements IResourceSecurityContext +(shipped dormant in ZeroAlloc.Authorization v2.1) by wrapping an inner +ISecurityContext + a resource value. ISecurityContext members forward to +the inner; Resource exposes the wrapped value. + +Not wired into AuthorizationBehavior yet — that's the next commit. +Internal-only; no PublicAPI change." +``` + +--- + +## Task 2: Wire the adapter into `AuthorizationBehavior.Handle` + +**Files:** +- Modify: `c:/Projects/Prive/ZeroAlloc/ZeroAlloc.Mediator/src/ZeroAlloc.Mediator.Authorization/AuthorizationBehavior.cs` + +**Step 1: Wrap `ctx` before the authorizer call** + +Open `AuthorizationBehavior.cs`. Find this block (around lines 60-69): + +```csharp + var ctx = sp.GetService(); + if (ctx is null) + { + throw new InvalidOperationException( + "AuthorizationBehavior could not resolve ISecurityContext. Ensure WithAuthorization() " + + "configured a security-context source (UseSecurityContextFactory / UseAnonymousSecurityContext / UseAccessor<>)."); + } + + var result = await authorizer.EvaluateAsync(ctx, ct).ConfigureAwait(false); +``` + +Replace the final `var result = await authorizer.EvaluateAsync(ctx, ct)...` line with: + +```csharp + // Wrap the resolved caller-identity context so resource-based policies + // (ctx is IResourceSecurityContext rc) light up automatically. + // ~16 B/dispatch; absorbed by the existing allocation budget. + var resourceCtx = new ResourceSecurityContextAdapter(ctx, request); + var result = await authorizer.EvaluateAsync(resourceCtx, ct).ConfigureAwait(false); +``` + +The two fail-open paths above (`sp is null`, `authorizer is null`) are unchanged — they short-circuit before the wrap, so dispatches that don't reach the authorizer pay nothing. + +**Step 2: Verify the build** + +```bash +cd c:/Projects/Prive/ZeroAlloc/ZeroAlloc.Mediator +dotnet build src/ZeroAlloc.Mediator.Authorization/ZeroAlloc.Mediator.Authorization.csproj -c Release +``` + +Expected: SUCCEEDED, 0 errors, 0 warnings. + +**Step 3: Run the existing test suite — must remain green** + +```bash +dotnet test tests/ZeroAlloc.Mediator.Authorization.Tests/ZeroAlloc.Mediator.Authorization.Tests.csproj -c Release +``` + +Expected: every existing test passes. The wrap is transparent to policies that only need `ISecurityContext` — the adapter's `Id`/`Roles`/`Claims` forwarders return the inner context's values unchanged. The `AllocationBudgetTests` budgeted tests may now bump up by ~16 B/iteration; if they fail, Task 4 raises the budget. For now, note any failures and proceed. + +**Step 4: Commit** + +```bash +git add src/ZeroAlloc.Mediator.Authorization/AuthorizationBehavior.cs +git commit -m "feat(authorization): expose dispatched request as resource in policies + +AuthorizationBehavior.Handle now wraps the resolved ISecurityContext in a +ResourceSecurityContextAdapter before passing to the authorizer. +Policies that type-check ctx is IResourceSecurityContext rc now +resolve correctly; policies that ignore the resource see the same +ISecurityContext members as before. + +Cost: ~16 B class allocation per dispatch (one object header + two +reference fields). Absorbed by the existing AuthorizationBehavior.Handle +budget; see AllocationBudgetTests. + +Lights up ZeroAlloc.Authorization v2.1's IResourceSecurityContext +contract for Mediator dispatch." +``` + +--- + +## Task 3: Add resource-context runtime tests + +**Files:** +- Create: `c:/Projects/Prive/ZeroAlloc/ZeroAlloc.Mediator/tests/ZeroAlloc.Mediator.Authorization.Tests/ResourceSecurityContextTests.cs` + +**Step 1: Write the test file** + +Create `ResourceSecurityContextTests.cs` with this content verbatim: + +```csharp +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using ZeroAlloc.Authorization; +using ZeroAlloc.Authorization.Generated; +using ZeroAlloc.Mediator; +using ZeroAlloc.Mediator.Authorization; +using Xunit; + +namespace ZeroAlloc.Mediator.Authorization.Tests; + +// Covers the v2.1-contract lighting-up wired by AuthorizationBehavior wrapping +// the resolved ISecurityContext in ResourceSecurityContextAdapter: +// +// - Policies that type-check IResourceSecurityContext resolve and +// see the dispatched request as Resource. +// - Policies that type-check IResourceSecurityContext +// correctly fall through (the cast fails when TResource differs). +// +// Drives AuthorizationBehavior.Handle directly (rather than IMediator.Send) +// for the same reason AllocationBudgetTests does: the cross-assembly behavior +// is not auto-wired into the test-assembly's MediatorService partial, and the +// allocation profile is identical either way. + +[Collection("non-parallel-authorization")] +public sealed class ResourceSecurityContextTests +{ + [Fact] + public async Task Policy_Sees_DispatchedRequest_AsResource() + { + // TestSecurityContexts.With(...) treats positional args as roles and + // always pins Id = "user-1". The policy compares request.UserId to + // ctx.Id, so the request's UserId must match the helper's Id. + using var sp = BuildProvider(TestSecurityContexts.With()); + using var scope = sp.CreateScope(); + AuthorizationBehaviorState.ServiceProvider = scope.ServiceProvider; + var req = new ResourceOwnerCommand(UserId: "user-1", Payload: 7); + + var response = await AuthorizationBehavior.Handle( + req, CancellationToken.None, + static (r, _) => ValueTask.FromResult(r.Payload * 2)); + + // Policy resolved IResourceSecurityContext, saw the + // request's UserId == ctx.Id, and allowed; handler ran (7 * 2). + Assert.Equal(14, response); + } + + [Fact] + public async Task Policy_TypeChecking_WrongRequestType_FallsThrough() + { + using var sp = BuildProvider(TestSecurityContexts.With()); + using var scope = sp.CreateScope(); + AuthorizationBehaviorState.ServiceProvider = scope.ServiceProvider; + var req = new ResourceWrongTypeCommand(UserId: "user-1"); + + var ex = await Assert.ThrowsAsync(async () => + await AuthorizationBehavior.Handle( + req, CancellationToken.None, + static (_, _) => ValueTask.FromResult(42))); + + // The policy on ResourceWrongTypeCommand type-checks + // IResourceSecurityContext (intentionally mismatched). + // The runtime adapter is IResourceSecurityContext, + // so the cast fails and the policy's else branch runs. + Assert.Equal("resource.wrong_type", ex.Failure.Code); + } + + private static ServiceProvider BuildProvider(ISecurityContext ctx) + { + var services = new ServiceCollection(); + services.AddZeroAllocAuthorization(); + services.AddScoped(_ => new TestSecurityContextAccessor { Current = ctx }); + services.AddMediator().WithAuthorization(o => o.UseAccessor()); + return services.BuildServiceProvider(); + } +} +``` + +**Step 2: Add fixtures** + +Open `TestFixtures.cs`. Locate the existing policy declarations (after `IntegrationTestPolicy`, before the request records) and add these two policies + their requests: + +```csharp +[Policy("ResourceOwner")] +public sealed class ResourceOwnerPolicy : IAuthorizationPolicy +{ + // Resource-based: allow only when the dispatched ResourceOwnerCommand's + // UserId matches the caller's identity. Demonstrates the v2.1 contract + // lit up by AuthorizationBehavior wrapping ctx in + // ResourceSecurityContextAdapter. + public ValueTask> EvaluateAsync( + ISecurityContext ctx, CancellationToken ct = default) + => new(ctx is IResourceSecurityContext rc + && string.Equals(rc.Resource.UserId, ctx.Id, System.StringComparison.Ordinal) + ? UnitResult.Success() + : new AuthorizationFailure("resource.not_owner", "resource owner mismatch")); +} + +[Policy("ResourceWrongType")] +public sealed class ResourceWrongTypePolicy : IAuthorizationPolicy +{ + // Intentionally type-checks the WRONG resource type + // (IResourceSecurityContext) while being applied to + // ResourceWrongTypeCommand. The cast must fail at runtime; the deny + // branch must fire with the "resource.wrong_type" code. + public ValueTask> EvaluateAsync( + ISecurityContext ctx, CancellationToken ct = default) + => new(ctx is IResourceSecurityContext + ? UnitResult.Success() + : new AuthorizationFailure("resource.wrong_type", "wrong resource type")); +} +``` + +Then add the corresponding requests in the request-record region of the file (next to `IntegrationTestRequest`): + +```csharp +[RequirePolicy("ResourceOwner")] +public sealed record ResourceOwnerCommand(string UserId, int Payload) : IRequest; + +[RequirePolicy("ResourceWrongType")] +public sealed record ResourceWrongTypeCommand(string UserId) : IRequest; +``` + +**Fixture helper note.** `TestSecurityContexts.With(params string[] roles)` (defined in `TestFixtures.cs`) builds a context whose `Id` is always `"user-1"` (hardcoded in the helper) and whose `Roles` is the supplied params. The test code in Step 1 uses `UserId: "user-1"` in the request and `TestSecurityContexts.With()` (no roles) to satisfy `ctx.Id == request.UserId`. Do not change the helper. + +**Step 3: Run the new tests** + +```bash +cd c:/Projects/Prive/ZeroAlloc/ZeroAlloc.Mediator +dotnet test tests/ZeroAlloc.Mediator.Authorization.Tests/ -c Release \ + --filter "FullyQualifiedName~ResourceSecurityContextTests" +``` + +Expected: 2 tests pass. + +If `Policy_Sees_DispatchedRequest_AsResource` fails with `AuthorizationDeniedException("resource.not_owner")`, the wrap in Task 2 isn't producing `IResourceSecurityContext` correctly — check that `AuthorizationBehavior.Handle`'s generic parameter `TRequest` is the concrete dispatched type (`ResourceOwnerCommand`), not `IRequest`. + +If `Policy_TypeChecking_WrongRequestType_FallsThrough` allows (no exception, `response == 42`), the cast in `ResourceWrongTypePolicy` is succeeding when it shouldn't — this would indicate variance corruption (`IResourceSecurityContext` *is* covariant, but the cast on a closed `ResourceOwnerCommand` should not satisfy a runtime `ResourceWrongTypeCommand` adapter). + +**Step 4: Run the full test suite** + +```bash +dotnet test tests/ZeroAlloc.Mediator.Authorization.Tests/ -c Release +``` + +Expected: every test passes, including the 2 new ones. If `AllocationBudgetTests.Allow_HappyPath_ZeroAlloc` or `AllocationBudgetTests.IAuthorizedRequest_DenyPath_ZeroAlloc` now fail, that's Task 4's adjustment. + +**Step 5: Commit** + +```bash +git add tests/ZeroAlloc.Mediator.Authorization.Tests/ResourceSecurityContextTests.cs \ + tests/ZeroAlloc.Mediator.Authorization.Tests/TestFixtures.cs +git commit -m "test(authorization): cover v2.1 resource-context contract lighting up + +Two runtime tests on AuthorizationBehavior.Handle: + + - Policy_Sees_DispatchedRequest_AsResource: a [Policy] that type-checks + IResourceSecurityContext sees the dispatched + request and allows when ctx.Id == request.UserId. + - Policy_TypeChecking_WrongRequestType_FallsThrough: a [Policy] that + type-checks the WRONG TResource (intentionally mismatched) hits the + cast's false branch and denies. + +Adds ResourceOwnerPolicy / ResourceWrongTypePolicy and their request +records to TestFixtures.cs. Tests run under the existing +[Collection(\"non-parallel-authorization\")] guard because they set the +static AuthorizationBehaviorState.ServiceProvider." +``` + +--- + +## Task 4: Adjust the allocation budget (only if measurement requires) + +**Files:** +- Maybe-modify: `c:/Projects/Prive/ZeroAlloc/ZeroAlloc.Mediator/tests/ZeroAlloc.Mediator.Authorization.Tests/AllocationBudgetTests.cs` + +**Step 1: Measure current state** + +```bash +cd c:/Projects/Prive/ZeroAlloc/ZeroAlloc.Mediator +dotnet test tests/ZeroAlloc.Mediator.Authorization.Tests/ -c Release \ + --filter "FullyQualifiedName~AllocationBudgetTests.Allow_HappyPath_ZeroAlloc|FullyQualifiedName~AllocationBudgetTests.IAuthorizedRequest_DenyPath_ZeroAlloc" +``` + +Two outcomes are possible: + +- **Both pass** — the existing 512 B budget absorbs the ~16 B/dispatch adapter allocation with headroom. No adjustment needed. Skip to Step 3. +- **One or both fail** with a budget-exceeded message — proceed to Step 2. + +**Step 2: Raise the budget** + +If either test failed, the failure message will read something like `AuthorizationBehavior.Handle (allow happy path) allocated X B over 1000 iterations; budget is 512`. Read the actual `X` value from the failure. + +Open `AllocationBudgetTests.cs`. Find the two `AllocationGate.AssertBudgetValueTask(512, 1000, ...)` calls (lines ~81 and ~98). The 16-byte object plus the JIT-optimizer's loss of the async-state-machine elision (if any) is the source of the bump. + +Change the budget from `512` to the smallest power-of-two-ish round number that absorbs the observed `X` plus headroom. The Allow happy path uses `static (r, _) => ValueTask.FromResult(r.Id * 2)`, which is sync-completion-friendly; expect ~16 B/iteration => `16000 / 1000 = 16 B avg`. The existing comment cites `0 B/call Release` and `~248 B/call Debug` (state-machine box). Adding the wrap brings the worst case to roughly `248 + 16 = 264 B/call Debug`. The 512 B existing envelope already covers that, so the most likely outcome is "both pass" — but if not: + +- Raise the first `AllocationGate.AssertBudgetValueTask(512, ...)` to `1024` (Allow happy path), or to the next-larger power-of-two-ish round that exceeds the observed `X + ~25%`. +- Raise the second `AllocationGate.AssertBudgetValueTask(512, ...)` to the same value (IAuthorizedRequest deny path). + +Update the leading comment on each test from `512 B absorbs the Debug box without masking real regressions.` to `N B absorbs the Debug box + the v2.1 ResourceSecurityContextAdapter wrap (~16 B/dispatch) without masking real regressions.`. Use the actual new `N`. + +Do NOT raise budgets speculatively. Only adjust the test(s) that actually failed, and only by the smallest margin that brings them green with the documented Debug-mode + adapter headroom. + +**Step 3: Re-run** + +```bash +dotnet test tests/ZeroAlloc.Mediator.Authorization.Tests/ -c Release +``` + +Expected: every test passes. + +**Step 4: Commit (only if a budget was raised)** + +If Step 2 was a no-op (budgets already absorbed the wrap), skip the commit and proceed to Task 5. + +If a budget was raised: + +```bash +git add tests/ZeroAlloc.Mediator.Authorization.Tests/AllocationBudgetTests.cs +git commit -m "test(authorization): raise Handle allocation budget for v2.1 resource wrap + +AuthorizationBehavior.Handle now wraps the resolved ISecurityContext in +ResourceSecurityContextAdapter (~16 B/dispatch). Raises the +Allow_HappyPath_ZeroAlloc and IAuthorizedRequest_DenyPath_ZeroAlloc +budgets from 512 B to N B to absorb the wrap + Debug-mode state-machine +box without masking real regressions. Comment documents the headroom." +``` + +--- + +## Task 5: Add a short docs section on resource-based policies + +**Files:** +- Modify: `c:/Projects/Prive/ZeroAlloc/ZeroAlloc.Mediator/docs/authorization.md` + +**Step 1: Discover the existing structure** + +Open `docs/authorization.md`. Skim to learn the heading hierarchy (`#` for title, `##` for top-level sections, `###` for sub-sections), the code-fence convention (` ```csharp `), and the cross-link convention (e.g. `[ZeroAlloc.Authorization](https://github.com/ZeroAlloc-Net/ZeroAlloc.Authorization)` for external repo links). + +Identify the right insertion point — typically after the "Quick start" / basic-policy section, but before any "Advanced" / "Allocation budget" sections. A good adjacent anchor is wherever the doc describes `[RequirePolicy]` on a request, since the resource-based pattern is the natural follow-on ("not just a policy name on a request; the request itself can be the resource"). + +**Step 2: Add the section** + +Append the following section at the chosen insertion point. Use the actual heading level that fits the surrounding hierarchy (almost certainly `##`): + +```markdown +## Resource-based policies + +When a policy needs to inspect the dispatched request itself (not just the caller's identity), type-check the security context for `IResourceSecurityContext`. `Mediator.Authorization` wraps the resolved `ISecurityContext` in a per-dispatch adapter so the cast resolves automatically — no host configuration required. + +```csharp +[Policy("OwnerOnlyDelete")] +public sealed class OwnerOnlyDeletePolicy : IAuthorizationPolicy +{ + public ValueTask> EvaluateAsync( + ISecurityContext ctx, CancellationToken ct = default) + => new(ctx is IResourceSecurityContext rc + && string.Equals(rc.Resource.UserId, ctx.Id, StringComparison.Ordinal) + ? UnitResult.Success() + : new AuthorizationFailure("delete.not_owner")); +} + +[RequirePolicy("OwnerOnlyDelete")] +public sealed record DeleteUserCommand(string UserId) : IRequest; +``` + +The resource exposed to the policy IS the dispatched request. To access a sub-property (e.g. `request.Order.OwnerId`), pull it inside the policy body via `rc.Resource.Order.OwnerId` — no marker interface, no extractor delegate. + +Policies that don't need the resource ignore the adapter; the `ISecurityContext` members (`Id`, `Roles`, `Claims`) forward through. The per-dispatch cost is one ~16 B class allocation, absorbed by the existing `AuthorizationBehavior.Handle` allocation budget. + +See [`ZeroAlloc.Authorization`'s `resource-based-authorization.md`](https://github.com/ZeroAlloc-Net/ZeroAlloc.Authorization/blob/main/docs/resource-based-authorization.md) for the underlying contract and the design discussion behind it. +``` + +If the surrounding code-fence convention uses triple-backtick + `csharp` exactly, match it. If it uses `cs` instead, match that. Match indentation and blank-line conventions of the surrounding sections. + +**Step 3: Verify the docs site builds (if one exists)** + +If the repo has a `docs/_config.yml` / `docusaurus.config.js` / similar, run the local docs preview. Otherwise just visually inspect the file in a Markdown previewer: + +```bash +cd c:/Projects/Prive/ZeroAlloc/ZeroAlloc.Mediator +ls docs/_config.yml docs/docusaurus.config.js 2>/dev/null || echo "no docs site config — visual inspection only" +``` + +Visually confirm: heading hierarchy is consistent, code blocks render, the external link to `ZeroAlloc.Authorization`'s docs points to a real path. + +**Step 4: Commit** + +```bash +git add docs/authorization.md +git commit -m "docs(authorization): add resource-based policies section + +Documents how to write a policy that inspects the dispatched request as +a typed resource via IResourceSecurityContext. Mediator +wraps the resolved ISecurityContext per-dispatch so the cast resolves +automatically — no host configuration. Links out to +ZeroAlloc.Authorization's resource-based-authorization.md for the +underlying contract." +``` + +--- + +## Task 6: Push the branch + open the PR + admin-merge + +**Step 1: Sanity check the commit history** + +```bash +cd c:/Projects/Prive/ZeroAlloc/ZeroAlloc.Mediator +git log --oneline origin/main..HEAD +``` + +Expected: design doc commit (`bb56eb3`) + up to 5 implementation commits: + +1. `feat(authorization): add internal ResourceSecurityContextAdapter` +2. `feat(authorization): expose dispatched request as resource in policies` +3. `test(authorization): cover v2.1 resource-context contract lighting up` +4. *(optional)* `test(authorization): raise Handle allocation budget for v2.1 resource wrap` +5. `docs(authorization): add resource-based policies section` + +The Task 4 commit is conditional on whether budgets needed raising. If it's missing, the chain is 4 commits (plus the design doc). + +**Step 2: Push the branch** + +```bash +git push -u origin feat/mediator-authorization-resource-context +``` + +**Step 3: Open the PR** + +```bash +gh pr create --title "feat(authorization): light up IResourceSecurityContext in Mediator dispatch" --body "$(cat <<'EOF' +## Summary + +Lights up the dormant `IResourceSecurityContext` contract shipped in `ZeroAlloc.Authorization` v2.1 (PR #28). `AuthorizationBehavior.Handle` now wraps the resolved `ISecurityContext` in an internal adapter that exposes the dispatched `TRequest` as `Resource`. Consumer policies that type-check `ctx is IResourceSecurityContext rc` now resolve correctly — no consumer changes required beyond writing the resource-based policy. + +## Why + +`ZeroAlloc.Authorization` v2.1 shipped the contract dormant — the interface exists, but no host populates it. `Mediator.Authorization` is the natural first host: it already resolves an `ISecurityContext` from DI per dispatch and calls `AuthorizerFor.EvaluateAsync(ctx, ct)`. The change is a fully-additive wrap before that call. + +## Design + plan + +- Design: `docs/plans/2026-05-23-mediator-authorization-resource-context-design.md` +- Plan: `docs/plans/2026-05-23-mediator-authorization-resource-context.md` + +## What changed + +- **NEW** `src/ZeroAlloc.Mediator.Authorization/ResourceSecurityContextAdapter.cs` — `internal sealed class ResourceSecurityContextAdapter : IResourceSecurityContext` forwarding `ISecurityContext` members to an inner context. No PublicAPI change. +- **MOD** `src/ZeroAlloc.Mediator.Authorization/AuthorizationBehavior.cs` — 2-line wrap of `ctx` before the `authorizer.EvaluateAsync(...)` call. +- **NEW** `tests/ZeroAlloc.Mediator.Authorization.Tests/ResourceSecurityContextTests.cs` — 2 runtime tests covering the resource-resolves and wrong-type-falls-through cases. +- **MAYBE-MOD** `tests/ZeroAlloc.Mediator.Authorization.Tests/AllocationBudgetTests.cs` — budget bump from 512 B to N B (only if measurement required; conditional per Task 4 in the plan). +- **MOD** `docs/authorization.md` — new "Resource-based policies" section linking out to `ZeroAlloc.Authorization`'s `resource-based-authorization.md`. + +## Allocation cost + +~16 B per dispatch (one object header + two reference fields). Absorbed by the existing `AuthorizationBehavior.Handle` allocation budget; see `AllocationBudgetTests`. + +## Test plan + +- [x] CI green: build + tests + aot-smoke +- [x] All existing tests remain green (the adapter forwards `Id`/`Roles`/`Claims` transparently) +- [x] Two new resource-context tests cover both the matching and mismatching `TResource` cases +- [x] `AuthorizationBehavior.Handle` allocation budget still respected (raised if measurement required) + +## Out of scope + +- `AI.Sentinel` adoption of the same pattern. Tracked separately. +- `IHasResource` sub-property extractor. Sub-properties go through `rc.Resource.X.Y` inside the policy body. + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +**Step 4: Watch CI** + +```bash +gh pr checks --watch +``` + +Expected: every check passes. + +If `lint-commits` fails because of commit-subject case (a known pattern across this repo's PRs), it's bypassable via admin-merge per Step 5 — branch protection allows squash-merge with admin override and the final squash commit has a clean subject. + +If a real CI check (build / test / aot-smoke) fails, diagnose via `gh run view --log-failed` and patch on-branch. + +**Step 5: Admin-merge once CI is green** + +The user pre-authorised admin-merge for this PR (same pattern as today's 6 prior PRs across StateMachine / Mapping / Authorization / Authorization v2.1.1). + +```bash +gh pr merge --admin --squash --delete-branch +``` + +Verify the merge: + +```bash +git fetch origin main +git log origin/main --oneline -3 +``` + +Expected: the squash commit appears at the tip of `main`. release-please will pick it up and bump `Mediator.Authorization` minor on the next release run (per the package's conventional-commits config). + +**Step 6: Local cleanup** + +```bash +git checkout main +git pull origin main +git branch -d feat/mediator-authorization-resource-context +``` + +--- + +## Verification checklist (before merge) + +- [ ] Task 1: `ResourceSecurityContextAdapter` exists; build clean; no PublicAPI change. +- [ ] Task 2: `AuthorizationBehavior.Handle` wraps `ctx` before the authorizer call; existing test suite still green. +- [ ] Task 3: 2 new `ResourceSecurityContextTests` pass; fixtures added to `TestFixtures.cs`. +- [ ] Task 4: Allocation budgets still respected (raised iff measurement required; no speculative bump). +- [ ] Task 5: `docs/authorization.md` has the new section linking to `ZeroAlloc.Authorization`'s docs. +- [ ] Task 6: PR opened with green CI; admin-merged; branch deleted. + +## Out of scope (documented in the design doc) + +- **`AI.Sentinel` adoption.** Tracked separately; same adapter pattern for tool-call dispatch. +- **`IHasResource` sub-property extractor.** Considered, rejected as YAGNI — sub-property access happens via `rc.Resource.X.Y` inside the policy body. +- **Opt-in / marker interface.** Considered, rejected — 16 B per-dispatch cost is small enough that mandatory wrap is the better trade-off. +- **AOT compatibility audit.** The adapter is a sealed class with no reflection / no dynamic code. AOT-safe by construction; PackSmoke/AotSmoke binaries continue to exercise `AuthorizationBehavior.Handle` and the wrap path transitively. From 7b079302fdce24d681ec201002c3e7fabdf7af81 Mon Sep 17 00:00:00 2001 From: Marcel Roozerkans Date: Sat, 23 May 2026 22:17:11 +0200 Subject: [PATCH 3/6] feat(authorization): add internal ResourceSecurityContextAdapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-dispatch adapter that implements IResourceSecurityContext (shipped dormant in ZeroAlloc.Authorization v2.1) by wrapping an inner ISecurityContext + a resource value. ISecurityContext members forward to the inner; Resource exposes the wrapped value. Not wired into AuthorizationBehavior yet — that's the next commit. Internal-only; no PublicAPI change. --- .../ResourceSecurityContextAdapter.cs | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/ZeroAlloc.Mediator.Authorization/ResourceSecurityContextAdapter.cs diff --git a/src/ZeroAlloc.Mediator.Authorization/ResourceSecurityContextAdapter.cs b/src/ZeroAlloc.Mediator.Authorization/ResourceSecurityContextAdapter.cs new file mode 100644 index 0000000..c95e9aa --- /dev/null +++ b/src/ZeroAlloc.Mediator.Authorization/ResourceSecurityContextAdapter.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using ZeroAlloc.Authorization; + +namespace ZeroAlloc.Mediator.Authorization; + +/// +/// Per-dispatch adapter that exposes the dispatched request as the typed +/// while forwarding all +/// members to the inner caller-identity context. +/// +/// +/// Constructed inside +/// once per dispatch. Cost is one ~16 B class allocation (object header + 2 reference +/// fields) — small enough to be absorbed by 's existing +/// allocation budget; see AllocationBudgetTests. +/// Internal-only by design: consumer policies type-check the public +/// interface, never the adapter type. +/// +internal sealed class ResourceSecurityContextAdapter : IResourceSecurityContext +{ + private readonly ISecurityContext _inner; + + public ResourceSecurityContextAdapter(ISecurityContext inner, TResource resource) + { + _inner = inner; + Resource = resource; + } + + public TResource Resource { get; } + + public string Id => _inner.Id; + public IReadOnlySet Roles => _inner.Roles; + public IReadOnlyDictionary Claims => _inner.Claims; +} From e9116e4315aa63981680afd66eb6847c97826bbd Mon Sep 17 00:00:00 2001 From: Marcel Roozerkans Date: Sat, 23 May 2026 22:20:01 +0200 Subject: [PATCH 4/6] feat(authorization): expose dispatched request as resource in policies AuthorizationBehavior.Handle now wraps the resolved ISecurityContext in a ResourceSecurityContextAdapter before passing to the authorizer. Policies that type-check ctx is IResourceSecurityContext rc now resolve correctly; policies that ignore the resource see the same ISecurityContext members as before. Cost: ~16 B class allocation per dispatch (one object header + two reference fields). Absorbed by the existing AuthorizationBehavior.Handle budget; see AllocationBudgetTests. Lights up ZeroAlloc.Authorization v2.1's IResourceSecurityContext contract for Mediator dispatch. --- .../AuthorizationBehavior.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/ZeroAlloc.Mediator.Authorization/AuthorizationBehavior.cs b/src/ZeroAlloc.Mediator.Authorization/AuthorizationBehavior.cs index 21c39e2..107e128 100644 --- a/src/ZeroAlloc.Mediator.Authorization/AuthorizationBehavior.cs +++ b/src/ZeroAlloc.Mediator.Authorization/AuthorizationBehavior.cs @@ -66,7 +66,11 @@ public static async ValueTask Handle( "configured a security-context source (UseSecurityContextFactory / UseAnonymousSecurityContext / UseAccessor<>)."); } - var result = await authorizer.EvaluateAsync(ctx, ct).ConfigureAwait(false); + // Wrap the resolved caller-identity context so resource-based policies + // (ctx is IResourceSecurityContext rc) light up automatically. + // ~16 B/dispatch; absorbed by the existing allocation budget. + var resourceCtx = new ResourceSecurityContextAdapter(ctx, request); + var result = await authorizer.EvaluateAsync(resourceCtx, ct).ConfigureAwait(false); if (result.IsSuccess) return await next(request, ct).ConfigureAwait(false); From a452fc0f089f3b2eba23cebb6c3f112d572592cf Mon Sep 17 00:00:00 2001 From: Marcel Roozerkans Date: Sat, 23 May 2026 22:22:56 +0200 Subject: [PATCH 5/6] test(authorization): cover v2.1 resource-context contract lighting up Two runtime tests on AuthorizationBehavior.Handle: - Policy_Sees_DispatchedRequest_AsResource: a [Policy] that type-checks IResourceSecurityContext sees the dispatched request and allows when ctx.Id == request.UserId. - Policy_TypeChecking_WrongRequestType_FallsThrough: a [Policy] that type-checks the WRONG TResource (intentionally mismatched) hits the cast's false branch and denies. Adds ResourceOwnerPolicy / ResourceWrongTypePolicy and their request records to TestFixtures.cs. Tests run under the existing [Collection("non-parallel-authorization")] guard because they set the static AuthorizationBehaviorState.ServiceProvider. --- .../ResourceSecurityContextTests.cs | 76 +++++++++++++++++++ .../TestFixtures.cs | 39 ++++++++++ 2 files changed, 115 insertions(+) create mode 100644 tests/ZeroAlloc.Mediator.Authorization.Tests/ResourceSecurityContextTests.cs diff --git a/tests/ZeroAlloc.Mediator.Authorization.Tests/ResourceSecurityContextTests.cs b/tests/ZeroAlloc.Mediator.Authorization.Tests/ResourceSecurityContextTests.cs new file mode 100644 index 0000000..1e013dd --- /dev/null +++ b/tests/ZeroAlloc.Mediator.Authorization.Tests/ResourceSecurityContextTests.cs @@ -0,0 +1,76 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using ZeroAlloc.Authorization; +using ZeroAlloc.Authorization.Generated; +using ZeroAlloc.Mediator; +using ZeroAlloc.Mediator.Authorization; +using Xunit; + +namespace ZeroAlloc.Mediator.Authorization.Tests; + +// Covers the v2.1-contract lighting-up wired by AuthorizationBehavior wrapping +// the resolved ISecurityContext in ResourceSecurityContextAdapter: +// +// - Policies that type-check IResourceSecurityContext resolve and +// see the dispatched request as Resource. +// - Policies that type-check IResourceSecurityContext +// correctly fall through (the cast fails when TResource differs). +// +// Drives AuthorizationBehavior.Handle directly (rather than IMediator.Send) +// for the same reason AllocationBudgetTests does: the cross-assembly behavior +// is not auto-wired into the test-assembly's MediatorService partial, and the +// allocation profile is identical either way. + +[Collection("non-parallel-authorization")] +public sealed class ResourceSecurityContextTests +{ + [Fact] + public async Task Policy_Sees_DispatchedRequest_AsResource() + { + // TestSecurityContexts.With(...) treats positional args as roles and + // always pins Id = "user-1". The policy compares request.UserId to + // ctx.Id, so the request's UserId must match the helper's Id. + using var sp = BuildProvider(TestSecurityContexts.With()); + using var scope = sp.CreateScope(); + AuthorizationBehaviorState.ServiceProvider = scope.ServiceProvider; + var req = new ResourceOwnerCommand(UserId: "user-1", Payload: 7); + + var response = await AuthorizationBehavior.Handle( + req, CancellationToken.None, + static (r, _) => ValueTask.FromResult(r.Payload * 2)); + + // Policy resolved IResourceSecurityContext, saw the + // request's UserId == ctx.Id, and allowed; handler ran (7 * 2). + Assert.Equal(14, response); + } + + [Fact] + public async Task Policy_TypeChecking_WrongRequestType_FallsThrough() + { + using var sp = BuildProvider(TestSecurityContexts.With()); + using var scope = sp.CreateScope(); + AuthorizationBehaviorState.ServiceProvider = scope.ServiceProvider; + var req = new ResourceWrongTypeCommand(UserId: "user-1"); + + var ex = await Assert.ThrowsAsync(async () => + await AuthorizationBehavior.Handle( + req, CancellationToken.None, + static (_, _) => ValueTask.FromResult(42))); + + // The policy on ResourceWrongTypeCommand type-checks + // IResourceSecurityContext (intentionally mismatched). + // The runtime adapter is IResourceSecurityContext, + // so the cast fails and the policy's else branch runs. + Assert.Equal("resource.wrong_type", ex.Failure.Code); + } + + private static ServiceProvider BuildProvider(ISecurityContext ctx) + { + var services = new ServiceCollection(); + services.AddZeroAllocAuthorization(); + services.AddScoped(_ => new TestSecurityContextAccessor { Current = ctx }); + services.AddMediator().WithAuthorization(o => o.UseAccessor()); + return services.BuildServiceProvider(); + } +} diff --git a/tests/ZeroAlloc.Mediator.Authorization.Tests/TestFixtures.cs b/tests/ZeroAlloc.Mediator.Authorization.Tests/TestFixtures.cs index f5a0c53..29d723c 100644 --- a/tests/ZeroAlloc.Mediator.Authorization.Tests/TestFixtures.cs +++ b/tests/ZeroAlloc.Mediator.Authorization.Tests/TestFixtures.cs @@ -74,6 +74,35 @@ public ValueTask> EvaluateAsync( : new AuthorizationFailure(AuthorizationFailure.DefaultDenyCode, "needs Admin")); } +[Policy("ResourceOwner")] +public sealed class ResourceOwnerPolicy : IAuthorizationPolicy +{ + // Resource-based: allow only when the dispatched ResourceOwnerCommand's + // UserId matches the caller's identity. Demonstrates the v2.1 contract + // lit up by AuthorizationBehavior wrapping ctx in + // ResourceSecurityContextAdapter. + public ValueTask> EvaluateAsync( + ISecurityContext ctx, CancellationToken ct = default) + => new(ctx is IResourceSecurityContext rc + && string.Equals(rc.Resource.UserId, ctx.Id, System.StringComparison.Ordinal) + ? UnitResult.Success() + : new AuthorizationFailure("resource.not_owner", "resource owner mismatch")); +} + +[Policy("ResourceWrongType")] +public sealed class ResourceWrongTypePolicy : IAuthorizationPolicy +{ + // Intentionally type-checks the WRONG resource type + // (IResourceSecurityContext) while being applied to + // ResourceWrongTypeCommand. The cast must fail at runtime; the deny + // branch must fire with the "resource.wrong_type" code. + public ValueTask> EvaluateAsync( + ISecurityContext ctx, CancellationToken ct = default) + => new(ctx is IResourceSecurityContext + ? UnitResult.Success() + : new AuthorizationFailure("resource.wrong_type", "wrong resource type")); +} + // Plain IRequest + [RequirePolicy] → throw path. [RequirePolicy("AlwaysAllow")] public sealed record GetThingThrowAllow(int Id) : IRequest; @@ -106,6 +135,12 @@ public sealed record GetThingUnauthorized(int Id) : IRequest; [RequirePolicy("IntegrationTest")] public sealed record IntegrationTestRequest(int Value) : IRequest; +[RequirePolicy("ResourceOwner")] +public sealed record ResourceOwnerCommand(string UserId, int Payload) : IRequest; + +[RequirePolicy("ResourceWrongType")] +public sealed record ResourceWrongTypeCommand(string UserId) : IRequest; + // Stub handlers — required by ZAM001 (every IRequest needs a registered handler in the // compilation). Tests drive AuthorizationBehavior.Handle directly, bypassing the dispatcher, // so the handlers are never invoked. @@ -130,6 +165,10 @@ public sealed class IntegrationTestHandler : IRequestHandler Handle(IntegrationTestRequest r, CancellationToken ct) => ValueTask.FromResult(r.Value * 2); } +public sealed class StubResourceOwnerHandler : IRequestHandler +{ public ValueTask Handle(ResourceOwnerCommand r, CancellationToken ct) => ValueTask.FromResult(0); } +public sealed class StubResourceWrongTypeHandler : IRequestHandler +{ public ValueTask Handle(ResourceWrongTypeCommand r, CancellationToken ct) => ValueTask.FromResult(0); } internal sealed record TestSecurityContext(string Id, IReadOnlySet Roles, From afc9ce646dbf1dc0436b45bd00d6a3ce31f3c650 Mon Sep 17 00:00:00 2001 From: Marcel Roozerkans Date: Sat, 23 May 2026 22:24:08 +0200 Subject: [PATCH 6/6] docs(authorization): add resource-based policies section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents how to write a policy that inspects the dispatched request as a typed resource via IResourceSecurityContext. Mediator wraps the resolved ISecurityContext per-dispatch so the cast resolves automatically — no host configuration. Links out to ZeroAlloc.Authorization's resource-based-authorization.md for the underlying contract. --- docs/authorization.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/authorization.md b/docs/authorization.md index eeb90a2..d096029 100644 --- a/docs/authorization.md +++ b/docs/authorization.md @@ -111,6 +111,32 @@ public sealed record ExportUsersCommand : IRequest; Both policies must pass. Evaluation order matches source order. OR mode depends on a future `Mode` parameter on the contract's `[RequirePolicy]` attribute (see Authorization backlog #1). +## Resource-based policies + +When a policy needs to inspect the dispatched request itself (not just the caller's identity), type-check the security context for `IResourceSecurityContext`. `Mediator.Authorization` wraps the resolved `ISecurityContext` in a per-dispatch adapter so the cast resolves automatically — no host configuration required. + +```csharp +[Policy("OwnerOnlyDelete")] +public sealed class OwnerOnlyDeletePolicy : IAuthorizationPolicy +{ + public ValueTask> EvaluateAsync( + ISecurityContext ctx, CancellationToken ct = default) + => new(ctx is IResourceSecurityContext rc + && string.Equals(rc.Resource.UserId, ctx.Id, StringComparison.Ordinal) + ? UnitResult.Success() + : new AuthorizationFailure("delete.not_owner")); +} + +[RequirePolicy("OwnerOnlyDelete")] +public sealed record DeleteUserCommand(string UserId) : IRequest; +``` + +The resource exposed to the policy IS the dispatched request. To access a sub-property (e.g. `request.Order.OwnerId`), pull it inside the policy body via `rc.Resource.Order.OwnerId` — no marker interface, no extractor delegate. + +Policies that don't need the resource ignore the adapter; the `ISecurityContext` members (`Id`, `Roles`, `Claims`) forward through. The per-dispatch cost is one ~16 B class allocation, absorbed by the existing `AuthorizationBehavior.Handle` allocation budget. + +See [`ZeroAlloc.Authorization`'s `resource-based-authorization.md`](https://github.com/ZeroAlloc-Net/ZeroAlloc.Authorization/blob/main/docs/core-concepts/resource-based-authorization.md) for the underlying contract and the design discussion behind it. + ## Pipeline ordering The `AuthorizationBehavior` registers at `[PipelineBehavior(Order = -1000)]` — runs early, before logging/validation/caching. To run another behavior before authz, give it a smaller order: