Добавить контракт изменений деталей аудита
Детали событий описываются списком изменений «параметр: было → стало» (AuditChangeDto, AuditDetails), а не произвольным JSON. Запись везде идёт через конструктор AuditDetails; разбор старого плоского формата сохранён. Операторские ручки аудита отдают AuditRecordViewDto с changes и именем пользователя (батч-резолв через ITenantRepository.FindNamesByIdsAsync). Детектор подозрительной активности читает логин через новый разбор.
This commit is contained in:
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user