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
└── IsItarAuthorizedRuntime 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 permissions —
AspNetRolesrows whereIsPermission=1. Names look likeSalesOrder.Create,Inventory.Read— module-scoped, action-scoped. - Special permissions —
IsSpecial=1, gated additionally byaspnet_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#
| Claim | Cardinality | Source | Example |
|---|---|---|---|
sub | 1 | aspnet_Users.UserId | 11111111-1111-... |
unique_name | 1 | aspnet_Users.UserName | alice |
role | N | AspNetRoles.RoleName where IsPermission=1 and the user’s groups grant it | SalesOrder.Create |
grp | N | aspmnx_GroupUsers.FkGroupId for this user (as GUID string) | <group-guid> |
mod_admin | N | Module keys where the corresponding aspnet_Profile.*Admin flag is true | ACCT, SCM, ENG |
itar | 1 | aspnet_Profile.IsItarAuthorized | true |
roleuses the standardroleclaim type so[Authorize(Roles = "SalesOrder.Create")]works out of the box (.NET’sJwtSecurityTokenHandlermapsroletoClaimTypes.Role).grpis included for auditability + downstream services that need the group ID rather than the resolved roles.mod_adminis a flat array (not a nested object) so authorization policies can check membership cheaply:User.HasClaim("mod_admin", "ENG").itaris a single boolean-as-string claim; use it as aRequireClaim("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_GetRoles4UserModulesis not called from Cube 2.0. The equivalent LINQ query is authored once inUserAuthService. 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_adminanditarclaims 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/permissionsfetch — but current data suggests we stay well below. - We don’t precompute per-module role lists. The legacy SP takes
moduleIdas 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.