Auth tweaks, Audit implementation, Webhooks

This commit is contained in:
2026-04-13 14:17:45 -07:00
parent b9a04df861
commit 334b6cf3e1
46 changed files with 4654 additions and 61 deletions

View File

@@ -0,0 +1,13 @@
namespace MicCheck.Api.Audit;
public record AuditLogFilter(
DateTimeOffset? From = null,
DateTimeOffset? To = null,
string? ResourceType = null,
string? Action = null,
int? ProjectId = null,
int? EnvironmentId = null,
int? ActorUserId = null,
int Page = 1,
int PageSize = 20
);

View File

@@ -5,27 +5,53 @@ namespace MicCheck.Api.Audit;
public class AuditLogQueryService(MicCheckDbContext db)
{
public async Task<IReadOnlyList<AuditLog>> ListByOrganizationAsync(int organizationId, CancellationToken ct = default)
public async Task<(int Total, IReadOnlyList<AuditLog> Items)> ListByOrganizationAsync(
int organizationId, AuditLogFilter filter, CancellationToken ct = default)
{
return await db.AuditLogs
.Where(l => l.OrganizationId == organizationId)
.OrderByDescending(l => l.CreatedAt)
.ToListAsync(ct);
var query = db.AuditLogs.Where(l => l.OrganizationId == organizationId);
return await ApplyFilterAndPageAsync(query, filter, ct);
}
public async Task<IReadOnlyList<AuditLog>> ListByProjectAsync(int projectId, CancellationToken ct = default)
public async Task<(int Total, IReadOnlyList<AuditLog> Items)> ListByProjectAsync(
int projectId, AuditLogFilter filter, CancellationToken ct = default)
{
return await db.AuditLogs
.Where(l => l.ProjectId == projectId)
.OrderByDescending(l => l.CreatedAt)
.ToListAsync(ct);
var query = db.AuditLogs.Where(l => l.ProjectId == projectId);
return await ApplyFilterAndPageAsync(query, filter, ct);
}
public async Task<IReadOnlyList<AuditLog>> ListByEnvironmentAsync(int environmentId, CancellationToken ct = default)
public async Task<(int Total, IReadOnlyList<AuditLog> Items)> ListByEnvironmentAsync(
int environmentId, AuditLogFilter filter, CancellationToken ct = default)
{
return await db.AuditLogs
.Where(l => l.EnvironmentId == environmentId)
var query = db.AuditLogs.Where(l => l.EnvironmentId == environmentId);
return await ApplyFilterAndPageAsync(query, filter, ct);
}
private static async Task<(int Total, IReadOnlyList<AuditLog> Items)> ApplyFilterAndPageAsync(
IQueryable<AuditLog> query, AuditLogFilter filter, CancellationToken ct)
{
if (filter.From.HasValue)
query = query.Where(l => l.CreatedAt >= filter.From.Value);
if (filter.To.HasValue)
query = query.Where(l => l.CreatedAt <= filter.To.Value);
if (!string.IsNullOrEmpty(filter.ResourceType))
query = query.Where(l => l.ResourceType == filter.ResourceType);
if (!string.IsNullOrEmpty(filter.Action))
query = query.Where(l => l.Action == filter.Action);
if (filter.ProjectId.HasValue)
query = query.Where(l => l.ProjectId == filter.ProjectId);
if (filter.EnvironmentId.HasValue)
query = query.Where(l => l.EnvironmentId == filter.EnvironmentId);
if (filter.ActorUserId.HasValue)
query = query.Where(l => l.ActorUserId == filter.ActorUserId);
var total = await query.CountAsync(ct);
var pageSize = Math.Clamp(filter.PageSize, 1, 100);
var items = await query
.OrderByDescending(l => l.CreatedAt)
.Skip((filter.Page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(ct);
return (total, items);
}
}

View File

@@ -1,4 +1,6 @@
using MicCheck.Api.Authorization;
using MicCheck.Api.Common;
using MicCheck.Api.Environments;
using MicCheck.Api.Projects;
using MicCheck.Api.Organizations;
using Microsoft.AspNetCore.Authorization;
@@ -13,25 +15,45 @@ namespace MicCheck.Api.Audit;
public class AuditLogsController(
AuditLogQueryService auditLogQueryService,
OrganizationService organizationService,
ProjectService projectService) : ControllerBase
ProjectService projectService,
EnvironmentService environmentService) : ControllerBase
{
[HttpGet("api/v1/organisations/{id}/audit-logs")]
public async Task<ActionResult<IReadOnlyList<AuditLogResponse>>> ListByOrganization(int id, CancellationToken ct)
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByOrganization(
int id,
[FromQuery] AuditLogFilter filter,
CancellationToken ct)
{
var org = await organizationService.FindByIdAsync(id, ct);
if (org is null) return NotFound();
var logs = await auditLogQueryService.ListByOrganizationAsync(id, ct);
return Ok(logs.Select(AuditLogResponse.From).ToList());
var (total, logs) = await auditLogQueryService.ListByOrganizationAsync(id, filter, ct);
return Ok(new PaginatedResponse<AuditLogResponse>(total, null, null, logs.Select(AuditLogResponse.From).ToList()));
}
[HttpGet("api/v1/projects/{projectId}/audit-logs")]
public async Task<ActionResult<IReadOnlyList<AuditLogResponse>>> ListByProject(int projectId, CancellationToken ct)
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByProject(
int projectId,
[FromQuery] AuditLogFilter filter,
CancellationToken ct)
{
var project = await projectService.FindByIdAsync(projectId, ct);
if (project is null) return NotFound();
var logs = await auditLogQueryService.ListByProjectAsync(projectId, ct);
return Ok(logs.Select(AuditLogResponse.From).ToList());
var (total, logs) = await auditLogQueryService.ListByProjectAsync(projectId, filter, ct);
return Ok(new PaginatedResponse<AuditLogResponse>(total, null, null, logs.Select(AuditLogResponse.From).ToList()));
}
[HttpGet("api/v1/environments/{apiKey}/audit-logs")]
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByEnvironment(
string apiKey,
[FromQuery] AuditLogFilter filter,
CancellationToken ct)
{
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
if (environment is null) return NotFound();
var (total, logs) = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, filter, ct);
return Ok(new PaginatedResponse<AuditLogResponse>(total, null, null, logs.Select(AuditLogResponse.From).ToList()));
}
}

View File

@@ -1,26 +1,41 @@
using System.Text.Json;
using MicCheck.Api.Data;
using MicCheck.Api.Webhooks;
namespace MicCheck.Api.Audit;
public class AuditService(MicCheckDbContext db, IHttpContextAccessor httpContextAccessor)
public class AuditService(MicCheckDbContext db, IHttpContextAccessor httpContextAccessor, WebhookQueue webhookQueue)
{
public virtual async Task LogAsync(
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public virtual async Task RecordAsync(
string resourceType,
string resourceId,
string action,
int organizationId,
int? projectId = null,
int? environmentId = null,
string? changes = null,
object? before = null,
object? after = null,
CancellationToken ct = default)
{
int? actorUserId = null;
string? changes = null;
if (before is not null || after is not null)
{
changes = JsonSerializer.Serialize(new
{
before,
after
}, JsonOptions);
}
var userIdClaim = httpContextAccessor.HttpContext?.User.FindFirst("UserId")?.Value;
if (int.TryParse(userIdClaim, out var parsedId))
actorUserId = parsedId;
var actorUserId = ResolveActorUserId();
db.AuditLogs.Add(new AuditLog
var log = new AuditLog
{
ResourceType = resourceType,
ResourceId = resourceId,
@@ -31,8 +46,75 @@ public class AuditService(MicCheckDbContext db, IHttpContextAccessor httpContext
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 virtual 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 = ResolveActorUserId();
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? ResolveActorUserId()
{
var claim = httpContextAccessor.HttpContext?.User.FindFirst("UserId")?.Value;
return int.TryParse(claim, out var id) ? id : null;
}
}