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

Детали событий описываются списком изменений «параметр: было → стало» (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
+5 -5
View File
@@ -68,7 +68,7 @@ public static class AuthEndpoints
ActorId: result.UserId,
TenantId: result.TenantId,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { login = NormalizeLogin(body.Login) })), ct);
DetailJson: AuditService.ToDetailJson([AuditDetails.Set("login", NormalizeLogin(body.Login))])), ct);
return EndpointResults.Forbidden(TenantSuspendedDetail);
}
@@ -84,7 +84,7 @@ public static class AuthEndpoints
ActorId: null,
TenantId: null,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { login = attemptedLogin })), ct);
DetailJson: AuditService.ToDetailJson([AuditDetails.Set("login", attemptedLogin)])), ct);
}
return EndpointResults.Unauthorized(InvalidCredentialsDetail);
@@ -98,7 +98,7 @@ public static class AuthEndpoints
ActorId: result.UserId,
TenantId: result.TenantId,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { login = result.Login })), ct);
DetailJson: AuditService.ToDetailJson([AuditDetails.Set("login", result.Login)])), ct);
SessionCookieWriter.Append(context, cookieOptions.Value, result.Token);
return Results.Ok(new { ok = true, login = result.Login });
@@ -125,12 +125,12 @@ public static class AuthEndpoints
ActorId: logout.OperatorId,
TenantId: logout.TenantId,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { login = logout.Login })), ct);
DetailJson: AuditService.ToDetailJson([AuditDetails.Set("login", logout.Login)])), ct);
}
if (user is not null)
{
await AuditAppender.AppendTenantAsync(context, AuditEvents.TenantLogout, new { login = user.Login }, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.TenantLogout, [AuditDetails.Set("login", user.Login)], ct);
}
context.Response.Cookies.Delete(cookieName);
@@ -120,7 +120,7 @@ public static class CardDetailsEndpoints
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
CardDto created = await service.CreateLocalCardAsync(ToCreateLocalDto(body), ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCreated, new { cardId = created.Id }, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCreated, [AuditDetails.Set("cardId", created.Id)], ct);
return await ReadCardAsync(context, created.Id, ct);
}
+11 -6
View File
@@ -182,7 +182,7 @@ public static class CardsEndpoints
return EndpointResults.BadRequest(outcome.Error);
}
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardMoved, new { cardId, to = body.To }, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardMoved, [AuditDetails.Set("cardId", cardId), AuditDetails.Set("to", body.To)], ct);
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardDto unified = await cardsService.GetCardAsync(cardId, ct);
@@ -202,7 +202,7 @@ public static class CardsEndpoints
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
await cardsService.TrashCardAsync(cardId, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardTrashed, new { cardId }, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardTrashed, [AuditDetails.Set("cardId", cardId)], ct);
return Results.Ok(new { ok = true });
}
@@ -219,7 +219,7 @@ public static class CardsEndpoints
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
string col = await cardsService.RestoreCardAsync(cardId, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardRestored, new { cardId, col }, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardRestored, [AuditDetails.Set("cardId", cardId), AuditDetails.Set("col", col)], ct);
return Results.Ok(new { ok = true, col });
}
@@ -240,7 +240,7 @@ public static class CardsEndpoints
return EndpointResults.NotFound(CardNotFoundDetail);
}
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardDeleted, new { cardId }, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardDeleted, [AuditDetails.Set("cardId", cardId)], ct);
return Results.Ok(new { ok = true });
}
@@ -284,7 +284,7 @@ public static class CardsEndpoints
return EndpointResults.NotFound(CardNotFoundDetail);
}
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCommentAdded, new { cardId }, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCommentAdded, [AuditDetails.Set("cardId", cardId)], ct);
return Results.Ok(new { comments = result.Comments });
}
@@ -401,7 +401,12 @@ public static class CardsEndpoints
return AuditAppender.AppendTenantAsync(
context,
AuditEvents.CardReclassified,
new { attempted = result.Attempted, reclassified = result.Reclassified, moved = result.Moved, trashed = result.Trashed },
[
AuditDetails.Set("attempted", result.Attempted),
AuditDetails.Set("reclassified", result.Reclassified),
AuditDetails.Set("moved", result.Moved),
AuditDetails.Set("trashed", result.Trashed),
],
ct);
}
@@ -94,7 +94,7 @@ public static class ContainersEndpoints
Note: body.Note ?? string.Empty),
ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerCreated, new { id = created.Id, name = created.Name }, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerCreated, [AuditDetails.Set("containerId", created.Id), AuditDetails.Set("name", created.Name)], ct);
return Results.Ok(new { id = created.Id });
}
@@ -153,7 +153,7 @@ public static class ContainersEndpoints
patchBody.Policy),
ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = updated.Id }, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, [AuditDetails.Set("containerId", updated.Id)], ct);
return Results.Ok(new { id = updated.Id });
}
@@ -171,7 +171,7 @@ public static class ContainersEndpoints
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
ContainerDto accepted = await containers.AcceptSuggestedAsync(containerId, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = accepted.Id }, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, [AuditDetails.Set("containerId", accepted.Id)], ct);
return Results.Ok(accepted);
}
@@ -189,7 +189,7 @@ public static class ContainersEndpoints
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
int moved = await containers.DeleteAsync(containerId, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerDeleted, new { id = containerId }, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerDeleted, [AuditDetails.Set("containerId", containerId)], ct);
return Results.Ok(new { ok = true, movedToInbox = moved });
}
+2 -1
View File
@@ -69,7 +69,8 @@ public static class JoinEndpoint
TenantId: result.TenantId,
Ip: ClientIp(context),
// Код инвайта — capability-токен: в аудит пишется только SHA-256-хэш (Security review).
DetailJson: AuditService.ToDetailJson(new { email = result.Login, codeHash = SessionTokens.HashToken(body.Code?.Trim() ?? string.Empty) })), ct);
DetailJson: AuditService.ToDetailJson(
[AuditDetails.Set("email", result.Login), AuditDetails.Set("codeHash", SessionTokens.HashToken(body.Code?.Trim() ?? string.Empty))])), ct);
return Results.Ok(new { ok = true, login = result.Login });
}
@@ -1,5 +1,6 @@
using Deal.Api.Extensions;
using Deal.Api.Services;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services;
@@ -132,6 +133,7 @@ public static class OperatorAnalyticsEndpoints
int? offset,
HttpContext context,
AnalyticsService analyticsService,
ITenantRepository tenantRepository,
CancellationToken ct)
{
if (context.GetCurrentOperator() is null)
@@ -141,7 +143,8 @@ public static class OperatorAnalyticsEndpoints
AnalyticsActivityDto activity = await analyticsService.ActivityAsync(
eventType, actorType, actorId, tenantId, from, to, limit, offset, ct);
return Results.Ok(activity);
IReadOnlyList<AuditRecordViewDto> view = await AuditViewFactory.ProjectAsync(activity.Items, tenantRepository, ct);
return Results.Ok(new { items = view, total = activity.Total, limit = activity.Limit, offset = activity.Offset });
}
/// <summary>
@@ -1,5 +1,6 @@
using Deal.Api.Extensions;
using Deal.Api.Services;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services;
@@ -39,6 +40,7 @@ public static class OperatorAuditEndpoints
int? offset,
HttpContext context,
AuditService auditService,
ITenantRepository tenantRepository,
CancellationToken ct)
{
var operatorIdentity = context.GetCurrentOperator();
@@ -51,7 +53,8 @@ public static class OperatorAuditEndpoints
eventType, actorType, tenantId, from, to, NormalizeLimit(limit), actorId, NormalizeOffset(offset));
IReadOnlyList<AuditRecordDto> items = await auditService.QueryAsync(filter, ct);
int total = await auditService.CountAsync(filter, ct);
return Results.Ok(new { items, total });
IReadOnlyList<AuditRecordViewDto> view = await AuditViewFactory.ProjectAsync(items, tenantRepository, ct);
return Results.Ok(new { items = view, total });
}
/// <summary>
@@ -64,7 +64,7 @@ public static class OperatorAuthEndpoints
ActorId: null,
TenantId: null,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { login = attemptedLogin })), ct);
DetailJson: AuditService.ToDetailJson([AuditDetails.Set("login", attemptedLogin)])), ct);
}
return EndpointResults.Unauthorized(InvalidCredentialsDetail);
@@ -78,7 +78,7 @@ public static class OperatorAuthEndpoints
ActorId: result.OperatorId,
TenantId: null,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { login = result.Login })), ct);
DetailJson: AuditService.ToDetailJson([AuditDetails.Set("login", result.Login)])), ct);
SetOperatorSessionCookie(context, cookieOptions.Value, result.Token);
return Results.Ok(new { ok = true, login = result.Login });
@@ -100,7 +100,7 @@ public static class OperatorAuthEndpoints
if (operatorIdentity is not null)
{
await AuditAppender.AppendOperatorAsync(context, AuditEvents.OperatorLogout, new { login = operatorIdentity.Login }, ct);
await AuditAppender.AppendOperatorAsync(context, AuditEvents.OperatorLogout, [AuditDetails.Set("login", operatorIdentity.Login)], ct);
}
return Results.Ok(new { ok = true });
@@ -92,7 +92,8 @@ public static class OperatorInvitesEndpoints
Ip: ClientIp(context),
// Код инвайта — capability-токен (по нему активируется приглашение): в аудит пишется
// только его SHA-256-хэш, чтобы утечка ленты не давала рабочие коды (Security review).
DetailJson: AuditService.ToDetailJson(new { email = result.Invite.Email, codeHash = SessionTokens.HashToken(result.Invite.Code) })), ct);
DetailJson: AuditService.ToDetailJson(
[AuditDetails.Set("email", result.Invite.Email), AuditDetails.Set("codeHash", SessionTokens.HashToken(result.Invite.Code))])), ct);
InviteDto invite = result.Invite;
return Results.Ok(new { invite.Code, invite.Email, invite.TenantId, invite.ExpiresAt, invite.Status });
@@ -126,7 +127,8 @@ public static class OperatorInvitesEndpoints
ActorId: operatorIdentity.OperatorId,
TenantId: null,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { email = result.Invite!.Email, codeHash = SessionTokens.HashToken(result.Invite.Code) })), ct);
DetailJson: AuditService.ToDetailJson(
[AuditDetails.Set("email", result.Invite!.Email), AuditDetails.Set("codeHash", SessionTokens.HashToken(result.Invite.Code))])), ct);
return Results.Ok(new { ok = true });
}
@@ -167,14 +167,8 @@ public static class OperatorLimitsEndpoints
ActorId: operatorIdentity.OperatorId,
TenantId: id,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new
{
tenantId = id,
oldBudget = current.BudgetTokens,
oldPeriod = current.Period,
budgetTokens = newBudget,
period = newPeriod,
})), ct);
DetailJson: AuditService.ToDetailJson(
[AuditDetails.Change("budget", current.BudgetTokens, newBudget), AuditDetails.Change("period", current.Period, newPeriod)])), ct);
return Results.Ok(BuildDetailDto(tenant.Name, updated));
}
@@ -128,7 +128,8 @@ public static class OperatorSettingsEndpoints
ActorId: operatorIdentity.OperatorId,
TenantId: null,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { apiId = effectiveApiId, apiHashSet = true })), ct);
DetailJson: AuditService.ToDetailJson(
[AuditDetails.Set("apiId", effectiveApiId), AuditDetails.Set("apiHashSet", true)])), ct);
TelegramKeysMaskedDto snapshot = await keys.GetMaskedAsync(ct);
return Results.Ok(snapshot);
@@ -113,7 +113,8 @@ public static class OperatorTenantsEndpoints
ActorId: operatorIdentity.OperatorId,
TenantId: createdTenant.Id,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { tenantId = createdTenant.Id, name = createdTenant.Name, email = result.OwnerLogin })), ct);
DetailJson: AuditService.ToDetailJson(
[AuditDetails.Set("name", createdTenant.Name), AuditDetails.Set("email", result.OwnerLogin)])), ct);
if (result.OwnerLogin is not null)
{
@@ -202,7 +203,8 @@ public static class OperatorTenantsEndpoints
ActorId: operatorIdentity.OperatorId,
TenantId: result.Tenant.Id,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { tenantId = result.Tenant.Id, status = result.Tenant.Status })), ct);
DetailJson: AuditService.ToDetailJson(
[AuditDetails.Change("status", result.PreviousStatus, result.Tenant.Status)])), ct);
}
return Results.Ok(new { ok = true, status = result.Tenant.Status });
@@ -242,7 +244,7 @@ public static class OperatorTenantsEndpoints
ActorId: operatorIdentity.OperatorId,
TenantId: result.TenantId,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { targetLogin = result.Login, tenantId = result.TenantId })), ct);
DetailJson: AuditService.ToDetailJson([AuditDetails.Set("targetLogin", result.Login)])), ct);
// Токен — это tenant-сессия (как после /api/auth/login): СТАВИМ ту же httpOnly-куку deal_session
// на ответ, чтобы браузер оператора сразу получил tenant-сессию (JS не может записать httpOnly-куку).
@@ -74,7 +74,7 @@ public static class SettingsEndpoints
SettingsService settingsService = context.RequestServices.GetRequiredService<SettingsService>();
PublicSettingsDto result = await settingsService.ApplyPatchAsync(body, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.SettingsUpdated, new { fields = body.Keys }, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.SettingsUpdated, [AuditDetails.Set("fields", string.Join(", ", body.Keys))], ct);
if (ShouldScheduleRatesRefresh(body))
{
@@ -170,7 +170,7 @@ public static class TelegramEndpoints
TelegramAuthResultDto result = await gateway.StartQrAsync(apiId, keys.ApiHash, ct);
if (result.Phase == ReadyPhase)
{
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, new { phase = result.Phase }, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, [AuditDetails.Set("phase", result.Phase)], ct);
}
return Results.Ok(new { phase = result.Phase, qrUrl = result.QrUrl ?? string.Empty });
@@ -197,7 +197,7 @@ public static class TelegramEndpoints
string phase = await gateway.SendCodeAsync((body.Code ?? string.Empty).Trim(), ct);
if (phase == ReadyPhase)
{
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, new { phase }, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, [AuditDetails.Set("phase", phase)], ct);
}
return Results.Ok(new { phase });
@@ -224,7 +224,7 @@ public static class TelegramEndpoints
string phase = await gateway.SendPasswordAsync(body.Password ?? string.Empty, ct);
if (phase == ReadyPhase)
{
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, new { phase }, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, [AuditDetails.Set("phase", phase)], ct);
}
return Results.Ok(new { phase });
@@ -343,7 +343,7 @@ public static class TelegramEndpoints
if (body.Enabled)
{
await AuditAppender.AppendTenantAsync(context, AuditEvents.ChannelEnabled, new { all = true, count = result.Count }, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.ChannelEnabled, [AuditDetails.Set("all", true), AuditDetails.Set("count", result.Count)], ct);
}
return Results.Ok(new { ok = true, count = result.Count, enabled = body.Enabled });
@@ -393,7 +393,7 @@ public static class TelegramEndpoints
if (result.Enabled)
{
await AuditAppender.AppendTenantAsync(context, AuditEvents.ChannelEnabled, new { dialogId = dialog_id }, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.ChannelEnabled, [AuditDetails.Set("dialogId", dialog_id)], ct);
}
return Results.Ok(new { ok = true, enabled = result.Enabled });
+15 -13
View File
@@ -13,12 +13,14 @@ public static class AuditAppender
/// <summary>
/// Пишет событие действия пользователя тенанта
/// </summary>
/// <param name="context">Контекст запроса (сессия пользователя, IP).</param>
/// <param name="eventType">Тип события — константа <see cref="AuditEvents"/>.</param>
/// <param name="details">Минимальные детали события (обычно анонимный объект) или null.</param>
/// <param name="changes">Изменения параметров события.</param>
/// <param name="ct">Токен отмены.</param>
public static async Task AppendTenantAsync(
HttpContext context,
string eventType,
object? details,
IReadOnlyList<AuditChangeDto> changes,
CancellationToken ct)
{
CurrentUser? user = context.GetCurrentUser();
@@ -35,19 +37,21 @@ public static class AuditAppender
ActorId: user.UserId,
TenantId: user.TenantId,
Ip: ClientIp(context),
DetailJson: DetailJson(details)),
DetailJson: DetailJson(changes)),
ct);
}
/// <summary>
/// Пишет событие действия оператора
/// </summary>
/// <param name="context">Контекст запроса (операторская сессия, IP).</param>
/// <param name="eventType">Тип события — константа <see cref="AuditEvents"/>.</param>
/// <param name="details">Минимальные детали события (обычно анонимный объект) или null.</param>
/// <param name="changes">Изменения параметров события.</param>
/// <param name="ct">Токен отмены.</param>
public static async Task AppendOperatorAsync(
HttpContext context,
string eventType,
object? details,
IReadOnlyList<AuditChangeDto> changes,
CancellationToken ct)
{
CurrentOperator? operatorIdentity = context.GetCurrentOperator();
@@ -64,18 +68,16 @@ public static class AuditAppender
ActorId: operatorIdentity.OperatorId,
TenantId: null,
Ip: ClientIp(context),
DetailJson: DetailJson(details)),
DetailJson: DetailJson(changes)),
ct);
}
// Сериализует детали события (null — деталей нет).
// details: Объект деталей или null.
// Возвращает: JSON деталей (camelCase) или null.
private static string? DetailJson(object? details) =>
details is null ? null : AuditService.ToDetailJson(details);
// Сериализует детали события (пустой список — деталей нет).
// changes: Изменения параметров события.
// Возвращает: JSON деталей или null.
private static string? DetailJson(IReadOnlyList<AuditChangeDto> changes) =>
changes.Count == 0 ? null : AuditService.ToDetailJson(changes);
// IP-адрес клиента для аудита (без порта; null, если недоступен).
// context: Контекст запроса.
// Возвращает: Строковое представление IP или null.
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
}
@@ -0,0 +1,66 @@
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Api.Services;
/// <summary>
/// Проекция записей аудита для операторской консоли
/// </summary>
public static class AuditViewFactory
{
/// <summary>
/// Дополняет записи аудита именами пользователей и разбирает изменения деталей
/// </summary>
/// <param name="records">Записи аудита.</param>
/// <param name="tenantRepository">Реестр пользователей для разрешения имён.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Записи для чтения оператором.</returns>
public static async Task<IReadOnlyList<AuditRecordViewDto>> ProjectAsync(
IReadOnlyList<AuditRecordDto> records,
ITenantRepository tenantRepository,
CancellationToken ct)
{
IReadOnlyDictionary<Guid, string> names = await ResolveNamesAsync(records, tenantRepository, ct);
var view = new List<AuditRecordViewDto>(records.Count);
foreach (AuditRecordDto record in records)
{
string? tenantName = record.TenantId is { } tenantId && names.TryGetValue(tenantId, out string? name)
? name
: null;
view.Add(new AuditRecordViewDto(
record.EventType,
record.ActorType,
record.ActorId,
record.TenantId,
tenantName,
record.Ip,
record.AuditChanges(),
record.DetailJson,
record.At,
record.Id));
}
return view;
}
// Разрешает имена пользователей одной выборкой по уникальным идентификаторам.
// records: Записи аудита.
// tenantRepository: Реестр пользователей.
// ct: Токен отмены.
// Возвращает: Словарь id → имя.
private static async Task<IReadOnlyDictionary<Guid, string>> ResolveNamesAsync(
IReadOnlyList<AuditRecordDto> records,
ITenantRepository tenantRepository,
CancellationToken ct)
{
Guid[] ids = records
.Select(record => record.TenantId)
.Where(id => id is not null)
.Select(id => id!.Value)
.Distinct()
.ToArray();
return ids.Length == 0
? new Dictionary<Guid, string>()
: await tenantRepository.FindNamesByIdsAsync(ids, ct);
}
}
@@ -61,6 +61,23 @@ public sealed class TenantRepository(DealDbContext dbContext) : ITenantRepositor
return entities;
}
public async Task<IReadOnlyDictionary<Guid, string>> FindNamesByIdsAsync(
IReadOnlyCollection<Guid> ids,
CancellationToken ct)
{
if (ids.Count == 0)
{
return new Dictionary<Guid, string>();
}
var rows = await dbContext.Tenants
.AsNoTracking()
.Where(t => ids.Contains(t.Id))
.Select(t => new { t.Id, t.Name })
.ToListAsync(ct);
return rows.ToDictionary(row => row.Id, row => row.Name);
}
/// <inheritdoc />
public async Task<bool> UpdateStatusAsync(
Guid id,
@@ -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).