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:
Ho Ngoc Hai
2026-03-28 23:54:02 +07:00
parent 90debb3e94
commit b378f39872
7 changed files with 146 additions and 23 deletions

View File

@@ -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.

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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();
}

View File

@@ -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 });
}

View File

@@ -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 ───
// ═══════════════════════════════════════════════

View File

@@ -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);