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ừ headerx-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ỏi | Trường trong spec | Ví dụ |
|---|---|---|
| Ai? | subject (user) | user đang đăng nhập |
| Làm gì? | action + resource | update trên Organizer.updateById |
| Ở đâu? | domain | Organizer_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: Merchant và Organizer (packages/core/src/application/base.ts → domainTypes).
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:
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:
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-000000000000là 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ự:
spec.domainper-route - khai báo{ from, key, type }hoặc một resolver method. Thắng khi có mặt.domainResolvertoàn cục - mặc định merchant ở §2.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:
- có
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
domainper-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ằngauthorize: { 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:
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:
| Controller | Route | Param | Scope hiện tại | Trạng thái |
|---|---|---|---|---|
commerce · organizer.controller.ts | updateById, deleteById | id | Organizer_<id> | ✅ Đã fix (domain per-route) |
commerce · organizer.controller.ts | find, findById, count, findOne | - | n/a | ✅ authorize: { skip: true } (route đọc, chỉ authenticate) |
commerce · organizer.controller.ts | create, 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.ts | FIND / COUNT / MANAGE_ORGANIZER_TARGETS | organizerId | Organizer_<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
identityvẫn kiểm tra quyền sở hữu org ở tầng service (_validateMembershipAuthorityđọcorganizers[]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:idcủa nó đa hình -findByIdentifiernhận UUID hoặc slug - nhưng domain được resolve từ param thô trước khi handler chạy, nên slug sẽ raOrganizer_<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-idlà đú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.