From ac0eb88a0f0bc32e884d0200ea1815bf563a40fe Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Mon, 28 Sep 2026 02:09:57 -0300 Subject: [PATCH] docs: root operator permissions load under their own tenant; claim strategy authenticates on its own --- .../docs/architecture/multitenancy-deep-dive.mdx | 10 +++++----- src/content/docs/changelog/index.mdx | 6 +++++- src/content/docs/cross-cutting-concerns/caching.mdx | 4 ++-- src/content/docs/guides/operator-impersonation.mdx | 4 ++-- src/content/docs/modules/multitenancy.mdx | 12 ++++++------ 5 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/content/docs/architecture/multitenancy-deep-dive.mdx b/src/content/docs/architecture/multitenancy-deep-dive.mdx index 70a37907..8ba51b06 100644 --- a/src/content/docs/architecture/multitenancy-deep-dive.mdx +++ b/src/content/docs/architecture/multitenancy-deep-dive.mdx @@ -1,6 +1,6 @@ --- title: Multitenancy deep dive -lastUpdated: 2026-06-11 +lastUpdated: 2026-09-28 description: How tenancy is the default in every layer - Finbuckle strategies, EF Core global query filter, IGlobalEntity opt-out, named filters, cross-tenant query discipline. sidebar: label: Multitenancy deep-dive @@ -35,15 +35,15 @@ Finbuckle.MultiTenant 10 resolves the tenant on every request. The kit configure ```csharp // MultitenancyModule.ConfigureServices (simplified) builder.Services.AddMultiTenant() - .WithClaimStrategy(ClaimConstants.Tenant) // 1. claim - no-op pre-auth (see below) - .WithHeaderStrategy(MultitenancyConstants.Identifier) // 2. "tenant" header - the primary resolver + .WithClaimStrategy(ClaimConstants.Tenant) // 1. claim - the caller's JWT (see below) + .WithHeaderStrategy(MultitenancyConstants.Identifier) // 2. "tenant" header - anonymous requests .WithDelegateStrategy(/* ?tenant= query fallback */) // 3. query string .WithDistributedCacheStore(TimeSpan.FromMinutes(60)) .WithStore>(ServiceLifetime.Scoped); ``` - -Finbuckle's strategy chain runs **before** `UseAuthentication()`. The claim strategy therefore sees an anonymous `User` on every normal request and is effectively a no-op. It's there for the **cross-tenant impersonation** case, where post-auth middleware re-resolves the tenant from the impersonation grant's claim. In the normal flow the header strategy is the primary resolver. + +Finbuckle's strategy chain runs **before** `UseAuthentication()`, but the claim strategy does not wait for it: when `User` is not authenticated yet, Finbuckle runs the default scheme's handler itself and reads the tenant claim from the result. An authenticated caller therefore always resolves to its own tenant, and a `tenant` header cannot move it. The header strategy decides only for anonymous requests (login, refresh, forgot-password). Moving a root operator to another tenant is the job of the post-auth root-operator override. The kit caches resolved tenants in `DistributedCacheStore` for 60 minutes (Valkey backplane) to keep hot-path requests off the DB. The `EFCoreStore` is the source of truth. diff --git a/src/content/docs/changelog/index.mdx b/src/content/docs/changelog/index.mdx index f01d8a12..1ebd8a10 100644 --- a/src/content/docs/changelog/index.mdx +++ b/src/content/docs/changelog/index.mdx @@ -1,6 +1,6 @@ --- title: Overview -lastUpdated: 2026-09-25 +lastUpdated: 2026-09-28 description: Release notes and version history for fullstackhero. sidebar: order: 1 @@ -11,6 +11,10 @@ seo: Notable changes to the kit, newest first. +## 2026-09-28 + +- **Identity: a root operator's cross-tenant request no longer fails with a random `401` (fix).** A root operator scopes a request to another tenant with the `tenant` header, and every permission-gated endpoint then checks the operator's permission set through `UserPermissionService`. That set is cached under `perm:u:{userId}`, a key with no tenant, and on a cache miss it was loaded under the tenant the request had just been moved to, where the root user does not exist, so the check threw `UnauthorizedException` and the request answered `401 Authentication failed`. Whether it failed depended only on whether the entry happened to be warm: it expires from the in-process cache every 2 minutes when no Redis is configured, and after an hour, a startup permission sync or any role or group change otherwise. The set is now loaded under the operator's own tenant (from the token's tenant claim), so the request succeeds whether the entry is cold or warm; nothing changes for requests that stay in the caller's tenant. The intermittent `401` seen in `TenantHeaderOverrideTests` in CI matches this failure. The docs and code comments that called Finbuckle's claim strategy a no-op are corrected too: it runs the authentication handler itself, so an authenticated caller always resolves to its own tenant claim and the `tenant` header only decides for anonymous requests. See [#1404](https://github.com/fullstackhero/dotnet-starter-kit/pull/1404). + ## 2026-09-25 - **Dependencies: .NET Aspire 13.5.4 and every NuGet package to latest.** The AppHost SDK and `Aspire.Hosting.*` move 13.4.0 → 13.5.4 together (mixing 13.4 and 13.5 packages fails at runtime). The .NET 10 platform packages (ASP.NET Core, EF Core, Extensions, SignalR) go 10.0.8 → 10.0.12, OpenTelemetry 1.15 → 1.19, Asp.Versioning 10.2, Npgsql EF 10.0.3, Scalar 2.17, QuestPDF 2026.9, Hangfire 1.8.25, MailKit/MimeKit 4.18, Testcontainers 4.15, and the rest to latest stable. Three majors: **StackExchange.Redis 3.3** (the same API as 2.13.17 on a rewritten IO core, now **RESP3 by default** - Valkey and ElastiCache both speak it; `Execute("FLUSHALL")`-style admin commands now need `AllowAdmin`), **NSubstitute 6** and **xunit.runner.visualstudio 4** (still runs xUnit v2). `Microsoft.OpenApi` and `MessagePack` stay on their 2.x lines on purpose. The new SonarAnalyzer adds **S8969** (redundant null-forgiving `!`), which is fatal under warnings-as-errors: the kit's own code is cleaned up, but **if you've added code, expect S8969 build errors after pulling** - delete the flagged `!`, and the compiler will tell you if one was actually needed. Asp.Versioning 10.2's `AV0029`/`AV0030` advisories and Aspire's `ASPIRE010` (CLI bundle) are suppressed; the kit keeps one OpenAPI document per version and runs Aspire via `dotnet run`. On the first launch Aspire 13.5 recreates the persistent Postgres and Valkey containers; data volumes are kept and the Postgres image stays on 18, so no wipe is needed. See [#1396](https://github.com/fullstackhero/dotnet-starter-kit/pull/1396). diff --git a/src/content/docs/cross-cutting-concerns/caching.mdx b/src/content/docs/cross-cutting-concerns/caching.mdx index a6e18e03..ca4a5b29 100644 --- a/src/content/docs/cross-cutting-concerns/caching.mdx +++ b/src/content/docs/cross-cutting-concerns/caching.mdx @@ -1,6 +1,6 @@ --- title: Caching -lastUpdated: 2026-06-11 +lastUpdated: 2026-09-28 description: HybridCache (L1 in-memory + L2 Valkey) with OpenTelemetry instrumentation, stampede protection, and tag-based invalidation. sidebar: label: Caching @@ -101,7 +101,7 @@ One connection pool per host for those consumers. The SignalR backplane (realtim ## Who uses it in the kit - **Multitenancy module** caches resolved tenants in the distributed cache (Finbuckle's `DistributedCacheStore`, 60-minute TTL) and tenant themes in HybridCache (`TenantThemeService`, with `RemoveByTagAsync` per-tenant invalidation). -- **Identity module** caches user permission sets in HybridCache (`UserPermissionService`, tag-invalidated; `RolePermissionSyncer` keeps role-permission state in sync at startup) and impersonation-grant revocation markers. +- **Identity module** caches user permission sets in HybridCache (`UserPermissionService`, tag-invalidated; `RolePermissionSyncer` keeps role-permission state in sync at startup) and impersonation-grant revocation markers. A root operator's permission set is loaded under the operator's own tenant, so a cross-tenant request keeps working when the entry is cold. - **Realtime** uses the distributed cache for the 3-second typing-indicator throttle per (channel, user) in `AppHub`. - **Idempotency** uses it to cache request responses by `Idempotency-Key`. diff --git a/src/content/docs/guides/operator-impersonation.mdx b/src/content/docs/guides/operator-impersonation.mdx index 0874f3d5..bfc889a4 100644 --- a/src/content/docs/guides/operator-impersonation.mdx +++ b/src/content/docs/guides/operator-impersonation.mdx @@ -1,6 +1,6 @@ --- title: Operator impersonation walkthrough -lastUpdated: 2026-05-19 +lastUpdated: 2026-09-28 description: End-to-end guide to using operator impersonation from the admin console - starting a grant, acting as the user, ending or revoking, reading the audit trail. sidebar: label: Operator impersonation @@ -139,7 +139,7 @@ Under the hood: The admin console adds the right `tenant: ` header to outbound calls automatically. From the operator's point of view, it just works. -For the mechanics of why this needs post-auth middleware rather than Finbuckle's pre-auth claim strategy, see the [multitenancy deep-dive](/docs/architecture/multitenancy-deep-dive/). +For why this needs post-auth middleware (Finbuckle's claim strategy resolves the operator to `root` before the header is read), see the [multitenancy deep-dive](/docs/architecture/multitenancy-deep-dive/). ## Audit query examples diff --git a/src/content/docs/modules/multitenancy.mdx b/src/content/docs/modules/multitenancy.mdx index f9f69023..1b4b67bf 100644 --- a/src/content/docs/modules/multitenancy.mdx +++ b/src/content/docs/modules/multitenancy.mdx @@ -1,6 +1,6 @@ --- title: Multitenancy module -lastUpdated: 2026-06-11 +lastUpdated: 2026-09-28 description: Finbuckle-driven tenant resolution (claim, header, query), distributed-cache store, per-tenant connection strings, theme customisation, and an async provisioning state machine. sidebar: label: Multitenancy @@ -25,7 +25,7 @@ Once this module is loaded (order 200), every other module's `DbContext` is tena - **EFCoreStore** as the source of truth - tenants live in `TenantDbContext` with `AppTenantInfo` as the record. - **Per-tenant connection strings** - each `AppTenantInfo` can carry its own DB connection, set on creation. - **`IGlobalEntity` opt-out** - entities that need to live across tenants (plans, impersonation grants, outbox messages) mark this interface and skip the tenant filter. -- **Root-operator header override** - SuperAdmin can scope a single request to another tenant via the `tenant` header for admin operations like cross-tenant user search; runs as post-auth middleware (the Finbuckle strategies see anonymous principals). +- **Root-operator header override** - SuperAdmin can scope a single request to another tenant via the `tenant` header for admin operations like cross-tenant user search; runs as post-auth middleware (the claim strategy has already resolved the operator to `root`). - **Provisioning state machine** - multi-step tenant creation (Database → Migrations → Seeding → CacheWarm). Steps are persisted; failures are resumable via `RetryTenantProvisioning`. - **Tenant lifecycle with plans** - `CreateTenantCommand` takes an optional `PlanKey` (falls back to `Billing:DefaultPlanKey`) and publishes `TenantSubscribedIntegrationEvent`; `RenewTenant` extends `ValidUpto` by the plan term and publishes `TenantRenewedIntegrationEvent` - the Billing module reacts to both with subscriptions + invoices. `AdjustTenantValidity` is the operator override that *may* backdate (the internal `SetValidity` used by renewal is forward-only and throws on backdating). - **Active/expiry enforcement as post-auth guards** - Finbuckle happily resolves inactive or expired tenants; two middleware guards (registered in `IModule.ConfigureMiddleware`) reject requests for deactivated tenants and enforce `ValidUpto` with a configurable grace window (`Billing:GraceWindowDays`, default 7). Inside the window, responses carry an `X-Subscription-Grace` header with days left. @@ -66,15 +66,15 @@ The Finbuckle strategy chain runs on every request, before authentication. The k ```csharp // MultitenancyModule.cs (simplified) builder.Services.AddMultiTenant() - .WithClaimStrategy(ClaimConstants.Tenant) // 1. claim - no-op pre-auth - .WithHeaderStrategy(MultitenancyConstants.Identifier) // 2. "tenant" header - primary + .WithClaimStrategy(ClaimConstants.Tenant) // 1. claim - the caller's JWT + .WithHeaderStrategy(MultitenancyConstants.Identifier) // 2. "tenant" header - anonymous requests .WithDelegateStrategy(ResolveTenantFromQuery) // 3. ?tenant= query string fallback .WithDistributedCacheStore(TimeSpan.FromMinutes(60)) .WithStore>(ServiceLifetime.Scoped); ``` - -Finbuckle's strategy chain runs before `UseAuthentication`, so the claim strategy sees an anonymous principal on every request - it's effectively a no-op in normal flow. The **header strategy is the real primary resolver**. The claim strategy is there for the cross-tenant impersonation case where a downstream pipeline can re-resolve with claims. + +Finbuckle's strategy chain runs **before** `UseAuthentication()`, but the claim strategy does not wait for it: when `User` is not authenticated yet, Finbuckle runs the default scheme's handler itself and reads the tenant claim from the result. An authenticated caller therefore always resolves to its own tenant, and a `tenant` header cannot move it. The header strategy decides only for anonymous requests (login, refresh, forgot-password). Moving a root operator to another tenant is the job of the post-auth root-operator override. The **root-operator header override** is what lets a SuperAdmin (whose JWT carries `tenant=root`) scope a single request to another tenant. It runs as post-auth middleware via `IModule.ConfigureMiddleware`, checks that the caller is in the root tenant, reads the `tenant` header on the request, looks up the target, and replaces the multi-tenant context on the `HttpContext`.