Casbin Authorization (chi tiết runtime) v1.1.0
Một request được phân quyền lúc chạy ra sao: Casbin model, adapter domain theo merchant, luồng request → quyết định, role bypass, và cache enforcer.
Phạm vi. Trang này mô tả cách hiện thực. Mô hình dữ liệu policy (PolicyDefinition, role, API) xem RBAC & Policy Definitions; quyết định kiến trúc xem ADR-0002; ảnh chụp grant theo role hiện tại xem Ma trận Phân quyền.
Các thành phần
| Thành phần | File | Vai trò |
|---|---|---|
| Casbin model | core/src/security/casbin-model.ts | Định nghĩa request/policy/matcher |
| Policy adapter | core/src/security/application-casbin-adapter.ts | Nạp policy của user từ PolicyDefinition vào Casbin |
| Active-merchant resolver | core/src/utilities/request.utility.ts | Đọc x-merchant-id → domain của request |
| Wiring enforcer | core/src/application/{verifier,issuer}.ts | normalizePayloadFn, kết nối cache, alwaysAllowRoles |
| Enforcer (framework) | @venizia/ignis CasbinAuthorizationEnforcer | Chạy enforce() mỗi request, cache theo user |
| Cache redis | core/src/components/cache-redis.component.ts | Backend cho cache policy của enforcer |
Identity là issuer JWKS (IssuerApplication); mọi service khác là verifier (VerifierApplication). Cả hai wire cùng một Casbin model + adapter.
Model
core/src/security/casbin-model.ts:
[request_definition]
r = sub, dom, obj, act
[policy_definition]
p = sub, dom, obj, act, eft
[role_definition]
g = _, _, _
[policy_effect]
e = some(where (p.eft == allow)) && !some(where (p.eft == deny))
[matchers]
m = g(r.sub, p.sub, r.dom) && keyMatch(r.dom, p.dom) && r.obj == p.obj && r.act == p.act| Token | Ý nghĩa |
|---|---|
sub | Subject — User_<id> hoặc Role_<id> |
dom | Domain — Merchant_<id> trên membership; * (global) trên role-permission policy |
obj | Resource — permission code, vd Organizer.onBoarding |
act | Action — read / create / update / delete / execute |
eft | Effect — allow (mặc định) / deny |
Thiết kế scale — domain nằm ở membership, không phải trên từng permission:
g = _, _, _có domain. Membership nghĩa là "user có role TRONG merchantd". Scope theo merchant nằm ở đây → adapter emit một g-line cho mỗi merchant user giữ role.- Role permission domain-agnostic. Adapter emit
p, Role, *, code, act— một dòng cho mỗi permission, không nhân theo domain.keyMatch(r.dom, "*")(matcher fn built-in) nhận mọi domain; các g-line ràng buộc merchant nào user thực sự giữ role. → kích thước policy/user là membership + permission (tuyến tính), không phải tích. - Role global (vd
001_guest) dùng một membershipg, User, Role, *. Một domain matching function (keyMatch) được đăng ký trên role-defgquaICasbinEnforcerOptions.domainMatching(verifier/issuer), nên membership*khớp mọi domain.
Request được allow khi có ít nhất một policy allow khớp và không có deny khớp.
Luồng request → quyết định
alwaysAllowRoles(super-admin999,admin900,operator600) bỏ qua enforcement hoàn toàn (xemverifier.ts/issuer.ts), không chạm logic domain.resolveActiveMerchanttrảundefinedcho họ.resolveActiveMerchant(request.utility.ts) đọc headerx-merchant-idvà trả về làm active merchant. Validation membership hiện đang comment, nên dùng thẳng giá trị header.normalizePayloadFn(trong verifier/issuer) dựng request Casbin:domain = activeMerchantId ? Merchant_<id> : undefined.
Header x-merchant-id
Mọi request đã xác thực tới verifier service nên kèm x-merchant-id — nó chọn domain merchant đang active để enforce request. Thiếu header này thì request không có domain merchant và chỉ grant domain * (global) mới khớp.
| Khía cạnh | Hợp đồng |
|---|---|
| Định dạng | UUID merchant, vd d01b061a-46a8-4f35-9954-747efade2f3f. |
| Ai gửi | Client (web/mobile) set theo merchant đang chọn; API gateway forward nguyên vẹn. |
| CORS | Phải nằm trong allow-list Access-Control-Allow-Headers, nếu không browser sẽ lược bỏ (đã thêm trong CORS của verifier.ts). |
| Placeholder trước khi chọn merchant | Trước khi chọn merchant client gửi 00000000-0000-0000-0000-000000000000. Giá trị này không khớp domain merchant thật nào — chỉ grant global (*) áp dụng, vì vậy route onboarding/tra-cứu cần role guest. |
| Role bypass | super-admin / admin / operator bỏ qua header — resolveActiveMerchant trả undefined, không enforce. |
| HQ owner | Khi header trỏ tới merchant HQ của một organizer, grant Owner được expand ra mọi merchant của organizer đó (xem HQ-expansion). |
Luồng: resolveActiveMerchant (request.utility.ts) đọc header → normalizePayloadFn biến thành domain request Casbin Merchant_<id> (hoặc undefined khi thiếu/bypass). Merchant id sai hoặc lạ → không có grant nào khớp → 403 (đây là đảm bảo isolation, không phải bug).
Ngữ nghĩa domain
Cột domain trên một row PolicyDefinition quyết định grant/assignment áp dụng ở đâu. Adapter dịch như sau:
Domain emit ra phụ thuộc role và cột domain của row:
| Trường hợp | Ý nghĩa | Domain policy emit ra |
|---|---|---|
Role ∈ GLOBAL_ROLE_IDENTIFIERS (vd 001_guest) | Role global → áp dụng mọi domain, bất kể assignment domain | * |
Merchant_<id> (domain tường minh) | Chỉ merchant đó | Merchant_<id> |
NULL domain | Global assignment → chiếu lên mọi merchant user thuộc về (membership) | một dòng/merchant thành viên |
⚠️ "Role global" và "
NULLdomain" khác nhau. Role global (GLOBAL_ROLE_IDENTIFIERStrongAppFixedRoles) thực sự merchant-agnostic, emit*.NULLdomain trên role thường (owner/employee) nghĩa là "mọi merchant của user" (scope tenant). Đánh dấu owner/employee là global sẽ vỡ isolation — nên globalness là allow-list tường minh trong code, không phải mặc định từ NULL.
PolicyDefinition → Casbin line
PolicyDefinition là bảng cạnh (edge) duy nhất. Adapter đọc các row của user và emit Casbin line.
| Variant | Subject → Target | Casbin line |
|---|---|---|
group | User → Merchant | (membership; cấp cho projection domain NULL) |
group | User → Role | g, User_<u>, Role_<r> |
policy | Role → Permission | p, Role_<r>, <domain>, <code>, <act>, <eft> |
policy | User → Permission | p, User_<u>, <domain>, <code>, <act>, <eft> (direct) |
Adapter
ApplicationCasbinAdapter.loadFilteredPolicy(model, filter) chạy một lần cho mỗi user (rồi enforcer cache ~5 phút). Các bước:
queryUserPolicies— 3 query đơn mục đích, chạy song song (Promise.all):queryMerchantMemberships— rowgrouptớiMerchant→merchantIds[].queryRoleAssignments— rowgrouptớiRole, INNER JOINRole(loại soft-deleted) →{ roleId, domain, identifier }[].queryDirectPolicies— rowpolicytớiPermission, INNER JOINPermission(loại soft-deleted) → grant trực tiếp cho user.
queryRolePermissions— permission cấp cho các role của user.- HQ-owner expansion (
queryHqOwnerOrgMerchants) — nếu user sở hữu merchant headquarter của một organizer, mở rộng role owner sang mọi merchant của organizer đó (kể cả merchant mới tạo). buildGroupPolicyLines— resolve domain của từng role: role global (AppFixedRoles.isGlobalRole) →*; domain tường minh → merchant đó; NULL → membership của user. Emit mộtgline không-domain cho mỗi role, và mộtpline cho mỗi (role-perm × domain). Wildcard*giữ raw quatoDomainVerb.buildDirectPolicyLines— cùng ngữ nghĩa domain cho permission trực tiếp của user.
Ví dụ line emit ra
Owner ở merchant A (assignment domain = A), được grant Material.find:
g, User_u1, Role_owner
p, Role_owner, Merchant_A, Material.find, read, allow→ request (User_u1, Merchant_A, Material.find, read) = allow; request (User_u1, Merchant_B, …) = deny (không có line Merchant_B, không *).
Guest (role global 001_guest, assignment domain bất kỳ), được grant Organizer.onBoarding:
g, User_u1, Role_guest
p, Role_guest, *, Organizer.onBoarding, create, allow→ request (User_u1, Merchant_<bất kỳ>, Organizer.onBoarding, create) = allow (chạy được cả với placeholder x-merchant-id lúc chưa có merchant). * đến từ việc role nằm trong GLOBAL_ROLE_IDENTIFIERS, không phải từ cột domain của assignment.
Role & bypass
| Role | Identifier | Enforcement |
|---|---|---|
| Super Admin / Admin / Operator | 999_* / 900_* / 600_* | alwaysAllow bypass — bỏ qua Casbin |
| Owner | 500_organizer-owner | domain theo merchant (+ HQ expansion) |
| Cashier | 110_cashier | domain theo merchant |
| Employee | 100_employee | domain theo merchant |
| Guest | 001_guest | global (*) — onboarding pre-merchant |
| Customer | 010_customer | không có quyền backend |
alwaysAllowRoles cấu hình trong verifier.ts / issuer.ts qua AuthorizeComponent. Fixed role nằm ở AppFixedRoles (core/src/models/schemas/identity/role/constants.ts).
Cache enforcer
Enforcer cache policy đã nạp của mỗi user (TTL mặc định ~5 phút, key casbin:<principalType>:<userId>). Dùng Redis nếu có, không thì in-memory.
useCacheRedis({ bindingKey })(base application) tạo + bind eager helper redis, vìconfigureAuthorization()wire enforcer ngay trongpreConfigure— trước khi component boot. Client dùnglazyConnect;CacheRedisComponentlàm ấm kết nối sau.getAuthorizationRedisConnection()resolve helper đó cho enforcer; trảundefined(→ in-memory) nếu không có cache redis nào đăng ký.
Hệ quả: đổi permission/role có hiệu lực ở lần cache hết hạn kế tiếp (~5 phút) hoặc lần sign-in kế tiếp — không tức thời.
Lưu ý (gotchas)
- Request pre-merchant. Client gửi
x-merchant-id: 00000000-0000-0000-0000-000000000000khi chưa chọn merchant. Chỉ grant domain*(global) mới khớp ở đó — vì vậy các endpoint onboarding/tra-cứu cần role guest hoặc route authenticate-only. - Permission phải được seed mới grant được. Nếu một route enforce code không có trong catalog
*Permissionscủa module, sẽ không có rowPermission, nên không tạo grant được và route trả 403 cho mọi người (trừ bypass). Hai dạng catalog gap hay gặp:- Sub-module chưa được aggregate. Có const
*Permissionscho sub-feature (vdSalesReportPermissions,AllocationUsagePermissions,ReservationPermissions) nhưng không spread vào catalog module (SalePermissions). Seeder chỉ duyệt catalog đã aggregate nên các code đó không bao giờ được insert → 403. - Grant migration âm thầm no-op. Migration
*-seed-role-permissionscầnRoleRepository/PolicyDefinitionRepository/PermissionRepository; nếuapplication.tscủa service khôngthis.repository(...)đủ 3 repo này thì migration ném"RoleRepository is not bounded"và để lại 0 grant. Route 403 cho mọi role dù row permission đã tồn tại. (Gặp ởpricing— fix bằng cách bind 3 repo trongpricing/src/application.ts.)
- Sub-module chưa được aggregate. Có const
- Soft-delete bị loại bởi INNER JOIN (role/permission) và filter
deletedAt IS NULL.
Liên quan
- RBAC & Policy Definitions — mô hình dữ liệu, role, API
- ADR-0002 Casbin via PolicyDefinition
- Ma trận Phân quyền — grant hiện tại theo role