# Permission Flow — Resolution Menu + CRUD
> **Status:** 📝 Planned (Phase 1 đợt 2)
> **Actors:** System (resolution) | Admin (configure matrix) | Any user (guard apply)
## 1. Mô hình
3 layer resolution:
```
User
└─ có nhiều Role (UserRoles table)
└─ có Permission (Permissions table, row per MenuKey × CRUD)
└─ gắn với MenuItem (tree Key → ParentKey)
```
Quyết định cuối cùng: **union** của tất cả permission từ mọi role của user. Nếu bất kỳ role nào `CanRead=true` cho `MenuKey=Contracts` → user được đọc.
## 2. Ma trận Permission
```
MenuKey Role.Admin Role.Drafter Role.CCM Role.BOD
─────────────────────────────────────────────────────────────
Dashboard R R R R
Contracts CRUD CR (self) RU RU
Suppliers CRUD R R R
Projects CRUD R R R
Users CRUD — — —
Roles CRUD — — —
Permissions CRUD — — —
Reports R — R R
```
(Chi tiết đầy đủ role × menu mapping ở [`../workflow-contract.md §5`](../workflow-contract.md))
## 3. Flow — user login → FE resolve menu
```mermaid
sequenceDiagram
actor U as User
participant FE as Frontend
participant API as MenusController.GetMyTree
participant Q as GetMyMenuTreeQueryHandler
participant CU as ICurrentUser
participant DB
U->>FE: Login thành công (có token)
FE->>API: GET /api/menus/me
API->>Q: Send(GetMyMenuTreeQuery)
Q->>CU: UserId + Roles
CU-->>Q: userId, roles[]
Q->>DB: SELECT Permissions JOIN Roles ON Permissions.RoleId = Roles.Id JOIN MenuItems ON Permissions.MenuKey = MenuItems.Key WHERE Roles.Id IN (user's roles)
DB-->>Q: raw rows
Q->>Q: Group by MenuKey UNION CRUD flags (OR) Build tree (Key → ParentKey)
Q-->>API: MenuTreeDto[] with resolved CRUD per node
API-->>FE: 200
FE->>FE: Cache in AuthContext + localStorage
FE->>FE: Render sidebar (filter nodes có CanRead=true)
```
**Example response:**
```json
[
{
"key": "Dashboard",
"label": "Tổng quan",
"icon": "LayoutDashboard",
"order": 1,
"parentKey": null,
"canRead": true,
"canCreate": false,
"canUpdate": false,
"canDelete": false,
"children": []
},
{
"key": "Master",
"label": "Danh mục",
"icon": "Database",
"order": 2,
"parentKey": null,
"children": [
{
"key": "Suppliers",
"label": "Nhà cung cấp",
"parentKey": "Master",
"canRead": true, "canCreate": true, "canUpdate": true, "canDelete": false,
"children": []
}
]
}
]
```
## 4. Flow — FE guard render
```mermaid
flowchart TD
Start([Component mount]) --> CheckMenu{usePermission can 'Contracts', 'Update'?}
CheckMenu -->|no permission| Hide[Không render nút 'Sửa']
CheckMenu -->|has permission| Show[Render nút 'Sửa']
Show --> ClickEdit[User click 'Sửa']
ClickEdit --> CallAPI[PATCH /api/contracts/:id]
CallAPI --> BEGuard{Controller Authorize 'Contracts.Update'?}
BEGuard -->|no| Reject[403 Forbidden]
BEGuard -->|yes| Proceed[Update DB + return 200]
```
**FE pattern (sẽ implement Phase 1 đợt 2):**
```tsx
// usePermission.ts
export function usePermission() {
const { menu } = useAuth() // menu cached từ login
return {
can: (menuKey: string, action: 'Read' | 'Create' | 'Update' | 'Delete') => {
const node = findInTree(menu, menuKey)
return node?.[`can${action}`] ?? false
},
}
}
// PermissionGuard.tsx
// Route guard
}>
}
/>
```
## 5. Flow — Admin configure permission matrix
```mermaid
sequenceDiagram
actor A as Admin
participant FE as Permission Matrix Page
participant API as PermissionsController
participant M as UpsertPermissionCommandHandler
participant DB
A->>FE: Mở /admin/permissions
FE->>API: GET /api/permissions?roleId={id}
API-->>FE: Array permissions (row per menuKey)
A->>FE: Tick checkbox "Contracts.CanUpdate = true" cho role Drafter
FE->>FE: Optimistic update UI
FE->>API: PUT /api/permissions {roleId, menuKey: "Contracts", canRead, canCreate, canUpdate, canDelete}
API->>M: Send(UpsertPermissionCommand)
M->>DB: SELECT existing Permission WHERE RoleId=? AND MenuKey=?
alt Exists
M->>DB: UPDATE flags
else Not exists
M->>DB: INSERT new row
end
M-->>API: 204
API-->>FE: 204
Note over FE,A: User với role Drafter sẽ thấy nút Update HĐ sau khi họ refresh/re-login (hoặc SignalR notify Phase 3)
```
**Ghi chú quan trọng:**
- Permission update **KHÔNG** real-time đến user đang online ở Phase 1 đợt 2 — họ phải logout/login để thấy.
- Phase 3 có thể thêm SignalR notify "permission changed" → FE auto refetch `/api/menus/me`.
- Phase 4 có thể invalidate JWT khi permission đổi (rare change, nhưng secure).
## 6. Backend guard
```csharp
// Api/Controllers/ContractsController.cs
[HttpPut("{id}")]
[Authorize(Policy = "Contracts.Update")] // custom policy
public async Task Update(Guid id, UpdateContractCommand cmd)
{
// ...
}
```
**Custom policy registration (Program.cs):**
```csharp
services.AddAuthorization(opts =>
{
foreach (var menu in MenuKeys.All)
{
foreach (var action in new[] { "Read", "Create", "Update", "Delete" })
{
opts.AddPolicy($"{menu}.{action}", p =>
p.Requirements.Add(new MenuPermissionRequirement(menu, action)));
}
}
});
services.AddSingleton();
```
**Handler:**
```csharp
public class MenuPermissionHandler : AuthorizationHandler
{
private readonly IApplicationDbContext _db;
// ...
protected override async Task HandleRequirementAsync(...)
{
var userId = context.User.GetUserId();
var hasPermission = await _db.Permissions
.Where(p => p.Role.Users.Any(u => u.Id == userId))
.Where(p => p.MenuKey == req.MenuKey)
.AnyAsync(p => req.Action switch
{
"Read" => p.CanRead,
"Create" => p.CanCreate,
"Update" => p.CanUpdate,
"Delete" => p.CanDelete,
_ => false,
});
if (hasPermission) context.Succeed(req);
}
}
```
## 7. Seed mặc định (Phase 1 đợt 2)
Seed trong `DbInitializer.InitializeAsync`:
1. **Menu tree seed** — từ `MenuKeys.cs` const class (Phase 1 đợt 2):
```
Dashboard
Master
├── Suppliers
├── Projects
└── Departments
Contracts
Forms
Approvals
Reports
System
├── Users
├── Roles
└── Permissions
```
2. **Default permissions:**
- `Admin` role → full CRUD mọi menu
- Các role khác → chỉ `Read` mặc định, admin config thêm sau qua UI
## 8. Edge cases
| Case | Xử lý |
|---|---|
| User có 0 role | Chỉ thấy `Dashboard` (nếu mở cho anonymous), không vào được menu khác |
| Role bị xóa | `FK ON DELETE CASCADE` xóa permissions liên quan |
| Menu bị remove khỏi `MenuKeys.cs` | Seed job cảnh báo + giữ orphan permissions (dev fix manual) |
| Admin tự xóa quyền admin của mình | Check trong UpsertPermissionCommand — nếu target role là Admin + current user đang ở role Admin → `throw ForbiddenException("Không thể tự xóa quyền admin")` |
| User có nhiều role conflict (1 role cho, 1 role cấm) | Union (OR) — có ít nhất 1 role cho là được |
## 9. Performance
- Menu tree cache trong `AuthContext` sau login → không hit API mỗi navigate
- `/api/menus/me` response size ~5KB gzipped (với ~30 menu nodes)
- Authorization handler cache scope request (1 query / request) — EF Core auto cache
- Phase 4 optimize: Redis distributed cache cho permission matrix (nếu >100 concurrent users)
## 10. Testing checklist (Phase 1 đợt 2)
- [ ] Admin login → thấy tất cả menu
- [ ] Tạo user role Drafter only → chỉ thấy menu Contracts + self HĐ
- [ ] Tạo role tùy chỉnh "CCM Reviewer" chỉ read Contracts + read Reports → verify không thấy Master menu
- [ ] User có 2 role (Drafter + Finance) → thấy union của cả 2
- [ ] Admin xóa quyền Update của role Drafter → user Drafter refresh → không thấy nút Sửa
- [ ] Backend 403 khi FE bypass (dev tools unhide nút) → gọi API trực tiếp bị chặn