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).
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 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.
Custom roles are yours to manage — the syncer only touches the built-in two.
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.