Аудит: читаемые имена и «было → стало» вместо сырых данных #28

Merged
rust merged 8 commits from t25_audit_readable into main 2026-09-14 23:36:11 +03:00
6 changed files with 69 additions and 8 deletions
Showing only changes of commit f31989a133 - Show all commits
@@ -182,7 +182,14 @@ public static class CardsEndpoints
return EndpointResults.BadRequest(outcome.Error); return EndpointResults.BadRequest(outcome.Error);
} }
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardMoved, [AuditDetails.Set(AuditFields.CardId, cardId), AuditDetails.Set(AuditFields.Destination, body.To)], ct); await AuditAppender.AppendTenantAsync(
context,
AuditEvents.CardMoved,
[
AuditDetails.Set(AuditFields.CardId, cardId),
AuditDetails.Change(AuditFields.ContainerId, outcome.From, outcome.To),
],
ct);
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>(); CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardDto unified = await cardsService.GetCardAsync(cardId, ct); CardDto unified = await cardsService.GetCardAsync(cardId, ct);
@@ -140,6 +140,7 @@ public static class ContainersEndpoints
} }
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>(); ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
ContainerDto before = await containers.GetAsync(containerId, ct);
ContainerDto updated = await containers.PatchAsync( ContainerDto updated = await containers.PatchAsync(
containerId, containerId,
new ContainerPatchDto( new ContainerPatchDto(
@@ -153,7 +154,13 @@ public static class ContainersEndpoints
patchBody.Policy), patchBody.Policy),
ct); ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, [AuditDetails.Set(AuditFields.ContainerId, updated.Id)], ct); await AuditAppender.AppendTenantAsync(
context,
AuditEvents.ContainerUpdated,
string.Equals(before.Name, updated.Name, StringComparison.Ordinal)
? [AuditDetails.Set(AuditFields.ContainerId, updated.Id)]
: [AuditDetails.Change(AuditFields.Name, before.Name, updated.Name)],
ct);
return Results.Ok(new { id = updated.Id }); return Results.Ok(new { id = updated.Id });
} }
@@ -22,6 +22,6 @@ public sealed class CardMover(CardsService cardsService) : ICardMover
CardResultDto result = CardsDefaultContainers.Contains(toContainerId) CardResultDto result = CardsDefaultContainers.Contains(toContainerId)
? await cardsService.MoveStageCardAsync(cardId, toContainerId, ct) ? await cardsService.MoveStageCardAsync(cardId, toContainerId, ct)
: await cardsService.MoveDashboardCardAsync(cardId, toContainerId, ct); : await cardsService.MoveDashboardCardAsync(cardId, toContainerId, ct);
return new CardMoveResultDto(result.Error); return new CardMoveResultDto(result.Error, From: result.Card?.PrevCol, To: result.Card?.Col);
} }
} }
@@ -6,4 +6,6 @@ namespace Deal.Modules.Cards.Application.Dtos;
/// Результат перехода карточки единым механизмом <see cref="ICardMover"/>. /// Результат перехода карточки единым механизмом <see cref="ICardMover"/>.
/// </summary> /// </summary>
/// <param name="Error">Текст 400-ошибки либо null (успех).</param> /// <param name="Error">Текст 400-ошибки либо null (успех).</param>
public sealed record CardMoveResultDto(string? Error); /// <param name="From">Контейнер-источник после перехода; null — переход не выполнен.</param>
/// <param name="To">Контейнер-назначение после перехода; null — переход не выполнен.</param>
public sealed record CardMoveResultDto(string? Error, string? From = null, string? To = null);
@@ -22,6 +22,14 @@ public static class AuditRecordDtoExtensions
private const string OldPrefix = "old"; private const string OldPrefix = "old";
private const string NewPrefix = "new"; private const string NewPrefix = "new";
// Соответствие суффикса «old<X>» имени ключа нового значения в устаревшем формате (имена не совпадают).
private static readonly IReadOnlyDictionary<string, string> LegacyNewNameByOldSuffix =
new Dictionary<string, string>(StringComparer.Ordinal)
{
["Budget"] = "budgetTokens",
["Period"] = "period",
};
// События аудита «неудачный вход» (тенант/оператор). // События аудита «неудачный вход» (тенант/оператор).
private static readonly string[] FailedLoginEvents = private static readonly string[] FailedLoginEvents =
{ {
@@ -140,10 +148,23 @@ public static class AuditRecordDtoExtensions
continue; continue;
} }
string? pairedNewName = property.Name.StartsWith(OldPrefix, StringComparison.Ordinal) string? pairedNewName = null;
&& property.Name.Length > OldPrefix.Length if (property.Name.StartsWith(OldPrefix, StringComparison.Ordinal)
? NewPrefix + property.Name[OldPrefix.Length..] && property.Name.Length > OldPrefix.Length)
: null; {
string suffix = property.Name[OldPrefix.Length..];
string defaultNewName = NewPrefix + suffix;
if (LegacyNewNameByOldSuffix.TryGetValue(suffix, out string? mapped)
&& root.TryGetProperty(mapped, out JsonElement mappedValue))
{
consumed.Add(mapped);
result.Add(new AuditChangeDto(LowerFirst(suffix), Stringify(property.Value), Stringify(mappedValue)));
continue;
}
pairedNewName = root.TryGetProperty(defaultNewName, out _) ? defaultNewName : null;
}
if (pairedNewName is not null && root.TryGetProperty(pairedNewName, out JsonElement newValue)) if (pairedNewName is not null && root.TryGetProperty(pairedNewName, out JsonElement newValue))
{ {
consumed.Add(pairedNewName); consumed.Add(pairedNewName);
@@ -57,6 +57,30 @@ public sealed class AuditRecordDtoExtensionsTests
}); });
} }
[Fact]
public void AuditChanges_LegacyLimitChange_PairsBudgetAndPeriod()
{
var record = Record(
"""{"tenantId":"6f3c0d1e-2b4a-4c8d-9e0f-1a2b3c4d5e6f","oldBudget":100000,"oldPeriod":"month","budgetTokens":1000000,"period":"month"}""");
IReadOnlyList<AuditChangeDto> changes = record.AuditChanges();
Assert.Collection(
changes,
budget =>
{
Assert.Equal("budget", budget.Field);
Assert.Equal("100000", budget.From);
Assert.Equal("1000000", budget.To);
},
period =>
{
Assert.Equal("period", period.Field);
Assert.Equal("month", period.From);
Assert.Equal("month", period.To);
});
}
[Fact] [Fact]
public void AuditChanges_InvalidJson_ReturnsEmpty() public void AuditChanges_InvalidJson_ReturnsEmpty()
{ {