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,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);
}
}
}
}

View File

@@ -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; }
}

View 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);
}

View 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();
}
}

View 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";
}

View 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
);

View 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);
}

View File

@@ -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);
}
}
}
}
}

View File

@@ -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);
}
}