# S118 — 2026-07-14 — Phân quyền PRO tra.bui + vá lỗ hổng ReportsController

> **Nguồn:** UAT anh Kiệt FDC (chat Zalo) — "phân quyền tra.bui đi nhé" (ảnh chat: bạn PRO login không thấy menu).
> **Mode:** HMW-ON · lead Opus 4.8 (owner-choice per-session) · em-main-led + 7 spawn + 3 script.
> **Commit:** `e88e75d` (docs-reconcile bootstrap) + `c2135ba` (PRO-perm seeder + test) + `c808b9a` (Reports-authz + test + gotcha) — 3 pushed, 2 deploy #388/#389 verified.

## Bối cảnh
Anh Kiệt báo `tra.bui` (Bùi Lê Thủy Trà, TP Cung ứng) đăng nhập không thấy menu quản lý NCC/Suppliers, dù "em phân quyền hình như đủ". Chat xoay quanh công bố NCC (S113: phải có Mã NCC mới cho công bố).

## Task 1 — Phân quyền tra.bui (Procurement)

### Điều tra (read-only, không đoán)
- Skill `permission-matrix` → `GetMyMenuTreeQuery` gate menu thuần theo Permission `CanRead` (KHÔNG Admin-bypass ở menu-tree).
- `DbInitializer.cs`: `tra.bui` seed sẵn roles `[Procurement, DeptManager]`.
- **Prod read-only** (SSH `vietreport-vps` + sqlcmd đọc password từ `appsettings.Production.json`, cơ chế plan-ca): tra.bui **active OK, roles đúng**, nhưng chỉ đọc **11 menu — toàn `Pe_*`** (Duyệt NCC), KHÔNG Master/Suppliers.
- **Root-cause:** seeder `RevokeTemporarilyHiddenModulesAsync` (S92 owner-decision, dòng ~2140-2181) chạy **mỗi startup** set CanRead=0 cho `Master`/`Suppliers`/`Projects`/`Departments`/`Catalog*`/`Contracts`/`Hrm*`/`Off*`/`Personal` với MỌI role non-admin ("chỉ Admin thấy"). `Pe_*` được chừa → PE giữ cho user.

### Owner 3-decision (AskUser)
1. **Scope:** Master đầy đủ (Suppliers + Projects + Departments + Catalogs + 4 leaf) + Dashboard + Reports.
2. **Durability:** làm bền qua code (không để grant tay bị revoke reset next-deploy).
3. **Edit-NCC:** chỉ công-bố/import/thêm-mới (KHÔNG sửa/xóa).

### Fix 2 tầng
1. **Grant tạm prod** — MERGE idempotent vào `Permissions` cho RoleId Procurement (`5B21CDC1-...`): tra.bui 11→**22** (verify LIVE). Row Master/Suppliers đã tồn tại all-0 (placeholder) → UPDATE; Dashboard/Reports chưa có → INSERT.
2. **Code-hóa bền** — method mới `SeedProcurementMasterAccessAsync` chạy **NGAY SAU** `RevokeTemporarilyHiddenModulesAsync` (mirror pattern re-grant-thắng-revoke S65 Hrm / S69 Office). **CHỈ role Procurement**, upgrade-only (nâng cờ desired lên true, không hạ, không đụng role khác).

### Reviewer catch (PASS-có-điều-kiện) → owner refine
- **M1:** menu `Reports` gọi `GET /reports/dashboard` lộ `TotalValueActive` + top NCC/dự án theo giá trị + xu hướng 12 tháng → **ngược S92** ("HĐ chỉ Admin thấy"). Owner chốt **bỏ cả Reports + Dashboard** → seeder còn **9 key**, tra.bui còn **20 menu**. Revert Dashboard/Reports trên prod DB.
- **M2:** `Suppliers.Update` chỉ mở Publish/Import; `PUT/DELETE /suppliers` gate `[Authorize(Roles="Admin,CatalogManager")]` → PRO vẫn 403 khi Sửa/Xóa NCC. Owner chốt chỉ cần công-bố/import/thêm → grant hiện đủ.

### Verify durability (bằng chứng vàng)
Serilog prod sau deploy restart:
```
15:35:47.115 [INF] Revoked 9 permission rows ... (chỉ Admin thấy - S58 + S92)
15:35:47.159 [INF] Seeded Procurement master access: 0 added + 9 upgraded ... (PRO exception to S92, S118)
```
→ revoke set 9 row false, seeder lật lại true 44ms sau ⇒ **seeder THẮNG revoke trên startup thật**. Verify ×2 deploy (tra.bui giữ 20). Scope-isolation prod: `binh.le` (CostControl, no-Procurement)=11 · DeptManager-standalone Master/Suppliers=0 (S92 nguyên vẹn role khác).

### Test
`+4 ProcurementMasterAccessSeedTests` (reflection gọi private seeder, không đụng prod code): upgrade-path + **scope-isolation** (DeptManager riêng vẫn revoke) + insert-path + idempotent. 501→505.

## Task 2 — Vá lỗ hổng ReportsController (task anh mở từ chip reviewer flag)

- **Lỗ hổng:** `ReportsController` chỉ `[Authorize]` trần (không policy) → MỌI user đăng nhập gọi được `GET /reports/dashboard` (tài chính HĐ toàn công ty) + `GET /reports/contracts/export` (dump toàn bộ HĐ). Menu bị S92 ẩn nhưng API mở.
- **Phân loại DTO:** `dashboard` + `contracts/export` = company-wide nhạy cảm; `my-dashboard` = per-user (scope `currentUser`, an toàn).
- **Owner chốt:** chỉ Admin (gate `Reports.Read` policy — linh hoạt mở role khác qua ma trận).
- **Fix:** `[Authorize(Policy="Reports.Read")]` per-method cho `Dashboard` + `ExportContracts`; `MyDashboard` giữ `[Authorize]` trần.
- **Test:** +4 reflection authz (`AuthorizePolicyRegressionTests`, mirror gotcha #44 S45). 505→509.
- **Prod verify:** Reports.Read=chỉ Admin (DB), dashboard no-token=401, health 200×3. Non-admin→403 by-construction.
- **gotcha #82** ghi: menu-flag grant ≠ quyền API thật (display-layer ⟂ API-authz-layer).

## Lessons
- **wipe-durability-check-reseed (#75/#76):** grant tay prod sẽ bị seeder revoke reset next-deploy → phải code-hóa; bắt kịp TRƯỚC khi báo "done".
- **menu-flag ≠ API-power (#82):** grep authz-attribute controller đích trước khi tin hiệu-lực grant.
- **#53 cicd-garble ×2 (verify-lane):** cả 2 cicd-monitor trả memory-curation thay verdict → em-main-solo recover (curl 401/health + sqlcmd DB, 2 nguồn khớp). verify-lane garble ≠ mất-việc.
- **prod-config-inspect-before-change:** điều tra prod-DB THẬT (SSH sqlcmd) trước khi kết luận — lật ngược giả thuyết ban đầu ("thiếu quyền" → thực ra "scope gap + cache").
- **Serilog-as-durability-proof:** log-line startup = bằng chứng seeder thắng revoke (mạnh hơn end-state DB đơn thuần).

## Carry (chờ anh)
- `binh.le` (CostControl+Drafter, 11 menu, khác phòng CCM) — có cần thấy NCC?
- 2 account trùng "Lê Thanh Bình": `binh.lethanh` vs `thanh.lethanh` (cùng roles, cùng 20 menu) — dùng cái nào, dọn không?
- Audit authz-vs-menu toàn bộ (nhiều controller master `[Authorize]` trần — gotcha #82) — non-blocking, có thể task riêng.

## State
**Mig 66 · 89 bảng · 509 test (45 Domain + 464 Infra) · gotcha 82 · menu 54 · bundle admin `cFI7ih4a`/user `MxgooVZw` (FE FROZEN — session BE/permission-only) · prod 37 NCC/17 user.**
