[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:
@ -8,6 +8,7 @@ using SolutionErp.Application.Forms.Services;
|
||||
using SolutionErp.Application.Reports.Services;
|
||||
using SolutionErp.Domain.Identity;
|
||||
using SolutionErp.Infrastructure.Forms;
|
||||
using SolutionErp.Infrastructure.HostedServices;
|
||||
using SolutionErp.Infrastructure.Identity;
|
||||
using SolutionErp.Infrastructure.Persistence;
|
||||
using SolutionErp.Infrastructure.Persistence.Interceptors;
|
||||
@ -30,6 +31,9 @@ public static class DependencyInjection
|
||||
services.AddScoped<IContractWorkflowService, ContractWorkflowService>();
|
||||
services.AddScoped<IContractExcelExporter, ContractExcelExporter>();
|
||||
|
||||
// Phase 3 iteration 2 — SLA auto-approve background service
|
||||
services.AddHostedService<SlaExpiryJob>();
|
||||
|
||||
services.AddScoped<AuditingInterceptor>();
|
||||
|
||||
services.AddDbContext<ApplicationDbContext>((sp, options) =>
|
||||
|
||||
@ -0,0 +1,106 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Application.Contracts.Services;
|
||||
using SolutionErp.Domain.Contracts;
|
||||
|
||||
namespace SolutionErp.Infrastructure.HostedServices;
|
||||
|
||||
// Background service chạy mỗi 15 phút, auto-approve HĐ quá SLA.
|
||||
// Xem docs/flows/sla-expiry-flow.md cho spec full.
|
||||
// Phase 3 iteration 2 MVP — chưa có notification, chỉ log + ContractApproval với Decision=AutoApprove.
|
||||
public class SlaExpiryJob : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _sp;
|
||||
private readonly ILogger<SlaExpiryJob> _logger;
|
||||
private static readonly TimeSpan Interval = TimeSpan.FromMinutes(15);
|
||||
|
||||
// Map phase → phase tiếp (happy path). Nếu null → không auto-approve (final/TuChoi).
|
||||
private static readonly Dictionary<ContractPhase, ContractPhase?> NextPhase = new()
|
||||
{
|
||||
[ContractPhase.DangSoanThao] = ContractPhase.DangGopY,
|
||||
[ContractPhase.DangGopY] = ContractPhase.DangDamPhan,
|
||||
[ContractPhase.DangDamPhan] = ContractPhase.DangInKy,
|
||||
[ContractPhase.DangInKy] = ContractPhase.DangKiemTraCCM,
|
||||
[ContractPhase.DangKiemTraCCM] = ContractPhase.DangTrinhKy,
|
||||
[ContractPhase.DangTrinhKy] = ContractPhase.DangDongDau,
|
||||
[ContractPhase.DangDongDau] = ContractPhase.DaPhatHanh,
|
||||
};
|
||||
|
||||
public SlaExpiryJob(IServiceProvider sp, ILogger<SlaExpiryJob> logger)
|
||||
{
|
||||
_sp = sp;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
// Wait 30s sau khi app start — tránh race với DbInitializer migrate
|
||||
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ProcessAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "SlaExpiryJob iteration failed");
|
||||
}
|
||||
await Task.Delay(Interval, stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessAsync(CancellationToken ct)
|
||||
{
|
||||
await using var scope = _sp.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<IApplicationDbContext>();
|
||||
var workflow = scope.ServiceProvider.GetRequiredService<IContractWorkflowService>();
|
||||
var dateTime = scope.ServiceProvider.GetRequiredService<IDateTime>();
|
||||
|
||||
var now = dateTime.UtcNow;
|
||||
var expired = await db.Contracts
|
||||
.Where(c => c.SlaDeadline != null && c.SlaDeadline < now)
|
||||
.Where(c => c.Phase != ContractPhase.DaPhatHanh && c.Phase != ContractPhase.TuChoi)
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (expired.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("SlaExpiryJob: no expired contracts");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("SlaExpiryJob: {Count} expired contracts", expired.Count);
|
||||
|
||||
foreach (var contract in expired)
|
||||
{
|
||||
if (!NextPhase.TryGetValue(contract.Phase, out var next) || next is null)
|
||||
{
|
||||
_logger.LogWarning("SlaExpiryJob: phase {Phase} không có next, skip contract {Id}", contract.Phase, contract.Id);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var hoursOver = (int)(now - contract.SlaDeadline!.Value).TotalHours;
|
||||
await workflow.TransitionAsync(
|
||||
contract,
|
||||
next.Value,
|
||||
actorUserId: null, // system
|
||||
actorRoles: new List<string>(), // system bypass check trong workflow
|
||||
decision: ApprovalDecision.AutoApprove,
|
||||
comment: $"[AUTO] Hết SLA phase {contract.Phase} ({hoursOver}h quá hạn). Tự động chuyển sang {next.Value}.",
|
||||
ct: ct);
|
||||
_logger.LogInformation("SlaExpiryJob: auto-approved contract {Id} {From} → {To}",
|
||||
contract.Id, contract.Phase, next.Value);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "SlaExpiryJob: failed auto-approve {Id}", contract.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -30,6 +30,20 @@ public static class DbInitializer
|
||||
await SeedMenuTreeAsync(db, logger);
|
||||
await SeedAdminPermissionsAsync(db, roleManager, logger);
|
||||
await SeedContractTemplatesAsync(db, logger);
|
||||
await WarnDefaultAdminPasswordAsync(userManager, logger);
|
||||
}
|
||||
|
||||
// Phase 5.1 security: log warning nếu admin vẫn dùng password mặc định sau deploy production.
|
||||
private static async Task WarnDefaultAdminPasswordAsync(UserManager<User> userManager, ILogger logger)
|
||||
{
|
||||
var admin = await userManager.FindByEmailAsync(AdminEmail);
|
||||
if (admin is null) return;
|
||||
if (await userManager.CheckPasswordAsync(admin, AdminPassword))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"⚠️ Admin user '{Email}' vẫn dùng password mặc định. ĐỔI NGAY trong production! " +
|
||||
"Xem docs/guides/security-checklist.md.", AdminEmail);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task SeedRolesAsync(RoleManager<Role> roleManager, ILogger logger)
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.6" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.17.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user