diff --git a/src/content/docs/changelog/index.mdx b/src/content/docs/changelog/index.mdx index bae5795b..31e9d4ab 100644 --- a/src/content/docs/changelog/index.mdx +++ b/src/content/docs/changelog/index.mdx @@ -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). diff --git a/src/content/docs/modules/identity.mdx b/src/content/docs/modules/identity.mdx index 652c36d5..5dd60a5d 100644 --- a/src/content/docs/modules/identity.mdx +++ b/src/content/docs/modules/identity.mdx @@ -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 @@ -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) | diff --git a/src/content/docs/security/authorization.mdx b/src/content/docs/security/authorization.mdx index c2362188..f1f9191f 100644 --- a/src/content/docs/security/authorization.mdx +++ b/src/content/docs/security/authorization.mdx @@ -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 @@ -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.