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}", 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.
How a permission flows through
- Declared as an
FshPermissionconstant in a module’s*.Contracts/Authorization/{Module}Permissions.cs. - Registered via
PermissionConstants.Register(MyPermissions.All)during the module’sConfigureServices. - Assigned to a role via
UpdateRolePermissionsCommand(or seeded at startup). - Granted to a user via
AssignUserRolesCommand(the user inherits all permissions of their roles + groups). - Issued as a claim in the JWT access token (the
permissionsarray). - Checked at the endpoint via
.RequirePermission(perm)— read from the claim, no DB round-trip per request.
Declaring a permission
public static class CatalogPermissions{ public static class Products { public static readonly FshPermission Create = new("Create product", "Create", "Products"); public static readonly FshPermission View = new("View products", "Read", "Products"); public static readonly FshPermission Update = new("Update product", "Update", "Products"); public static readonly FshPermission Delete = new("Delete product", "Delete", "Products"); public static readonly FshPermission AdjustStock = new("Adjust product stock","AdjustStock", "Products"); } // ... Brands, Categories
public static IReadOnlyList<FshPermission> All { get; } = /* aggregate everything */;}Each FshPermission has a Name property (computed as Permissions.{Resource}.{Action} — e.g. Permissions.Products.Create).
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 (used in older controller-based code paths):
[RequiredPermission(nameof(CatalogPermissions.Products.Create))]public IResult CreateProduct(...) { ... }Both register the same metadata; RequiredPermissionAuthorizationHandler reads it from the endpoint’s MetadataCollection and validates the user’s claims against the registered permission.
Roles, groups, and permission 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 permissions.
- Groups are user collections that can have roles. A user inherits permissions from groups they belong to in addition to their direct roles.
- The kit’s
IUserPermissionServiceresolves the effective permission set when issuing a JWT — claim-level enforcement is then cheap.
User: alice ├─ Roles: ["catalog-editor", "viewer"] │ catalog-editor → Products.Create, Products.Update │ viewer → Products.View, Brands.View, Categories.View └─ Groups: ["region-east"] region-east → Tickets.View, Tickets.Update
Effective permissions: union of all of the aboveJWT claim "permissions": ["Products.Create", "Products.Update", "Products.View", "Brands.View", "Categories.View", "Tickets.View", "Tickets.Update"]The permissions claim is the source of truth at the gate. Role + group reshuffling at admin time does not invalidate live tokens — they continue to carry the snapshot from when they were issued. Revoke the sessions (or wait for token expiry) to apply changes.
The role-permission syncer
RolePermissionSyncer + RolePermissionSyncHostedService keep the user’s permission claim cache fresh. At app startup, they read every role’s permission set into HybridCache. When UpdateRolePermissionsCommand runs, the cache is updated. Token issuance reads from cache, not DB.
Anonymous endpoints
Skip .RequirePermission and apply AllowAnonymous instead:
endpoints.MapPost("/users/self-register", handler) .AllowAnonymous();The PathAwareAuthorizationHandler lets some endpoints be anonymous and others gated, in the same hub of routes. The auth pipeline doesn’t require a JWT for anonymous endpoints.
Audit trail of permission decisions
The Auditing module captures every permission denial:
await Audit.ForSecurity(SecurityAction.PermissionDenied) .WithUser(currentUser.GetUserId()) .WithSecurityContext(ipAddress, userAgent) .WithSeverity(AuditSeverity.Warning) .WriteAsync(ct);Query /api/v1/audits/security filtered by EventType=Security, Severity>=Warning to see permission failures over time. Spikes from a single user typically mean the user’s role is misconfigured; spikes from many users on one endpoint suggest a deploy that changed permission requirements.
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.
- Architecture test
RequiredPermissionAttributeIsCanonicalTestsexists to catch this — make sure it stays green.
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 that carries the permission claim.
- Auditing module — capture permission-deny events.