Добавить контракт изменений деталей аудита

Детали событий описываются списком изменений «параметр: было → стало» (AuditChangeDto, AuditDetails), а не произвольным JSON. Запись везде идёт через конструктор AuditDetails; разбор старого плоского формата сохранён. Операторские ручки аудита отдают AuditRecordViewDto с changes и именем пользователя (батч-резолв через ITenantRepository.FindNamesByIdsAsync). Детектор подозрительной активности читает логин через новый разбор.
This commit is contained in:
2026-09-13 19:17:57 +03:00
parent 585397b9a3
commit 02a4f9c749
26 changed files with 407 additions and 94 deletions
@@ -27,7 +27,7 @@ public interface ITenantRepository
public Task<IReadOnlyList<TenantRecordDto>> ListAsync(CancellationToken ct);
/// <summary>
/// Страница реестра тенантов (шардированный обход для 1000+ схем).
/// Возвращает страницу реестра тенантов (шардированный обход для 1000+ схем).
/// </summary>
/// <param name="offset">Сдвиг от начала (устойчивый порядок — CreatedAt, затем Id).</param>
/// <param name="limit">Размер страницы (≥1; валидирует потребитель).</param>
@@ -37,6 +37,16 @@ public interface ITenantRepository
int limit,
CancellationToken ct);
/// <summary>
/// Имена тенантов по идентификаторам
/// </summary>
/// <param name="ids">Идентификаторы тенантов.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Словарь id → имя; отсутствующие не включаются.</returns>
public Task<IReadOnlyDictionary<Guid, string>> FindNamesByIdsAsync(
IReadOnlyCollection<Guid> ids,
CancellationToken ct);
/// <summary>
/// Устанавливает статус тенанта.
/// </summary>
@@ -1,8 +1,22 @@
using System.Text.Json;
namespace Deal.Modules.Tenants.Application.Models;
// Расширения записей аудита
internal static class AuditRecordDtoExtensions
/// <summary>
/// Расширения записей аудита
/// </summary>
public static class AuditRecordDtoExtensions
{
// Ключ служебного поля-контейнера изменений в деталях события.
private const string ChangesProperty = "changes";
// Параметр события, дублирующий TenantId самой записи, — в изменения не попадает.
private const string TenantIdProperty = "tenantId";
// Префиксы парных ключей «было»/«стало» в устаревшем плоском формате деталей.
private const string OldPrefix = "old";
private const string NewPrefix = "new";
// События аудита «неудачный вход» (тенант/оператор).
private static readonly string[] FailedLoginEvents =
{
@@ -30,4 +44,130 @@ internal static class AuditRecordDtoExtensions
/// <param name="record">Запись аудита.</param>
/// <returns>True — событие из SuccessfulLoginEvents.</returns>
public static bool IsSuccessfulLogin(this AuditRecordDto record) => SuccessfulLoginEvents.Contains(record.EventType);
/// <summary>
/// Человекочитаемые изменения параметров события
/// </summary>
/// <param name="record">Запись аудита.</param>
/// <returns>Изменения в порядке записи; пусто — деталей нет или они не разобраны.</returns>
public static IReadOnlyList<AuditChangeDto> AuditChanges(this AuditRecordDto record)
{
if (string.IsNullOrWhiteSpace(record.DetailJson))
{
return [];
}
try
{
using JsonDocument document = JsonDocument.Parse(record.DetailJson);
if (document.RootElement.ValueKind != JsonValueKind.Object)
{
return [];
}
return document.RootElement.TryGetProperty(ChangesProperty, out JsonElement changes)
&& changes.ValueKind == JsonValueKind.Array
? ParseChanges(changes)
: ParseFlatDetails(document.RootElement);
}
catch (JsonException)
{
// Повреждённые детали — изменений нет; запись остаётся читаемой.
return [];
}
}
/// <summary>
/// Значение параметра события
/// </summary>
/// <param name="record">Запись аудита.</param>
/// <param name="field">Код параметра.</param>
/// <returns>Значение «стало» параметра; при отсутствии — значение «было»; иначе null.</returns>
public static string? DetailValue(this AuditRecordDto record, string field)
{
foreach (AuditChangeDto change in record.AuditChanges())
{
if (string.Equals(change.Field, field, StringComparison.Ordinal))
{
return change.To ?? change.From;
}
}
return null;
}
// Разбирает контейнер изменений нового формата («changes»).
// changes: Массив объектов { field, from, to }.
// Возвращает: Изменения; некорректные элементы пропускаются.
private static List<AuditChangeDto> ParseChanges(JsonElement changes)
{
var result = new List<AuditChangeDto>(changes.GetArrayLength());
foreach (JsonElement item in changes.EnumerateArray())
{
if (item.ValueKind != JsonValueKind.Object
|| !item.TryGetProperty("field", out JsonElement field)
|| field.ValueKind != JsonValueKind.String)
{
continue;
}
string? from = item.TryGetProperty("from", out JsonElement fromElement) ? Stringify(fromElement) : null;
string? to = item.TryGetProperty("to", out JsonElement toElement) ? Stringify(toElement) : null;
result.Add(new AuditChangeDto(field.GetString()!, from, to));
}
return result;
}
// Разбирает устаревший плоский формат деталей: пары old*/new* → «было → стало», прочее — «задано».
// root: Корневой объект деталей.
// Возвращает: Изменения в порядке свойств.
private static List<AuditChangeDto> ParseFlatDetails(JsonElement root)
{
List<JsonProperty> properties = root.EnumerateObject().ToList();
var consumed = new HashSet<string>(StringComparer.Ordinal);
var result = new List<AuditChangeDto>(properties.Count);
foreach (JsonProperty property in properties)
{
if (!consumed.Add(property.Name) || property.Name == TenantIdProperty)
{
continue;
}
string? pairedNewName = property.Name.StartsWith(OldPrefix, StringComparison.Ordinal)
&& property.Name.Length > OldPrefix.Length
? NewPrefix + property.Name[OldPrefix.Length..]
: null;
if (pairedNewName is not null && root.TryGetProperty(pairedNewName, out JsonElement newValue))
{
consumed.Add(pairedNewName);
result.Add(new AuditChangeDto(
LowerFirst(property.Name[OldPrefix.Length..]),
Stringify(property.Value),
Stringify(newValue)));
continue;
}
result.Add(new AuditChangeDto(property.Name, null, Stringify(property.Value)));
}
return result;
}
// Приводит значение JSON к строке отображения.
// element: Значение JSON.
// Возвращает: Строковое представление либо null для JSON null.
private static string? Stringify(JsonElement element) => element.ValueKind switch
{
JsonValueKind.Null or JsonValueKind.Undefined => null,
JsonValueKind.String => element.GetString(),
_ => element.GetRawText(),
};
// Понижает регистр первой буквы имени параметра (oldBudget → budget).
// value: Имя параметра.
// Возвращает: Имя с первой строчной буквой.
private static string LowerFirst(string value) =>
value.Length == 0 ? value : char.ToLowerInvariant(value[0]) + value[1..];
}
@@ -0,0 +1,12 @@
namespace Deal.Modules.Tenants.Application.Models;
/// <summary>
/// Изменение параметра в деталях события аудита
/// </summary>
/// <param name="Field">Код параметра (человекочитаемое имя — в ресурсах интерфейса).</param>
/// <param name="From">Значение до изменения; null — параметр задан впервые.</param>
/// <param name="To">Значение после изменения.</param>
public sealed record AuditChangeDto(
string Field,
string? From,
string? To);
@@ -0,0 +1,41 @@
using System.Globalization;
namespace Deal.Modules.Tenants.Application.Models;
/// <summary>
/// Конструктор деталей события аудита
/// </summary>
public static class AuditDetails
{
/// <summary>
/// Задаёт параметр события
/// </summary>
/// <param name="field">Код параметра.</param>
/// <param name="value">Значение параметра.</param>
/// <returns>Изменение «параметр задан».</returns>
public static AuditChangeDto Set(string field, object? value) =>
new(field, null, Format(value));
/// <summary>
/// Задаёт изменение параметра «было → стало»
/// </summary>
/// <param name="field">Код параметра.</param>
/// <param name="from">Значение до изменения.</param>
/// <param name="to">Значение после изменения.</param>
/// <returns>Изменение «было → стало».</returns>
public static AuditChangeDto Change(string field, object? from, object? to) =>
new(field, Format(from), Format(to));
// Приводит значение к строке отображения (инвариантная культура).
// value: Значение параметра.
// Возвращает: Строковое представление либо null.
private static string? Format(object? value) => value switch
{
null => null,
string text => text,
bool flag => flag ? "true" : "false",
DateTimeOffset moment => moment.ToString("O", CultureInfo.InvariantCulture),
IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture),
_ => value.ToString(),
};
}
@@ -0,0 +1,26 @@
namespace Deal.Modules.Tenants.Application.Models;
/// <summary>
/// Запись аудита для чтения оператором
/// </summary>
/// <param name="EventType">Тип события — константа каталога <c>AuditEvents</c>.</param>
/// <param name="ActorType">Тип актора — константа <c>AuditActorTypes</c>.</param>
/// <param name="ActorId">Идентификатор актора; null, если актор неизвестен.</param>
/// <param name="TenantId">Идентификатор пользователя события; null для операторских/системных событий.</param>
/// <param name="TenantName">Имя пользователя события; null, если не разрешено.</param>
/// <param name="Ip">IP-адрес клиента (без порта); null для серверных действий.</param>
/// <param name="Changes">Человекочитаемые изменения параметров события.</param>
/// <param name="DetailJson">Сырые детали события в JSON; null, если деталей нет.</param>
/// <param name="At">Время события (UTC).</param>
/// <param name="Id">Identity-идентификатор строки.</param>
public sealed record AuditRecordViewDto(
string EventType,
string ActorType,
Guid? ActorId,
Guid? TenantId,
string? TenantName,
string? Ip,
IReadOnlyList<AuditChangeDto> Changes,
string? DetailJson,
DateTimeOffset At,
long Id);
@@ -7,11 +7,13 @@ namespace Deal.Modules.Tenants.Application.Models;
/// <param name="Error">Код ошибки при Ok=false (см. константы); null при успехе.</param>
/// <param name="Changed">true — статус реально изменён (пишется аудит tenant_status_changed); false — уже был таким.</param>
/// <param name="Tenant">Запись тенанта (актуальный статус — в <see cref="TenantRecordDto.Status"/>); null при Ok=false.</param>
/// <param name="PreviousStatus">Статус до изменения; null при Ok=false.</param>
public sealed record TenantStatusChangeResultDto(
bool Ok,
string? Error,
bool Changed,
TenantRecordDto? Tenant)
TenantRecordDto? Tenant,
string? PreviousStatus = null)
{
/// <summary>
/// Код ошибки: тенант не найден
@@ -1,4 +1,5 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
using Deal.SharedKernel.Observability;
@@ -20,8 +21,11 @@ public sealed class AuditService(IAuditLogStore store)
/// </summary>
public const int DefaultQueryLimit = 100;
// Опции JSON деталей: camelCase (конвенция DetailJson/JSON проекта).
private static readonly JsonSerializerOptions DetailJsonOptions = new(JsonSerializerDefaults.Web);
// Опции JSON деталей: camelCase (конвенция DetailJson/JSON проекта), без null-полей.
private static readonly JsonSerializerOptions DetailJsonOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
/// <summary>
/// Записывает событие аудита
@@ -49,11 +53,12 @@ public sealed class AuditService(IAuditLogStore store)
public Task<int> CountAsync(AuditQueryDto filter, CancellationToken ct) => store.CountAsync(filter, ct);
/// <summary>
/// Сериализует детали события в JSON.
/// Сериализует изменения деталей события в JSON
/// </summary>
/// <param name="details">Объект деталей (обычно анонимный: { login =... }).</param>
/// <returns>JSON-строка деталей.</returns>
public static string ToDetailJson(object? details) => JsonSerializer.Serialize(details, DetailJsonOptions);
/// <param name="changes">Человекочитаемые изменения параметров события.</param>
/// <returns>JSON-строка деталей вида <c>{ changes: [...] }</c>.</returns>
public static string ToDetailJson(IReadOnlyList<AuditChangeDto> changes) =>
JsonSerializer.Serialize(new { changes }, DetailJsonOptions);
/// <summary>
/// Актор «пользователь тенанта» по разрешённой сессии
@@ -1,4 +1,3 @@
using System.Text.Json;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
@@ -338,33 +337,10 @@ public sealed class SuspiciousActivityService
return counts;
}
// Извлекает логин из DetailJson записи (поле login); сбой разбора — null.
// Извлекает логин из деталей записи (параметр login); повреждённые детали — null.
// record: Запись аудита.
// Возвращает: Логин либо null (деталей нет/не строка/повреждённый JSON).
private static string? ExtractLogin(AuditRecordDto record)
{
if (string.IsNullOrWhiteSpace(record.DetailJson))
{
return null;
}
try
{
using JsonDocument document = JsonDocument.Parse(record.DetailJson);
if (document.RootElement.ValueKind == JsonValueKind.Object
&& document.RootElement.TryGetProperty("login", out JsonElement login)
&& login.ValueKind == JsonValueKind.String)
{
return login.GetString();
}
}
catch (JsonException)
{
// Повреждённые детали — логин неизвестен; запись в правиле не участвует.
}
return null;
}
// Возвращает: Логин либо null (деталей нет/параметр не задан).
private static string? ExtractLogin(AuditRecordDto record) => record.DetailValue("login");
// Уровень находки по превышению порога (≥2× порог — high, иначе medium).
// count: Фактическое значение правила.
@@ -138,11 +138,16 @@ public sealed class TenantAdminService(
// Идемпотентность: повторный suspend уже приостановленного — Ok без изменения (аудит не дублируется).
if (tenant.Status == status)
{
return new TenantStatusChangeResultDto(Ok: true, Error: null, Changed: false, Tenant: tenant);
return new TenantStatusChangeResultDto(Ok: true, Error: null, Changed: false, Tenant: tenant, PreviousStatus: tenant.Status);
}
bool updated = await tenantRepository.UpdateStatusAsync(id, status, ct);
return new TenantStatusChangeResultDto(Ok: true, Error: null, Changed: updated, Tenant: tenant with { Status = status });
return new TenantStatusChangeResultDto(
Ok: true,
Error: null,
Changed: updated,
Tenant: tenant with { Status = status },
PreviousStatus: tenant.Status);
}
// Новый одноразовый пароль владельца: 16 url-safe символов (общий UrlSafeToken, Security review C36).