[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

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:
pqhuy1987
2026-05-13 21:46:51 +07:00
parent a74e671431
commit 40f64c6b32
4 changed files with 96 additions and 10 deletions

View File

@ -49,9 +49,10 @@ export function PeWorkflowPanel({
.filter((v, i, arr) => arr.findIndex(x => x.userId === v.userId) === i) .filter((v, i, arr) => arr.findIndex(x => x.userId === v.userId) === i)
// Mig 24 — V2 schema chỉ cho phép approver trong CurrentApproval.approvers // Mig 24 — V2 schema chỉ cho phép approver trong CurrentApproval.approvers
// duyệt cấp hiện tại. Nếu actor không khớp → disable nút "Duyệt forward" // thao tác cấp hiện tại. UAT S22+1 feedback: "không quyền thao tác = ko quyền
// (Trả lại / Từ chối vẫn enabled vì Service không kiểm Bước/Cấp với 2 // mọi hành động" — disable cả 3 button (Duyệt + Trả lại + Từ chối) khi actor
// hành động này — Approver có thể reject bất cứ lúc nào trong phiên). // không match currentLevel.ApproverUserId. BE mirror guard trong
// EnsureCanRejectV2Async (defense-in-depth — UI disable + BE reject).
// Admin bypass. // Admin bypass.
const v2Approvers = evaluation.currentApproval?.approvers ?? [] const v2Approvers = evaluation.currentApproval?.approvers ?? []
const actorInV2Level = isAdmin const actorInV2Level = isAdmin
@ -239,11 +240,12 @@ export function PeWorkflowPanel({
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai && evaluation.phase !== PurchaseEvaluationPhase.TraLai
const isCancel = p === PurchaseEvaluationPhase.TuChoi const isCancel = p === PurchaseEvaluationPhase.TuChoi
const isForwardApprove = !isSendBack && !isCancel const isForwardApprove = !isSendBack && !isCancel
// Mig 24 — disable Duyệt forward nếu V2 pin + actor không trong cấp hiện tại // Mig 24 + UAT S22+1 — disable cả 3 button khi actor không match
const isDisabled = isForwardApprove && blockedByV2Level // currentLevel.ApproverUserId. "Không quyền = ko quyền mọi hành động."
const isDisabled = blockedByV2Level
const label = isSendBack ? '← Trả lại' : isCancel ? '✗ Từ chối' : '✓ Duyệt' const label = isSendBack ? '← Trả lại' : isCancel ? '✗ Từ chối' : '✓ Duyệt'
const title = isDisabled && evaluation.currentApproval const title = isDisabled && evaluation.currentApproval
? `Cấp ${evaluation.currentApproval.levelOrder} chỉ ${evaluation.currentApproval.approvers.map(a => a.fullName).join(' / ')} mới duyệt được.` ? `Cấp ${evaluation.currentApproval.levelOrder} chỉ ${evaluation.currentApproval.approvers.map(a => a.fullName).join(' / ')} mới thao tác được (Duyệt / Trả lại / Từ chối).`
: isForwardApprove : isForwardApprove
? `Duyệt → ${PurchaseEvaluationPhaseLabel[p]}` ? `Duyệt → ${PurchaseEvaluationPhaseLabel[p]}`
: undefined : undefined

View File

@ -51,7 +51,9 @@ export function PeWorkflowPanel({
.filter((v, i, arr) => arr.findIndex(x => x.userId === v.userId) === i) .filter((v, i, arr) => arr.findIndex(x => x.userId === v.userId) === i)
// Mig 24 — V2 schema chỉ cho phép approver trong CurrentApproval.approvers // Mig 24 — V2 schema chỉ cho phép approver trong CurrentApproval.approvers
// duyệt cấp hiện tại. Admin bypass. // thao tác cấp hiện tại. UAT S22+1: disable cả 3 button (Duyệt + Trả lại
// + Từ chối) khi actor không match. BE mirror EnsureCanRejectV2Async.
// Admin bypass.
const v2Approvers = evaluation.currentApproval?.approvers ?? [] const v2Approvers = evaluation.currentApproval?.approvers ?? []
const actorInV2Level = isAdmin const actorInV2Level = isAdmin
|| (currentUser?.id && v2Approvers.some(a => a.userId === currentUser.id)) || (currentUser?.id && v2Approvers.some(a => a.userId === currentUser.id))
@ -235,11 +237,12 @@ export function PeWorkflowPanel({
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai && evaluation.phase !== PurchaseEvaluationPhase.TraLai
const isCancel = p === PurchaseEvaluationPhase.TuChoi const isCancel = p === PurchaseEvaluationPhase.TuChoi
const isForwardApprove = !isSendBack && !isCancel const isForwardApprove = !isSendBack && !isCancel
// Mig 24 — disable Duyệt forward nếu V2 pin + actor không trong cấp hiện tại // Mig 24 + UAT S22+1 — disable cả 3 button khi actor không match
const isDisabled = isForwardApprove && blockedByV2Level // currentLevel.ApproverUserId. "Không quyền = ko quyền mọi hành động."
const isDisabled = blockedByV2Level
const label = isSendBack ? '← Trả lại' : isCancel ? '✗ Từ chối' : '✓ Duyệt' const label = isSendBack ? '← Trả lại' : isCancel ? '✗ Từ chối' : '✓ Duyệt'
const title = isDisabled && evaluation.currentApproval const title = isDisabled && evaluation.currentApproval
? `Cấp ${evaluation.currentApproval.levelOrder} chỉ ${evaluation.currentApproval.approvers.map(a => a.fullName).join(' / ')} mới duyệt được.` ? `Cấp ${evaluation.currentApproval.levelOrder} chỉ ${evaluation.currentApproval.approvers.map(a => a.fullName).join(' / ')} mới thao tác được (Duyệt / Trả lại / Từ chối).`
: isForwardApprove : isForwardApprove
? `Duyệt → ${PurchaseEvaluationPhaseLabel[p]}` ? `Duyệt → ${PurchaseEvaluationPhaseLabel[p]}`
: undefined : undefined

View File

@ -74,6 +74,12 @@ public class PurchaseEvaluationWorkflowService(
// ===== REJECT BRANCH (extended Mig 28 — F1 multi-mode Trả lại) ===== // ===== REJECT BRANCH (extended Mig 28 — F1 multi-mode Trả lại) =====
if (decision == ApprovalDecision.Reject) 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) if (targetPhase == PurchaseEvaluationPhase.TuChoi)
{ {
// Từ chối hoàn toàn — phiếu khoá vĩnh viễn (lock edit Mig 16). // 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ợ."); 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 ===== // ===== F1 (Mig 28 — S21 t4) — Apply Return Mode =====
// Switch theo effectiveMode → set Phase + pointer. 3 mode đầu giữ ChoDuyet // Switch theo effectiveMode → set Phase + pointer. 3 mode đầu giữ ChoDuyet
// (peer review chain). Mode Drafter set Phase=TraLai như S17. // (peer review chain). Mode Drafter set Phase=TraLai như S17.

View File

@ -334,6 +334,44 @@ public class PurchaseEvaluationWorkflowServiceReturnModeTests
} }
} }
// ============ UAT S22+1: V2 actor scope guard cho Reject ============
[Fact]
public async Task Reject_NonApprover_V2_Throws_ForbiddenException()
{
// UAT S22+1 — actor không phải approver Cấp hiện tại + V2 pin + non-admin
// → BE guard throw ForbiddenException. Mirror FE button disable logic.
var (svc, fix, db, _) = CreateService();
using (fix)
{
var (a1, a2) = await SeedApproversAsync(fix, "guard1");
// SeedWorkflow Level 2 ApproverUserId = a2. Cấp hiện tại = Level 2.
var (wf, _, _, _) = await SeedWorkflowAsync(db, a1.Id, a2.Id, allowReturnToDrafterL2: true);
var pe = BuildPeAtLevel2(wf.Id, drafterId: Guid.NewGuid(), code: "PE-G-V2-001");
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
// Outsider gọi Reject — không phải a2 (current Level approver).
var outsider = await fix.CreateUserAsync(
"outsider-guard@test.local", "Outsider Guard", departmentId: null,
roles: new[] { AppRoles.CostControl });
var act = async () => await svc.TransitionAsync(
evaluation: pe,
targetPhase: PurchaseEvaluationPhase.TraLai,
actorUserId: outsider.Id,
actorRoles: new[] { AppRoles.CostControl },
decision: ApprovalDecision.Reject,
comment: "outsider thử forge reject",
returnMode: WorkflowReturnMode.Drafter,
ct: CancellationToken.None);
await act.Should().ThrowAsync<ForbiddenException>()
.WithMessage("*Không phải lượt bạn*Trả lại / Từ chối*");
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "Guard chặn trước mutate phase");
}
}
[Fact] [Fact]
public async Task SkipToFinal_AdminBypass_Succeeds() public async Task SkipToFinal_AdminBypass_Succeeds()
{ {