From f149661d362ce991d16c73a8cdba6a77c725a994 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Wed, 13 May 2026 21:16:59 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20PurchaseEvaluation:=20Plan=20E=20?= =?UTF-8?q?=E2=80=94=20ph=C3=A2n=20quy=E1=BB=81n=20strict=20V2=20scope=20(?= =?UTF-8?q?List=20+=20Detail)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thắt chặt phân quyền PE V2 từ UAT loose sang strict actor.UserId scope: Trước (loose): mọi authenticated user thấy mọi phiếu V2 (`ApprovalWorkflowId != null`). Sau (strict): - ListPurchaseEvaluationsQuery: phiếu V2 chỉ visible khi actor là approver trong any Step.Level.ApproverUserId của workflow đã pin. Pre-compute userApprovalWfIds = DISTINCT workflow IDs có user trong Levels. - GetPurchaseEvaluationQuery: same — actor must be V2 approver in any Level của workflow pin để thấy phiếu (ngoài Drafter scope + role eligible phase). Drafter vẫn thấy phiếu mình tạo (regardless V2/V1). Admin bypass full. Inbox đã strict từ Session 17 (ResolveV2InboxIdsAsync match current Cấp + ApproverUserId) — KHÔNG đụng. Tests defer: Plan C carry — 4 integration tests Strict V2 List + Detail (Drafter own / V2 approver / non-approver throw 403) khi UAT confirm. Verify: - dotnet build SolutionErp.slnx — 0 err, 2 warning DocxRenderer pre-existing - dotnet test SolutionErp.slnx — 103/103 PASS regression-free Co-Authored-By: Claude Opus 4.7 (1M context) --- .../PurchaseEvaluationFeatures.cs | 37 ++++++++++++++----- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs index 788eab8..eb590c6 100644 --- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs +++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs @@ -7,6 +7,7 @@ using SolutionErp.Application.Common.Interfaces; using SolutionErp.Application.Common.Models; using SolutionErp.Application.PurchaseEvaluations.Dtos; using SolutionErp.Application.PurchaseEvaluations.Services; +using SolutionErp.Domain.ApprovalWorkflowsV2; // Plan E V2 strict scope query using SolutionErp.Domain.Contracts; using SolutionErp.Domain.Identity; using SolutionErp.Domain.PurchaseEvaluations; @@ -315,18 +316,28 @@ public class ListPurchaseEvaluationsQueryHandler( from s in sj.DefaultIfEmpty() select new { e, p, s }; - // IDOR: non-admin chỉ thấy phiếu mình là Drafter hoặc role eligible phase. - // [Session 17 UAT loose] Phiếu pin V2 (ApprovalWorkflowId != null) → - // tạm cho mọi authenticated user thấy để UAT (sẽ thắt chặt sau khi - // hoàn thiện logic permission V2-aware). + // IDOR strict (Plan E S22 — Session 21 +1): non-admin chỉ thấy phiếu khi: + // 1. là Drafter (mình tạo) + // 2. role eligible cho phase legacy V1 (eligiblePhases) + // 3. V2 approver: pin workflow có actor.UserId trong any Step.Level.ApproverUserId + // Trước đây UAT loose `|| x.e.ApprovalWorkflowId != null` cho mọi + // authenticated thấy phiếu V2 — đã thắt chặt sau UAT confirm flow ổn. if (!currentUser.Roles.Contains(AppRoles.Admin)) { var userId = currentUser.UserId; var eligiblePhases = GetEligiblePhases(currentUser.Roles); + // Pre-compute V2 workflow IDs nơi user là approver trong any Step.Level + var userApprovalWfIds = userId is null + ? new List() + : await db.ApprovalWorkflowLevels.AsNoTracking() + .Where(l => l.ApproverUserId == userId.Value) + .Select(l => l.Step!.ApprovalWorkflowId) + .Distinct() + .ToListAsync(ct); q = q.Where(x => x.e.DrafterUserId == userId || eligiblePhases.Contains(x.e.Phase) - || x.e.ApprovalWorkflowId != null); // V2 loose UAT + || (x.e.ApprovalWorkflowId != null && userApprovalWfIds.Contains(x.e.ApprovalWorkflowId.Value))); } if (request.Type is not null) q = q.Where(x => x.e.Type == request.Type); @@ -501,10 +512,18 @@ public class GetPurchaseEvaluationQueryHandler( { var isDrafter = e.DrafterUserId == currentUser.UserId; var eligiblePhases = ListPurchaseEvaluationsQueryHandler.GetEligiblePhases(currentUser.Roles); - // [Session 17 UAT loose] Phiếu pin V2 → cho mọi authenticated user - // xem được (sẽ thắt chặt sau khi user UAT confirm flow). - var isPinnedV2 = e.ApprovalWorkflowId is not null; - if (!isDrafter && !eligiblePhases.Contains(e.Phase) && !isPinnedV2) + // V2 strict scope (Plan E S22 — Session 21 +1): actor là approver + // trong any Step.Level của workflow đã pin. Trước đây loose + // `isPinnedV2 = e.ApprovalWorkflowId is not null` đã thắt chặt sau + // UAT confirm flow V2. + var isV2Approver = false; + if (e.ApprovalWorkflowId is Guid awIdForCheck && currentUser.UserId is Guid uidForCheck) + { + isV2Approver = await db.ApprovalWorkflowLevels.AsNoTracking() + .AnyAsync(l => l.Step!.ApprovalWorkflowId == awIdForCheck + && l.ApproverUserId == uidForCheck, ct); + } + if (!isDrafter && !eligiblePhases.Contains(e.Phase) && !isV2Approver) throw new ForbiddenException("Bạn không có quyền xem phiếu này."); }