docs/database/database-guide.md: - Conventions (naming, data types, audit fields, soft delete) - Schema hien tai (Identity tables sau migration Init) + seed 12 role + admin - Schema planned: Phase 1 dot 2 (Supplier/Project/Department + Permission Matrix) - Schema planned: Phase 3 (Contract + Approval + Comment + Attachment + Template + Clause + CodeSequence) - Mermaid ERD cho tung phase - Migration workflow (create/apply/revert) - Index strategy + unique indexes - Backup/restore SQL - Common pitfalls + SQL cheatsheet docs/flows/ — 6 flow documentation: - README.md: index - auth-flow.md: login/refresh/me/logout (IMPLEMENTED, sequence + edge cases + security checklist) - permission-flow.md: Phase 1 dot 2 - Role x MenuKey x CRUD resolution + FE guard + BE policy - contract-creation-flow.md: Phase 2 - Drafter flow chon template -> fill -> preview -> save draft - contract-approval-flow.md: Phase 3 - state machine 9 phase chi tiet + reject flow + timeline UI - form-render-flow.md: Phase 2 - OpenXml + ClosedXML + LibreOffice PDF convert - sla-expiry-flow.md: Phase 3 - BackgroundService auto-approve qua SLA + warning notify Update references: - CLAUDE.md (root): them 2 row Tai lieu quan trong - docs/CLAUDE.md: update project layout voi flows/ + database/ - docs/STATUS.md: log docs addition - docs/changelog/migration-todos.md: tick Phase 0 docs items Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
290 lines
9.1 KiB
Markdown
290 lines
9.1 KiB
Markdown
# 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<br/>JOIN Roles ON Permissions.RoleId = Roles.Id<br/>JOIN MenuItems ON Permissions.MenuKey = MenuItems.Key<br/>WHERE Roles.Id IN (user's roles)
|
||
|
||
DB-->>Q: raw rows
|
||
|
||
Q->>Q: Group by MenuKey<br/>UNION CRUD flags (OR)<br/>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<br/>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<br/>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
|
||
<PermissionGuard menuKey="Contracts" action="Update">
|
||
<Button>Sửa</Button>
|
||
</PermissionGuard>
|
||
|
||
// Route guard
|
||
<Route
|
||
path="/admin/permissions"
|
||
element={
|
||
<PermissionGuard menuKey="Permissions" action="Read" fallback={<Forbidden />}>
|
||
<PermissionMatrixPage />
|
||
</PermissionGuard>
|
||
}
|
||
/>
|
||
```
|
||
|
||
## 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<br/>{roleId, menuKey: "Contracts", canRead, canCreate, canUpdate, canDelete}
|
||
API->>M: Send(UpsertPermissionCommand)
|
||
|
||
M->>DB: SELECT existing Permission<br/>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<br/>sẽ thấy nút Update HĐ<br/>sau khi họ refresh/re-login<br/>(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<IActionResult> 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<IAuthorizationHandler, MenuPermissionHandler>();
|
||
```
|
||
|
||
**Handler:**
|
||
|
||
```csharp
|
||
public class MenuPermissionHandler : AuthorizationHandler<MenuPermissionRequirement>
|
||
{
|
||
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
|