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

@@ -1,11 +1,12 @@
using MicCheck.Api.Audit;
using MicCheck.Api.Common;
using MicCheck.Api.Data;
using MicCheck.Api.Webhooks;
using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features;
public class FeatureService(MicCheckDbContext db, AuditService auditService)
public class FeatureService(MicCheckDbContext db, AuditService auditService, WebhookQueue webhookQueue)
{
private const int MaxFeaturesPerProject = 400;
@@ -67,8 +68,11 @@ public class FeatureService(MicCheckDbContext db, AuditService auditService)
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == projectId, ct);
if (project is not null)
await auditService.LogAsync("Feature", feature.Id.ToString(), "created",
project.OrganizationId, projectId, ct: ct);
await auditService.RecordAsync(
"Feature", feature.Id.ToString(), "created",
project.OrganizationId, projectId,
after: new { feature.Name, Type = feature.Type.ToString(), feature.InitialValue, feature.Description },
ct: ct);
return feature;
}
@@ -79,14 +83,20 @@ public class FeatureService(MicCheckDbContext db, AuditService auditService)
var feature = await db.Features.FirstOrDefaultAsync(f => f.Id == id, ct)
?? throw new KeyNotFoundException($"Feature {id} not found.");
var before = new { feature.Name, feature.Description };
feature.Name = name;
feature.Description = description;
await db.SaveChangesAsync(ct);
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == feature.ProjectId, ct);
if (project is not null)
await auditService.LogAsync("Feature", feature.Id.ToString(), "updated",
project.OrganizationId, feature.ProjectId, ct: ct);
await auditService.RecordAsync(
"Feature", feature.Id.ToString(), "updated",
project.OrganizationId, feature.ProjectId,
before: before,
after: new { feature.Name, feature.Description },
ct: ct);
return feature;
}
@@ -96,7 +106,33 @@ public class FeatureService(MicCheckDbContext db, AuditService auditService)
var feature = await db.Features.FirstOrDefaultAsync(f => f.Id == id, ct);
if (feature is null) return;
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == feature.ProjectId, ct);
db.Features.Remove(feature);
await db.SaveChangesAsync(ct);
if (project is not null)
{
await auditService.RecordAsync(
"Feature", id.ToString(), "deleted",
project.OrganizationId, feature.ProjectId,
before: new { feature.Name, Type = feature.Type.ToString() },
ct: ct);
var environments = await db.Environments.Where(e => e.ProjectId == feature.ProjectId).ToListAsync(ct);
foreach (var env in environments)
{
await webhookQueue.EnqueueAsync(new WebhookEvent
{
EventType = WebhookEventTypes.FlagDeleted,
EnvironmentId = env.Id,
OrganizationId = project.OrganizationId,
Data = new FlagDeletedData(
null,
DateTimeOffset.UtcNow,
new FeatureSummary(feature.Id, feature.Name))
});
}
}
}
}

View File

@@ -1,9 +1,11 @@
using MicCheck.Api.Audit;
using MicCheck.Api.Data;
using MicCheck.Api.Webhooks;
using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features;
public class FeatureStateService(MicCheckDbContext db)
public class FeatureStateService(MicCheckDbContext db, WebhookQueue webhookQueue, AuditService auditService)
{
public async Task<IReadOnlyList<FeatureState>> ListByEnvironmentAsync(int environmentId, CancellationToken ct = default)
{
@@ -23,11 +25,15 @@ public class FeatureStateService(MicCheckDbContext db)
var state = await db.FeatureStates.FirstOrDefaultAsync(fs => fs.Id == id, ct)
?? throw new KeyNotFoundException($"FeatureState {id} not found.");
var before = SnapshotState(state);
state.Enabled = enabled;
state.Value = value;
state.UpdatedAt = DateTimeOffset.UtcNow;
state.Version++;
await db.SaveChangesAsync(ct);
await DispatchFlagUpdatedAsync(state, before, ct);
return state;
}
@@ -36,11 +42,60 @@ public class FeatureStateService(MicCheckDbContext db)
var state = await db.FeatureStates.FirstOrDefaultAsync(fs => fs.Id == id, ct)
?? throw new KeyNotFoundException($"FeatureState {id} not found.");
var before = SnapshotState(state);
if (enabled.HasValue) state.Enabled = enabled.Value;
if (value is not null) state.Value = value;
state.UpdatedAt = DateTimeOffset.UtcNow;
state.Version++;
await db.SaveChangesAsync(ct);
await DispatchFlagUpdatedAsync(state, before, ct);
return state;
}
private async Task DispatchFlagUpdatedAsync(FeatureState state, FeatureStateSnapshot before, CancellationToken ct)
{
var feature = await db.Features.FirstOrDefaultAsync(f => f.Id == state.FeatureId, ct);
var environment = await db.Environments.FirstOrDefaultAsync(e => e.Id == state.EnvironmentId, ct);
var project = environment is not null
? await db.Projects.FirstOrDefaultAsync(p => p.Id == environment.ProjectId, ct)
: null;
var featureSummary = feature is not null
? new FeatureSummary(feature.Id, feature.Name)
: new FeatureSummary(state.FeatureId, string.Empty);
var environmentSummary = environment is not null
? new EnvironmentSummary(environment.Id, environment.Name)
: new EnvironmentSummary(state.EnvironmentId, string.Empty);
var after = new FlagStateSnapshot(state.Id, state.Enabled, state.Value, featureSummary, environmentSummary);
var previousSnapshot = new FlagStateSnapshot(state.Id, before.Enabled, before.Value, featureSummary, environmentSummary);
var data = new FlagUpdatedData(null, DateTimeOffset.UtcNow, after, previousSnapshot);
await webhookQueue.EnqueueAsync(new WebhookEvent
{
EventType = WebhookEventTypes.FlagUpdated,
EnvironmentId = state.EnvironmentId,
OrganizationId = project?.OrganizationId ?? 0,
Data = data
});
if (project is not null)
{
await auditService.RecordAsync(
"FeatureState", state.Id.ToString(), "updated",
project.OrganizationId, environment!.ProjectId, state.EnvironmentId,
before: new { before.Enabled, before.Value },
after: new { state.Enabled, state.Value },
ct: ct);
}
}
private static FeatureStateSnapshot SnapshotState(FeatureState state) =>
new(state.Enabled, state.Value);
private record FeatureStateSnapshot(bool Enabled, string? Value);
}