# Session 54 — IT staff tự reassign ticket (cross-stack authz)

**Date:** 2026-06-08
**Mode:** HMW-mode ON · 6-agent fan-out (Agent-tool spawn, sequential-gated)
**Commit:** `ca4b602` → Gitea Run #376 PASS ~4m18s · prod-verified
**Test:** 203 → **216 PASS** (58 Domain + 158 Infra · +13)
**Bundle:** admin `DfCfHUE9`→`DmjI8Cmn` · user `_3S0BPJ2`→`YxL_MljK` (cả 2 rotate)
**Migration:** giữ 47 (NO new — DepartmentId reuse)

---

## Bối cảnh

Anh `/session-start` → chọn (qua AskUserQuestion) **"2 xong sau đó là 1"** = task 2 (mirror ItTicket reassign → fe-user) trước, task 1 (Phase 9 Ops) sau. Task 2 done + prod-verified → anh `/session-end` (task 1 chưa khởi động, anh dừng).

## Decision chính (em main — KHÔNG mirror mù)

Recon phát hiện reassign-UI S53 (fe-admin only) gọi `PUT /assign` (`[Authorize(Roles="Admin")]`) + `GET /users` (`[Authorize(Policy="Users.Read")]`) — **cả 2 Admin-only**. Mirror mù sang fe-user = tái tạo **gotcha #44 (silent 403)**: nhân viên thường bấm nút → 403 câm.

→ AskUserQuestion: "ai được reassign trên fe-user?" → anh chốt **"Tổ IT tự đổi (cross-stack, đứng-đắn)"**. Persona = nhân viên dept-IT (S52 seed nv.cao/nv.truong) tự đổi tay xử lý trong tổ → phải **nới BE authz**, không chỉ sửa FE.

## Design (em main)

- **Authz model:** `canReassign` = caller là **Admin** HOẶC `caller.DepartmentId == Department(Code=="IT").Id`. Assignee bắt buộc thuộc tổ IT đang active.
- **Predicate IT:** reuse round-robin S52 verbatim `db.Departments.Where(d => d.Code=="IT" && !d.IsDeleted)`.
- **`ICurrentUser` không có DepartmentId** → handler query `db.Users.Where(Id==cu.UserId).Select(DepartmentId)`.
- **Gate nút FE (chống gotcha #44):** thêm capability endpoint `GET /it-tickets/assignable-staff` trả `{ canReassign: bool, staff: [{id,fullName}] }` — BE tính 1 lần, FE đọc flag. `[Authorize]` any-auth → KHÔNG bao giờ 403 → 0 console noise. Người ngoài → `{false, []}` (0 leak). BE handler `/assign` mới là cửa bảo mật thật (defense-in-depth).
- **Bonus:** siết assignee phải thuộc IT (handler cũ cho gán bất kỳ user — looseness âm thầm) → khớp dropdown scoped. Cả 2 app **hội tụ SHA256-identical** lại (reverse divergence S53).

## Phân rã (6-agent)

| Agent | Việc | Kết quả |
|---|---|---|
| 🟨 implementer-backend | `GetAssignableItStaffQuery` + `AssignItTicketHandler` authz + controller 2 endpoint | build 0/0, DTO contract confirmed |
| 🟧 implementer-frontend | 2 app `ItTicketsPage.tsx` + types | npm ×2 PASS, **SHA256 `4bcaf2f…`** |
| 🟪 test-specialist | `ItTicketReassignAuthzTests.cs` 13 case | 203→216, 0 fail, no prod bug |
| 🟥 reviewer | adversarial pre-commit (authz security) | **PASS** 0 blocker · role-string "Admin" chain-verified |
| 🟩 cicd-monitor | post-deploy Run #376 | **PASS** bundle rotate + smoke + Mig 47 |
| 👤 em main | design + recon + commit + residual reconcile | — |

BE ∥ FE parallel (file-disjoint, contract chốt) → test (cần BE compiled) → reviewer → cicd.

## reviewer — điểm chí mạng

Verify role-string "Admin" THẬT (gotcha #44 bài học decoy "QTV"): chain trace `AppRoles.Admin="Admin"` → `SeedRolesAsync Role.Name="Admin"` → Identity `GetRolesAsync` trả Name → `JwtTokenService Claim(ClaimTypes.Role)` → `CurrentUserService FindAll(ClaimTypes.Role)` → `cu.Roles.Contains("Admin")` **đúng**. "QTV" chỉ là `ShortName` display (`RoleLabels`), không vào JWT. No `RoleClaimType` override. **Guard không thủng, fail-closed** (itDeptId null → non-admin bị chặn, admin vẫn qua).

## Residual event (em main single-writer containment)

3 agent (BE/FE/test) ghi MEMORY.md nhầm `src/Backend/.claude/agent-memory/` (cwd-relative Write khi `cd` vào `src/Backend` cho build) → stray dir + canonical thiếu S54 delta. Em main:
1. `git status` bắt `?? src/Backend/.claude/` + nghi (BE/FE/test MEMORY không hiện modified ở canonical).
2. Inspect stray = 2 pattern file viết tốt (BE) + stub index.
3. Reconcile: move 2 pattern file → canonical `.claude/agent-memory/implementer-backend/` + `rm -rf src/Backend/.claude`.
4. Harvest delta (B2/B3): APPEND S54 activity entry vào 3 canonical MEMORY (implementer-backend + implementer-frontend + test-specialist) từ return-message của agent (write mis-landed → em main proxy).

→ memory `feedback_agent_cwd_relative_memory_misland` (mở rộng `feedback_monitor_residual_write_containment` + `feedback_session_end_memory_write_verify`).

## cicd Run #376 — evidence

- Run #376 (run_number 262) · `ca4b602` · success · ~4m18s.
- Test gate 216 PASS (CI both projects trước build).
- Bundle admin `DfCfHUE9`→`DmjI8Cmn` / user `_3S0BPJ2`→`YxL_MljK` (verified AFTER status=success).
- Smoke: `/health/{live,ready}` 200/200 · `GET /assignable-staff` 401 · `PUT /assign` 401 (body) · FE roots 200/200.
- Migration prod top = Mig 47, giữ nguyên. Control `/it-tickets/zzz` = 404 (chứng minh 401 là auth gate thật).
- **Note (documented):** `PUT/POST` bodyless smoke → IIS trả **411 Length Required** TRƯỚC `[Authorize]` (pre-auth Content-Length check) → dùng `-d '{}'` để thấy 401 thật. Lặp lại #364/#367.

## Files changed

**Production (6):**
- `src/Backend/SolutionErp.Application/Office/WorkflowAppsFeatures.cs` — REGION 5: +`GetAssignableItStaffQuery`/Handler/`AssignableStaffResult`/`AssignableStaffDto`; `AssignItTicketHandler` +authz +assignee-IT-check.
- `src/Backend/SolutionErp.Api/Controllers/ItTicketsController.cs` — `/assign` hạ Authorize + NEW `GET /assignable-staff`.
- `fe-{admin,user}/src/pages/office/ItTicketsPage.tsx` — SHA256-identical reassign.
- `fe-{admin,user}/src/types/workflowApps.ts` — +2 type.

**Test (1):** `tests/SolutionErp.Infrastructure.Tests/Application/ItTicketReassignAuthzTests.cs` (13 case).

**Agent-memory (6):** implementer-backend MEMORY + 2 pattern file (reconciled từ stray), implementer-frontend MEMORY, test-specialist MEMORY, reviewer MEMORY.

## Defer / NEXT

- **Task 1 Phase 9 Ops** (anh dừng S54): SMTP email outbound (code-able) · SQL backup register (command-ready) · creds + UAT (anh-infra). Detail trong HANDOFF.
- **flag monthly audit 2026-07-01:** cicd `sys.tables=93` vs STATUS 92 (1-count drift, pre-existing).
- **Optional:** pre-existing `types/workflowApps.ts` 2-app divergence (fe-admin có `AttendanceReportDto`/fe-user thiếu — không từ S54).
