Skip to content
fullstackhero

Concept

Authorization

Permission-based authorization — fine-grained gates on every endpoint via .RequirePermission() with a flat registry, role-and-group aggregation, and the silent-no-op gotcha that catches every team once.

views 0 Last updated

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

  1. Declared as an FshPermission constant in a module’s *.Contracts/Authorization/{Module}Permissions.cs.
  2. Registered via PermissionConstants.Register(MyPermissions.All) during the module’s ConfigureServices.
  3. Assigned to a role via UpdateRolePermissionsCommand (or seeded at startup).
  4. Granted to a user via AssignUserRolesCommand (the user inherits all permissions of their roles + groups).
  5. Issued as a claim in the JWT access token (the permissions array).
  6. Checked at the endpoint via .RequirePermission(perm) — read from the claim, no DB round-trip per request.

Declaring a permission

src/Modules/Catalog/Modules.Catalog.Contracts/Authorization/CatalogPermissions.cs
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 IUserPermissionService resolves 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 above
JWT 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 RequiredPermissionAttributeIsCanonicalTests exists to catch this — make sure it stays green.

Always import RequiredPermissionAttribute from FSH.Framework.Shared.Identity.Authorization. Never declare a same-named local copy.