fix(audit): implement audit logging pipeline and fix response format
- Add POST /api/v1/audit/logs endpoint to IAM AuditController for creating entries - Hook audit logging into BFF login (fire-and-forget after successful login) - Hook audit logging into SuperAdmin merchant actions (approve/suspend/reactivate) - Fix IamApiService AuditLogDto to match actual API response (logs[] not items[]) - Fix AuditLogDto fields: ActorName→ActorEmail, Status→Success, Details→Action - Fix Admin AuditLog.razor to use updated DTO fields Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -143,8 +143,28 @@ All 9 warnings are due to **empty test data**, not code issues:
|
||||
| 5.5 User pagination | Only 5 users, <20 threshold | Add more users to test |
|
||||
| 9.2 Audit log empty | IAM audit API returns no events | Perform actions (login/role change) to generate audit entries |
|
||||
|
||||
## Re-test After Fix (2026-03-28 22:50)
|
||||
|
||||
### Bugs Found & Fixed:
|
||||
1. **MerchantService 500 error** — `IsDeleted`, `CreatedAt`, `BusinessName` are private backing fields Ignored in EF config. LINQ couldn't translate `m.IsDeleted` → Fixed to use `EF.Property<bool>(m, "_isDeleted")`
|
||||
2. **BFF auth forwarding** — `SuperAdminController.CreateAuthClient()` set Bearer header manually, conflicting with `AuthForwardingHandler` which already reads bff_session cookie → Removed duplicate header setting
|
||||
3. **Frontend DTO mismatch** — API returns `shopsCount`/`type` but DTO had `ShopCount`/`BusinessType` → Fixed field names
|
||||
4. **Response format** — API returns `{ items: [...] }` directly (no `data` wrapper) → Added fallback parsing
|
||||
|
||||
### Re-test Results:
|
||||
| Test | Before | After |
|
||||
|------|--------|-------|
|
||||
| Merchants list | W (empty) | **P** — "GoodGo Demo Cafe" displays with badges |
|
||||
| Dashboard KPI | 0 merchants | **P** — Shows real merchant count |
|
||||
| Merchant actions | W (no data) | **P** — "Tạm ngưng" button visible for Active merchant |
|
||||
|
||||
## Conclusion
|
||||
|
||||
**0 Failures / 45 Passes / 9 Warnings (data-dependent)**
|
||||
**0 Failures / 51 Passes / 3 Warnings**
|
||||
|
||||
All Super Admin features are **functionally correct**. The 9 warnings are all caused by lack of test data (no merchants registered), not by code bugs. When real merchant data exists, these features will work as designed.
|
||||
Remaining 3 warnings are non-critical:
|
||||
- W8: User pagination — only 5 users, correct behavior
|
||||
- W9: Audit log empty — IAM `CreateAuditLogCommand` exists but not invoked by any handler yet
|
||||
- W5: User pagination threshold not reached
|
||||
|
||||
All merchant-related warnings resolved by fixing EF Core query translation bugs.
|
||||
|
||||
@@ -84,9 +84,9 @@ else
|
||||
<td>
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<div class="admin-user-avatar" style="width:28px;height:28px;font-size:10px;background-color:#8B5CF6;">
|
||||
@GetInitials(log.ActorName ?? log.ActorId ?? "?")
|
||||
@GetInitials(log.ActorEmail ?? log.ActorId ?? "?")
|
||||
</div>
|
||||
<span style="font-weight:500;">@(log.ActorName ?? log.ActorId ?? "System")</span>
|
||||
<span style="font-weight:500;">@(log.ActorEmail ?? log.ActorId ?? "System")</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@@ -98,9 +98,9 @@ else
|
||||
<td style="color:var(--admin-text-secondary);">@(log.ResourceType ?? "—") @(log.ResourceId != null ? $"#{log.ResourceId[..Math.Min(8, log.ResourceId.Length)]}" : "")</td>
|
||||
<td style="font-size:12px;color:var(--admin-text-tertiary);font-family:monospace;">@(log.IpAddress ?? "—")</td>
|
||||
<td>
|
||||
<div class="admin-status-badge @(log.Status == "Success" ? "admin-status-badge--online" : "admin-status-badge--offline")">
|
||||
<div class="admin-status-badge @(log.Success?.ToString() == "Success" ? "admin-status-badge--online" : "admin-status-badge--offline")">
|
||||
<span class="admin-status-badge__dot"></span>
|
||||
@(log.Status == "Success" ? "Thành công" : log.Status ?? "—")
|
||||
@(log.Success?.ToString() == "Success" ? "Thành công" : log.Success?.ToString() ?? "—")
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -124,7 +124,7 @@ else
|
||||
var list = _activeTab == "all" ? _logs.AsEnumerable() : _logs.Where(l => IsCategory(l, _activeTab));
|
||||
if (!string.IsNullOrEmpty(SearchQuery))
|
||||
list = list.Where(l => (l.EventType ?? "").Contains(SearchQuery, StringComparison.OrdinalIgnoreCase)
|
||||
|| (l.ActorName ?? "").Contains(SearchQuery, StringComparison.OrdinalIgnoreCase)
|
||||
|| (l.ActorEmail ?? "").Contains(SearchQuery, StringComparison.OrdinalIgnoreCase)
|
||||
|| (l.ResourceType ?? "").Contains(SearchQuery, StringComparison.OrdinalIgnoreCase));
|
||||
return list;
|
||||
}
|
||||
|
||||
@@ -41,12 +41,12 @@
|
||||
<tr>
|
||||
<td style="white-space:nowrap;">@(log.Timestamp?.ToLocalTime().ToString("dd/MM HH:mm:ss") ?? "—")</td>
|
||||
<td><span class="sa-badge sa-badge--info">@(log.EventType ?? "—")</span></td>
|
||||
<td>@(log.ActorName ?? log.ActorId ?? "—")</td>
|
||||
<td>@(log.ActorEmail ?? log.ActorId ?? "—")</td>
|
||||
<td>@(log.ResourceType ?? "—") @(log.ResourceId != null ? $"#{log.ResourceId[..Math.Min(8, log.ResourceId.Length)]}" : "")</td>
|
||||
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">@(log.Details ?? "—")</td>
|
||||
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">@(log.Action ?? "—")</td>
|
||||
<td>
|
||||
<span class="sa-badge @(log.Status == "Success" ? "sa-badge--success" : log.Status == "Failed" ? "sa-badge--danger" : "sa-badge--neutral")">
|
||||
@(log.Status ?? "—")
|
||||
<span class="sa-badge @(log.Success == true ? "sa-badge--success" : "sa-badge--danger")">
|
||||
@(log.Success == true ? "Thành công" : "Thất bại")
|
||||
</span>
|
||||
</td>
|
||||
<td style="font-size:11px;color:var(--sa-text-tertiary);">@(log.IpAddress ?? "—")</td>
|
||||
|
||||
@@ -328,9 +328,9 @@ public class IamApiService
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
public record AuditLogDto(
|
||||
int? Id, DateTime? Timestamp, string? EventType, string? ActorId,
|
||||
string? ActorName, string? ResourceType, string? ResourceId,
|
||||
string? Details, string? IpAddress, string? Status);
|
||||
Guid? Id, DateTime? Timestamp, string? EventType, string? ActorId,
|
||||
string? ActorEmail, string? ResourceType, string? ResourceId,
|
||||
string? Action, bool? Success, string? IpAddress);
|
||||
|
||||
public async Task<List<AuditLogDto>> GetAuditLogsAsync(int take = 50)
|
||||
{
|
||||
@@ -341,11 +341,18 @@ public class IamApiService
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var json = await response.Content.ReadFromJsonAsync<JsonElement>(_jsonOptions);
|
||||
if (json.TryGetProperty("data", out var data) && data.TryGetProperty("items", out var items))
|
||||
return items.Deserialize<List<AuditLogDto>>(_jsonOptions) ?? new();
|
||||
if (json.TryGetProperty("items", out var items2))
|
||||
return items2.Deserialize<List<AuditLogDto>>(_jsonOptions) ?? new();
|
||||
return json.Deserialize<List<AuditLogDto>>(_jsonOptions) ?? new();
|
||||
if (json.TryGetProperty("data", out var data))
|
||||
{
|
||||
// EN: API returns { data: { logs: [...] } } or { data: { items: [...] } }
|
||||
// VI: API trả { data: { logs: [...] } } hoặc { data: { items: [...] } }
|
||||
if (data.TryGetProperty("logs", out var logs))
|
||||
return logs.Deserialize<List<AuditLogDto>>(_jsonOptions) ?? new();
|
||||
if (data.TryGetProperty("items", out var items))
|
||||
return items.Deserialize<List<AuditLogDto>>(_jsonOptions) ?? new();
|
||||
if (data.ValueKind == JsonValueKind.Array)
|
||||
return data.Deserialize<List<AuditLogDto>>(_jsonOptions) ?? new();
|
||||
}
|
||||
return new();
|
||||
}
|
||||
return new();
|
||||
}
|
||||
|
||||
@@ -132,6 +132,32 @@ public class BffAuthController : ControllerBase
|
||||
// VI: Trả về thông tin session (không có token thô) — WASM dùng để hiển thị tên/vai trò.
|
||||
var sessionInfo = ExtractSessionFromJwt(accessToken, expiresIn);
|
||||
_logger.LogInformation("EN: BFF login success for {Email} role={Role}.", sessionInfo?.Email, sessionInfo?.Role);
|
||||
|
||||
// EN: Fire-and-forget audit log — don't block login response
|
||||
// VI: Ghi audit log fire-and-forget — không chặn login response
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var auditClient = _clientFactory.CreateClient("IamService");
|
||||
auditClient.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", $"Bearer {accessToken}");
|
||||
await auditClient.PostAsJsonAsync("/api/v1/audit/logs", new
|
||||
{
|
||||
eventTypeId = 1, // Login
|
||||
resourceType = "User",
|
||||
actorId = sessionInfo?.UserId,
|
||||
actorEmail = sessionInfo?.Email,
|
||||
action = "Login via BFF",
|
||||
details = $"Role: {sessionInfo?.Role}",
|
||||
success = true
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to create audit log for login");
|
||||
}
|
||||
});
|
||||
|
||||
return Ok(new { success = true, data = sessionInfo });
|
||||
}
|
||||
|
||||
|
||||
@@ -144,21 +144,27 @@ public class SuperAdminController : ControllerBase
|
||||
public async Task<IActionResult> ApproveMerchant(Guid id)
|
||||
{
|
||||
var client = CreateAuthClient("MerchantService");
|
||||
return await ProxyPost(client, $"/api/v1/admin/merchants/{id}/approve");
|
||||
var result = await ProxyPost(client, $"/api/v1/admin/merchants/{id}/approve");
|
||||
_ = LogAuditAsync(21, "Merchant", id, "Approve merchant");
|
||||
return result;
|
||||
}
|
||||
|
||||
[HttpPost("merchants/{id}/suspend")]
|
||||
public async Task<IActionResult> SuspendMerchant(Guid id, [FromBody] JsonElement body)
|
||||
{
|
||||
var client = CreateAuthClient("MerchantService");
|
||||
return await ProxyPostWithBody(client, $"/api/v1/admin/merchants/{id}/suspend", body);
|
||||
var result = await ProxyPostWithBody(client, $"/api/v1/admin/merchants/{id}/suspend", body);
|
||||
_ = LogAuditAsync(13, "Merchant", id, "Suspend merchant");
|
||||
return result;
|
||||
}
|
||||
|
||||
[HttpPost("merchants/{id}/reactivate")]
|
||||
public async Task<IActionResult> ReactivateMerchant(Guid id)
|
||||
{
|
||||
var client = CreateAuthClient("MerchantService");
|
||||
return await ProxyPost(client, $"/api/v1/admin/merchants/{id}/reactivate");
|
||||
var result = await ProxyPost(client, $"/api/v1/admin/merchants/{id}/reactivate");
|
||||
_ = LogAuditAsync(14, "Merchant", id, "Reactivate merchant");
|
||||
return result;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
@@ -283,6 +289,35 @@ public class SuperAdminController : ControllerBase
|
||||
return Ok(new { success = true, data = flag });
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// ─── AUDIT HELPER ───
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// EN: Fire-and-forget audit log via IAM service POST /api/v1/audit/logs.
|
||||
/// VI: Ghi audit log fire-and-forget qua IAM service POST /api/v1/audit/logs.
|
||||
/// </summary>
|
||||
private async Task LogAuditAsync(int eventTypeId, string resourceType, Guid? resourceId, string action)
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = CreateAuthClient("IamService");
|
||||
await client.PostAsJsonAsync("/api/v1/audit/logs", new
|
||||
{
|
||||
eventTypeId,
|
||||
resourceType,
|
||||
resourceId,
|
||||
action,
|
||||
details = $"SuperAdmin action via BFF",
|
||||
success = true
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to create audit log for {Action}", action);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// ─── PROXY HELPERS ───
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Swashbuckle.AspNetCore.Annotations;
|
||||
using IamService.API.Application.Common;
|
||||
using IamService.API.Application.Commands.Audit;
|
||||
using IamService.API.Application.Queries.Audit;
|
||||
|
||||
namespace IamService.API.Controllers;
|
||||
@@ -12,7 +13,6 @@ namespace IamService.API.Controllers;
|
||||
[ApiVersion("1.0")]
|
||||
[Route("api/v{version:apiVersion}/audit")]
|
||||
[Authorize(AuthenticationSchemes = "Bearer")]
|
||||
[Authorize(Policy = "RequireAuditor")]
|
||||
[SwaggerTag("Audit log management - requires Auditor role")]
|
||||
public class AuditController : ControllerBase
|
||||
{
|
||||
@@ -20,6 +20,7 @@ public class AuditController : ControllerBase
|
||||
public AuditController(IMediator mediator) => _mediator = mediator;
|
||||
|
||||
[HttpGet("logs")]
|
||||
[Authorize(Policy = "RequireAuditor")]
|
||||
[SwaggerOperation(Summary = "Get audit logs", OperationId = "GetAuditLogs")]
|
||||
public async Task<IActionResult> GetLogs(
|
||||
[FromQuery] DateTime? fromDate,
|
||||
@@ -34,4 +35,38 @@ public class AuditController : ControllerBase
|
||||
var result = await _mediator.Send(new GetAuditLogsQuery(fromDate, toDate, eventTypeId, actorId, resourceType, skip, take), ct);
|
||||
return Ok(ApiResponse<AuditLogsResult>.Ok(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create an audit log entry (called by BFF after actions like login, merchant approve/suspend).
|
||||
/// VI: Tạo audit log entry (gọi bởi BFF sau các actions như login, duyệt/tạm ngưng merchant).
|
||||
/// </summary>
|
||||
[HttpPost("logs")]
|
||||
[Authorize(Policy = "RequireAdmin")]
|
||||
[SwaggerOperation(Summary = "Create audit log entry", OperationId = "CreateAuditLog")]
|
||||
public async Task<IActionResult> CreateLog([FromBody] CreateAuditLogRequest request, CancellationToken ct)
|
||||
{
|
||||
var id = await _mediator.Send(new CreateAuditLogCommand(
|
||||
request.EventTypeId,
|
||||
request.ResourceType,
|
||||
request.ActorId,
|
||||
request.ActorEmail,
|
||||
request.ResourceId,
|
||||
request.Action,
|
||||
request.Details,
|
||||
HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||
HttpContext.Request.Headers.UserAgent.ToString(),
|
||||
request.Success), ct);
|
||||
|
||||
return Ok(ApiResponse<object>.Ok(new { id }));
|
||||
}
|
||||
}
|
||||
|
||||
public record CreateAuditLogRequest(
|
||||
int EventTypeId,
|
||||
string ResourceType,
|
||||
Guid? ActorId = null,
|
||||
string? ActorEmail = null,
|
||||
Guid? ResourceId = null,
|
||||
string? Action = null,
|
||||
string? Details = null,
|
||||
bool Success = true);
|
||||
|
||||
Reference in New Issue
Block a user