--- name: permission-matrix description: Hệ thống phân quyền Role × MenuKey × CRUD. ~60 menu key (12 root + Ct_*/Wf_*/Pe_*/PeWf_*/Bg_*/Catalogs). FE PermissionGuard + usePermission. BE AuthorizationHandler + ~240 policy. 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 (post Session 6 — 2026-04-30):** Phase 1 đợt 2 base + extended qua mọi phase. ~60 menu key total: > - Core: Dashboard / Master+3 leaves / Forms / Reports / System+Users/Roles/Permissions (12 base) > - Contracts root + 28 Ct_* (7 type × {Group/List/Create/Pending}) + Workflows root + 7 Wf_* > - PurchaseEvaluations root + 6 Pe_* (2 type × 3 action) + PeWorkflows root + 2 PeWf_* > - Budgets root + 3 Bg_* (List/Create/Pending) > - Catalogs group + 4 leaves (Units/Materials/Services/WorkItems) > > **Inheritance roots (4 group, gotcha #35):** `Contracts` → Ct_*, `Workflows` → Wf_*, `PurchaseEvaluations` → Pe_*, `PeWorkflows` → PeWf_*. Khi thêm root mới có children → PHẢI extend 3 chỗ trong `GetMyMenuTreeQuery` (xem gotcha #35). Budgets KHÔNG inherit (Bg_* phải grant tay). ## 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 — ~60 key sau Phase 8) ``` 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 Budgets (root, NO inherit — grant tay) ├── Bg_List ├── Bg_Create └── Bg_Pending Reports System ├── Users ├── Roles ├── Permissions ├── Workflows (root inherit) │ └── Wf_ × 7 type = 7 leaf └── PeWorkflows (root inherit) └── PeWf_ × 2 type = 2 leaf ``` 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 48 policy `{menu}.{action}` trong AddAuthorization - `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)