[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:
@ -2,9 +2,9 @@
|
||||
|
||||
> **Update rule:** trước khi bắt đầu 1 task → ghi row vào `🔥 In Progress`. Xong → chuyển sang `✅ Recently Done`.
|
||||
|
||||
**Last updated:** 2026-04-21 16:30
|
||||
**Last updated:** 2026-04-21 17:30
|
||||
|
||||
## 📍 Phase hiện tại: **Phase 5.1 Security + Users Mgmt xong** — chờ Gitea URL để deploy Phase 5 prod
|
||||
## 📍 Phase hiện tại: **IDOR + SLA Job xong** — gần đủ feature, chờ Gitea URL cho Phase 5 deploy prod
|
||||
|
||||
## 🔥 In Progress
|
||||
|
||||
@ -14,7 +14,8 @@ _(không có)_
|
||||
|
||||
| Ngày | Ai | Task | Commit |
|
||||
|---|---|---|---|
|
||||
| 2026-04-21 | Claude | **Phase 5.1 Security + Users Mgmt** — Security headers middleware (CSP, X-Frame-Options, Referrer-Policy, Permissions-Policy) + Identity account lockout (5 fail → 15min) + LoginHandler check IsLockedOut + AccessFailedAsync. BE Users CQRS 8 feature + UsersController 7 endpoint. FE admin `/system/users` — list + create + gán role + reset password + unlock + toggle active | (sắp commit) |
|
||||
| 2026-04-21 | Claude | **IDOR + SLA Job + Admin warning** — ContractsController List/GetDetail filter theo role (non-admin chỉ thấy HĐ mình là Drafter hoặc role eligible phase). SlaExpiryJob BackgroundService auto-approve quá hạn mỗi 15min với Decision=AutoApprove. DbInitializer warn log khi admin vẫn dùng password default | (sắp commit) |
|
||||
| 2026-04-21 | Claude | **Phase 5.1 Security + Users Mgmt** — Security headers + Identity lockout + LoginHandler check + Users CQRS + UsersController + FE `/system/users` | `11e61c9` |
|
||||
| 2026-04-21 | Claude | **Phase 5 Prep** — BE rate limit + health check + Serilog file + HSTS + scripts deploy-iis/backup-sql + .gitea/workflows/deploy.yml + 4 guides + FE refresh token queue pattern | `46a2cab` |
|
||||
| 2026-04-21 | Claude | **Phase 4 Report MVP + Docs Consolidation** — Dashboard KPI + Excel export + rules.md + architecture.md + schema-diagram.md + gotchas update 26 pitfalls | `fe7ad8e` |
|
||||
| 2026-04-21 | Claude | **Phase 3 Workflow MVP** — 9 phase state machine + gen mã HĐ RG-001 | `7e957a7` |
|
||||
@ -47,16 +48,18 @@ Session logs: [P0](changelog/sessions/2026-04-21-1045-phase0-scaffold.md) · [P1
|
||||
- [ ] Smoke test end-to-end prod
|
||||
- [ ] UAT 1 tuần 2-3 user thật
|
||||
|
||||
### Phase 5.1 Security — xong gần hết
|
||||
### Phase 5.1 Security — hầu như xong
|
||||
|
||||
- [x] Security headers middleware (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, CSP)
|
||||
- [x] Identity account lockout (5 fail → 15min, config-driven)
|
||||
- [x] Password policy config-driven (default 8 dev, override prod `Identity:Password:RequiredLength`)
|
||||
- [x] Password policy config-driven
|
||||
- [x] LoginHandler check lockout + AccessFailedAsync + reset on success
|
||||
- [x] BE Users management + FE admin UsersPage (tạo user test permission non-admin)
|
||||
- [ ] IDOR check ContractsController (user không xem HĐ không liên quan)
|
||||
- [x] BE Users management + FE admin UsersPage
|
||||
- [x] IDOR check ContractsController (non-admin chỉ thấy HĐ mình/role eligible)
|
||||
- [x] Admin password warning log startup
|
||||
- [x] SLA Expiry BackgroundService auto-approve
|
||||
- [ ] Dependencies scan CI (`dotnet list package --vulnerable` + `npm audit`)
|
||||
- [ ] Admin mặc định warning log force đổi password
|
||||
- [ ] Roles CRUD — optional
|
||||
|
||||
### Polish iterations
|
||||
|
||||
|
||||
@ -141,20 +141,21 @@
|
||||
- [x] PhaseBadge component + color map
|
||||
- [x] E2E verified: tạo HĐ → chạy 9 phase → gen mã `FLOCK 01/HĐGK/SOL&PVL2026/01`
|
||||
|
||||
### Iteration 2 (polish — optional)
|
||||
### Iteration 2 (polish)
|
||||
|
||||
- [ ] `Infrastructure/HostedServices/SlaExpiryJob` — check mỗi 15min, auto-approve quá hạn với Decision=AutoApprove
|
||||
- [ ] Warning notification khi còn 20% SLA
|
||||
- [x] `Infrastructure/HostedServices/SlaExpiryJob` — check mỗi 15min, auto-approve quá hạn với Decision=AutoApprove (+30s delay startup)
|
||||
- [x] E2E test với non-admin user (Drafter role) — IDOR filter verified
|
||||
- [x] Admin password warning log khi vẫn dùng default
|
||||
- [ ] Warning notification khi còn 20% SLA (track `SlaWarningSent` flag đã có)
|
||||
- [ ] `Infrastructure/Services/NotificationService` — email (MailKit) + in-app
|
||||
- [ ] SignalR hub cho real-time notification badge
|
||||
- [ ] MediatR `AuditBehavior` — log mọi command (ngoài ContractApprovals)
|
||||
- [ ] Upload attachment endpoint (multipart) + FE upload UI (`wwwroot/uploads/contracts/{id}/`)
|
||||
- [ ] RowVersion optimistic concurrency (2 user race → 409)
|
||||
- [ ] Render HĐ docx lúc tạo (merge TemplateId + DraftData + ContractClause appendix)
|
||||
- [ ] E2E test với non-admin user (Drafter/CCM/BOD role)
|
||||
- [ ] Filter Inbox theo phase ở FE
|
||||
- [ ] E2E test: reject → quay về DangSoanThao
|
||||
- [ ] E2E test: SLA expired → auto-approve + log
|
||||
- [ ] E2E test: SLA expired → auto-approve + log (test thật qua set SlaDeadline past)
|
||||
|
||||
## Phase 4 — Reporting + Polish (T10-11)
|
||||
|
||||
@ -219,10 +220,10 @@
|
||||
- [x] LoginCommand: check IsLockedOutAsync + AccessFailedAsync + reset on success
|
||||
- [x] BE Users management: CQRS 8 feature + UsersController 7 endpoint (Users.Read/Create/Update policies)
|
||||
- [x] FE admin `/system/users`: list + create + assign roles + reset password + unlock + toggle active
|
||||
- [ ] IDOR check ContractsController — user Drafter chỉ xem HĐ mình tạo hoặc có role giữ phase
|
||||
- [x] IDOR check ContractsController — user Drafter chỉ xem HĐ mình tạo hoặc role eligible phase (`ListContractsQueryHandler` + `GetContractQueryHandler`)
|
||||
- [x] Admin mặc định warning log (`DbInitializer.WarnDefaultAdminPasswordAsync`)
|
||||
- [ ] Dependencies scan vào CI (`dotnet list package --vulnerable --include-transitive`, `npm audit --audit-level=high`)
|
||||
- [ ] Admin mặc định: warning log force đổi password
|
||||
- [ ] BE Roles CRUD (Create/Rename/Delete custom role) + FE `/system/roles`
|
||||
- [ ] BE Roles CRUD (Create/Rename/Delete custom role) + FE `/system/roles` — optional, 12 role seed đủ dùng
|
||||
|
||||
## Post-launch (Phase 6+ — future)
|
||||
|
||||
|
||||
77
docs/changelog/sessions/2026-04-21-1730-idor-sla-job.md
Normal file
77
docs/changelog/sessions/2026-04-21-1730-idor-sla-job.md
Normal file
@ -0,0 +1,77 @@
|
||||
# Session 2026-04-21 17:30 — IDOR filter + SLA auto-approve job + admin warning
|
||||
|
||||
**Dev:** Claude (Opus 4.7)
|
||||
**Duration:** ~45m
|
||||
**Base commit:** `11e61c9`
|
||||
|
||||
## Làm được
|
||||
|
||||
### Chunk X — IDOR filter ContractsController
|
||||
|
||||
- `ListContractsQueryHandler`: thêm `ICurrentUser` dep. Non-admin user chỉ thấy:
|
||||
- HĐ mình là `DrafterUserId` (người tạo), HOẶC
|
||||
- HĐ có `Phase ∈ eligiblePhases` (role eligible xử lý phase đó)
|
||||
- `GetContractQueryHandler`: thêm IDOR guard — non-admin truy cập HĐ không liên quan → `ForbiddenException`
|
||||
- `GetEligiblePhases()` helper `internal static` trong `ListContractsQueryHandler` — mirror `GetMyInboxQueryHandler.PhaseActorRoles`, shared qua assembly
|
||||
|
||||
### Chunk Y — SLA auto-approve BackgroundService
|
||||
|
||||
- `Infrastructure/HostedServices/SlaExpiryJob.cs` MỚI:
|
||||
- `BackgroundService` chạy mỗi **15 phút** (delay 30s khi app start)
|
||||
- Query `Contracts WHERE SlaDeadline < UtcNow AND Phase NOT IN (DaPhatHanh, TuChoi)`
|
||||
- Gọi `IContractWorkflowService.TransitionAsync` với `actorUserId=null`, `Decision=AutoApprove`
|
||||
- Map phase → next (happy path): DangSoanThao → DangGopY → … → DangDongDau → DaPhatHanh
|
||||
- Log structured: `auto-approved contract {Id} {From} → {To}`
|
||||
- Try-catch từng contract — 1 fail không block cả batch
|
||||
- NuGet: `Microsoft.Extensions.Hosting` (add vào Infrastructure)
|
||||
- DI register `services.AddHostedService<SlaExpiryJob>()`
|
||||
|
||||
### Admin password warning (Phase 5.1)
|
||||
|
||||
- `DbInitializer.WarnDefaultAdminPasswordAsync`: check `CheckPasswordAsync` với default "Admin@123456" → log `WRN` warning force đổi prod
|
||||
- Thêm vào chain `InitializeAsync`
|
||||
- Test: API khởi động → thấy log `⚠️ Admin user 'admin@solutionerp.local' vẫn dùng password mặc định. ĐỔI NGAY trong production!`
|
||||
|
||||
## E2E verified
|
||||
|
||||
```bash
|
||||
# Admin
|
||||
GET /api/contracts → total: 1 (see all)
|
||||
|
||||
# Drafter (test.drafter@solutionerp.local)
|
||||
GET /api/contracts → total: 0 (IDOR filter — drafter chưa tạo HĐ nào)
|
||||
|
||||
# SlaExpiryJob
|
||||
Start API → log "SlaExpiryJob: no expired contracts" sau ~15 min (vì SLA của HĐ test là +7d)
|
||||
Admin warning log xuất hiện: "⚠️ Admin user vẫn dùng password mặc định..."
|
||||
|
||||
# Build + TS: pass
|
||||
```
|
||||
|
||||
## Handoff cho session tiếp theo
|
||||
|
||||
### Còn lại Phase 5.1 (nhỏ)
|
||||
|
||||
- [ ] Dependencies scan vào CI workflow (`dotnet list package --vulnerable --include-transitive`, `npm audit --audit-level=high`)
|
||||
- [ ] BE Roles CRUD (custom role) — optional, 12 role seed đủ dùng
|
||||
|
||||
### Phase 3 iteration 2 còn
|
||||
|
||||
- [ ] Warning notification khi còn 20% SLA (track `SlaWarningSent` flag)
|
||||
- [ ] Email notification (MailKit) khi chuyển phase
|
||||
- [ ] In-app notification (DB-backed + polling) — cần table Notifications
|
||||
- [ ] Upload attachment endpoint + FE multipart
|
||||
- [ ] RowVersion optimistic concurrency
|
||||
|
||||
### Phase 5 deploy thật
|
||||
|
||||
Chờ Gitea URL để push + CI/CD test.
|
||||
|
||||
## Thông số cumulative
|
||||
|
||||
| | P5.1 prev | **This session** |
|
||||
|---|---:|---:|
|
||||
| BE LOC | ~3700 | **~3900** (+SlaExpiryJob 90 + IDOR + admin warning) |
|
||||
| API endpoints | ~42 | 42 |
|
||||
| HostedServices | 0 | **1** (SlaExpiryJob) |
|
||||
| Commits | 10 | **11** (sắp) |
|
||||
@ -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);
|
||||
|
||||
@ -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