Auth tweaks, Audit implementation, Webhooks
This commit is contained in:
27
src/api/MicCheck.Api/Webhooks/WebhookBackgroundService.cs
Normal file
27
src/api/MicCheck.Api/Webhooks/WebhookBackgroundService.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using MicCheck.Api.Data;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace MicCheck.Api.Webhooks;
|
||||
|
||||
public class WebhookBackgroundService(
|
||||
WebhookQueue queue,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<WebhookBackgroundService> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await foreach (var webhookEvent in queue.ReadAllAsync(stoppingToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var dispatcher = scope.ServiceProvider.GetRequiredService<WebhookDispatcher>();
|
||||
await dispatcher.DispatchAsync(webhookEvent, attemptNumber: 1, stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Unhandled error dispatching webhook event {EventType}", webhookEvent.EventType);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ public class WebhookDeliveryLog
|
||||
public string? ResponseBody { get; set; }
|
||||
public bool Success { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public int AttemptNumber { get; init; }
|
||||
public DateTimeOffset AttemptedAt { get; init; }
|
||||
public TimeSpan Duration { get; set; }
|
||||
}
|
||||
|
||||
27
src/api/MicCheck.Api/Webhooks/WebhookDeliveryLogResponse.cs
Normal file
27
src/api/MicCheck.Api/Webhooks/WebhookDeliveryLogResponse.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
namespace MicCheck.Api.Webhooks;
|
||||
|
||||
public record WebhookDeliveryLogResponse(
|
||||
int Id,
|
||||
int WebhookId,
|
||||
string EventType,
|
||||
bool Success,
|
||||
int? ResponseStatusCode,
|
||||
string? ResponseBody,
|
||||
string? ErrorMessage,
|
||||
int AttemptNumber,
|
||||
DateTimeOffset AttemptedAt,
|
||||
TimeSpan Duration
|
||||
)
|
||||
{
|
||||
public static WebhookDeliveryLogResponse From(WebhookDeliveryLog log) => new(
|
||||
log.Id,
|
||||
log.WebhookId,
|
||||
log.EventType,
|
||||
log.Success,
|
||||
log.ResponseStatusCode,
|
||||
log.ResponseBody,
|
||||
log.ErrorMessage,
|
||||
log.AttemptNumber,
|
||||
log.AttemptedAt,
|
||||
log.Duration);
|
||||
}
|
||||
110
src/api/MicCheck.Api/Webhooks/WebhookDispatcher.cs
Normal file
110
src/api/MicCheck.Api/Webhooks/WebhookDispatcher.cs
Normal file
@@ -0,0 +1,110 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using MicCheck.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MicCheck.Api.Webhooks;
|
||||
|
||||
public class WebhookDispatcher(MicCheckDbContext db, IHttpClientFactory httpClientFactory, ILogger<WebhookDispatcher> logger)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
public async Task DispatchAsync(WebhookEvent webhookEvent, int attemptNumber = 1, CancellationToken ct = default)
|
||||
{
|
||||
var webhooks = await LoadWebhooksForEventAsync(webhookEvent, ct);
|
||||
if (webhooks.Count == 0) return;
|
||||
|
||||
var payload = new WebhookPayload(webhookEvent.EventType, webhookEvent.Data);
|
||||
var payloadJson = JsonSerializer.Serialize(payload, JsonOptions);
|
||||
|
||||
var tasks = webhooks.Select(w => DeliverAsync(w, payloadJson, attemptNumber, ct));
|
||||
await Task.WhenAll(tasks);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<Webhook>> LoadWebhooksForEventAsync(WebhookEvent webhookEvent, CancellationToken ct)
|
||||
{
|
||||
var query = db.Webhooks.Where(w => w.Enabled);
|
||||
|
||||
if (webhookEvent.EnvironmentId.HasValue)
|
||||
{
|
||||
query = query.Where(w =>
|
||||
(w.Scope == WebhookScope.Environment && w.EnvironmentId == webhookEvent.EnvironmentId) ||
|
||||
(w.Scope == WebhookScope.Organization && w.OrganizationId == webhookEvent.OrganizationId));
|
||||
}
|
||||
else
|
||||
{
|
||||
query = query.Where(w =>
|
||||
w.Scope == WebhookScope.Organization && w.OrganizationId == webhookEvent.OrganizationId);
|
||||
}
|
||||
|
||||
return await query.ToListAsync(ct);
|
||||
}
|
||||
|
||||
private async Task DeliverAsync(Webhook webhook, string payloadJson, int attemptNumber, CancellationToken ct)
|
||||
{
|
||||
var started = DateTimeOffset.UtcNow;
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
int? statusCode = null;
|
||||
string? responseBody = null;
|
||||
string? errorMessage = null;
|
||||
var success = false;
|
||||
|
||||
try
|
||||
{
|
||||
var client = httpClientFactory.CreateClient("Webhooks");
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, webhook.Url);
|
||||
request.Content = new StringContent(payloadJson, Encoding.UTF8, "application/json");
|
||||
|
||||
if (!string.IsNullOrEmpty(webhook.Secret))
|
||||
request.Headers.Add("X-Flagsmith-Signature", ComputeSignature(webhook.Secret, payloadJson));
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(10));
|
||||
|
||||
using var response = await client.SendAsync(request, cts.Token);
|
||||
statusCode = (int)response.StatusCode;
|
||||
responseBody = await response.Content.ReadAsStringAsync(ct);
|
||||
success = response.IsSuccessStatusCode;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = ex.Message;
|
||||
logger.LogWarning(ex, "Webhook delivery failed for webhook {WebhookId} attempt {Attempt}", webhook.Id, attemptNumber);
|
||||
}
|
||||
finally
|
||||
{
|
||||
stopwatch.Stop();
|
||||
}
|
||||
|
||||
db.WebhookDeliveryLogs.Add(new WebhookDeliveryLog
|
||||
{
|
||||
WebhookId = webhook.Id,
|
||||
EventType = payloadJson.Length > 100
|
||||
? payloadJson[..100]
|
||||
: payloadJson,
|
||||
PayloadJson = payloadJson,
|
||||
ResponseStatusCode = statusCode,
|
||||
ResponseBody = responseBody,
|
||||
Success = success,
|
||||
ErrorMessage = errorMessage,
|
||||
AttemptNumber = attemptNumber,
|
||||
AttemptedAt = started,
|
||||
Duration = stopwatch.Elapsed
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public static string ComputeSignature(string secret, string payload)
|
||||
{
|
||||
var keyBytes = Encoding.UTF8.GetBytes(secret);
|
||||
var payloadBytes = Encoding.UTF8.GetBytes(payload);
|
||||
var hashBytes = HMACSHA256.HashData(keyBytes, payloadBytes);
|
||||
return "sha256=" + Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
16
src/api/MicCheck.Api/Webhooks/WebhookEvent.cs
Normal file
16
src/api/MicCheck.Api/Webhooks/WebhookEvent.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace MicCheck.Api.Webhooks;
|
||||
|
||||
public class WebhookEvent
|
||||
{
|
||||
public required string EventType { get; init; }
|
||||
public int? EnvironmentId { get; init; }
|
||||
public int OrganizationId { get; init; }
|
||||
public required object Data { get; init; }
|
||||
}
|
||||
|
||||
public static class WebhookEventTypes
|
||||
{
|
||||
public const string FlagUpdated = "FLAG_UPDATED";
|
||||
public const string FlagDeleted = "FLAG_DELETED";
|
||||
public const string AuditLogCreated = "AUDIT_LOG_CREATED";
|
||||
}
|
||||
39
src/api/MicCheck.Api/Webhooks/WebhookPayload.cs
Normal file
39
src/api/MicCheck.Api/Webhooks/WebhookPayload.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace MicCheck.Api.Webhooks;
|
||||
|
||||
public record WebhookPayload(
|
||||
[property: JsonPropertyName("event_type")] string EventType,
|
||||
[property: JsonPropertyName("data")] object Data
|
||||
);
|
||||
|
||||
public record FlagUpdatedData(
|
||||
[property: JsonPropertyName("changed_by")] string? ChangedBy,
|
||||
[property: JsonPropertyName("timestamp")] DateTimeOffset Timestamp,
|
||||
[property: JsonPropertyName("new_state")] FlagStateSnapshot? NewState,
|
||||
[property: JsonPropertyName("previous_state")] FlagStateSnapshot? PreviousState
|
||||
);
|
||||
|
||||
public record FlagDeletedData(
|
||||
[property: JsonPropertyName("changed_by")] string? ChangedBy,
|
||||
[property: JsonPropertyName("timestamp")] DateTimeOffset Timestamp,
|
||||
[property: JsonPropertyName("feature")] FeatureSummary Feature
|
||||
);
|
||||
|
||||
public record FlagStateSnapshot(
|
||||
[property: JsonPropertyName("id")] int Id,
|
||||
[property: JsonPropertyName("enabled")] bool Enabled,
|
||||
[property: JsonPropertyName("feature_state_value")] string? FeatureStateValue,
|
||||
[property: JsonPropertyName("feature")] FeatureSummary Feature,
|
||||
[property: JsonPropertyName("environment")] EnvironmentSummary Environment
|
||||
);
|
||||
|
||||
public record FeatureSummary(
|
||||
[property: JsonPropertyName("id")] int Id,
|
||||
[property: JsonPropertyName("name")] string Name
|
||||
);
|
||||
|
||||
public record EnvironmentSummary(
|
||||
[property: JsonPropertyName("id")] int Id,
|
||||
[property: JsonPropertyName("name")] string Name
|
||||
);
|
||||
15
src/api/MicCheck.Api/Webhooks/WebhookQueue.cs
Normal file
15
src/api/MicCheck.Api/Webhooks/WebhookQueue.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace MicCheck.Api.Webhooks;
|
||||
|
||||
public class WebhookQueue
|
||||
{
|
||||
private readonly Channel<WebhookEvent> _channel = Channel.CreateUnbounded<WebhookEvent>(
|
||||
new UnboundedChannelOptions { SingleReader = true });
|
||||
|
||||
public ValueTask EnqueueAsync(WebhookEvent webhookEvent) =>
|
||||
_channel.Writer.WriteAsync(webhookEvent);
|
||||
|
||||
public IAsyncEnumerable<WebhookEvent> ReadAllAsync(CancellationToken ct) =>
|
||||
_channel.Reader.ReadAllAsync(ct);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using MicCheck.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MicCheck.Api.Webhooks;
|
||||
|
||||
public class WebhookRetryBackgroundService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<WebhookRetryBackgroundService> logger) : BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan[] RetryDelays = [TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(30)];
|
||||
private const int MaxAttempts = 3;
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ProcessPendingRetriesAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error during webhook retry processing");
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessPendingRetriesAsync(CancellationToken ct)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
|
||||
var dispatcher = scope.ServiceProvider.GetRequiredService<WebhookDispatcher>();
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
for (var attemptNumber = 1; attemptNumber < MaxAttempts; attemptNumber++)
|
||||
{
|
||||
var delay = RetryDelays[attemptNumber - 1];
|
||||
var retryAfter = now - delay;
|
||||
|
||||
var failedDeliveries = await db.WebhookDeliveryLogs
|
||||
.Where(d => !d.Success && d.AttemptNumber == attemptNumber && d.AttemptedAt <= retryAfter)
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var delivery in failedDeliveries)
|
||||
{
|
||||
var alreadyRetried = await db.WebhookDeliveryLogs
|
||||
.AnyAsync(d => d.WebhookId == delivery.WebhookId
|
||||
&& d.AttemptNumber == attemptNumber + 1
|
||||
&& d.AttemptedAt > delivery.AttemptedAt, ct);
|
||||
|
||||
if (alreadyRetried) continue;
|
||||
|
||||
var webhook = await db.Webhooks.FirstOrDefaultAsync(w => w.Id == delivery.WebhookId, ct);
|
||||
if (webhook is null || !webhook.Enabled) continue;
|
||||
|
||||
try
|
||||
{
|
||||
var orgId = webhook.OrganizationId ?? 0;
|
||||
var webhookEvent = new WebhookEvent
|
||||
{
|
||||
EventType = delivery.EventType,
|
||||
EnvironmentId = webhook.EnvironmentId,
|
||||
OrganizationId = orgId,
|
||||
Data = delivery.PayloadJson
|
||||
};
|
||||
|
||||
await dispatcher.DispatchAsync(webhookEvent, attemptNumber + 1, ct);
|
||||
logger.LogInformation("Retried webhook {WebhookId} attempt {Attempt}", delivery.WebhookId, attemptNumber + 1);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Retry attempt {Attempt} failed for webhook {WebhookId}", attemptNumber + 1, delivery.WebhookId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,4 +53,35 @@ public class WebhookService(MicCheckDbContext db)
|
||||
db.Webhooks.Remove(webhook);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Webhook>> ListByOrganizationAsync(int organizationId, CancellationToken ct = default)
|
||||
{
|
||||
return await db.Webhooks
|
||||
.Where(w => w.OrganizationId == organizationId && w.Scope == WebhookScope.Organization)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<Webhook> CreateForOrganizationAsync(int organizationId, string url, string? secret, bool enabled, CancellationToken ct = default)
|
||||
{
|
||||
var webhook = new Webhook
|
||||
{
|
||||
Url = url,
|
||||
Secret = secret,
|
||||
Scope = WebhookScope.Organization,
|
||||
OrganizationId = organizationId,
|
||||
Enabled = enabled,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
db.Webhooks.Add(webhook);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return webhook;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<WebhookDeliveryLog>> ListDeliveriesAsync(int webhookId, CancellationToken ct = default)
|
||||
{
|
||||
return await db.WebhookDeliveryLogs
|
||||
.Where(d => d.WebhookId == webhookId)
|
||||
.OrderByDescending(d => d.AttemptedAt)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user