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
4 changes: 2 additions & 2 deletions src/content/docs/building-blocks/caching.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Caching building block
lastUpdated: 2026-06-11
lastUpdated: 2026-10-02
description: HybridCache (L1 in-memory + L2 Valkey or distributed memory) with OpenTelemetry instrumentation, stampede protection, and a shared multiplexer for DataProtection.
sidebar:
label: Caching
Expand Down Expand Up @@ -71,7 +71,7 @@ await cache.GetOrCreateAsync(key, factory, tags: ["tenant:" + tenantId.ToString(
await cache.RemoveByTagAsync("tenant:" + tenantId.ToString(), ct).ConfigureAwait(false);
```

Identity uses HybridCache for per-user permission sets (`UserPermissionService`, warmed by the `RolePermissionSyncer`) and impersonation grants (`ImpersonationGrantService`); Multitenancy caches tenant themes (`TenantThemeService`) and fronts the tenant EF store with Finbuckle's `DistributedCacheStore`, which rides on the same `IDistributedCache` this block registers.
Identity uses HybridCache for per-user permission sets (`UserPermissionService`, invalidated through the permissions tag whenever the `RolePermissionSyncer` adds or removes a system-role claim) and impersonation grants (`ImpersonationGrantService`); Multitenancy caches tenant themes (`TenantThemeService`) and fronts the tenant EF store with Finbuckle's `DistributedCacheStore`, which rides on the same `IDistributedCache` this block registers.

## Configuration

Expand Down
1 change: 1 addition & 0 deletions src/content/docs/changelog/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Notable changes to the kit, newest first.

## 2026-11-03

- **Identity: narrowing a permission now revokes it from the built-in roles on existing tenants (fix, behaviour change).** `RolePermissionSyncer` used to only add the permissions missing from `Basic` and `Admin`, so a permission that stopped being `IsBasic`, or that a module stopped registering, stayed on those roles in every tenant that already existed, and the API refuses permission edits on system roles, so it could only be removed by hand in `RoleClaims`. The sync is now authoritative for those two roles: at every API start it also removes each permission claim the catalog no longer grants to the role (the root tenant's `Admin` keeps the `IsRoot` permissions), logs each removal at Warning with the role, the permission and the tenant, and invalidates the permission cache so live sessions lose it on their next request. Only claims of type `permission` are reconciled, and custom roles are untouched. **Upgrade note:** if you narrowed `IsBasic` or removed a permission in your fork, the next start revokes it from `Basic` (or `Admin`) in every existing tenant; check the Warning lines after deploying. If you run another host that registers `IdentityModule` with only some of the other modules (a worker, say), set the new **`PermissionSyncOptions:RemoveRevoked`** to `false` there before upgrading (default `true`), or its partial catalog revokes the missing modules' permissions in every tenant at startup; with it off, that host only adds. A role whose catalog target is empty is never stripped, and an instance that loses a removal race with another one starting at the same time logs it at Information instead of Error. During a rolling deploy, an old-version instance that restarts re-adds a revoked claim until the next start of a new-version instance. See [Authorization](/docs/security/authorization/#the-role-permission-syncer), [hosts that load only some modules](/docs/security/authorization/#hosts-that-load-only-some-modules) and [#1426](https://github.com/fullstackhero/dotnet-starter-kit/pull/1426).
- **Identity: the two-factor issuer name is configurable (fix).** `EnrollTwoFactorCommandHandler` hardcoded `FullStackHero` as the issuer of the `otpauth://` URI, so every product built on the kit showed up under that name in the user's authenticator app. A new **`TwoFactorOptions:Issuer`** setting chooses it; it defaults to `FullStackHero`, so existing deployments behave exactly as before, and a blank value fails options validation at startup. The issuer is percent-encoded in both the label and the `issuer=` parameter, so names with spaces or accents produce a valid URI. Only new enrolments get the new name: existing enrolments keep working unchanged, because the shared secret does not change, and no migration is needed. See [Two-factor authentication](/docs/security/two-factor/#issuer-name), [#1440](https://github.com/fullstackhero/dotnet-starter-kit/issues/1440) and [#1443](https://github.com/fullstackhero/dotnet-starter-kit/pull/1443).
- **Dashboard: no more `403` polls for users without the permission (fix).** The topbar notification bell (`GET /notifications/unread-count`), the topbar chat unread pill (`GET /chat/channels`) and the overview usage card (`GET /billing/usage`) queried their endpoints for every user, so a role without `Permissions.Notifications.Inbox.View`, `Permissions.Chat.Channels.View` or `Permissions.Billing.View` got a `403` on each poll and the API logged an error line for each one. Each query now runs only when the user holds that permission. Without it, the bell and the chat pill are hidden, and the usage card and the Resources stat card say there is no access. The `/chat` and `/subscription` pages are unchanged, because the nav already hides them from users without the permission. See [Add a dashboard page](/docs/guides/add-a-dashboard-page/#step-5---permission-gating-nav--actions-not-routes) and [#1445](https://github.com/fullstackhero/dotnet-starter-kit/pull/1445).
- **Frontend containers: the inactivity timeout is configurable from the environment (fix).** Both apps read `inactivityIdleMs` and `inactivityWarningMs` from `/config.json`, but the container's `config.json.template` never carried them, so an operator could not change the auto sign-out time without rebuilding the image. The template now renders them from `FSH_INACTIVITY_IDLE_MS` and `FSH_INACTIVITY_WARNING_MS`. Unset or empty keeps the built-in timings (admin 10 minutes, dashboard 20, each with a 60-second warning), so existing deployments behave as before; a value that is not a positive integer stops the container at start with a clear error rather than rendering invalid JSON. `deploy/docker` exposes them as `FSH_ADMIN_INACTIVITY_IDLE_MS`, `FSH_DASHBOARD_INACTIVITY_IDLE_MS` and `FSH_INACTIVITY_WARNING_MS` in `.env`. The Terraform static-site `runtime_config` and the Aspire dev setup are unchanged. See [Frontend development](/docs/frontend/development/#the-container-pattern) and [#1442](https://github.com/fullstackhero/dotnet-starter-kit/pull/1442).
Expand Down
7 changes: 6 additions & 1 deletion src/content/docs/modules/identity.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Identity module
lastUpdated: 2026-10-02
lastUpdated: 2026-10-14
description: JWT bearer + refresh tokens, ASP.NET Identity with roles + permissions, user groups, operator impersonation, two-factor TOTP, sessions, and password-policy enforcement.
sidebar:
label: Identity
Expand Down Expand Up @@ -257,10 +257,15 @@ Yes, the role-permission routes really are `/{id}/permissions` directly under `/
"AccessTokenMinutes": 45,
"RefreshTokenDays": 7,
"SigningKey": "set via secrets manager or env var"
},
"PermissionSyncOptions": {
"RemoveRevoked": true // startup sync also revokes what the catalog no longer grants
}
}
```

Set `PermissionSyncOptions:RemoveRevoked` to `false` only on a host that registers `IdentityModule` without every other module; see [Authorization - hosts that load only some modules](/docs/security/authorization/#hosts-that-load-only-some-modules).

Lockout is configured by `IdentityOptions` and defaults to 5 failed attempts → 15-minute lock. Outbox events are dispatched by the framework's `OutboxDispatcherHostedService` (on by default) - the module deliberately registers no dispatcher of its own, since a second one would race the same rows.

## How to extend
Expand Down
25 changes: 23 additions & 2 deletions src/content/docs/security/authorization.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Authorization
lastUpdated: 2026-10-02
lastUpdated: 2026-10-14
description: Permission-based authorization - fine-grained gates on every endpoint via .RequirePermission() with a flat registry, default + fallback policy wiring, and the silent-no-op gotcha that catches every team once.
sidebar:
label: Authorization
Expand Down Expand Up @@ -140,10 +140,31 @@ So a permission grant or revocation applies to live sessions within seconds - no

## The role-permission syncer

`RolePermissionSyncer` (driven by `RolePermissionSyncHostedService` at startup) walks every tenant and inserts any **newly registered** permissions that are missing from the built-in `Admin` / `Basic` roles' claim rows. It's idempotent - only missing claims are added - and if it writes anything it drops the per-user permission cache so logged-in sessions pick the new permissions up immediately. This is what makes "add a permission to a module, restart, Admin can use it" work without a manual seeding step.
`RolePermissionSyncer` (driven by `RolePermissionSyncHostedService` at startup) walks every tenant and makes the built-in `Admin` / `Basic` roles' permission claims match the permission catalog: it inserts any **newly registered** permission that is missing and removes any permission claim the catalog no longer grants to that role. It's idempotent, and if it writes anything it drops the per-user permission cache so logged-in sessions see the change immediately. This is what makes "add a permission to a module, restart, Admin can use it" work without a manual seeding step, and it works the same way in reverse: turning `IsBasic` off for a permission, or deleting a permission from a module, revokes it from every existing tenant's `Basic` (or `Admin`) role at the next start. Each removed claim is logged at Warning with the role, the permission and the tenant, so a mistaken change to the permission constants shows up in the logs instead of passing silently.

The sync can be authoritative because the API refuses permission edits on the built-in roles, so there is no operator change to lose. The root tenant's `Admin` keeps the `IsRoot` permissions; only claims of type `permission` are reconciled, so other claim types on those roles are left alone.

Custom roles are yours to manage - the syncer only touches the built-in two.

### Hosts that load only some modules

What the sync removes depends only on the modules the running process loaded, because each module registers its permissions at startup. If you run a second host (a worker, say) that registers `IdentityModule` with only some of the other modules, its catalog is partial, and at every start it would revoke the missing modules' permissions from `Admin` and `Basic` in every tenant. Turn removal off on that host:

```jsonc
// appsettings.json of the partial host
{
"PermissionSyncOptions": {
"RemoveRevoked": false // default true; env var: PermissionSyncOptions__RemoveRevoked=false
}
}
```

With `RemoveRevoked` off, the sync still adds missing permissions but removes nothing, and logs that once at startup. Keep the default on the API host, which loads every module. Independently of the setting, a role whose catalog target comes out empty is never stripped: the sync logs a Warning and skips the removal for that role.

Two instances starting at the same time can both try to remove the same claim. The one that loses the race logs it at Information and moves on, because the other instance already wrote the same end state.

During a rolling deploy, an instance still on a version before this behaviour only adds permissions, so if it restarts it puts a revoked claim back; the claim is removed again at the next start of a new-version instance.

## Anonymous endpoints

Because the fallback policy gates everything by default, anonymous endpoints must opt out explicitly:
Expand Down
3 changes: 2 additions & 1 deletion src/content/docs/security/production-checklist.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Production security checklist
lastUpdated: 2026-09-30
lastUpdated: 2026-10-14
description: Ten configuration items you must check before shipping fullstackhero to production. Skip none.
sidebar:
label: Production checklist
Expand Down Expand Up @@ -197,6 +197,7 @@ These aren't blockers, but the sooner the better:
- **Add monitoring + alerts** on auth-failure spikes, impersonation events, and 5xx error rates.
- **Run a vulnerability scan** of the deployed image (Snyk, Trivy, Dependabot) to catch transitive CVEs early.
- **Check what your object store serves anonymously.** Every shipped stack grants anonymous read on `public/*` only; private files need a presigned URL. If you set `Storage:S3:Prefix`, the policy must cover `{Prefix}/public/*`. In Docker Compose, firewall the S3 port (`FSH_S3_PORT`) so only your proxy reaches it, or bind it to `127.0.0.1`. See [Storage - key layout and visibility](/docs/building-blocks/storage/#key-layout-and-visibility).
- **Turn permission removal off on any extra host that loads only some modules.** A worker that registers `IdentityModule` without every other module would revoke the missing modules' permissions from `Admin` and `Basic` in every tenant at startup; set `PermissionSyncOptions:RemoveRevoked` to `false` there. See [Authorization - hosts that load only some modules](/docs/security/authorization/#hosts-that-load-only-some-modules).
- **Set up backup + restore tests** for Postgres + RustFS. Backups you haven't tested restoring are not backups.
- **Document an incident-response runbook** - who's on-call, where the alerts go, how to revoke every session (admin revoke-all is built in), how to rotate the JWT signing key in an emergency.

Expand Down