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 ea25127e..fa55834c 100644 --- a/src/content/docs/changelog/index.mdx +++ b/src/content/docs/changelog/index.mdx @@ -1,6 +1,6 @@ --- title: Overview -lastUpdated: 2026-09-27 +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-27 - **Storage: browser uploads work in the Docker Compose deployment (fix, breaking for docker-compose).** Browsers upload and download through presigned S3 URLs, and SigV4 signs the host, so the API always handed out URLs for the endpoint it talks to itself. In `deploy/docker/docker-compose.yml` that is `http://rustfs:9000`, a compose-internal name a browser cannot resolve, and RustFS published no port anyway, so every file upload from the admin and dashboard failed; `PublicBaseUrl` never applied, because it only rewrites plain public-read URLs. A new optional **`Storage:S3:PresignServiceUrl`** names the host presigned PUT and GET URLs are signed for, and their scheme follows it; every other S3 call keeps using `ServiceUrl`. It is validated at startup as an absolute `http(s)` URL with no path, and left empty the behaviour is unchanged. The compose stack now sets it from a new **`FSH_S3_PUBLIC_URL`**, publishes the RustFS S3 API on `FSH_S3_PORT` (default `9000`), and allows `FSH_ADMIN_URL` and `FSH_DASHBOARD_URL` through `RUSTFS_CORS_ALLOWED_ORIGINS`; `fsh new` fills `FSH_S3_PUBLIC_URL` with `http://localhost:9000`. **Upgrade note:** add `FSH_S3_PUBLIC_URL` to `deploy/docker/.env` (compose refuses to start without it), free host port 9000 or set `FSH_S3_PORT`, and route a TLS hostname at that port with the `Host` header forwarded unchanged, or the store answers `403 SignatureDoesNotMatch`. Aspire and the AWS Terraform stack are unaffected, and on AWS S3 or any store whose own endpoint browsers can reach, leave `PresignServiceUrl` empty. A test factory of your own that re-registers the S3 stack must now also register the keyed presign client (`S3StorageService.PresignClientKey`), as `FshWebApplicationFactory` does. See [Storage](/docs/building-blocks/storage/) and [#1402](https://github.com/fullstackhero/dotnet-starter-kit/pull/1402). 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`.