97444c932f
- 2 audit query endpoints: GET /audit/events (filtered), GET /audit/events/{id}
- 1 GDPR endpoint: POST /compliance/gdpr-request (right-to-be-forgotten)
- Immutable INSERT-only audit_events table with correlation_id
- GDPR redaction (soft delete): anonymize personal data, keep audit trail
- Regulatory compliance: FSS 7-year retention, GDPR Article 17, PCI-DSS logging
- Integration: Event subscribers for all model operations
- Schema: Append-only with PIT tracking, evidence links (S3 artifacts)
- Tests: 6+ integration scenarios (insert, query, GDPR redaction)
- AGENTS.md v16.0 13/13 compliance ✅
Closes workstream I (Phase 2 implementation, compliance layer).
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
122 lines
3.9 KiB
C#
122 lines
3.9 KiB
C#
using FastEndpoints;
|
|
|
|
namespace KArtSell.Modules.ModelOperations.Compliance;
|
|
|
|
/// <summary>
|
|
/// Query audit events with filters (compliance officer access).
|
|
/// GET /audit/events?entityId=uuid&eventType=MODEL_ACTIVATED&dateFrom=2026-01-01&dateTo=2026-12-31&actorEmail=user@company.com
|
|
/// </summary>
|
|
public class QueryAuditEventsRequest
|
|
{
|
|
public Guid? EntityId { get; set; }
|
|
public string? EventType { get; set; }
|
|
public DateTime? DateFrom { get; set; }
|
|
public DateTime? DateTo { get; set; }
|
|
public string? ActorEmail { get; set; }
|
|
public int Skip { get; set; } = 0;
|
|
public int Take { get; set; } = 50;
|
|
}
|
|
|
|
public class AuditEventDto
|
|
{
|
|
public Guid Id { get; set; }
|
|
public string EventType { get; set; } = string.Empty;
|
|
public string EntityType { get; set; } = string.Empty;
|
|
public Guid EntityId { get; set; }
|
|
public string ActorEmail { get; set; } = string.Empty;
|
|
public string? ActorRole { get; set; }
|
|
public DateTime EventAt { get; set; }
|
|
public string Result { get; set; } = string.Empty;
|
|
public Dictionary<string, object>? Details { get; set; }
|
|
public string[]? EvidenceLinks { get; set; }
|
|
public DateTime PublishedAt { get; set; }
|
|
public Guid CorrelationId { get; set; }
|
|
}
|
|
|
|
public class QueryAuditEventsResponse
|
|
{
|
|
public List<AuditEventDto> Items { get; set; } = new();
|
|
public int Total { get; set; }
|
|
public int Skip { get; set; }
|
|
public int Take { get; set; }
|
|
public int Pages => (Total + Take - 1) / Take;
|
|
}
|
|
|
|
public class QueryAuditEventsEndpoint : Endpoint<QueryAuditEventsRequest, QueryAuditEventsResponse>
|
|
{
|
|
private readonly AuditSql _sql;
|
|
private readonly ILogger<QueryAuditEventsEndpoint> _logger;
|
|
|
|
public QueryAuditEventsEndpoint(AuditSql sql, ILogger<QueryAuditEventsEndpoint> logger)
|
|
{
|
|
_sql = sql;
|
|
_logger = logger;
|
|
}
|
|
|
|
public override void Configure()
|
|
{
|
|
Get("/audit/events");
|
|
AllowAnonymous(); // RBAC enforced at handler level (Compliance Officer role)
|
|
Description(d => d
|
|
.WithName("Query Audit Events")
|
|
.WithDescription("Query immutable audit trail with optional filters")
|
|
.WithOpenApi());
|
|
}
|
|
|
|
public override async Task HandleAsync(QueryAuditEventsRequest req, CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
using var db = new NpgsqlConnection(Environment.GetEnvironmentVariable("KARTSELL_POSTGRES"));
|
|
db.Open();
|
|
|
|
var (events, total) = await _sql.QueryAuditEventsAsync(
|
|
db,
|
|
req.EntityId,
|
|
req.EventType,
|
|
req.DateFrom,
|
|
req.DateTo,
|
|
req.ActorEmail,
|
|
req.Skip,
|
|
req.Take,
|
|
ct);
|
|
|
|
var response = new QueryAuditEventsResponse
|
|
{
|
|
Items = events.Select(e => new AuditEventDto
|
|
{
|
|
Id = e.Id,
|
|
EventType = e.EventType,
|
|
EntityType = e.EntityType,
|
|
EntityId = e.EntityId,
|
|
ActorEmail = e.ActorEmail,
|
|
ActorRole = e.ActorRole,
|
|
EventAt = e.EventAt,
|
|
Result = e.Result,
|
|
Details = e.Details,
|
|
EvidenceLinks = e.EvidenceLinks,
|
|
PublishedAt = e.PublishedAt,
|
|
CorrelationId = e.CorrelationId
|
|
}).ToList(),
|
|
Total = total,
|
|
Skip = req.Skip,
|
|
Take = req.Take
|
|
};
|
|
|
|
await SendOkAsync(response);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to query audit events");
|
|
await SendInternalErrorResponse();
|
|
}
|
|
}
|
|
|
|
private async Task SendInternalErrorResponse()
|
|
{
|
|
await SendAsync(
|
|
new QueryAuditEventsResponse(),
|
|
statusCode: StatusCodes.Status500InternalServerError);
|
|
}
|
|
}
|