ADR 0005 — Group / role / permission → JWT claim mapping#

Status: Accepted (Phase 1, 2026-07-14) Depends on: 0001 — Authentication model

Context#

Phase 0 stubbed roles and permissions on the JWT as empty arrays. To progress on Phase 1 we need a real mapping from the legacy security model to JWT claims.

The legacy security model has four moving parts:

aspnet_Users ──< aspmnx_GroupUsers >── AspMnxGroup ──< AspMnxGroupRole >── AspNetRoles
                                                                              │
                                                                              ├── ModuleId (FK → MnxModule)
                                                                              ├── IsPermission (bit)
                                                                              └── IsSpecial (bit)

aspnet_Profile (per-user module admin flags)
   ├── CompanyAdmin, AcctAdmin, ProdAdmin, ScmAdmin, CrmAdmin, EngAdmin
   └── IsItarAuthorized

Runtime resolution today goes through aspmnx_GetRoles4UserModules(userId, moduleId, parentModule, childModule) in manexcloud/BLL/LicManager.cs — returns RoleName + Assigned:bit. The result is cached in HttpContext.Current.Session["GetRolesOfUser"] per session.

Two role flavours:

  • Normal permissionsAspNetRoles rows where IsPermission=1. Names look like SalesOrder.Create, Inventory.Read — module-scoped, action-scoped.
  • Special permissionsIsSpecial=1, gated additionally by aspnet_Profile.IsItarAuthorized (ITAR = International Traffic in Arms Regulations, compliance-required).

Decision#

Populate five claim types on the JWT at login time, resolved by the auth service in Manex.Web from the legacy tables. Server-side authorization uses standard ASP.NET Core [Authorize] / policies against these claims.

Claim shape#

ClaimCardinalitySourceExample
sub1aspnet_Users.UserId11111111-1111-...
unique_name1aspnet_Users.UserNamealice
roleNAspNetRoles.RoleName where IsPermission=1 and the user’s groups grant itSalesOrder.Create
grpNaspmnx_GroupUsers.FkGroupId for this user (as GUID string)<group-guid>
mod_adminNModule keys where the corresponding aspnet_Profile.*Admin flag is trueACCT, SCM, ENG
itar1aspnet_Profile.IsItarAuthorizedtrue
  • role uses the standard role claim type so [Authorize(Roles = "SalesOrder.Create")] works out of the box (.NET’s JwtSecurityTokenHandler maps role to ClaimTypes.Role).
  • grp is included for auditability + downstream services that need the group ID rather than the resolved roles.
  • mod_admin is a flat array (not a nested object) so authorization policies can check membership cheaply: User.HasClaim("mod_admin", "ENG").
  • itar is a single boolean-as-string claim; use it as a RequireClaim("itar", "true") in policy definitions.

Special-permission handling#

Special roles (IsSpecial=1) are omitted from the role claim unless itar is true. This mirrors what the legacy SP does — union of “normal roles assigned via groups” and “special roles assigned via groups filtered by ITAR flag.”

Resolution query#

At login, UserAuthService.BuildUserInfoAsync runs the equivalent of aspmnx_GetRoles4UserModules in EF Core LINQ against the entity graph:

var roles = await (from gu in db.AspMnxGroupUsers
                   where gu.FkUserId == userId
                   join gr in db.AspMnxGroupRoles on gu.FkGroupId equals gr.FkGroupId
                   join r  in db.AspNetRoles       on gr.FkRoleId  equals r.RoleId
                   where r.IsPermission
                      && (!r.IsSpecial || profile.IsItarAuthorized)
                   select r.RoleName)
                   .Distinct()
                   .ToArrayAsync(ct);

The legacy SP aspmnx_GetRoles4UserModules also filters by moduleId, parentModule, childModule. Phase 1 does the resolution once at login and includes all roles the user could ever exercise; per-request filtering happens on the .NET side via [Authorize] and HasClaim checks.

Refresh semantics#

JWT lifetime is 60 minutes (from ADR 0001). Role / group changes made in the admin UI take effect at most 60 minutes later, when the token expires and the client re-logs in. This is acceptable for Phase 1; if faster propagation is required we add a per-user token version stamp in Phase 2.

Consequences#

  • The JWT grows to roughly 1.5-2 KB for a user with ~20 permissions. Well under HTTP header limits.
  • No server-side session store required. Every request carries its own authorization context.
  • Legacy stored proc aspmnx_GetRoles4UserModules is not called from Cube 2.0. The equivalent LINQ query is authored once in UserAuthService. The SP stays live because the legacy site still runs.
  • [Authorize(Roles = "…")] and standard authorization policies work with no custom middleware.
  • Cross-session changes to aspnet_Profile (e.g., an admin toggles ITAR) take one token expiry to propagate.
  • mod_admin and itar claims cannot be self-modified by a compromised client — they’re signed as part of the JWT.

What we deliberately don’t do#

  • We don’t put every fine-grained permission in the JWT. For high-cardinality permissions (>50 for a single user), we’d bloat the token. If we hit that ceiling we introduce a “permission set version” stamp + a /api/v1/auth/permissions fetch — but current data suggests we stay well below.
  • We don’t precompute per-module role lists. The legacy SP takes moduleId as a parameter and returns only that module’s roles; we return the union and let the .NET side filter per endpoint. Simpler, easier to reason about.
  • We don’t replicate the legacy session cache. HttpContext.Current.Session["GetRolesOfUser"] is a legacy-only concept; the JWT is our new “session cache,” signed and stateless.