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.cs—CallerContext.FromClaims(ClaimsPrincipal)reads the"groupId"/"superAdmin"JWT claims once; this is the only place those claim names are parsed.ClaimsPrincipalis 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 againstGetAccessibleIdsAsync) andHasAccessAsync(single-record check). Reuse this from a new entity's service; don't re-derive the filtering logic per entity.Application/Common/ServiceResult.cs—ServiceResult/ServiceResult<T>carry anOk/NotFound/Forbiddenoutcome (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 aServiceResult/ServiceResult<T>toNoContent/Ok/NotFound/Forbid. This is the only place that translation happens; controllers never branch onOutcomethemselves (a binary-response endpoint like a logoFile()result is the one exception — the controller still needs a couple of lines to pickFile(...)over the extension's defaultOk(...)mapping, seeClientsController.GetLogo).ITerritoryAccessRepository(Application/Common/Interfaces) /TerritoryAccessRepository(Infrastructure/Persistence/Repositories/Mas) is the one shared repository —HasAccessAsync(groupId, territoryType, territoryId)andGetAccessibleIdsAsync(groupId, territoryType). Reuse it for any new gated entity with that entity's ownterritoryTypeconstant; 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.