Skip to main content

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:

  1. [RequiresMenuPermission(menuName, action)] (Api/Filters/RequiresMenuPermissionAttribute.cs, enforced by MenuPermissionAuthorizationHandler, also Api/Filters) — a per-action attribute (not per-controller: a GET needs View, a POST needs Create, etc.) checked against the caller's effective MAS_ROLE_MENU/MAS_USER_MENU permission bitmask for a stable seeded MAS_MENU.MenuName code. 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 MenuPermissionActionFilter onto a real ASP.NET Core IAuthorizationHandler. The attribute now inherits AuthorizeAttribute and implements IAuthorizationRequirementData/IAuthorizationRequirement itself, 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 from Application/Common/ to Api/Filters/: Application has zero ASP.NET Core package references, and AuthorizeAttribute/ IAuthorizationRequirementData both live in Microsoft.AspNetCore.Authorization, so the attribute can't stay in Application and 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 IAuthorizeData and a requirement source, ASP.NET Core's AuthorizationMiddleware combines 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 authenticated ClaimsPrincipal. The new handler has no such guarantee, so MenuPermissionAuthorizationHandler.HandleRequirementAsync explicitly checks context.User.Identity?.IsAuthenticated and returns immediately for an unauthenticated caller, rather than assuming one and surfacing a 500 instead of the expected 401. :::

  2. Service-layer gating — the entity's Application-layer service decides, the controller only maps the ServiceResult to HTTP (see the Territory Access Pattern page). Two distinct flavors exist, chosen by what's being gated, not by preference:

    • TerritoryFilter row-level gating — "which records can this user/group see," via MAS_USER_TERRITORY. For entities with a real per-record ownership concept.
    • All-or-nothing caller.IsSuperAdmin check — no partial access exists for these actions; either the caller is a SuperAdmin or the action is Forbidden, full stop. Used for privilege-management actions where a TerritoryFilter-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." See RoleMenuGrantService/GroupRoleAssignmentService/ UserMenuOverrideService/UserGroupService/RoleService/MenuService's admin actions.

    IAccessControlService facade (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 calling TerritoryFilter/caller.IsSuperAdmin independently: AuthorizeResourceAsync (a single already-fetched record), FilterAccessibleAsync (a collection), AuthorizeAsync (the no-resource privilege-management gate). Which mechanism/polarity a resourceType uses is data, not per-service logicAccessPolicyRegistry.Policies (same file) turns the decision rule below into a dictionary, AccessPolicyRegistryTests checks it directly, and AccessPolicyRegistryCoverageTests (Api.Tests/Controllers/) cross-checks every registry row against its controller's real [RequiresMenuPermission] usage plus a completeness assertion that every controller under Api/Controllers/Operations lands 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 personal ClientCodes override via PersonalOverrideCodesSelector) and WarehouseService (a composed BRANCH allow-list and WAREHOUSE deny-list, via SecondaryTerritoryType/ SecondaryPolarity and the facade's two-resourceId overloads).

    :::caution AddressService stays off the facade — deliberately Its access decision is conditional on the resource's own shape (only addresses tagged "EN" in AddType are 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 way Warehouse's is. Folding it in would need a special-cased escape hatch that buys nothing over the direct TerritoryFilter/IsSuperAdmin call it already makes — see AccessPolicy.cs's own doc comment for the full reasoning. :::

    Department/MasterOrder/WarehouseOrder/TransportOrder's GetAllPagedAsync also keep injecting ITerritoryAccessRepository directly alongside IAccessControlService — their paged reads push the caller's raw accessible-ID set down into the repository as a SQL-level filter parameter, a third shape neither AuthorizeResourceAsync nor FilterAccessibleAsync covers. Every other call site in those files uses the facade.

  3. Direct repository injection, [Authorize] only, no service layer — for entities with no independent access story beyond "any authenticated user can read this lookup table." No CallerContext ever 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_MENU row, 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 AppSettings is not in this bucket AppSettingsController carries [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 building AccessPolicyRegistryCoverageTests (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 on Address, 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 on City, Country, Choices, Scenarios (see the Automation Engine page), SysConfig, AppSettings, EmailAccounts, and TpAuthConfig.

  • Privilege-management entity/sub-resource (Role, UserGroup, and their nested sub-resources GroupRoleAssignments/RoleMenuGrants/ UserMenuOverrides, plus MenuController's own admin actions) → the all-or-nothing IsSuperAdmin service gate always applies. The two top-level screens (Role, UserGroup) also carry mechanism 1, since they're real MAS_MENU rows a role/override can be granted or denied. Their nested sub-resource controllers (GroupRoleAssignmentsController, RoleMenuGrantsController, UserMenuOverridesController) and MenuController's admin/Create/Update/Deactivate actions 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 their IsSuperAdmin gate 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 .../lookup action 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 admin View permission — the entity's own TerritoryFilter gating (still fully applied) is the real access control for that route. Established on Branches/Clients/Countries/Warehouses' own /lookup actions; EntitiesController (an AddressService-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 by AddressService'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/).