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
10 changes: 5 additions & 5 deletions src/content/docs/architecture/multitenancy-deep-dive.mdx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -35,15 +35,15 @@ Finbuckle.MultiTenant 10 resolves the tenant on every request. The kit configure
```csharp
// MultitenancyModule.ConfigureServices (simplified)
builder.Services.AddMultiTenant<AppTenantInfo>()
.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<EFCoreStore<TenantDbContext, AppTenantInfo>>(ServiceLifetime.Scoped);
```

<Callout type="warning" title="ClaimStrategy is pre-auth">
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.
<Callout type="warning" title="ClaimStrategy authenticates on its own">
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.
</Callout>

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.
Expand Down
6 changes: 5 additions & 1 deletion src/content/docs/changelog/index.mdx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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).
Expand Down
4 changes: 2 additions & 2 deletions src/content/docs/cross-cutting-concerns/caching.mdx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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`.

Expand Down
4 changes: 2 additions & 2 deletions src/content/docs/guides/operator-impersonation.mdx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -139,7 +139,7 @@ Under the hood:

The admin console adds the right `tenant: <target-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

Expand Down
12 changes: 6 additions & 6 deletions src/content/docs/modules/multitenancy.mdx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -66,15 +66,15 @@ The Finbuckle strategy chain runs on every request, before authentication. The k
```csharp
// MultitenancyModule.cs (simplified)
builder.Services.AddMultiTenant<AppTenantInfo>()
.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<EFCoreStore<TenantDbContext, AppTenantInfo>>(ServiceLifetime.Scoped);
```

<Callout type="warning" title="ClaimStrategy is pre-auth, by design">
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.
<Callout type="warning" title="ClaimStrategy authenticates on its own">
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.
</Callout>

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`.
Expand Down