ADR 0005 — Group / role / permission → JWT claim mapping#
Status: Accepted (Phase 1, 2026-07-14) — revised 2026-08-18 against real legacy schema in cubedb/
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, taken directly from cubedb/CUBE/Tables/:
aspnet_Users ──< aspmnx_groupUsers >── aspmnx_Groups ──< aspmnx_GroupRoles >── aspnet_Roles
│
├── ModuleId (FK → MnxModule)
└── IsSpecial (bit)
aspnet_Profile (per-user module admin flags)
├── CompanyAdmin, AcctAdmin, ProdAdmin, ScmAdmin, CrmAdmin, EngAdmin
├── CAPAAdmin, SuperUser, ProjectsTasks (EQS)
└── IsItarAuthorized
MnxModule
├── ModuleId, ModuleName, Abbreviation
└── IsPermission (bit) — distinguishes permission-check nodes from nav-only nodesConcrete column casing (from cubedb/CUBE/Tables/):
aspmnx_Groups—groupId,groupName,createDateaspmnx_groupUsers—groupusersID,fkuserid,fkgroupidaspmnx_GroupRoles—GroupRoleID,fkRoleId,fkGroupIdaspnet_Roles—RoleId,RoleName,LoweredRoleName,Description,ModuleId,IsSpecial— noIsPermissioncolumnaspnet_Profile— the admin flags above plusIsItarAuthorizedMnxModule—ModuleId,ModuleName,IsPermission
Runtime resolution today goes through aspmnx_GetRoles4UserModules(userId, moduleId, parentModule, childModule) (cubedb/CUBE/Procedures/dbo.aspmnx_GetRoles4UserModules.sql). It:
- Resolves
@moduleIdfrom parent/child navigation if not supplied. - Returns roles for that
ModuleIdwhere the user has them viaaspmnx_groupUsers → aspmnx_GroupRoles. - Unions in all
IsSpecial=1roles the user is granted via a group — special roles are module-agnostic at resolution time. - Overrides
Assigned=1when the user has the matching module admin flag onaspnet_Profile:CompanyAdmin→ any moduleSGA→AcctAdminMFG→ProdAdminSCM→ScmAdminSALES→CrmAdminENG→EngAdminEQS→ProjectsTasks
- Filters out rows where
Assigned=0(unassigned permissions).
Legacy runtime caches the result in HttpContext.Current.Session["GetRolesOfUser"] per session.
Two role flavours in aspnet_Roles:
- Regular module roles —
IsSpecial=0. Belong to a specificModuleId. Names look likeSalesOrder.Create,Inventory.Read. - Special roles —
IsSpecial=1. Cross-cutting; unioned in when granted via a group, regardless of module context.IsItarAuthorizedis not used to gate special roles — it’s a row-level data-scope concern used elsewhere (WO/inventory SPs), surfaced as a separate claim.
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 | aspnet_Roles.RoleName where the user is granted the role via group or granted implicitly by an admin flag on aspnet_Profile | 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 | ACCTG, 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 data-scope claim, not a role gate. Enforce it in row-level filters (e.g.WHERE ITAR = 0 OR @itar = 1) — the same pattern the legacy SPs use. Do not use it to gate special roles.
Admin-flag expansion#
Rather than encoding the CompanyAdmin=1 → all roles logic in every endpoint policy, UserAuthService.BuildUserInfoAsync expands admin flags into the role claim at token-issue time:
CompanyAdmin=1→ include every role inaspnet_RoleswhereMnxModule.IsPermission=1.AcctAdmin=1→ include every role whereMnxModule.ModuleName='SGA'.ProdAdmin=1→ include every role whereMnxModule.ModuleName='MFG'.ScmAdmin,CrmAdmin,EngAdmin,ProjectsTasks→ their respective modules.
This makes [Authorize(Roles="…")] work uniformly without special-casing admin flags in every controller.
Resolution query (LINQ against EF Core entities)#
At login, UserAuthService.BuildUserInfoAsync runs the equivalent of aspmnx_GetRoles4UserModules union’d across every permission-bearing module:
// 1. Roles granted via group membership.
var groupRoles =
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
join m in db.MnxModules on r.ModuleId equals m.ModuleId into mj
from m in mj.DefaultIfEmpty()
where r.IsSpecial == true || (m != null && m.IsPermission == true)
select r.RoleName;
// 2. Roles granted implicitly by aspnet_Profile admin flags.
var adminRoles =
from r in db.AspnetRoles
join m in db.MnxModules on r.ModuleId equals m.ModuleId
where m.IsPermission == true
&& (
profile.CompanyAdmin
|| (profile.AcctAdmin && m.ModuleName == "SGA")
|| (profile.ProdAdmin && m.ModuleName == "MFG")
|| (profile.ScmAdmin == true && m.ModuleName == "SCM")
|| (profile.CrmAdmin == true && m.ModuleName == "SALES")
|| (profile.EngAdmin == true && m.ModuleName == "ENG")
|| (profile.ProjectsTasks && m.ModuleName == "EQS")
)
select r.RoleName;
var roles = await groupRoles.Concat(adminRoles).Distinct().ToArrayAsync(ct);Entity definitions live under src/Manex.Data/Entities/ (AspmnxGroupUser, AspmnxGroupRole, AspnetRole, AspnetProfile, MnxModule) and are registered on MnxEFContext. [Column] attributes handle the lowercase-column quirks in aspmnx_groupUsers and aspmnx_GroupRoles.
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 / admin-flag 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. For
CompanyAdmin=1users we materialize every permission-bearing role — audit query 6 indeploy/sql/phase-1-schema-audit.sqlmeasures the worst case. Well under HTTP header limits at expected volumes. - 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 and we don’t modify the legacy database. [Authorize(Roles = "…")]and standard authorization policies work with no custom middleware.- Cross-session changes to
aspnet_Profile(e.g., an admin toggles ITAR or a module admin flag) 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 write to the legacy DB. Cube 2.0 never modifies
aspnet_*oraspmnx_*tables — those stay owned by the legacy site. The audit script (deploy/sql/phase-1-schema-audit.sql) is read-only. - We don’t put every fine-grained permission in the JWT unconditionally. For high-cardinality permissions (>100 for a single user, e.g.
CompanyAdmin), we still materialize them — but if we hit HTTP header ceilings we introduce a “permission set version” stamp + a/api/v1/auth/permissionsfetch. - We don’t precompute per-module role lists at the API level. The legacy SP takes
moduleId+ parent/child module as parameters and walksMnxModuleRelationship. We union across all permission-bearing modules once at login 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. - We don’t use
itarto gate role resolution. ITAR is enforced at the data layer (row-level filters on ITAR-flagged records), mirroring how legacy SPs likeGetProductionControlDetails_SP_4use it.