Authorization model: three mechanisms, and which one gates what
Documented 2026-08-03 after an audit of every controller under
Api/Controllers/Operations (42 total) turned up no written rule for which of
three overlapping mechanisms a given controller/action should use. This
section is the write-down of the rule the codebase was already following
inconsistently — it does not change runtime behavior for any entity whose
current setup matches the rule below; it only centralizes the previously
ad-hoc, hardcoded menu-name strings (see "Centralizing menu names" below) and
gives future entities a rule to follow instead of copying whichever existing
controller looks closest.
The three mechanisms
The three mechanisms are layered independently — an action can be gated by more than one at once, and when it is, all of them must pass, not just one:
-
[RequiresMenuPermission(menuName, action)](Api/Filters/RequiresMenuPermissionAttribute.cs, enforced byMenuPermissionAuthorizationHandler, alsoApi/Filters) — a per-action attribute (not per-controller: a GET needsView, a POST needsCreate, etc.) checked against the caller's effectiveMAS_ROLE_MENU/MAS_USER_MENUpermission bitmask for a stable seededMAS_MENU.MenuNamecode. This is "can this user's role/override reach this screen at all," independent of which records on that screen they can see. SuperAdmin bypasses it entirely (same bypass every other gate in this codebase gives SuperAdmin).Migrated 2026-08-19 off a bespoke global
MenuPermissionActionFilteronto a real ASP.NET CoreIAuthorizationHandler. The attribute now inheritsAuthorizeAttributeand implementsIAuthorizationRequirementData/IAuthorizationRequirementitself, so it self-registers as both the authorize-data source and the requirement instance — no named-policy registration needed per call site. It also moved fromApplication/Common/toApi/Filters/:Applicationhas zero ASP.NET Core package references, andAuthorizeAttribute/IAuthorizationRequirementDataboth live inMicrosoft.AspNetCore.Authorization, so the attribute can't stay inApplicationand pick up that base type. Behavior (SuperAdmin bypass, 403 on missing grant, 401 on missing/invalid auth) is unchanged for every existing call site.:::caution Anonymous-request gotcha found during the migration Because the attribute is both an
IAuthorizeDataand a requirement source, ASP.NET Core'sAuthorizationMiddlewarecombines it with the endpoint's own "must be authenticated" requirement into one combined policy, evaluated as a set — not sequentially. The old filter only ever ran after a separate, earlier stage had already rejected an anonymous caller, so it could safely assume an authenticatedClaimsPrincipal. The new handler has no such guarantee, soMenuPermissionAuthorizationHandler.HandleRequirementAsyncexplicitly checkscontext.User.Identity?.IsAuthenticatedand returns immediately for an unauthenticated caller, rather than assuming one and surfacing a 500 instead of the expected 401. ::: -
Service-layer gating — the entity's
Application-layer service decides, the controller only maps theServiceResultto HTTP (see the Territory Access Pattern page). Two distinct flavors exist, chosen by what's being gated, not by preference:TerritoryFilterrow-level gating — "which records can this user/group see," viaMAS_USER_TERRITORY. For entities with a real per-record ownership concept.- All-or-nothing
caller.IsSuperAdmincheck — no partial access exists for these actions; either the caller is a SuperAdmin or the action isForbidden, full stop. Used for privilege-management actions where aTerritoryFilter-style "which records" question doesn't apply — granting a role menu access, assigning a group a role, overriding a user's menu isn't a per-record read, it's "can this caller manage privileges at all." SeeRoleMenuGrantService/GroupRoleAssignmentService/UserMenuOverrideService/UserGroupService/RoleService/MenuService's admin actions.
IAccessControlServicefacade (shipped 2026-08-24, extended 2026-08-25). Both flavors above are now expressed through one interface (Application/Common/Authorization/IAccessControlService.cs) instead of each service callingTerritoryFilter/caller.IsSuperAdminindependently:AuthorizeResourceAsync(a single already-fetched record),FilterAccessibleAsync(a collection),AuthorizeAsync(the no-resource privilege-management gate). Which mechanism/polarity aresourceTypeuses is data, not per-service logic —AccessPolicyRegistry.Policies(same file) turns the decision rule below into a dictionary,AccessPolicyRegistryTestschecks it directly, andAccessPolicyRegistryCoverageTests(Api.Tests/Controllers/) cross-checks every registry row against its controller's real[RequiresMenuPermission]usage plus a completeness assertion that every controller underApi/Controllers/Operationslands in exactly one bucket. Most territory-gated and privilege-management entities are migrated onto the facade; two composed shapes needed registry extensions to fit —ClientService(a personalClientCodesoverride viaPersonalOverrideCodesSelector) andWarehouseService(a composed BRANCH allow-list and WAREHOUSE deny-list, viaSecondaryTerritoryType/SecondaryPolarityand the facade's two-resourceId overloads).:::caution
AddressServicestays off the facade — deliberately Its access decision is conditional on the resource's own shape (only addresses tagged"EN"inAddTypeare gated at all — a plain address always passes) and composes a personal-override/territory/client-code check that isn't a clean AND of two territory checks the wayWarehouse's is. Folding it in would need a special-cased escape hatch that buys nothing over the directTerritoryFilter/IsSuperAdmincall it already makes — seeAccessPolicy.cs's own doc comment for the full reasoning. :::Department/MasterOrder/WarehouseOrder/TransportOrder'sGetAllPagedAsyncalso keep injectingITerritoryAccessRepositorydirectly alongsideIAccessControlService— their paged reads push the caller's raw accessible-ID set down into the repository as a SQL-level filter parameter, a third shape neitherAuthorizeResourceAsyncnorFilterAccessibleAsynccovers. Every other call site in those files uses the facade. -
Direct repository injection,
[Authorize]only, no service layer — for entities with no independent access story beyond "any authenticated user can read this lookup table." NoCallerContextever enters the picture.
The decision rule
Which combination an entity/action should use:
-
Pure lookup/combobox entity with no admin screen of its own (no
MAS_MENUrow, nothing a user "opens" as its own page) → mechanism 3 only: direct repository,[Authorize], nothing else. Confirmed on 19+ controllers today:AddressTypes,Carriers,ChoiceTypes,ClientBranches,Currencies,Forwarders,Incoterms,LabelTranslations,ModeOfTransports,OrderProfiles,OrderTypes,Routes,ServiceLevels,ServiceTypes,Statuses,Suppliers,Terminals,Vessels,Triggers/ActionCatalog(see the Automation Engine page — both are code-synced catalogs with no admin screen of their own, existing purely as dropdown sources for the Scenarios screen's own gate).:::note
AppSettingsis not in this bucketAppSettingsControllercarries[RequiresMenuPermission(... MasterDataAppSettings...)]since its own migration — it belongs in the "real admin screen, no per-record ownership" bucket below. An earlier version of this page listed it here in error; found and fixed while buildingAccessPolicyRegistryCoverageTests(2026-08-25). ::: -
Entity with a real admin screen, and per-record ownership (transactional or "this branch/client/etc. owns this row" master data) → mechanism 1 +
TerritoryFilter, both independently enforced. Confirmed onAddress,Branch,Client,Company,Department,Equipment,ForumType,Queue,TrCode,User,Warehouse, and — closing the gap this page used to flag below —MasterOrder/WarehouseOrder/TransportOrder. -
Entity with a real admin screen, but no per-record ownership (a cached, whole-table lookup where every authorized viewer sees the same rows — "the whole list is small," see the Reference CRUD Pattern's caching step) → mechanism 1 only, no
TerritoryFilter. Confirmed onCity,Country,Choices,Scenarios(see the Automation Engine page),SysConfig,AppSettings,EmailAccounts, andTpAuthConfig. -
Privilege-management entity/sub-resource (
Role,UserGroup, and their nested sub-resourcesGroupRoleAssignments/RoleMenuGrants/UserMenuOverrides, plusMenuController's own admin actions) → the all-or-nothingIsSuperAdminservice gate always applies. The two top-level screens (Role,UserGroup) also carry mechanism 1, since they're realMAS_MENUrows a role/override can be granted or denied. Their nested sub-resource controllers (GroupRoleAssignmentsController,RoleMenuGrantsController,UserMenuOverridesController) andMenuController'sadmin/Create/Update/Deactivateactions deliberately do not carry mechanism 1 on top — they're only ever reached through their parent's own gated screen, not independent menu items, and since only a SuperAdmin can ever pass theirIsSuperAdmingate anyway (and SuperAdmin bypasses mechanism 1 unconditionally), adding the attribute there would be redundant ceremony, not a real additional check. Confirmed intentional by this audit, not a gap. -
Combobox/dropdown sub-route nested inside an otherwise-gated controller (e.g. a
GET .../lookupaction living beside a fully mechanism-1-gated CRUD set) → deliberately skip mechanism 1 on that one action, even though its sibling actions on the same controller carry it. A combobox meant to be dropped into any future page's form can't depend on the embedding page's users also holding the source entity's own adminViewpermission — the entity's ownTerritoryFiltergating (still fully applied) is the real access control for that route. Established onBranches/Clients/Countries/Warehouses' own/lookupactions;EntitiesController(anAddressService-backed combobox with no menu screen or write path of its own) is the same reasoning taken to its logical end — a whole controller built for exactly this purpose, gated only byAddressService's existing ENTITY-territory filtering.
:::note Formerly-known gap, now closed
MasterOrdersController/WarehouseOrdersController used to be the one
confirmed real gap under this rule — both are per-record/territory-scoped
transactional entities with a real admin screen that should carry mechanism 1
alongside their existing TerritoryFilter gating. They now do
(MenuPermissionNames.OperationsMasterOrders/OperationsWarehouseOrders).
OPERATIONS_WAREHOUSE_ORDERS' MAS_MENU row and the fixed
OPERATIONS_MASTER_ORDERS permission level still need their seed scripts run
per client before this is actually enforced on a given database — check
POST_ROLLOUT_ACTIONS.md.
:::
Forward reference: where planned MFA inserts
Not built yet (TASKS.md, P1) — noted here so the insertion point isn't
re-derived from scratch when that task is picked up. There is no server-side
session in this app: a JWT is minted once at login, and every mechanism above
runs per-request purely against claims already baked into whichever token the
caller presents — there's no recurring session-level layer to hook a
second-factor check into after the fact. The real insertion point is inside
LoginService.LoginAsync, between the primary-factor verified check
succeeding and the token being issued — i.e. before the JWT (and so the
"session") exists at all. SsoLoginService shares that same token-issuing
path, but MFA is explicitly out of scope for SSO accounts (the client's own
IdP already owns MFA for those) — don't wire the new step into that shared
path.
Centralizing menu names
Every [RequiresMenuPermission(...)] call site uses a constant from
Application/Common/MenuPermissionNames.cs (e.g.
MenuPermissionNames.MasterDataBranches), never a raw string literal — with
16+ call sites and growing, a typo in a hand-typed literal wouldn't fail to
compile, it would just silently never match a real MAS_MENU.MenuName row.
Add a new constant there, not a fresh string literal, when an entity joins
mechanism 1.
Reference files
RequiresMenuPermissionAttribute.cs, MenuPermissionAction.cs,
MenuPermissionAuthorizationHandler.cs, MenuPermissionNames.cs,
TerritoryFilter.cs, IAccessControlService.cs/AccessControlService.cs/
AccessPolicy.cs (Application/Common/Authorization/), BranchService.cs
(the reference migrated-onto-the-facade service), ClientService.cs/
WarehouseService.cs (the personal-override/composed-secondary-territory
shapes), AddressService.cs (the one remaining holdout), CallerContext.cs,
WarehousesController.cs (gated CRUD + ungated /lookup in one file),
EntitiesController.cs, RoleMenuGrantService.cs (the IsSuperAdmin-only
gate), AccessPolicyRegistryCoverageTests.cs (Api.Tests/Controllers/).