Skip to main content

Territory access pattern: per-record access control via MAS_USER_TERRITORY

Some Mas*-backed entities (Branch, Company, Client, Warehouse today; more may follow) are gated by a per-record grant table, MAS_USER_TERRITORY, discriminated by a territoryType string (e.g. "BRANCH").

:::caution Misleading column name Despite the column name, MAS_USER_TERRITORY.UserId is actually a MAS_USER_GROUP.GroupId — access is granted per user-group, not per individual user (the same misleading-naming pattern already documented on MAS_USER_ROLE.UserId in UserAuthRepository's doc comment, confirmed the same way: the scaffolded MasUserTerritory.User navigation is typed MasUserGroup, not MasUser). :::

All enforcement lives in an Application-layer service, one per gated entity — never in the controller. Controllers are composition roots for HTTP only: build a CallerContext, call the service, map its ServiceResult/ServiceResult<T> to an HTTP response. This applies the CRUD pattern's "thin controller" rule strictly, rather than carving out an exception for territory logic.

Building blocks

  • Application/Common/CallerContext.csCallerContext.FromClaims(ClaimsPrincipal) reads the "groupId"/"superAdmin" JWT claims once; this is the only place those claim names are parsed. ClaimsPrincipal is a BCL type (System.Security.Claims), not an ASP.NET Core one, so Application can depend on it without pulling in a web-framework reference.
  • Application/Common/TerritoryFilter.cs — the shared algorithm every entity's service calls into: FilterAccessibleAsync (list filtering: SuperAdmin sees everything, otherwise intersect against GetAccessibleIdsAsync) and HasAccessAsync (single-record check). Reuse this from a new entity's service; don't re-derive the filtering logic per entity.
  • Application/Common/ServiceResult.csServiceResult/ServiceResult<T> carry an Ok/NotFound/Forbidden outcome (plus a value for the generic form). Application services return these instead of deciding HTTP status codes themselves.
  • Api/Extensions/ServiceResultExtensions.cs.ToActionResult(this) maps a ServiceResult/ServiceResult<T> to NoContent/Ok/NotFound/ Forbid. This is the only place that translation happens; controllers never branch on Outcome themselves (a binary-response endpoint like a logo File() result is the one exception — the controller still needs a couple of lines to pick File(...) over the extension's default Ok(...) mapping, see ClientsController.GetLogo).
  • ITerritoryAccessRepository (Application/Common/Interfaces) / TerritoryAccessRepository (Infrastructure/Persistence/Repositories/Mas) is the one shared repository — HasAccessAsync(groupId, territoryType, territoryId) and GetAccessibleIdsAsync(groupId, territoryType). Reuse it for any new gated entity with that entity's own territoryType constant; never build a second per-entity access table/repository.

The per-entity service pattern

Per-entity service (e.g. BranchService, Application/Modules/Mas/Services/) is where the actual decision is made per entity: GetAllAsync filters via TerritoryFilter; UpdateAsync/DeleteAsync resolve the record's real code first — via a GetByIdAsync-style lookup when the entity routes by numeric id (Branch/Company), or directly from the URL's code segment when the entity is code-keyed (Client/Warehouse) — never trust a client-submitted code in the request body for the access check, that's a privilege-escalation vector — then return Forbidden/NotFound/Ok accordingly.

Documented exceptions

Not every read needs gating — BranchService.GetByCodeAsync/CompanyService's equivalent are deliberately left ungated because they're also how Eos.Web's top bar resolves the current user's own working branch/company (from the "branchCode"/"companyCode" claims, sourced from MAS_USER_ROLE — a different table/concept from MAS_USER_TERRITORY); a user's own branch/company isn't guaranteed to be one their group holds a management grant for. ClientService/WarehouseService have no such top-bar concept, so their single-record reads are gated too (returning NotFound for both "doesn't exist" and "exists but inaccessible" — never leak existence). Weigh this same tension before gating a single-record read on a future entity.

A logo/image endpoint consumed via a plain <img src> tag can never be [Authorize]-gated under this app's JWT-bearer auth — browsers only attach the Authorization header for requests made through apiClient (the axios interceptor), never for a bare <img> tag's own request. CompaniesController.GetLogo hit this for real: the controller-level [Authorize] blocked every logo request with a 401, which the <img> tag's onError silently swallowed into the text-logo fallback — looked exactly like "no logo uploaded" even when one existed. Fixed with [AllowAnonymous] on that one action, which was safe here because CompanyService.GetLogoAsync was already ungated by design (same reasoning as GetByCodeAsync above).

:::danger Do not reuse this fix for a territory-gated entity's logo [AllowAnonymous] would strip the real access decision a gated service makes (e.g. ClientService.GetLogoAsync). For those, the frontend must fetch the image through apiClient (Bearer token attached) as a blob and set <img src> to an URL.createObjectURL(...) of it, keeping the backend gate intact — not yet done for ClientsPage's logo as of this writing. :::

Reference files

CallerContext.cs, TerritoryFilter.cs, ServiceResult.cs, ServiceResultExtensions.cs, ITerritoryAccessRepository.cs, TerritoryAccessRepository.cs, BranchService.cs/BranchesController.cs.