[CLAUDE] Phase5.1/3.2: IDOR filter + SLA auto-approve job + admin password warning
IDOR filter ContractsController:
- ListContractsQueryHandler + ICurrentUser: non-admin chi thay HD minh la Drafter hoac role eligible phase hien tai
- GetContractQueryHandler + ICurrentUser: throw ForbiddenException neu truy cap HD khong lien quan
- GetEligiblePhases() internal static trong ListContractsQueryHandler — mirror GetMyInboxQueryHandler.PhaseActorRoles (Drafter/DeptManager → DangSoanThao/DangDamPhan/DangInKy, ProjectManager+PRO+CCM+FIN+ACT+EQU → DangGopY, CostControl → DangKiemTraCCM, Director+AuthorizedSigner → DangTrinhKy, HrAdmin → DangDongDau)
SLA Expiry BackgroundService (Phase 3 iteration 2 partial):
- Infrastructure/HostedServices/SlaExpiryJob MOI: BackgroundService moi 15 phut (delay 30s startup)
- Query Contracts WHERE SlaDeadline < UtcNow AND Phase NOT IN (DaPhatHanh, TuChoi)
- Map phase → next (happy path). Goi IContractWorkflowService.TransitionAsync voi actorUserId=null + Decision=AutoApprove + comment 'AUTO: het SLA phase X (Nh qua han)'
- Try-catch tung contract, 1 fail khong block batch
- Log structured: 'SlaExpiryJob: auto-approved contract {Id} {From} → {To}'
- Package Microsoft.Extensions.Hosting added to Infrastructure
- DI register AddHostedService<SlaExpiryJob>
Admin password warning (Phase 5.1):
- DbInitializer.WarnDefaultAdminPasswordAsync: check CheckPasswordAsync voi AdminPassword default → log WRN '⚠️ Admin user vẫn dùng password mặc định. ĐỔI NGAY trong production!'
- Chain vao InitializeAsync sau cac seed
E2E verified:
- Admin GET /contracts → total 1 (see all)
- Drafter GET /contracts → total 0 (IDOR filter, chua tao HD nao)
- API startup log: '⚠️ Admin user admin@solutionerp.local vẫn dùng password mặc định'
- Build + TS check → pass
Docs:
- STATUS.md: Phase 5.1 hau nhu xong (IDOR + admin warning + SLA job tick), cumulative BE 3900 LOC
- migration-todos.md: tick Phase 5.1 IDOR + admin warning, Phase 3 iter 2 SlaExpiryJob + E2E non-admin + admin warning
- session log 2026-04-21-1730-idor-sla-job.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -188,7 +188,8 @@ public record ListContractsQuery(
|
||||
Guid? SupplierId = null,
|
||||
Guid? ProjectId = null) : PagedRequest, IRequest<PagedResult<ContractListItemDto>>;
|
||||
|
||||
public class ListContractsQueryHandler(IApplicationDbContext db) : IRequestHandler<ListContractsQuery, PagedResult<ContractListItemDto>>
|
||||
public class ListContractsQueryHandler(IApplicationDbContext db, ICurrentUser currentUser)
|
||||
: IRequestHandler<ListContractsQuery, PagedResult<ContractListItemDto>>
|
||||
{
|
||||
public async Task<PagedResult<ContractListItemDto>> Handle(ListContractsQuery request, CancellationToken ct)
|
||||
{
|
||||
@ -197,6 +198,14 @@ public class ListContractsQueryHandler(IApplicationDbContext db) : IRequestHandl
|
||||
join p in db.Projects.AsNoTracking() on c.ProjectId equals p.Id
|
||||
select new { c, s, p };
|
||||
|
||||
// IDOR: non-admin chỉ thấy HĐ mình là Drafter hoặc role eligible phase hiện tại
|
||||
if (!currentUser.Roles.Contains(AppRoles.Admin))
|
||||
{
|
||||
var userId = currentUser.UserId;
|
||||
var eligiblePhases = GetEligiblePhases(currentUser.Roles);
|
||||
q = q.Where(x => x.c.DrafterUserId == userId || eligiblePhases.Contains(x.c.Phase));
|
||||
}
|
||||
|
||||
if (request.Phase is not null) q = q.Where(x => x.c.Phase == request.Phase);
|
||||
if (request.SupplierId is not null) q = q.Where(x => x.c.SupplierId == request.SupplierId);
|
||||
if (request.ProjectId is not null) q = q.Where(x => x.c.ProjectId == request.ProjectId);
|
||||
@ -224,6 +233,26 @@ public class ListContractsQueryHandler(IApplicationDbContext db) : IRequestHandl
|
||||
|
||||
return new PagedResult<ContractListItemDto>(items, total, request.Page, request.PageSize);
|
||||
}
|
||||
|
||||
// Mirror GetMyInboxQueryHandler.PhaseActorRoles — shared qua internal static
|
||||
internal static List<ContractPhase> GetEligiblePhases(IReadOnlyList<string> userRoles)
|
||||
{
|
||||
var phases = new HashSet<ContractPhase>();
|
||||
void AddIfAny(string[] required, params ContractPhase[] toAdd)
|
||||
{
|
||||
if (userRoles.Any(r => required.Contains(r)))
|
||||
foreach (var p in toAdd) phases.Add(p);
|
||||
}
|
||||
AddIfAny([AppRoles.Drafter, AppRoles.DeptManager],
|
||||
ContractPhase.DangSoanThao, ContractPhase.DangDamPhan, ContractPhase.DangInKy);
|
||||
AddIfAny([AppRoles.ProjectManager, AppRoles.Procurement, AppRoles.CostControl,
|
||||
AppRoles.Finance, AppRoles.Accounting, AppRoles.Equipment],
|
||||
ContractPhase.DangGopY);
|
||||
AddIfAny([AppRoles.CostControl], ContractPhase.DangKiemTraCCM);
|
||||
AddIfAny([AppRoles.Director, AppRoles.AuthorizedSigner], ContractPhase.DangTrinhKy);
|
||||
AddIfAny([AppRoles.HrAdmin], ContractPhase.DangDongDau);
|
||||
return phases.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
// ========== INBOX — HĐ chờ role/tôi xử lý ==========
|
||||
@ -281,8 +310,10 @@ public class GetMyInboxQueryHandler(
|
||||
|
||||
public record GetContractQuery(Guid Id) : IRequest<ContractDetailDto>;
|
||||
|
||||
public class GetContractQueryHandler(IApplicationDbContext db, UserManager<User> userManager)
|
||||
: IRequestHandler<GetContractQuery, ContractDetailDto>
|
||||
public class GetContractQueryHandler(
|
||||
IApplicationDbContext db,
|
||||
UserManager<User> userManager,
|
||||
ICurrentUser currentUser) : IRequestHandler<GetContractQuery, ContractDetailDto>
|
||||
{
|
||||
public async Task<ContractDetailDto> Handle(GetContractQuery request, CancellationToken ct)
|
||||
{
|
||||
@ -293,6 +324,17 @@ public class GetContractQueryHandler(IApplicationDbContext db, UserManager<User>
|
||||
.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
|
||||
?? throw new NotFoundException("Contract", request.Id);
|
||||
|
||||
// IDOR guard: non-admin chỉ xem được HĐ mình là Drafter hoặc role eligible phase hiện tại
|
||||
var isAdmin = currentUser.Roles.Contains(AppRoles.Admin);
|
||||
if (!isAdmin)
|
||||
{
|
||||
var isDrafter = c.DrafterUserId == currentUser.UserId;
|
||||
var eligiblePhases = ListContractsQueryHandler.GetEligiblePhases(currentUser.Roles);
|
||||
var isEligibleByRole = eligiblePhases.Contains(c.Phase);
|
||||
if (!isDrafter && !isEligibleByRole)
|
||||
throw new ForbiddenException("Bạn không có quyền xem HĐ này.");
|
||||
}
|
||||
|
||||
var supplier = await db.Suppliers.AsNoTracking().FirstOrDefaultAsync(s => s.Id == c.SupplierId, ct);
|
||||
var project = await db.Projects.AsNoTracking().FirstOrDefaultAsync(p => p.Id == c.ProjectId, ct);
|
||||
var department = c.DepartmentId is null ? null : await db.Departments.AsNoTracking().FirstOrDefaultAsync(d => d.Id == c.DepartmentId, ct);
|
||||
|
||||
Reference in New Issue
Block a user