Skip to content

Phạm vi Domain Phân quyền

Quy tắc (2026-06): mỗi route có authorize đều được đánh giá trong một domain (<loại>_<id>). Mặc định domain đó là merchant lấy từ header x-merchant-id. Route thao tác trên một organizer (không phải merchant) phải tự khai báo domain riêng, nếu không phép check sẽ resolve sai phạm vi và trả về 403 sai.

1. Mô hình: mỗi lần check trả lời ba câu hỏi

Câu hỏiTrường trong specVí dụ
Ai?subject (user)user đang đăng nhập
Làm gì?action + resourceupdate trên Organizer.updateById
Ở đâu?domainOrganizer_e734… vs Merchant_00000000…

Một quyền không bao giờ tuyệt đối - nó luôn gắn với một domain. Một user có thể là OWNER trong Organizer_e734… nhưng không có quyền nào trong Merchant_abc…. "Ở đâu" chính là domain, viết dạng <loại>_<id> (vd Organizer_e734…, Merchant_abc…).

Hệ thống chỉ đăng ký đúng hai loại domain: MerchantOrganizer (packages/core/src/application/base.tsdomainTypes).

2. "Merchant check" mặc định đến từ đâu

Domain resolver được cắm một lần, lúc khởi động application trong configureAuthorization() (issuer.ts / verifier.ts), áp cho mọi route của mọi service:

ts
this.bind(AuthorizeBindingKeys.OPTIONS).toValue({
  defaultDecision: AuthorizationDecisions.DENY,
  alwaysAllowRoles: [SUPER_ADMIN, ADMIN, OPERATOR],
  domainResolver: this.getMerchantScopedDomainResolver(), // ← mặc định toàn cục
});

getMerchantScopedDomainResolver() (base.ts) đọc header và tạo merchant domain:

ts
const merchantId = context.req.header('x-merchant-id'); // ACTIVE_MERCHANT_HEADER
if (!merchantId) return null;
return { type: Merchant.AUTHORIZATION_SUBJECT!, id: merchantId }; // → "Merchant_<id>"

Vì sao merchant là mặc định: đơn vị multi-tenant vật lý của nền tảng là Merchant (MST / HĐĐT / ví). ~95% resource (sản phẩm, kho, đơn hàng, thu chi) thuộc về một merchant cụ thể, nên scope merchant là đúng cho gần như mọi thứ. Organizer (branding cấp tổ chức, nằm trên merchant) là ngoại lệ hiếm.

00000000-0000-0000-0000-000000000000 là hằng số SYSTEM_MERCHANT_ID (packages/core/src/utilities/request.utility.ts) - nghĩa "chưa chọn merchant". FE gửi giá trị này ở màn cấp-tổ-chức (không có context merchant). Nó vẫn resolve thành một domain (Merchant_00000000…) không khớp grant thật nào.

3. Cách resolve domain (thứ tự ưu tiên)

resolveRequestDomain() (packages/core/src/components/auth/authorize/common/resolve-request-domain.ts) chọn domain theo thứ tự:

  1. spec.domain per-route - khai báo { from, key, type } hoặc một resolver method. Thắng khi có mặt.
  2. domainResolver toàn cục - mặc định merchant ở §2.
  3. SYSTEM_WIDE - không khớp gì.

Cả đầu request (resolveRequestDomain) lẫn đầu lưu grant (AuthorizationPolicyBuilder.serializeDomain) đều serialize một typed domain y hệt nhau bằng [type, id].join('_'). Cùng công thức → token khớp khi (và chỉ khi) route resolve đúng phạm vi mà grant đã được lưu.

4. Cái bẫy: route cấp-org âm thầm thừa kế scope merchant

Một route bị sai scope khi đồng thời:

  • authorize (không phải { skip: true }), và
  • người gọi không thuộc always-allow role (SUPER_ADMIN / ADMIN / OPERATOR), và
  • nó thao tác trên một organizer (grant nằm ở Organizer_<id>), nhưng
  • không khai báo domain per-route → rơi xuống merchant resolver.

Hậu quả: request hỏi Casbin về quyền trong Merchant_<header>, trong khi grant của user nằm ở Organizer_<id>không khớp → 403 sai, dù user thực sự sở hữu organizer đó.

Các route đọc của OrganizerController (find/findById/count/findOne) né bẫy này bằng authorize: { skip: true } (chỉ authenticate) - một workaround có sẵn. Các route ghi thì không, nên lỗ hổng lộ ra ở đó.

5. Bản fix: khai báo organizer domain per-route

Với route thao tác một organizer đã tồn tại qua path param, scope theo organizer thay vì merchant:

ts
updateById: {
  request: { body: UpdateByIdOrganizerRequest, headers: MerchantScopedRequestHeaders },
  authorize: {
    action: AuthorizationActions.UPDATE,
    resource: OrganizerPermissions.UPDATE_BY_ID.code,
    domain: { from: 'param', key: 'id', type: Organizer.AUTHORIZATION_SUBJECT! }, // ← ghi đè
  },
},

from: 'param' đọc id từ URL (/organizers/{id}), type: 'Organizer' → domain resolve Organizer_<id>, khớp với grant per-org của OWNER. Mặc định merchant toàn cục không bị động đến (vẫn đúng cho mọi route khác).

6. Audit - các route cấp-org (2026-06-25)

Dấu hiệu xác định route cấp-org là permission có subject: Organizer.AUTHORIZATION_SUBJECT. Đúng hai controller mang dấu hiệu này:

ControllerRouteParamScope hiện tạiTrạng thái
commerce · organizer.controller.tsupdateById, deleteByIdidOrganizer_<id>Đã fix (domain per-route)
commerce · organizer.controller.tsfind, findById, count, findOne-n/aauthorize: { skip: true } (route đọc, chỉ authenticate)
commerce · organizer.controller.tscreate, onBoarding, aggregate- (tạo org mới)Merchant_<header>⚠️ Vấn đề riêng - quyền tạo org, chưa có organizer id để scope; verify độc lập
identity · policy-definition/organizer/definitions.tsFIND / COUNT / MANAGE_ORGANIZER_TARGETSorganizerIdOrganizer_<organizerId>Đã fix 2026-06-25 (domain per-route; organizerId là UUID thuần, không có nhánh slug)

Không package nào khác định nghĩa permission subject Organizer, và ngoài các route đã fix ở trên, không route nào khai báo domain per-route. Mọi thứ còn lại được scope theo merchant đúng thiết kế và hoạt động tốt với mặc định toàn cục.

Các route membership của identity vẫn kiểm tra quyền sở hữu org ở tầng service (_validateMembershipAuthority đọc organizers[] trong JWT). Khi domain đã scope đúng, check ở service trở thành lớp phòng-thủ-thêm bên trên quyết định của Casbin, không còn là cổng duy nhất.

Ngoại lệ findById (commerce/organizer): cố ý giữ authorize: { skip: true }. Param :id của nó đa hình - findByIdentifier nhận UUID hoặc slug - nhưng domain được resolve từ param thô trước khi handler chạy, nên slug sẽ ra Organizer_<slug> và không bao giờ khớp grant key theo UUID (403 sai). Muốn scope phải dùng resolver method canonicalize slug→UUID; không đáng cho một route đọc, nên để authenticate-only.

7. Checklist cho route có authorize mới

  • Resource scope theo merchant (mặc định, ~95%): không cần làm gì - resolver toàn cục + x-merchant-id là đúng.
  • Resource scope theo organizer, thao tác một org đã có qua param: thêm domain: { from: 'param', key: '<idParam>', type: Organizer.AUTHORIZATION_SUBJECT! }.
  • Không có domain nào (thuần cấp user/hệ thống): dùng authorize: { skip: true } (chỉ authenticate) và ghi rõ lý do, theo mẫu các route đọc của Organizer.
  • Đừng bao giờ dựa vào mặc định merchant cho resource cấp-org - nó fail-closed (403) ngay khi người gọi không phải admin always-allow.

Proprietary and Confidential. Unauthorized copying, distribution, or use of this software is strictly prohibited.