fullstackhero uses permission-based authorization with explicit gates on every endpoint - not blanket role checks. Roles are groupings of permissions; permissions are the unit of authorization. Users belong to roles (and optionally groups); roles carry permissions; endpoints check permissions.
endpoints.MapDelete("/products/{productId:guid}", handler) .RequirePermission(CatalogPermissions.Products.Delete);That indirection - user → role → permission → endpoint - is what makes “give one user delete-but-not-create” trivial. Adjust the user’s role’s permission set; no code change.
The policy wiring - default AND fallback
The permission gate is a single authorization policy named RequiredPermission. The Identity module registers it and then wires it as both the DefaultPolicy and the FallbackPolicy:
services.AddAuthorizationBuilder().AddRequiredPermissionPolicy();services.AddAuthorization(options =>{ options.DefaultPolicy = options.GetPolicy(RequiredPermissionDefaults.PolicyName)!; options.FallbackPolicy = options.GetPolicy(RequiredPermissionDefaults.PolicyName);});Both assignments matter, and the second one is the lesson every team learns the hard way:
- FallbackPolicy covers endpoints with no auth metadata at all - nothing slips through unauthenticated just because someone forgot to annotate it.
- DefaultPolicy covers endpoints that opt in via
.RequireAuthorization()- including the module route groups (Catalog, Billing, Chat, Files, …). Without it, a group-level.RequireAuthorization()attaches ASP.NET Core’s built-in authenticated-only default policy, which suppresses the fallback - so.RequirePermission(...)was never evaluated and any authenticated tenant member could perform gated writes. That fail-open was a real bug, fixed in PR #1290 and pinned by an integration regression test (CreateProduct_Should_Return403_When_AuthenticatedUserLacksPermission).
If you fork the auth setup, keep both lines. The policy itself requires an authenticated user (JWT bearer scheme) plus the permission requirement.
How a permission flows through
- Declared as a constant in a module’s
*.Contracts/Authorization/{Module}Permissions.cs, alongside anFshPermissionrecord for the registry. - Registered via
PermissionConstants.Register(MyPermissions.All)in the module’sConfigureServices. - Assigned to a role as a role claim (
ClaimType = "permission") - via the role-permissions admin endpoint or the startup syncer. - Granted to a user by putting the user in that role.
- Checked at the endpoint:
.RequirePermission(perm)attachesRequiredPermissionAttributemetadata;RequiredPermissionAuthorizationHandlerresolves the current user’s effective permission set server-side and verifies it contains the required permission.
The permission set is not a JWT claim. The JWT carries the user’s roles; the handler resolves roles → permission claims from the database, cached in HybridCache (1 hour in Redis, 2 minutes in-process). That means permission changes take effect on live sessions without re-issuing tokens - see “When changes take effect” below.
Declaring a permission
public static class CatalogPermissions{ public static class Products { public const string Resource = "Catalog.Products"; public const string View = $"Permissions.{Resource}.View"; public const string Create = $"Permissions.{Resource}.Create"; public const string Update = $"Permissions.{Resource}.Update"; public const string Delete = $"Permissions.{Resource}.Delete"; } // ... Brands, Categories
public static IReadOnlyList<FshPermission> All { get; } = [ new("View Products", ActionConstants.View, Products.Resource, IsBasic: true), new("Create Products", ActionConstants.Create, Products.Resource), new("Update Products", ActionConstants.Update, Products.Resource), new("Delete Products", ActionConstants.Delete, Products.Resource), // ... ];}Two shapes, one name. The const string is what endpoints reference; the FshPermission(Description, Action, Resource) record is what the registry holds - its Name is computed as Permissions.{Resource}.{Action} and must match the constant. The flags matter: IsBasic: true permissions are granted to the built-in Basic role, IsRoot: true ones are reserved for the root tenant’s Admin.
Registering during module startup
public void ConfigureServices(IHostApplicationBuilder builder){ PermissionConstants.Register(CatalogPermissions.All); // ... other registrations}PermissionConstants.Register dedupes by Name. Registering twice is a no-op. There’s no removal API - once registered, a permission is part of the runtime for the process lifetime.
Applying a gate
The fluent helper is the canonical way:
endpoints.MapPost("/products", handler) .RequirePermission(CatalogPermissions.Products.Create);The equivalent attribute form:
[RequiredPermission(CatalogPermissions.Products.Create)]Both produce the same metadata - .RequirePermission() is literally WithMetadata(new RequiredPermissionAttribute(...)). RequiredPermissionAuthorizationHandler reads it from the endpoint’s metadata via the IRequiredPermissionMetadata interface and checks the user’s effective permission set. Endpoints with no permission metadata pass the handler (authentication is still enforced by the policy itself).
Passing more than one permission requires all of them (AND, not OR):
endpoints.MapPost("/products/{id}/stock", handler) .RequirePermission(CatalogPermissions.Products.Update, CatalogPermissions.Products.AdjustStock);A user holding only one of the two gets 403. Before #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:
var stock = endpoints.MapGroup("/stock") .RequirePermission(CatalogPermissions.Products.Update);
stock.MapPost("/adjust", handler) .RequirePermission(CatalogPermissions.Products.AdjustStock); // needs Update AND AdjustStockBefore #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.
- Groups are user collections that can carry roles. A user in a group inherits the group’s roles - those roles show up as role claims in the user’s JWT alongside their directly assigned roles.
- The built-in roles are
AdminandBasic. Admin gets every non-root permission (the root tenant’s Admin also gets theIsRootones); Basic gets theIsBasicsubset.
When changes take effect
Effective permissions are resolved server-side and cached per user (HybridCache: 1 h distributed, 2 min local). The kit invalidates that cache aggressively:
- Updating a role’s permissions invalidates the cache for every user holding that role - directly or via group membership (
InvalidateAffectedUsersAsync). - Assigning/removing a user’s roles invalidates that user’s entry.
So a permission grant or revocation applies to live sessions within seconds - no token re-issue needed, because permissions were never baked into the token. Role claims in the JWT, by contrast, are a login-time snapshot; they refresh on the next token issue.
The role-permission syncer
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:
// 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, or both try to insert the same missing one. identity.RoleClaims has a unique index on (RoleId, ClaimType, ClaimValue), so a role holds each permission once and the second insert is rejected by the database. In either case the instance that loses the race logs it at Information and moves on, because the other instance already wrote the same end state. The tenant seeder (IdentityDbInitializer) handles a rejected insert the same way, so a seed overlapping the startup sync on a new tenant doesn’t fail either.
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:
endpoints.MapPost("/self-register", handler) .AllowAnonymous();Separately, PathAwareAuthorizationHandler (an IAuthorizationMiddlewareResultHandler) lets the /scalar, /openapi, and /favicon.ico paths through the authorization middleware so the API docs UI works without a token.
Auditing authorization activity
The Auditing module’s security log (/api/v1/audits/security, gated by the audit-trail view permission) captures logins, token issuance/revocation, and impersonation events out of the box. The SecurityAction enum also defines PermissionDenied / PolicyFailed (default severity: Warning) for your own handlers to write via the audit client:
await Audit.ForSecurity(SecurityAction.PermissionDenied) .WithUser(currentUser.GetUserId()) .WithSecurityContext(ipAddress, userAgent) .WriteAsync(ct);The kit does not automatically write an audit row for every 403 - if you want a denial trail, emit it where you enforce custom checks.
The silent-no-op gotcha (again)
The kit’s RequiredPermissionAttribute implements FSH.Framework.Shared.Identity.Authorization.IRequiredPermissionMetadata. The RequiredPermissionAuthorizationHandler finds the metadata via interface lookup. If a duplicate RequiredPermissionAttribute lives in another assembly without implementing the interface, the handler finds nothing on the endpoint’s metadata collection and treats it as unprotected.
Symptoms:
- Build passes.
- Endpoint responds normally to authenticated requests.
- Authorization is effectively off - any authenticated user can hit the endpoint.
Always import RequiredPermissionAttribute from FSH.Framework.Shared.Identity.Authorization. Never declare a same-named local copy.
Related
- Identity module - role + permission management endpoints.
- Shared building block -
PermissionConstants,FshPermission,RequiredPermissionAttribute. - Authentication - issuing the JWT whose identity the permission check resolves.
- Auditing module - the security event log.