--- name: permission-matrix description: Hệ thống phân quyền Role × MenuKey × CRUD (12 root + Ct_*/Wf_*/Pe_*/PeWf_*/Catalogs — số menu-key · policy canonical → docs/STATUS.md, KHÔNG chép số ở đây). FE PermissionGuard + usePermission. BE AuthorizationHandler + policy `{menu}.{action}`. Dùng khi debug access denied, gán role, menu không hiện, inheritance không work. when-to-use: - "permission denied" - "access denied" - "menu không hiện" - "gán role cho user" - "seed permission" - "permission matrix edit" - "menu inheritance không work" --- # Permission Matrix Skill > **Status (cập-nhật 2026-07-15 — S122 W4):** base Phase 1 đợt 2 + extended qua mọi phase. > 🔴 **Số menu-key · số policy = canonical ở [`docs/STATUS.md`](../../../docs/STATUS.md)** (row `Menu keys` / `Policies`) — **KHÔNG chép số vào file này** (B1). Policy = **DERIVED**: `|MenuKeys.All| × |Actions|` (`Api/Program.cs`) ⇒ đổi menu **BUỘC** đổi cả 2 row cùng lúc. > 🔍 **Đếm THẬT = đọc mã, đừng tin doc:** `MenuKeys.All` ở `src/Backend/SolutionErp.Domain/Identity/MenuKeys.cs:147`. > > **Nhóm menu (hình-dạng, KHÔNG phải con-số):** > - Core: Dashboard / Master+3 leaves / Forms / Reports / System+Users/Roles/Permissions > - Contracts root + `Ct_*` (7 type × {Group/List/Create/Pending}) + Workflows root + `Wf_*` > - PurchaseEvaluations root + `Pe_*` (2 type × 3 action) + PeWorkflows root + `PeWf_*` > - Catalogs group + 4 leaves (Units/Materials/Services/WorkItems) > - Office/HRM/… — xem `MenuKeys.cs` (mã là nguồn) > - 🧊 ~~Budgets root + `Bg_*`~~ — **XOÁ S61 (Mig 50)**, module Budget cũ thay bằng `PeWorkItemBudgets` (ngân-sách per-gói-thầu). Bia-mộ: `MenuKeys.cs:70` + `fe-{admin,user}/src/lib/menuKeys.ts:29`. *(Skill này liệt `Bg_*` như menu ĐANG SỐNG suốt từ S61 → S122 — stale **nặng hơn** lệch con-số vì nó mô-tả thứ **không tồn tại**; vá @S122 W4.)* > > **Inheritance roots (4 group, gotcha #35):** `Contracts` → `Ct_*`, `Workflows` → `Wf_*`, `PurchaseEvaluations` → `Pe_*`, `PeWorkflows` → `PeWf_*`. Thêm root mới có children → **PHẢI extend 3 chỗ** trong `GetMyMenuTreeQuery` (gotcha #35). 🔴 **`KeHoachKyKet` (KHKK, +S161) CỐ Ý KHÔNG inherit** — switch `GetMyMenuTreeQuery.cs:71-74` vẫn đúng 4 case; 6 leaf `Khkk_*` grant TỪNG DÒNG riêng (seeder `DbInitializer.cs:2107` grant root + 6 leaf explicit, chạy SAU revoke để THẮNG). Đừng đọc "root mới" thành "tự inherit như `Ct_*`". *(🧊 câu cũ "Budgets KHÔNG inherit (Bg_* phải grant tay)" — gỡ theo module.)* ## Model ``` User ────< UserRoles ────< Role ────< Permissions ────< MenuItem (RoleId, MenuKey, CRUD flags) ``` - 1 User có N Role (qua `AspNetUserRoles` rename → `UserRoles`) - 1 Role có N Permission (1 row per MenuKey × 4 CRUD flag) - Union (OR) nhiều role → user có quyền nếu **bất kỳ role nào** cho quyền đó - Admin role → **bypass** check (luôn pass mọi policy) ## Menu tree (seed — số key SỐNG → `docs/STATUS.md` "Menu keys", B1 KHÔNG chép số ở đây; nguồn mã = `MenuKeys.cs` + `DbInitializer.cs`) ``` Dashboard Master ├── Suppliers ├── Projects ├── Departments └── Catalogs (group) ├── UnitsOfMeasure ├── MaterialItems ├── ServiceItems └── WorkItems Contracts (root inherit) └── Ct__ × 7 type = 28 leaf Forms PurchaseEvaluations (root inherit) └── Pe__ × 2 type = 6 leaf KeHoachKyKet (root — const VÀO `All` @S161 W1 ⇒ sinh 4 policy KeHoachKyKet.{R,C,U,D}; Mig 69 KHKK) └── Khkk_G1 "Kế hoạch ký kết HĐ (NCC-TP)" (DbInitializer.cs:1780-1787) ├── Khkk_WfView Luồng duyệt ├── Khkk_List Danh sách ├── Khkk_Create Thao tác ├── Khkk_Pending Đang duyệt ├── Khkk_Approved Đã duyệt └── Khkk_Deleted Đã xóa Reports System ├── Users ├── Roles ├── Permissions ├── Workflows (root inherit) │ └── Wf_ × 7 type = 7 leaf └── PeWorkflows (root inherit) └── PeWf_ × 2 type = 2 leaf ``` > 🧊 ~~`Budgets (root, NO inherit)` + `Bg_List/Bg_Create/Bg_Pending`~~ — **XOÁ S61 (Mig 50)** cùng module Budget; cây trên từng liệt nó như ĐANG SỐNG tới S164 (cùng bệnh header :26 đã vá @S122 — vá header quên vá CÂY = dư-lượng bất-đối-xứng). > 🔸 `HopDongCung` (GĐ4 bản-cứng): const CÓ trong `MenuKeys.cs:43` nhưng **NGOÀI `All`** ⇒ **0 policy · 0 menu-row** tới khi có module (1-mốc theo owner). ĐỪNG liệt nó vào cây key sống. > 🔴 Gate FE cho KHKK: dùng **đúng key policy của endpoint** (`KeHoachKyKet`) — KHÔNG OR `Khkk_*` key con (gotcha **#85**: menu-hiện rộng hơn API-cho ⇒ 403 rải UI; `MenuPermissionHandler.cs:40` so khớp CHÍNH XÁC 1 key, không kế-thừa). Tree hierarchy qua `ParentKey` field. Seed trong `DbInitializer.SeedMenuTreeAsync` + Pe/Wf/Bg seeders riêng. ## Code pointers **Backend:** - `Domain/Identity/MenuKeys.cs` — const class, single source of truth - `Domain/Identity/MenuItem.cs` — entity (Key PK, Label, ParentKey, Order, Icon) - `Domain/Identity/Permission.cs` — entity (RoleId, MenuKey, 4 flag) - `Application/Permissions/Queries/GetMyMenuTree/GetMyMenuTreeQuery.cs` — resolve per-user, union OR, filter tree - `Application/Permissions/PermissionFeatures.cs` — list/upsert - `Api/Authorization/MenuPermissionRequirement.cs` + `MenuPermissionHandler.cs` — policy check - `Api/Program.cs` — register policy `{menu}.{action}` trong AddAuthorization (**số = `|MenuKeys.All| × |Actions|` DERIVED**; canonical → `docs/STATUS.md` row `Policies` — KHÔNG hardcode ở đây) - `Infrastructure/Persistence/DbInitializer.cs` — `SeedMenuTreeAsync` + `SeedAdminPermissionsAsync` - `Api/Controllers/MenusController.cs`, `RolesController.cs`, `PermissionsController.cs` **Frontend (fe-admin):** - `src/lib/menuKeys.ts` — const mirror, cần **đồng bộ tay** với BE - `src/types/menu.ts` — MenuNode type - `src/hooks/usePermission.ts` — `can(menuKey, action)` helper - `src/components/PermissionGuard.tsx` — wrap button/content - `src/components/Layout.tsx` — render sidebar động từ AuthContext.menu - `src/pages/system/PermissionsPage.tsx` — ma trận edit UI - `src/contexts/AuthContext.tsx` — `loadMenu()` on login + localStorage cache ## BE policy usage Register trong Program.cs: ```csharp services.AddAuthorization(opts => { foreach (var menu in MenuKeys.All) foreach (var action in MenuKeys.Actions) opts.AddPolicy($"{menu}.{action}", p => p.Requirements.Add(new MenuPermissionRequirement(menu, action))); }); services.AddScoped(); ``` Apply ở controller: ```csharp [HttpPut("{id:guid}")] [Authorize(Policy = "Contracts.Update")] public async Task Update(...) { } ``` ## FE guard usage ```tsx // Hook const { can } = usePermission() if (!can('Contracts', 'Update')) return null // Component wrap // Route guard }> } /> ``` ## Workflow — gán quyền cho role mới 1. Admin login → `/system/permissions` 2. Chọn role (vd "CostControl") 3. Tick checkbox trên matrix grid — mỗi lần tick tự động PUT `/api/permissions` upsert 4. User thuộc role đó logout/login lại → thấy permission mới (menu refresh từ `/api/menus/me`) ## Guard rules đã implement - **Admin bypass:** role `Admin` luôn pass mọi policy (kể cả chưa seed row Permission) - **Not user active:** `User.IsActive=false` → AuthorizationHandler return fail - **Self-demote protection:** admin đang edit không thể giảm quyền role Admin (check trong `UpsertPermissionCommandHandler`) ## Common pitfalls - **Quên refresh menu sau update permission** → user thấy menu cũ. Giải pháp: logout/login, hoặc Phase 3 thêm SignalR push. - **MenuKey typo** — TS không check vì menu.key là string. Luôn dùng `MenuKeys.Contracts` const, không hardcode `"Contracts"`. - **FE cache menu trong localStorage** → sau user được assign role mới, FE thấy menu cũ. Login lại fix. - **Hai role conflict** (1 cho, 1 cấm): union OR → có ít nhất 1 role cho là được. - **403 ở API nhưng FE không hide button** → FE guard chỉ UX, BE phải là source of truth. Phải apply `[Authorize(Policy = "X.Y")]` ở controller. ## Phase tiếp theo - **Phase 3:** SignalR notify khi permission đổi → FE tự refetch `/api/menus/me` - **Phase 4:** Per-user override (ngoài role) — thêm bảng `UserPermissionOverrides` - **Phase 4:** Invalidate JWT khi role đổi (rare event, nhưng secure)