Auth tweaks, Audit implementation, Webhooks
This commit is contained in:
13
src/api/MicCheck.Api/Audit/AuditLogFilter.cs
Normal file
13
src/api/MicCheck.Api/Audit/AuditLogFilter.cs
Normal 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
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user