Architecture Decision Record
ADR-033: Resource-Ownership Authorization (Row-Level + Action Filter)
Status
Accepted (2026-07-02, revised 2026-07-25, 2026-08-01, 2026-08-31, 2026-09-10). Revised 2026-10-01 (the fail-closed owner gate and the per-record ownership check are now framework code in OwnershipHelper; see Revision below). Revised 2026-10-06: the CSV export's fail-closed AllowUnscopedExport opt-in, which lets only the bypass role export an unscoped table, is now recorded in Adoption. Revised 2026-10-07: anchors refreshed after the v1.233.0 release.
Context
ADR-020 added a permission (capability) layer over RBAC: it answers "what may this role do",
resolving a role to a permission so an endpoint can require a capability instead of a role name. It
explicitly scoped out the orthogonal question, "is this my order", recording that "per-resource
ownership (a customer may read only their own data) stays a separate concern (OwnerOrAdminFilter),
and a route needing both composes the two" (020-permission-based-authorization.md:165-166).
That carve-out names a mechanism that already ships in framework code but had no decision record of its own. RBAC and permissions are principal-scoped: a customer with the Customer role may read orders, but that role says nothing about which orders. Two endpoint shapes need a different, resource-scoped check that the role/permission model cannot express:
- Single-resource routes (
GET /orders/{id},GET /customers/{id}): the id in the URL identifies one resource, and a non-admin caller must be denied if that resource is not theirs. - Collection/list routes (
GET /orders,GET /shoppingcarts): there is no id to check; the result set itself must be narrowed to the caller's own rows rather than returning everyone's data.
These are different problems (reject-one vs filter-many) and cannot be one mechanism. This ADR records the shipped resource-ownership axis that sits beside ADR-020, not inside it.
Decision
Provide a row/resource-level ownership axis in MMCA.Common.API (the Authorization folder), with two
enforcement points keyed on the caller's owner claim (customer_id by default) and a configurable
bypass role (Admin by default).
- Single-resource action filter.
OwnerOrAdminFilter(Source/Presentation/MMCA.Common.API/Authorization/OwnerOrAdminFilter.cs:31) is a sealedIAsyncActionFilterwhose primary constructor takesICurrentUserServiceandIOptions<OwnerOrAdminFilterOptions>. Its ownership vocabulary comes fromOwnerOrAdminFilterOptions(Source/Presentation/MMCA.Common.API/Authorization/OwnerOrAdminFilterOptions.cs:13). The two parameter names keep the framework's conventional defaults:OwnerClaimType"customer_id"(OwnerOrAdminFilterOptions.cs:16) andOwnerParameterName"id"(OwnerOrAdminFilterOptions.cs:31).BypassRolehas no default and is[Required](OwnerOrAdminFilterOptions.cs:23-24), validated by data annotations on first resolve, because the framework knows no role names: a host that applies the filter without naming a role from its own role constants fails loudly instead of silently bypassing for nobody. It short-circuits to the action for the bypass role (OwnershipHelper.IsAdmin(currentUserService, settings.BypassRole),OwnerOrAdminFilter.cs:43); otherwise it reads the caller's owner claim viaGetClaimValue<int>(settings.OwnerClaimType)(OwnerOrAdminFilter.cs:49) and returnsForbidResult(HTTP 403) if the claim is missing (OwnerOrAdminFilter.cs:51,OwnerOrAdminFilter.cs:53) or if the requested owner parameter resolves to an int that does not equal the claim (OwnerOrAdminFilter.cs:73,OwnerOrAdminFilter.cs:75).TryGetOwnerParameterreads that parameter from a route value (/customers/{id}) or, when the route lacks it, from a model-bound query/body argument (?userId=42), so the guard also covers list/query routes that carry the owner as a bound argument, not only route ids. It is registered scoped byAddAPI(Source/Presentation/MMCA.Common.API/DependencyInjection.cs:85) and applied as[ServiceFilter(typeof(OwnerOrAdminFilter))], at class level (Store) or per action (ADC'sBookmarksController.cs:85,:106). - Collection ownership specification.
OwnershipHelper(Source/Presentation/MMCA.Common.API/Authorization/OwnershipHelper.cs:11) is a static helper.GetOwnershipSpecification<TSpec, TId>returnsnullfor the bypass role (OwnershipHelper.cs:48), and otherwise reads the caller's id claim (GetClaimValue<TId>(claimType),OwnershipHelper.cs:51) and builds aSpecificationvia the supplied factory (OwnershipHelper.cs:52); a convenience overload defaults the claim to"customer_id"(OwnershipHelper.cs:65,OwnershipHelper.cs:70). The returned spec is aSpecification<TEntity, TId>(Source/Core/MMCA.Common.Domain/Specifications/Specification.cs:15) whoseCriteriaexpression (Specification.cs:23) the existing query pipeline (IEntityQueryService) translates to SQL, so a non-admin list query returns only the caller's rows. Anullspec (bypass role) applies no filter. - Framework gates for the ambiguous
nulland for one record. The same helper shipsRequireResolvableOwner<TId>(OwnershipHelper.cs:91-104), which succeeds for the bypass role or a parsable owner claim and otherwise returnsError.Forbidden(a 403,OwnershipHelper.cs:174-175), andValidateOwnershipAsync<TId>(OwnershipHelper.cs:133-157), the per-mutation check: the bypass role passes without a lookup (OwnershipHelper.cs:146), a missing owner claim is refused withError.Forbiddenbefore any lookup (OwnershipHelper.cs:151-153), and a caller who does not own the record getsError.NotFound, a 404 (OwnershipHelper.cs:167-171), supplied by a caller-provided existence predicate. - The bypass role is the single override on both.
OwnershipHelper.IsAdmin(Source/Presentation/MMCA.Common.API/Authorization/OwnershipHelper.cs:18) comparesICurrentUserService.Role(Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Auth/ICurrentUserService.cs:22) to itsbypassRoleargument ("Admin"by default) case-insensitively (OwnershipHelper.cs:21). Both enforcement points consult it, so a caller in the bypass role sees and touches any resource through either path. - The filter denies by default. When the owner parameter cannot be resolved (absent, non-int, or
carried inside a bound model whose
ToString()does not parse), the request is rejected. The filter originally fell through to the action in that case, which meant it silently stopped guarding any action whose parameter was optional or not an int: "nothing to compare" was being read as "nothing to enforce". An action that legitimately has no owner parameter opts out with[AllowMissingOwner](Source/Presentation/MMCA.Common.API/Authorization/AllowMissingOwnerAttribute.cs), honored on the action or its controller through the endpoint metadata. The attribute is an assertion that the action is guarded some other way, so each application site must name that guard: an ownership specification that already narrows the rows, or its own authorization policy. The opt-out excuses only a missing parameter; an action carrying a foreign owner id is still denied, and a missing owner claim is still denied regardless. - Two failure shapes, by design. The single-resource filter denies with 403 (
ForbidResult); the collection path never 403s, it returns a filtered (possibly empty) result set. Both flow through the caller's normalResult/HTTP edge (ADR-013), not exceptions.
Applying the filter at controller level covers every action on that controller, including ones
inherited from EntityControllerBase / AggregateRootEntityControllerBase. Adding it is therefore an
audit of the whole controller, not just of the routes that motivated it.
Adoption. MMCA.Store wires both in production. The filter guards
MMCA.Store/.../Sales.API/Controllers/ShoppingCartsController.cs:50 and
MMCA.Store/.../Identity.API/Controllers/CustomersController.cs:35 as a [ServiceFilter]. The
ownership specification scopes list/get queries:
ShoppingCartsController builds a ShoppingCartByCustomerSpecification
(MMCA.Store/.../Sales.API/Controllers/ShoppingCartsController.cs:65-67,
MMCA.Store/.../Sales.Application/ShoppingCarts/Specifications/ShoppingCartByCustomerSpecification.cs:19,
which filters by Id because a cart is keyed by customer, ShoppingCartByCustomerSpecification.cs:24)
and hands it to every read through one GetReadSpecificationAsync override
(ShoppingCartsController.cs:200-202), so the list, the paged list, the by-id read and every page the CSV
export streams are narrowed by the same expression rather than by a copy per action.
OrdersController builds an OrdersByCustomerSpecification through a private
GetOwnershipSpecification() method
(MMCA.Store/.../Sales.API/Controllers/OrdersController.cs:66-68,
MMCA.Store/.../Sales.Application/Orders/Specifications/OrdersByCustomerSpecification.cs:13, filtering
by CustomerId, OrdersByCustomerSpecification.cs:18), passed as specification: GetOwnershipSpecification() into each query (OrdersController.cs:102, OrdersController.cs:140,
OrdersController.cs:174) and into its CSV export through a GetExportSpecification override
(OrdersController.cs:241-242).
The export itself fails closed on a null scope: EntityControllerBase.ExportAsync refuses with
Error.Forbidden when the read specification is null and the controller has not opted in through
AllowUnscopedExport
(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/EntityControllerBase.cs:273-274,
:608-613), which defaults to false (EntityControllerBase.cs:508). Both Sales controllers opt in
only for the bypass role, AllowUnscopedExport => OwnershipHelper.IsAdmin(currentUserService, RoleNames.Admin) (ShoppingCartsController.cs:209, OrdersController.cs:249), so an admin exports
the whole table and a non-admin with an unresolvable claim still gets a 403. CustomersController
opts in unconditionally (CustomersController.cs:48), because its export is already gated by
[HasPermission(IdentityPermissions.CustomersManage)] and its holders list every customer unscoped.
OrdersController does not use the class-level filter for its
mutating routes; it runs an explicit per-mutation ownership check, a private ValidateOwnershipAsync
(OrdersController.cs:386-402) that delegates to OwnershipHelper.ValidateOwnershipAsync
(OrdersController.cs:390), which lets the bypass role through via IsAdmin
(OwnershipHelper.cs:146). The only ownership logic the controller supplies is the existence predicate
(OrdersController.cs:394-396). Its two denial branches return different statuses on purpose:
- Missing owner claim (the caller carries no
customer_id, checked atOwnershipHelper.cs:151-153):Error.Forbidden, a 403 (OwnershipHelper.cs:174-175). Nothing was looked up, so there is no resource whose existence a 403 could leak; this matches the filter's own missing-claimForbidResult. - Owner mismatch (the claim is present but the order is someone else's, the existence check at
OrdersController.cs:394-396):Error.NotFound, a 404 rather than a 403 (OwnershipHelper.cs:167-171), so the response does not reveal that another customer's order exists.
A null specification means two different things, so the collection reads gate on it. Store's two
row-scoped controllers each carry a private RequireResolvableOwner() wrapper
(MMCA.Store/.../Sales.API/Controllers/ShoppingCartsController.cs:79-84, OrdersController.cs:80-85)
that delegates to the shared OwnershipHelper.RequireResolvableOwner (OwnershipHelper.cs:91-104) and
maps a failure through HandleFailure.
OwnershipHelper.GetOwnershipSpecification returns null both for an admin (scoping deliberately
skipped) and for a non-admin whose customer_id claim cannot be resolved, and only the first may query
unscoped. The gate lets the bypass role and any caller with a resolvable claim through, and answers
everyone else with Error.Forbidden, a 403 through the same Result/HTTP edge as the rest
(OwnershipHelper.cs:101-103, :174-175). It runs on every collection read and
on the CSV export (ShoppingCartsController.cs:100, :120, :182; OrdersController.cs:95, :129,
:166, :229). This is the concrete mitigation for the "claim-based ownership trusts the token"
trade-off below: claim-less Customer tokens are issuable in practice, because customer linking on
registration can fail without failing the registration itself, so "no claim" is treated as deny rather
than as no scoping.
Every host that applies the filter configures its vocabulary, because the bypass role is required
configuration rather than a framework default. MMCA.ADC's Engagement module is the worked example.
AddModuleEngagementAPI
(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/DependencyInjection.cs:43) calls
services.Configure<OwnerOrAdminFilterOptions>(...) (DependencyInjection.cs:45) to point the shared
filter at ADC's own ownership terms: ClaimTypes.NameIdentifier as the owner claim
(DependencyInjection.cs:53), its own Organizer role constant as the bypass role
(DependencyInjection.cs:54), and the userId
query argument its Bookmarks list endpoints bind (DependencyInjection.cs:55). ADC's token carries the
user id in sub alone; the JWT bearer handler maps inbound sub onto ClaimTypes.NameIdentifier, so
that is the type the principal the filter inspects actually carries (DependencyInjection.cs:47-52).
The filter type is unchanged; only the options differ, which is exactly what the options object exists
for. The Bookmarks
delete keeps a separate DB-backed inline ownership check that returns 404-not-403 (the same
per-mutation, existence-hiding pattern Store's OrdersController uses).
The deny-by-default audit. Both Store controllers apply the filter at class level, so every action
on them was reviewed and the ones with no owner parameter now carry [AllowMissingOwner] with the
guard that replaces the check named at each site:
| Action | Guard that replaces the parameter check |
|---|---|
ShoppingCartsController.GetAllAsync (both overloads, :94, :109) |
ShoppingCartByCustomerSpecification through GetReadSpecificationAsync already narrows the rows to the caller, plus the RequireResolvableOwner() gate (:100, :120) |
ShoppingCartsController.GetAllForLookupAsync (:139) |
[HasPermission(SalesPermissions.ShoppingCartsManage)] (:138) |
ShoppingCartsController.ExportAsync (:174) |
the same GetReadSpecificationAsync scoping the list endpoints read, plus the RequireResolvableOwner() gate (:182) |
CustomersController.GetAllAsync (both overloads, :56, :68), GetAllForLookupAsync (:85) |
[HasPermission(IdentityPermissions.CustomersManage)] (:55, :67, :84) |
CustomersController.ExportAsync (:117) |
[HasPermission(IdentityPermissions.CustomersManage)] (:116) |
CustomersController.DeleteAsync (:151) |
[HasPermission(IdentityPermissions.CustomersManage)] (:149, [AllowMissingOwner] at :150), so an owner cannot plain soft-delete their Customer row and skip the anonymizing erasure (ADR-005, :143-146) |
Store states each of those guards as a capability, never as a role name: an endpoint requires what it does and the module's grant table decides who holds it (ADR-020).
CustomersController.CreateAsync was inherited without its own policy and had been relying on the
filter failing open. Deny-by-default closes that, but only incidentally, because the action happens to
carry no owner parameter; it now states its own guard,
[HasPermission(IdentityPermissions.CustomersManage)] (CustomersController.cs:134) beside the
[AllowMissingOwner] opt-out (CustomersController.cs:135), matching the admin-gated create page that
is its only caller (CustomersController.cs:127-132). ADC's BookmarksController needs no annotation:
both filtered actions bind a [Required] non-nullable userId, so model validation rejects a missing
value before the filter runs.
Rationale
- Reject-one and filter-many are genuinely two mechanisms. A single-resource route has an id to compare, so a short action filter that 403s on a mismatch is the cheapest correct guard. A collection route has no id; narrowing it means pushing a predicate into the query, which a filter cannot do without re-running the query itself. Forcing both through one abstraction would either over-fetch then post-filter (leaky, and breaks paging counts) or fail to scope lists at all.
- Ownership lives beside RBAC, not inside it. Role/permission resolution (ADR-020) is a property of the principal; ownership is a relation between the principal and a specific row. Keeping them separate lets a route compose both (require a capability and own the resource) without either model growing a resource-condition concept it was not designed for.
- A
Specificationcomposes with the existing pipeline. The row-scope is expressed as aSpecification<TEntity, TId>whoseCriteriais an EF-translatable expression (Specification.cs:9,Specification.cs:23), so it slots intoIEntityQueryServicealongside filtering, sorting, paging, and projection (and can beAnd-composed with other specs,SpecificationExtensions.cs:54, building anAndSpecification,Specification.cs:81) rather than introducing a parallel query path.
Trade-offs
- Opt-in per controller/handler. Neither point is automatic: a controller that forgets the
[ServiceFilter]or omits the ownership spec from a query leaks across customers, the same audit-the-inventory caveat as ADR-019 / ADR-020 / ADR-021. The two-enforcement-point split also means one route can guard mutations but forget to scope its list (or vice versa). - Claim-based ownership trusts the token. Both points key on the configured owner claim
(
customer_idby default) being present and correct, so their correctness depends entirely on the upstream token validation (ADR-004); a missing claim 403s (filter) or yields anullspec (helper, which for a non-admin returnsnulland therefore no scoping, so callers must not treat a missing claim as "admin"). Store's two row-scoped controllers close that with theRequireResolvableOwner()gate recorded in Adoption, but the helper's return value is still ambiguous by itself (OwnershipHelper.cs:46-52), so every new collection read still opts in to the gate rather than inheriting it; the opt-in is one call to the sharedOwnershipHelper.RequireResolvableOwner, not a copy kept per controller. - The filter assumes the owner parameter equals the owning id.
OwnerOrAdminFiltercompares its configured owner parameter, resolved from either a route value or a model-bound argument, against the configured owner claim (OwnerOrAdminFilter.cs:73). That holds where the resource is keyed by the owner (the cart, the customer profile, a user's own bookmarks) but not where a resource has a separate id and a foreign-key owner; those (orders) need the spec or an explicit per-id check instead. - This is ownership, not ABAC. It answers "is this row mine" against a single id claim with an admin override; it does not evaluate arbitrary resource attributes, hierarchies, or delegated access. A richer policy would be a different mechanism, not a parameter on this one.
Related
ADR-020 (the role/permission RBAC layer this complements, and whose explicit
020-permission-based-authorization.md:165-166 scope-out this fills), ADR-034 (the generic entity query
pipeline / IEntityQueryService the collection-scoping Specification slots into), ADR-013 (failures
surface as Result/HTTP at the edge, the filter as a 403 ForbidResult), ADR-004 (the validated
principal and owner claim both enforcement points trust), ADR-078 (the CSV export endpoint, whose
per-controller scoping hook is what lets an export inherit the same ownership specification as the list
it mirrors).
Revision (2026-07-25)
An audit against the code. No behavior changed; the ADR text did.
- The per-mutation check's failure shape was described as one branch, and it is two.
ValidateOwnershipAsyncwas recorded as deliberately returning 404 rather than 403. That is true only of the owner-mismatch branch. The missing-claim branch returnsError.Forbidden(403), which leaks nothing because no lookup has happened yet. Adoption now states each branch separately. - Refreshed line anchors for
OwnerOrAdminFilter(the class doc comment grew and the deny-by-default[AllowMissingOwner]fallback was inserted between the claim check and the owner comparison, so the mismatch citations moved further than the rest),ICurrentUserService.Role, both Store ownership specifications,OrdersController, and the ADR-020 carve-out quote.
Revision (2026-08-01)
Anchor-only correction. No behavior changed; OrdersController was refactored (a constructor
parameter added, GetOwnershipSpecification() and the IsAdmin property extracted,
ValidateOwnershipAsync moved) since the 2026-07-25 pass, which shifted every line anchor pointing
into it. Refreshed: GetOwnershipSpecification() (now OrdersController.cs:64-66), its three call
sites (OrdersController.cs:105, :143, :177), ValidateOwnershipAsync (now
OrdersController.cs:431) and the IsAdmin property it reads (OrdersController.cs:62), the
missing-owner-claim Error.Forbidden branch (OrdersController.cs:439, :441-446), and the
owner-mismatch Error.NotFound branch (OrdersController.cs:454, :454-456).
OrdersByCustomerSpecification.cs:13 and :18 were re-checked and are unchanged.
Revision (2026-08-31)
Behavior changed on both adopters since the 2026-08-01 pass, so this is not an anchor refresh.
- The ambiguous
nullspecification is now gated.OwnershipHelper.GetOwnershipSpecificationreturnsnullfor an admin and for a non-admin whose owner claim cannot be resolved, and Store's collection reads used to run unscoped in both cases. Both row-scoped controllers now carry aRequireResolvableOwner()gate that 403s the second caller (ShoppingCartsController.cs:80,OrdersController.cs:78-88). Recorded in Adoption and referenced from the claim-based-ownership trade-off it answers. - The Store guards are capabilities, not a role policy. The deny-by-default table named a
RequireAdminpolicy; that identifier no longer exists anywhere in MMCA.Store source. The annotated actions state[HasPermission(SalesPermissions.ShoppingCartsManage)]and[HasPermission(IdentityPermissions.CustomersManage)]instead (ADR-020), includingCustomersController.CreateAsync(CustomersController.cs:131). - The table was two rows short. Both CSV exports carry
[AllowMissingOwner]and were missing:ShoppingCartsController.ExportAsync(:178), guarded by theGetReadSpecificationAsyncrow scoping plus the fail-closed gate, andCustomersController.ExportAsync(:110), guarded by the customer-management capability. Sales exports read the caller's own ownership specification throughGetReadSpecificationAsync(ShoppingCartsController.cs:206) andGetExportSpecification(OrdersController.cs:244-245), so an export matches the list endpoint it mirrors (the ADR-078 follow-up); the Customers export stays capability-gated, because its list endpoints are not row scoped and there is no ownership specification to reproduce. - ADC's owner claim is
ClaimTypes.NameIdentifier, notuser_id. The token carries the user id insubalone and the JWT bearer handler maps it ontoClaimTypes.NameIdentifier, which is what the Engagement module configures (DependencyInjection.cs:53). Theuser_idwording was wrong, not merely mis-anchored. - Refreshed anchors: the ADR-020 carve-out quote (now
020-permission-based-authorization.md:92-93, and the staleADRs/path prefix dropped, since the ADRs are canonical underWebsite/docs-src/adr/),CustomersController[ServiceFilter](:34),ValidateOwnershipAsync(OrdersController.cs:414) with itsError.Forbidden(:422,:424-428) andError.NotFound(:431-433,:437-439) branches, and the ADC Engagement registration (DependencyInjection.cs:43,:45,:54,:55).
Revision (2026-09-10)
ADC adopts both enforcement points, so the "Engagement Bookmarks only" framing above is no longer the whole inventory. The Adoption text describes ADC through the action-filter half alone; its Conference module carries the row-level half as well, on the canonical shape rather than an inline variant of it.
- Two Conference controllers resolve the specification through the shared helper.
EventQuestionAnswersControlleroverridesGetExportSpecification()and resolves it throughOwnershipHelper.GetOwnershipSpecification(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Events/EventQuestionAnswersController.cs:85-90, the call at:86), andSessionQuestionAnswersControllerdoes the same (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionQuestionAnswersController.cs:107-112, the call at:108). Both take the two-generic overload inMMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnershipHelper.cs:34, yielding anOwnedByUserSpecificationfor an attendee andnullfor an Organizer. BecauseEntityControllerBase.GetReadSpecificationAsyncdefaults toGetExportSpecification, the override scopes every read on those controllers, not only the export. - Both fail closed on an unresolvable owner. Each controller carries the same private
RequireResolvableOwner()gate Store's row-scoped controllers use (EventQuestionAnswersController.cs:104-117, called at:126,:144,:155and:169;SessionQuestionAnswersController.cs:126-139, called at:148,:166,:177and:191). That is the answer to the ambiguousnullrecorded in the 2026-08-31 revision: the helper returnsnullfor an Organizer bypass and for a claim-less non-Organizer, and only the first may read unscoped, so the second gets 403 instead of an unscoped read. - The gate is a per-controller method, not framework code.
RequireResolvableOwnerexists four times, once per row-scoped controller:MMCA.Store/.../OrdersController.cs:82,MMCA.Store/.../ShoppingCartsController.cs:80, and ADC's two above. Nothing in MMCA.Common declares it. Four identical copies of a fail-closed authorization gate is the kind of duplication that eventually diverges in one copy, so it is named here as a candidate for extraction rather than as the settled shape. - One Store site is adjacent, not a fifth adopter. Catalog's
ReviewsControllerusesOwnershipHelper.IsAdmin(MMCA.Store/.../ReviewsController.cs:72) and scopes through its ownResolveOwner()(:373-374) rather than throughGetOwnershipSpecification, so it belongs to the helper's audience without being an instance of the row-level pattern this record describes.
Revision (2026-10-01)
The fail-closed gate this record named as a candidate for extraction (Revision 2026-09-10, item 3) is
now framework code. MMCA.Common v1.216.0 (MMCA.Common/CHANGELOG.md:26) adds two members to
OwnershipHelper: RequireResolvableOwner<TId> (OwnershipHelper.cs:91-104), the "bypass role or
resolvable owner claim" gate that answers the ambiguous null with a 403, and
ValidateOwnershipAsync<TId> (OwnershipHelper.cs:133-157), the per-record check (bypass role passes,
missing claim 403 at :151-153, non-owner 404 at :167-171). The four row-scoped controllers keep a
private RequireResolvableOwner() only as a thin wrapper that delegates to the helper
(ShoppingCartsController.cs:79-84, OrdersController.cs:80-85, and ADC's
EventQuestionAnswersController.cs:128-136 and SessionQuestionAnswersController.cs:128-136, called at
:145, :163, :174, :188 in each), and Store's OrdersController.ValidateOwnershipAsync and
Catalog's ReviewsController (ReviewsController.cs:290) delegate to the helper's per-record check,
so the IsAdmin property on OrdersController no longer exists. This supersedes Revision 2026-09-10,
item 4: ReviewsController no longer calls OwnershipHelper.IsAdmin directly (its only helper use is
ReviewsController.cs:290), and its ResolveOwner() is now declared at ReviewsController.cs:246
(called at :142), so the :72 and :373-374 anchors there are historical. The statuses (403 for a missing claim,
404 for a non-owner) are unchanged. Decision, Adoption and the claim-based-ownership trade-off now
describe the helper. Also refreshed: the OwnershipHelper anchors, the AddAPI registration
(DependencyInjection.cs:85), the filter's application sites (class level in Store, per action on
ADC's BookmarksController.cs:85, :106), the Store controller anchors, the And composition
(SpecificationExtensions.cs:48), and the ADR-020 carve-out quote
(020-permission-based-authorization.md:159-160). The deny-by-default table gains
CustomersController.DeleteAsync (CustomersController.cs:145-148), which carries
[HasPermission(IdentityPermissions.CustomersManage)] and [AllowMissingOwner] so an owner cannot
soft-delete their Customer row without the anonymizing erasure (ADR-005).
Revision (2026-10-06)
An audit against the code. Enforcement behavior is unchanged; one gate the record omitted is now in it.
- The export's unscoped opt-in is recorded.
EntityControllerBase.ExportAsyncrefuses anullread specification withError.Forbiddenunless the controller overridesAllowUnscopedExport(EntityControllerBase.cs:273-274, defaultfalseat:508). Store's two row-scoped controllers opt in for the bypass role only (ShoppingCartsController.cs:209,OrdersController.cs:249) andCustomersControlleropts in unconditionally behind its capability gate (CustomersController.cs:48). ADC's two Q&A controllers do the same with theirOrganizerbypass role (EventQuestionAnswersController.cs:121,SessionQuestionAnswersController.cs:121). Adoption now states it. - Current locations for anchors recorded only in Revision 2026-10-01: the v1.216.0 changelog entry
is
MMCA.Common/CHANGELOG.md:367(under the[1.216.0]header at:350); ADC's privateRequireResolvableOwner()wrappers are atEventQuestionAnswersController.cs:137-138andSessionQuestionAnswersController.cs:137-138, called at:154,:172,:183,:197in each, and theirGetExportSpecificationoverrides are at:109. - Line anchors in the live sections were re-verified against current source and refreshed:
OrdersController.ValidateOwnershipAsync(:386-402, helper call:390, existence predicate:394-396), theCustomersControllerdeny-by-default table andCreateAsyncguard, and theAndcomposition (SpecificationExtensions.cs:54).
Revision (2026-10-07)
Re-verified against current source. Enforcement behavior is unchanged: the filter, the ownership
specification, the fail-closed RequireResolvableOwner<TId> gate and the per-record
ValidateOwnershipAsync<TId> check read as recorded. Only anchors moved.
- Anchors re-verified against current source: the ADR-020 carve-out quote in Context and Related is
now
020-permission-based-authorization.md:165-166; the v1.216.0 changelog entry recorded in Revisions 2026-10-01 and 2026-10-06 isMMCA.Common/CHANGELOG.md:392, under the[1.216.0]header at:375; and the two-genericGetOwnershipSpecification<TSpec, TId>overload the ADC Q&A controllers take (Revision 2026-09-10, item 1) is declared atMMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnershipHelper.cs:35. The liveOwnershipHelperanchors (:18,:21,:46-52,:65,:70,:91-104,:133-157,:167-171,:174-175) are unchanged.