---
name: pattern-controller-lower-authorize-handler-finegrained
description: Controller hạ [Authorize] về any-auth → MediatR handler enforce fine-grained Admin-OR-dept membership; pairs with scoped-capability endpoint to avoid silent 403
metadata:
  type: feedback
---

Khi authz không phải role-tĩnh đơn (vd "Admin HOẶC thành viên 1 dept cụ thể"), KHÔNG nhồi vào `[Authorize(Roles=...)]` ở controller. Hạ controller về `[Authorize]` (any-authenticated) + enforce điều kiện fine-grained TRONG handler (query db dept membership → throw `ForbiddenException`). `[Authorize(Roles="Admin")]` chỉ cover Admin, KHÔNG cover "member của dept X" vì dept là data-driven, không phải role-name.

**Why:** S54 ItTicket reassign — IT staff phải tự reassign được, nhưng "IT staff" = `User.DepartmentId == Department(Code=="IT").Id`, không tồn tại role-name "IT". `ICurrentUser` chỉ expose `Roles` + `UserId`, KHÔNG có `DepartmentId` → bắt buộc query `db.Users` lấy DepartmentId. Round-robin S52 đã dùng predicate `Departments.Where(d => d.Code=="IT" && !d.IsDeleted)` — TÁI DÙNG Y HỆT cho consistency.

**How to apply:**
- Controller: `[Authorize]` ở class-level đủ; bỏ `[Authorize(Roles=...)]` ở action khi cần OR-of-(role, dept-membership).
- Handler: `isAdmin = cu.Roles.Contains("Admin")`; `myDeptId = db.Users.Where(Id==cu.UserId).Select(DepartmentId)`; check `!isAdmin && !(deptId is Guid m && myDeptId==m)` → `throw new ForbiddenException(msg)`.
- Siết thêm side-constraint cùng tầng: vd assignee phải thuộc dept IT → `ConflictException` (409, khác 403 caller-authz).
- Exceptions map qua GlobalExceptionMiddleware (Forbidden→403, Conflict→409, NotFound→404, Unauthorized→401) — KHÔNG try-catch controller.

Linked: [[pattern-scoped-capability-endpoint-anti-silent-403]].
