[CLAUDE] PurchaseEvaluation: Plan E — phân quyền strict V2 scope (List + Detail)

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) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-13 21:16:59 +07:00
parent 215b1e036a
commit f149661d36

View File

@ -7,6 +7,7 @@ using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Common.Models; using SolutionErp.Application.Common.Models;
using SolutionErp.Application.PurchaseEvaluations.Dtos; using SolutionErp.Application.PurchaseEvaluations.Dtos;
using SolutionErp.Application.PurchaseEvaluations.Services; using SolutionErp.Application.PurchaseEvaluations.Services;
using SolutionErp.Domain.ApprovalWorkflowsV2; // Plan E V2 strict scope query
using SolutionErp.Domain.Contracts; using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Identity; using SolutionErp.Domain.Identity;
using SolutionErp.Domain.PurchaseEvaluations; using SolutionErp.Domain.PurchaseEvaluations;
@ -315,18 +316,28 @@ public class ListPurchaseEvaluationsQueryHandler(
from s in sj.DefaultIfEmpty() from s in sj.DefaultIfEmpty()
select new { e, p, s }; select new { e, p, s };
// IDOR: non-admin chỉ thấy phiếu mình là Drafter hoặc role eligible phase. // IDOR strict (Plan E S22 — Session 21 +1): non-admin chỉ thấy phiếu khi:
// [Session 17 UAT loose] Phiếu pin V2 (ApprovalWorkflowId != null) → // 1. là Drafter (mình tạo)
// tạm cho mọi authenticated user thấy để UAT (sẽ thắt chặt sau khi // 2. role eligible cho phase legacy V1 (eligiblePhases)
// hoàn thiện logic permission V2-aware). // 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)) if (!currentUser.Roles.Contains(AppRoles.Admin))
{ {
var userId = currentUser.UserId; var userId = currentUser.UserId;
var eligiblePhases = GetEligiblePhases(currentUser.Roles); 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<Guid>()
: await db.ApprovalWorkflowLevels.AsNoTracking()
.Where(l => l.ApproverUserId == userId.Value)
.Select(l => l.Step!.ApprovalWorkflowId)
.Distinct()
.ToListAsync(ct);
q = q.Where(x => q = q.Where(x =>
x.e.DrafterUserId == userId x.e.DrafterUserId == userId
|| eligiblePhases.Contains(x.e.Phase) || 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); 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 isDrafter = e.DrafterUserId == currentUser.UserId;
var eligiblePhases = ListPurchaseEvaluationsQueryHandler.GetEligiblePhases(currentUser.Roles); var eligiblePhases = ListPurchaseEvaluationsQueryHandler.GetEligiblePhases(currentUser.Roles);
// [Session 17 UAT loose] Phiếu pin V2 → cho mọi authenticated user // V2 strict scope (Plan E S22 — Session 21 +1): actor là approver
// xem được (sẽ thắt chặt sau khi user UAT confirm flow). // trong any Step.Level của workflow đã pin. Trước đây loose
var isPinnedV2 = e.ApprovalWorkflowId is not null; // `isPinnedV2 = e.ApprovalWorkflowId is not null` đã thắt chặt sau
if (!isDrafter && !eligiblePhases.Contains(e.Phase) && !isPinnedV2) // 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."); throw new ForbiddenException("Bạn không có quyền xem phiếu này.");
} }