[CLAUDE] PE-Workflow: UAT S22+1 — disable cả 3 button khi không quyền + BE guard
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m29s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m29s
User UAT feedback: "Nếu đã không được quyền thao tác thì ko được quyền thao tác hết tất cả các hành động" — trước đây chỉ "Duyệt" disabled, "Trả lại" + "Từ chối" vẫn enabled (design intent S17 cũ). FE 2 app mirror (PeWorkflowPanel.tsx): - `isDisabled = blockedByV2Level` (drop `isForwardApprove &&` qualifier) - Tooltip update "mới thao tác được (Duyệt / Trả lại / Từ chối)" - Comment refresh ghi UAT S22+1 spec + cross-ref BE EnsureCanRejectV2Async BE defense-in-depth (PurchaseEvaluationWorkflowService.cs): - Helper mới `EnsureCanRejectV2Async` mirror FE actorInV2Level logic: Skip silent khi admin/V1/non-ChoDuyet/no actor/no pointer. Throw ForbiddenException khi V2 + ChoDuyet + actor != currentLevel.ApproverUserId. - Invoke ở top Reject branch (cover cả TuChoi + Trả lại sub-branches). - Chặn request forge: non-approver gọi PATCH /transitions direct sẽ 403. Test (test-before §7 — security guard critical algorithm): - ReturnMode tests existing 7/7 vẫn PASS (a2.Id = currentLevel approver, guard accept) - +1 NEW test `Reject_NonApprover_V2_Throws_ForbiddenException` — outsider Drafter role gọi Reject phiếu V2 → throw + Phase không mutate Verify: - dotnet test SolutionErp.slnx — 104/104 PASS (+1 guard regression) Δ: 103 → 104 - npm run build × 2 app — pass (482ms + 583ms) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -74,6 +74,12 @@ public class PurchaseEvaluationWorkflowService(
|
||||
// ===== REJECT BRANCH (extended Mig 28 — F1 multi-mode Trả lại) =====
|
||||
if (decision == ApprovalDecision.Reject)
|
||||
{
|
||||
// UAT S22+1 — V2 actor scope guard (defense-in-depth).
|
||||
// FE PeWorkflowPanel disable cả 3 button (Duyệt + Trả lại + Từ chối)
|
||||
// khi actor không match currentLevel.ApproverUserId — BE mirror
|
||||
// guard tránh request forge non-approver gọi PATCH direct.
|
||||
await EnsureCanRejectV2Async(evaluation, actorUserId, isAdmin, ct);
|
||||
|
||||
if (targetPhase == PurchaseEvaluationPhase.TuChoi)
|
||||
{
|
||||
// Từ chối hoàn toàn — phiếu khoá vĩnh viễn (lock edit Mig 16).
|
||||
@ -186,6 +192,43 @@ public class PurchaseEvaluationWorkflowService(
|
||||
throw new ConflictException($"Transition {fromPhase} → {targetPhase} không hỗ trợ.");
|
||||
}
|
||||
|
||||
// ===== V2 actor scope guard cho Reject (UAT S22+1) =====
|
||||
// Mirror FE PeWorkflowPanel.actorInV2Level — chỉ Approver Cấp hiện tại (V2
|
||||
// schema) hoặc Admin được Reject (Trả lại / Từ chối) phiếu V2. Defense-
|
||||
// in-depth: UI đã disable cả 3 button nhưng BE chặn request forge non-
|
||||
// approver gọi PATCH direct.
|
||||
//
|
||||
// Skip guard (silent return) khi điều kiện chưa đủ để check:
|
||||
// - isAdmin = true → bypass
|
||||
// - V1 schema (ApprovalWorkflowId null) → legacy behavior unchanged
|
||||
// - Phase != ChoDuyet → reject từ phase khác (vd auto job system)
|
||||
// - actorUserId null → system caller (vd cron)
|
||||
// - Pointer chưa init (CurrentWorkflowStepIndex / CurrentApprovalLevelOrder null)
|
||||
// → workflow chưa start, guard không relevant
|
||||
private async Task EnsureCanRejectV2Async(
|
||||
PurchaseEvaluation evaluation, Guid? actorUserId, bool isAdmin, CancellationToken ct)
|
||||
{
|
||||
if (isAdmin) return;
|
||||
if (evaluation.ApprovalWorkflowId is not Guid awId) return;
|
||||
if (evaluation.Phase != PurchaseEvaluationPhase.ChoDuyet) return;
|
||||
if (actorUserId is not Guid actorId) return;
|
||||
if (evaluation.CurrentWorkflowStepIndex is not int csi) return;
|
||||
if (evaluation.CurrentApprovalLevelOrder is not int curLvl) return;
|
||||
|
||||
var workflow = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Include(w => w.Steps).ThenInclude(s => s.Levels)
|
||||
.FirstOrDefaultAsync(w => w.Id == awId, ct);
|
||||
if (workflow is null) return; // schema lỗi — silent skip để LogTransition catch
|
||||
|
||||
var stepsOrdered = workflow.Steps.OrderBy(s => s.Order).ToList();
|
||||
if (csi < 0 || csi >= stepsOrdered.Count) return; // pointer corrupt
|
||||
var step = stepsOrdered[csi];
|
||||
var currentLevel = step.Levels.FirstOrDefault(l => l.Order == curLvl);
|
||||
if (currentLevel?.ApproverUserId != actorId)
|
||||
throw new ForbiddenException(
|
||||
"Không phải lượt bạn — chỉ NV Cấp duyệt hiện tại mới được Trả lại / Từ chối phiếu.");
|
||||
}
|
||||
|
||||
// ===== F1 (Mig 28 — S21 t4) — Apply Return Mode =====
|
||||
// Switch theo effectiveMode → set Phase + pointer. 3 mode đầu giữ ChoDuyet
|
||||
// (peer review chain). Mode Drafter set Phase=TraLai như S17.
|
||||
|
||||
Reference in New Issue
Block a user