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
2 changes: 2 additions & 0 deletions src/content/docs/changelog/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ Notable changes to the kit, newest first.

## 2026-11-03

- **Authorization: a route group's `.RequirePermission()` still applies when an endpoint inside it adds its own (fix).** `RequiredPermissionAuthorizationHandler` read only the most specific permission metadata, so `.RequirePermission(b)` on an endpoint replaced `.RequirePermission(a)` on its route group and a user holding only `b` got through. The handler now combines every permission gate on the endpoint and requires all of them, the same all-of rule as `.RequirePermission(a, b)`. A blank gate anywhere in the chain denies the request. No kit endpoint gates a route group today, so nothing changes for the shipped API; your own group-gated endpoints become stricter. See [Authorization](/docs/security/authorization/#applying-a-gate), [#1429](https://github.com/fullstackhero/dotnet-starter-kit/issues/1429) and [#1452](https://github.com/fullstackhero/dotnet-starter-kit/pull/1452).
- **Identity: `PUT /api/v1/identity/profile` no longer has an `email` field (breaking, fix).** `UpdateUserCommand.Email` was validated as an e-mail address but never saved, so a client that sent a new address got a `200` and nothing changed. Changing an e-mail needs a confirmation flow, so the field is removed from the command and its validator rather than wired through. The API's JSON options skip unknown properties, so a client that still sends `email` gets the same `200` and unchanged address as before, but a malformed address is no longer rejected with a `400`. The dashboard's profile save stopped sending it. See [Identity](/docs/modules/identity/), [#1431](https://github.com/fullstackhero/dotnet-starter-kit/issues/1431) and [#1454](https://github.com/fullstackhero/dotnet-starter-kit/pull/1454).
- **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).
Expand Down
4 changes: 2 additions & 2 deletions 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-14
lastUpdated: 2026-11-03
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 @@ -189,7 +189,7 @@ Auth e-mail links resolve through `FrontendOptions` (a dedicated config, separat
| POST | `/token/issue` | Login |
| POST | `/token/refresh` | Rotate refresh token |
| GET | `/profile` | Current user profile (returns a strong `ETag`) |
| PUT | `/profile` | Update own profile (honours `If-Match`, answers `412` on a stale token) |
| PUT | `/profile` | Update own profile (honours `If-Match`, answers `412` on a stale token). There is no e-mail field: an `email` in the body is ignored |
| PUT | `/profile/image` | Set / clear profile image |
| GET | `/permissions` | Current user permissions |
| GET | `/permissions/catalog` | All registered permissions (catalog) |
Expand Down
14 changes: 13 additions & 1 deletion 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-14
lastUpdated: 2026-11-03
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 @@ -123,6 +123,18 @@ endpoints.MapPost("/products/{id}/stock", handler)

A user holding only one of the two gets `403`. Before [#1415](https://github.com/fullstackhero/dotnet-starter-kit/issues/1415) the handler checked only the first permission and ignored the rest. Blank permission strings are dropped from the metadata; if nothing is left (for example `.RequirePermission("")`), the gate denies every request instead of opening.

Gates stack across route groups. A `.RequirePermission()` on a group and another on an endpoint inside it are both enforced, so the caller needs every permission from both:

```csharp
var stock = endpoints.MapGroup("/stock")
.RequirePermission(CatalogPermissions.Products.Update);

stock.MapPost("/adjust", handler)
.RequirePermission(CatalogPermissions.Products.AdjustStock); // needs Update AND AdjustStock
```

Before [#1429](https://github.com/fullstackhero/dotnet-starter-kit/issues/1429) the handler read only the most specific metadata entry, so the endpoint's gate replaced the group's and a user holding only `AdjustStock` got through.

## Roles, groups, and aggregation

- **Roles** are flat (no inheritance hierarchy). A user can be in multiple roles; the user's effective permission set is the **union** of every role's permission claims.
Expand Down