102 lines
3.7 KiB
C#
102 lines
3.7 KiB
C#
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using D3ROVoice.Api.Data;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace D3ROVoice.Api.Services;
|
|
|
|
public sealed class AdminOperationException : Exception
|
|
{
|
|
public AdminOperationException(string message) : base(message) { }
|
|
}
|
|
|
|
public interface IAdminOperationService
|
|
{
|
|
Task<JsonElement> ExecuteAsync(
|
|
string actorEmail,
|
|
string operation,
|
|
string idempotencyKey,
|
|
object request,
|
|
string targetType,
|
|
Func<object, string> targetId,
|
|
string memo,
|
|
Func<Task<object?>> readBefore,
|
|
Func<Task<object>> mutate);
|
|
}
|
|
|
|
public sealed class AdminOperationService : IAdminOperationService
|
|
{
|
|
private readonly AppDbContext _db;
|
|
|
|
public AdminOperationService(AppDbContext db)
|
|
{
|
|
_db = db;
|
|
}
|
|
|
|
public async Task<JsonElement> ExecuteAsync(
|
|
string actorEmail,
|
|
string operation,
|
|
string idempotencyKey,
|
|
object request,
|
|
string targetType,
|
|
Func<object, string> targetId,
|
|
string memo,
|
|
Func<Task<object?>> readBefore,
|
|
Func<Task<object>> mutate)
|
|
{
|
|
actorEmail = actorEmail.Trim().ToLowerInvariant();
|
|
memo = memo.Trim();
|
|
if (actorEmail.Length is < 3 or > 150) throw new AdminOperationException("invalid_actor");
|
|
if (!Guid.TryParseExact(idempotencyKey, "D", out _)) throw new AdminOperationException("invalid_idempotency_key");
|
|
if (memo.Length is < 3 or > 1000) throw new AdminOperationException("invalid_audit_memo");
|
|
|
|
var requestJson = JsonSerializer.Serialize(request);
|
|
var requestHash = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes($"{operation}:{requestJson}")));
|
|
|
|
await using var transaction = await _db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable);
|
|
var existing = await _db.AdminOperationRequests.AsNoTracking().SingleOrDefaultAsync(entry =>
|
|
entry.ActorEmail == actorEmail && entry.IdempotencyKey == idempotencyKey);
|
|
if (existing != null)
|
|
{
|
|
if (existing.Operation != operation || existing.RequestHash != requestHash)
|
|
throw new AdminOperationException("idempotency_key_reused_with_different_request");
|
|
using var replay = JsonDocument.Parse(existing.ResponseJson);
|
|
return replay.RootElement.Clone();
|
|
}
|
|
|
|
var before = await readBefore();
|
|
var result = await mutate();
|
|
var responseJson = JsonSerializer.Serialize(result);
|
|
var resolvedTargetId = targetId(result);
|
|
if (string.IsNullOrWhiteSpace(resolvedTargetId) || resolvedTargetId.Length > 200)
|
|
throw new AdminOperationException("invalid_audit_target");
|
|
|
|
_db.AdminOperationRequests.Add(new AdminOperationRequest
|
|
{
|
|
ActorEmail = actorEmail,
|
|
IdempotencyKey = idempotencyKey,
|
|
Operation = operation,
|
|
RequestHash = requestHash,
|
|
ResponseJson = responseJson,
|
|
CreatedAt = DateTime.UtcNow
|
|
});
|
|
_db.AdminAuditEntries.Add(new AdminAuditEntry
|
|
{
|
|
ActorEmail = actorEmail,
|
|
Action = operation,
|
|
TargetType = targetType,
|
|
TargetId = resolvedTargetId,
|
|
BeforeJson = before == null ? null : JsonSerializer.Serialize(before),
|
|
AfterJson = responseJson,
|
|
Memo = memo,
|
|
IdempotencyKey = idempotencyKey,
|
|
CreatedAt = DateTime.UtcNow
|
|
});
|
|
await _db.SaveChangesAsync();
|
|
await transaction.CommitAsync();
|
|
|
|
using var response = JsonDocument.Parse(responseJson);
|
|
return response.RootElement.Clone();
|
|
}
|
|
}
|