Files
mic-check/src/api/MicCheck.Api/Audit/AuditService.cs
James Wampler f920a6d0a5 Extract IAuditService, drop virtual mock seam from AuditService
Consumers now depend on IAuditService instead of the concrete class,
so tests mock the interface (Mock<IAuditService>()) rather than
subclassing AuditService via virtual methods.
2026-07-05 22:30:10 -07:00

145 lines
4.1 KiB
C#
Executable File

using System.Text.Json;
using MicCheck.Api.Data;
using MicCheck.Api.Webhooks;
namespace MicCheck.Api.Audit;
public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContextAccessor, WebhookQueue webhookQueue) : IAuditService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public async Task RecordAsync(
string resourceType,
string resourceId,
string action,
int organizationId,
int? projectId = null,
int? environmentId = null,
object? before = null,
object? after = null,
CancellationToken ct = default)
{
string? changes = null;
if (before is not null || after is not null)
{
changes = JsonSerializer.Serialize(new
{
before,
after
}, JsonOptions);
}
var actorUserId = GetCurrentUserId();
var log = new AuditLog
{
ResourceType = resourceType,
ResourceId = resourceId,
Action = action,
OrganizationId = organizationId,
ProjectId = projectId,
EnvironmentId = environmentId,
ActorUserId = actorUserId,
Changes = changes,
CreatedAt = DateTimeOffset.UtcNow
};
db.AuditLogs.Add(log);
await db.SaveChangesAsync(ct);
await webhookQueue.EnqueueAsync(new WebhookEvent
{
EventType = WebhookEventTypes.AuditLogCreated,
EnvironmentId = environmentId,
OrganizationId = organizationId,
Data = new
{
audit_log_id = log.Id,
resource_type = resourceType,
resource_id = resourceId,
action,
created_at = log.CreatedAt
}
});
}
// Backward-compatible overload used by existing callers
public async Task LogAsync(
string resourceType,
string resourceId,
string action,
int organizationId,
int? projectId = null,
int? environmentId = null,
string? changes = null,
CancellationToken ct = default)
{
var actorUserId = GetCurrentUserId();
var log = new AuditLog
{
ResourceType = resourceType,
ResourceId = resourceId,
Action = action,
OrganizationId = organizationId,
ProjectId = projectId,
EnvironmentId = environmentId,
ActorUserId = actorUserId,
Changes = changes,
CreatedAt = DateTimeOffset.UtcNow
};
db.AuditLogs.Add(log);
await db.SaveChangesAsync(ct);
await webhookQueue.EnqueueAsync(new WebhookEvent
{
EventType = WebhookEventTypes.AuditLogCreated,
EnvironmentId = environmentId,
OrganizationId = organizationId,
Data = new
{
audit_log_id = log.Id,
resource_type = resourceType,
resource_id = resourceId,
action,
created_at = log.CreatedAt
}
});
}
private int? GetCurrentUserId()
{
var claim = httpContextAccessor.HttpContext?.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
return int.TryParse(claim, out var id) ? id : null;
}
}
public interface IAuditService
{
Task RecordAsync(
string resourceType,
string resourceId,
string action,
int organizationId,
int? projectId = null,
int? environmentId = null,
object? before = null,
object? after = null,
CancellationToken ct = default);
Task LogAsync(
string resourceType,
string resourceId,
string action,
int organizationId,
int? projectId = null,
int? environmentId = null,
string? changes = null,
CancellationToken ct = default);
}