From 02a4f9c749fe7c908d5446b37d5ed61f26a2729a Mon Sep 17 00:00:00 2001 From: stepan Date: Sun, 13 Sep 2026 19:17:57 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=BA=D1=82=20?= =?UTF-8?q?=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=B4?= =?UTF-8?q?=D0=B5=D1=82=D0=B0=D0=BB=D0=B5=D0=B9=20=D0=B0=D1=83=D0=B4=D0=B8?= =?UTF-8?q?=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Детали событий описываются списком изменений «параметр: было → стало» (AuditChangeDto, AuditDetails), а не произвольным JSON. Запись везде идёт через конструктор AuditDetails; разбор старого плоского формата сохранён. Операторские ручки аудита отдают AuditRecordViewDto с changes и именем пользователя (батч-резолв через ITenantRepository.FindNamesByIdsAsync). Детектор подозрительной активности читает логин через новый разбор. --- src/core/Deal.Api/Endpoints/AuthEndpoints.cs | 10 +- .../Endpoints/CardDetailsEndpoints.cs | 2 +- src/core/Deal.Api/Endpoints/CardsEndpoints.cs | 17 ++- .../Deal.Api/Endpoints/ContainersEndpoints.cs | 8 +- src/core/Deal.Api/Endpoints/JoinEndpoint.cs | 3 +- .../Endpoints/OperatorAnalyticsEndpoints.cs | 5 +- .../Endpoints/OperatorAuditEndpoints.cs | 5 +- .../Endpoints/OperatorAuthEndpoints.cs | 6 +- .../Endpoints/OperatorInvitesEndpoints.cs | 6 +- .../Endpoints/OperatorLimitsEndpoints.cs | 10 +- .../Endpoints/OperatorSettingsEndpoints.cs | 3 +- .../Endpoints/OperatorTenantsEndpoints.cs | 8 +- .../Deal.Api/Endpoints/SettingsEndpoints.cs | 2 +- .../Deal.Api/Endpoints/TelegramEndpoints.cs | 10 +- src/core/Deal.Api/Services/AuditAppender.cs | 28 ++-- .../Deal.Api/Services/AuditViewFactory.cs | 66 ++++++++ .../Repositories/TenantRepository.cs | 17 +++ .../Abstractions/ITenantRepository.cs | 12 +- .../Extensions/AuditRecordDtoExtensions.cs | 144 +++++++++++++++++- .../Application/Models/AuditChangeDto.cs | 12 ++ .../Application/Models/AuditDetails.cs | 41 +++++ .../Application/Models/AuditRecordViewDto.cs | 26 ++++ .../Models/TenantStatusChangeResultDto.cs | 4 +- .../Application/Services/AuditService.cs | 17 ++- .../Services/SuspiciousActivityService.cs | 30 +--- .../Services/TenantAdminService.cs | 9 +- 26 files changed, 407 insertions(+), 94 deletions(-) create mode 100644 src/core/Deal.Api/Services/AuditViewFactory.cs create mode 100644 src/core/Deal.Modules.Tenants/Application/Models/AuditChangeDto.cs create mode 100644 src/core/Deal.Modules.Tenants/Application/Models/AuditDetails.cs create mode 100644 src/core/Deal.Modules.Tenants/Application/Models/AuditRecordViewDto.cs diff --git a/src/core/Deal.Api/Endpoints/AuthEndpoints.cs b/src/core/Deal.Api/Endpoints/AuthEndpoints.cs index 3410fe1..45a9396 100644 --- a/src/core/Deal.Api/Endpoints/AuthEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/AuthEndpoints.cs @@ -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); diff --git a/src/core/Deal.Api/Endpoints/CardDetailsEndpoints.cs b/src/core/Deal.Api/Endpoints/CardDetailsEndpoints.cs index e37d653..44791e5 100644 --- a/src/core/Deal.Api/Endpoints/CardDetailsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/CardDetailsEndpoints.cs @@ -120,7 +120,7 @@ public static class CardDetailsEndpoints CardsService service = context.RequestServices.GetRequiredService(); 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); } diff --git a/src/core/Deal.Api/Endpoints/CardsEndpoints.cs b/src/core/Deal.Api/Endpoints/CardsEndpoints.cs index 68856da..e90d8ac 100644 --- a/src/core/Deal.Api/Endpoints/CardsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/CardsEndpoints.cs @@ -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(); CardDto unified = await cardsService.GetCardAsync(cardId, ct); @@ -202,7 +202,7 @@ public static class CardsEndpoints CardsService cardsService = context.RequestServices.GetRequiredService(); 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(); 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); } diff --git a/src/core/Deal.Api/Endpoints/ContainersEndpoints.cs b/src/core/Deal.Api/Endpoints/ContainersEndpoints.cs index 8e63672..34e50a6 100644 --- a/src/core/Deal.Api/Endpoints/ContainersEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/ContainersEndpoints.cs @@ -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(); 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(); 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 }); } diff --git a/src/core/Deal.Api/Endpoints/JoinEndpoint.cs b/src/core/Deal.Api/Endpoints/JoinEndpoint.cs index a46a872..065b981 100644 --- a/src/core/Deal.Api/Endpoints/JoinEndpoint.cs +++ b/src/core/Deal.Api/Endpoints/JoinEndpoint.cs @@ -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 }); } diff --git a/src/core/Deal.Api/Endpoints/OperatorAnalyticsEndpoints.cs b/src/core/Deal.Api/Endpoints/OperatorAnalyticsEndpoints.cs index 3f9b896..9c59991 100644 --- a/src/core/Deal.Api/Endpoints/OperatorAnalyticsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/OperatorAnalyticsEndpoints.cs @@ -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 view = await AuditViewFactory.ProjectAsync(activity.Items, tenantRepository, ct); + return Results.Ok(new { items = view, total = activity.Total, limit = activity.Limit, offset = activity.Offset }); } /// diff --git a/src/core/Deal.Api/Endpoints/OperatorAuditEndpoints.cs b/src/core/Deal.Api/Endpoints/OperatorAuditEndpoints.cs index f1fdefd..6ba7bb2 100644 --- a/src/core/Deal.Api/Endpoints/OperatorAuditEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/OperatorAuditEndpoints.cs @@ -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 items = await auditService.QueryAsync(filter, ct); int total = await auditService.CountAsync(filter, ct); - return Results.Ok(new { items, total }); + IReadOnlyList view = await AuditViewFactory.ProjectAsync(items, tenantRepository, ct); + return Results.Ok(new { items = view, total }); } /// diff --git a/src/core/Deal.Api/Endpoints/OperatorAuthEndpoints.cs b/src/core/Deal.Api/Endpoints/OperatorAuthEndpoints.cs index 0b530bf..7f5d8c4 100644 --- a/src/core/Deal.Api/Endpoints/OperatorAuthEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/OperatorAuthEndpoints.cs @@ -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 }); diff --git a/src/core/Deal.Api/Endpoints/OperatorInvitesEndpoints.cs b/src/core/Deal.Api/Endpoints/OperatorInvitesEndpoints.cs index 54f10e8..83e58a4 100644 --- a/src/core/Deal.Api/Endpoints/OperatorInvitesEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/OperatorInvitesEndpoints.cs @@ -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 }); } diff --git a/src/core/Deal.Api/Endpoints/OperatorLimitsEndpoints.cs b/src/core/Deal.Api/Endpoints/OperatorLimitsEndpoints.cs index 9f733a4..ff72170 100644 --- a/src/core/Deal.Api/Endpoints/OperatorLimitsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/OperatorLimitsEndpoints.cs @@ -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)); } diff --git a/src/core/Deal.Api/Endpoints/OperatorSettingsEndpoints.cs b/src/core/Deal.Api/Endpoints/OperatorSettingsEndpoints.cs index 9c9d9cb..e0ae264 100644 --- a/src/core/Deal.Api/Endpoints/OperatorSettingsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/OperatorSettingsEndpoints.cs @@ -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); diff --git a/src/core/Deal.Api/Endpoints/OperatorTenantsEndpoints.cs b/src/core/Deal.Api/Endpoints/OperatorTenantsEndpoints.cs index 73f8f05..794d17a 100644 --- a/src/core/Deal.Api/Endpoints/OperatorTenantsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/OperatorTenantsEndpoints.cs @@ -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-куку). diff --git a/src/core/Deal.Api/Endpoints/SettingsEndpoints.cs b/src/core/Deal.Api/Endpoints/SettingsEndpoints.cs index 8e2715e..7a0c23d 100644 --- a/src/core/Deal.Api/Endpoints/SettingsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/SettingsEndpoints.cs @@ -74,7 +74,7 @@ public static class SettingsEndpoints SettingsService settingsService = context.RequestServices.GetRequiredService(); 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)) { diff --git a/src/core/Deal.Api/Endpoints/TelegramEndpoints.cs b/src/core/Deal.Api/Endpoints/TelegramEndpoints.cs index 3af092e..7ea2670 100644 --- a/src/core/Deal.Api/Endpoints/TelegramEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/TelegramEndpoints.cs @@ -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 }); diff --git a/src/core/Deal.Api/Services/AuditAppender.cs b/src/core/Deal.Api/Services/AuditAppender.cs index 817f2ef..adb0c64 100644 --- a/src/core/Deal.Api/Services/AuditAppender.cs +++ b/src/core/Deal.Api/Services/AuditAppender.cs @@ -13,12 +13,14 @@ public static class AuditAppender /// /// Пишет событие действия пользователя тенанта /// + /// Контекст запроса (сессия пользователя, IP). /// Тип события — константа . - /// Минимальные детали события (обычно анонимный объект) или null. + /// Изменения параметров события. + /// Токен отмены. public static async Task AppendTenantAsync( HttpContext context, string eventType, - object? details, + IReadOnlyList 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); } /// /// Пишет событие действия оператора /// + /// Контекст запроса (операторская сессия, IP). /// Тип события — константа . - /// Минимальные детали события (обычно анонимный объект) или null. + /// Изменения параметров события. + /// Токен отмены. public static async Task AppendOperatorAsync( HttpContext context, string eventType, - object? details, + IReadOnlyList 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 changes) => + changes.Count == 0 ? null : AuditService.ToDetailJson(changes); // IP-адрес клиента для аудита (без порта; null, если недоступен). - // context: Контекст запроса. - // Возвращает: Строковое представление IP или null. private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString(); } diff --git a/src/core/Deal.Api/Services/AuditViewFactory.cs b/src/core/Deal.Api/Services/AuditViewFactory.cs new file mode 100644 index 0000000..ca4f13d --- /dev/null +++ b/src/core/Deal.Api/Services/AuditViewFactory.cs @@ -0,0 +1,66 @@ +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; + +namespace Deal.Api.Services; + +/// +/// Проекция записей аудита для операторской консоли +/// +public static class AuditViewFactory +{ + /// + /// Дополняет записи аудита именами пользователей и разбирает изменения деталей + /// + /// Записи аудита. + /// Реестр пользователей для разрешения имён. + /// Токен отмены. + /// Записи для чтения оператором. + public static async Task> ProjectAsync( + IReadOnlyList records, + ITenantRepository tenantRepository, + CancellationToken ct) + { + IReadOnlyDictionary names = await ResolveNamesAsync(records, tenantRepository, ct); + var view = new List(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> ResolveNamesAsync( + IReadOnlyList 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() + : await tenantRepository.FindNamesByIdsAsync(ids, ct); + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/TenantRepository.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/TenantRepository.cs index f9272df..52c5870 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/TenantRepository.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/TenantRepository.cs @@ -61,6 +61,23 @@ public sealed class TenantRepository(DealDbContext dbContext) : ITenantRepositor return entities; } + public async Task> FindNamesByIdsAsync( + IReadOnlyCollection ids, + CancellationToken ct) + { + if (ids.Count == 0) + { + return new Dictionary(); + } + + 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); + } + /// public async Task UpdateStatusAsync( Guid id, diff --git a/src/core/Deal.Modules.Tenants/Application/Abstractions/ITenantRepository.cs b/src/core/Deal.Modules.Tenants/Application/Abstractions/ITenantRepository.cs index d526252..9bc4c42 100644 --- a/src/core/Deal.Modules.Tenants/Application/Abstractions/ITenantRepository.cs +++ b/src/core/Deal.Modules.Tenants/Application/Abstractions/ITenantRepository.cs @@ -27,7 +27,7 @@ public interface ITenantRepository public Task> ListAsync(CancellationToken ct); /// - /// Страница реестра тенантов (шардированный обход для 1000+ схем). + /// Возвращает страницу реестра тенантов (шардированный обход для 1000+ схем). /// /// Сдвиг от начала (устойчивый порядок — CreatedAt, затем Id). /// Размер страницы (≥1; валидирует потребитель). @@ -37,6 +37,16 @@ public interface ITenantRepository int limit, CancellationToken ct); + /// + /// Имена тенантов по идентификаторам + /// + /// Идентификаторы тенантов. + /// Токен отмены. + /// Словарь id → имя; отсутствующие не включаются. + public Task> FindNamesByIdsAsync( + IReadOnlyCollection ids, + CancellationToken ct); + /// /// Устанавливает статус тенанта. /// diff --git a/src/core/Deal.Modules.Tenants/Application/Extensions/AuditRecordDtoExtensions.cs b/src/core/Deal.Modules.Tenants/Application/Extensions/AuditRecordDtoExtensions.cs index 04e8434..5427526 100644 --- a/src/core/Deal.Modules.Tenants/Application/Extensions/AuditRecordDtoExtensions.cs +++ b/src/core/Deal.Modules.Tenants/Application/Extensions/AuditRecordDtoExtensions.cs @@ -1,8 +1,22 @@ +using System.Text.Json; + namespace Deal.Modules.Tenants.Application.Models; -// Расширения записей аудита -internal static class AuditRecordDtoExtensions +/// +/// Расширения записей аудита +/// +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 /// Запись аудита. /// True — событие из SuccessfulLoginEvents. public static bool IsSuccessfulLogin(this AuditRecordDto record) => SuccessfulLoginEvents.Contains(record.EventType); + + /// + /// Человекочитаемые изменения параметров события + /// + /// Запись аудита. + /// Изменения в порядке записи; пусто — деталей нет или они не разобраны. + public static IReadOnlyList 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 []; + } + } + + /// + /// Значение параметра события + /// + /// Запись аудита. + /// Код параметра. + /// Значение «стало» параметра; при отсутствии — значение «было»; иначе null. + 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 ParseChanges(JsonElement changes) + { + var result = new List(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 ParseFlatDetails(JsonElement root) + { + List properties = root.EnumerateObject().ToList(); + var consumed = new HashSet(StringComparer.Ordinal); + var result = new List(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..]; } diff --git a/src/core/Deal.Modules.Tenants/Application/Models/AuditChangeDto.cs b/src/core/Deal.Modules.Tenants/Application/Models/AuditChangeDto.cs new file mode 100644 index 0000000..8981e71 --- /dev/null +++ b/src/core/Deal.Modules.Tenants/Application/Models/AuditChangeDto.cs @@ -0,0 +1,12 @@ +namespace Deal.Modules.Tenants.Application.Models; + +/// +/// Изменение параметра в деталях события аудита +/// +/// Код параметра (человекочитаемое имя — в ресурсах интерфейса). +/// Значение до изменения; null — параметр задан впервые. +/// Значение после изменения. +public sealed record AuditChangeDto( + string Field, + string? From, + string? To); diff --git a/src/core/Deal.Modules.Tenants/Application/Models/AuditDetails.cs b/src/core/Deal.Modules.Tenants/Application/Models/AuditDetails.cs new file mode 100644 index 0000000..2940030 --- /dev/null +++ b/src/core/Deal.Modules.Tenants/Application/Models/AuditDetails.cs @@ -0,0 +1,41 @@ +using System.Globalization; + +namespace Deal.Modules.Tenants.Application.Models; + +/// +/// Конструктор деталей события аудита +/// +public static class AuditDetails +{ + /// + /// Задаёт параметр события + /// + /// Код параметра. + /// Значение параметра. + /// Изменение «параметр задан». + public static AuditChangeDto Set(string field, object? value) => + new(field, null, Format(value)); + + /// + /// Задаёт изменение параметра «было → стало» + /// + /// Код параметра. + /// Значение до изменения. + /// Значение после изменения. + /// Изменение «было → стало». + 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(), + }; +} diff --git a/src/core/Deal.Modules.Tenants/Application/Models/AuditRecordViewDto.cs b/src/core/Deal.Modules.Tenants/Application/Models/AuditRecordViewDto.cs new file mode 100644 index 0000000..71cdf9c --- /dev/null +++ b/src/core/Deal.Modules.Tenants/Application/Models/AuditRecordViewDto.cs @@ -0,0 +1,26 @@ +namespace Deal.Modules.Tenants.Application.Models; + +/// +/// Запись аудита для чтения оператором +/// +/// Тип события — константа каталога AuditEvents. +/// Тип актора — константа AuditActorTypes. +/// Идентификатор актора; null, если актор неизвестен. +/// Идентификатор пользователя события; null для операторских/системных событий. +/// Имя пользователя события; null, если не разрешено. +/// IP-адрес клиента (без порта); null для серверных действий. +/// Человекочитаемые изменения параметров события. +/// Сырые детали события в JSON; null, если деталей нет. +/// Время события (UTC). +/// Identity-идентификатор строки. +public sealed record AuditRecordViewDto( + string EventType, + string ActorType, + Guid? ActorId, + Guid? TenantId, + string? TenantName, + string? Ip, + IReadOnlyList Changes, + string? DetailJson, + DateTimeOffset At, + long Id); diff --git a/src/core/Deal.Modules.Tenants/Application/Models/TenantStatusChangeResultDto.cs b/src/core/Deal.Modules.Tenants/Application/Models/TenantStatusChangeResultDto.cs index 8ce19d0..ead3018 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/TenantStatusChangeResultDto.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/TenantStatusChangeResultDto.cs @@ -7,11 +7,13 @@ namespace Deal.Modules.Tenants.Application.Models; /// Код ошибки при Ok=false (см. константы); null при успехе. /// true — статус реально изменён (пишется аудит tenant_status_changed); false — уже был таким. /// Запись тенанта (актуальный статус — в ); null при Ok=false. +/// Статус до изменения; null при Ok=false. public sealed record TenantStatusChangeResultDto( bool Ok, string? Error, bool Changed, - TenantRecordDto? Tenant) + TenantRecordDto? Tenant, + string? PreviousStatus = null) { /// /// Код ошибки: тенант не найден diff --git a/src/core/Deal.Modules.Tenants/Application/Services/AuditService.cs b/src/core/Deal.Modules.Tenants/Application/Services/AuditService.cs index acf49fc..5b3f534 100644 --- a/src/core/Deal.Modules.Tenants/Application/Services/AuditService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/AuditService.cs @@ -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) /// 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, + }; /// /// Записывает событие аудита @@ -49,11 +53,12 @@ public sealed class AuditService(IAuditLogStore store) public Task CountAsync(AuditQueryDto filter, CancellationToken ct) => store.CountAsync(filter, ct); /// - /// Сериализует детали события в JSON. + /// Сериализует изменения деталей события в JSON /// - /// Объект деталей (обычно анонимный: { login =... }). - /// JSON-строка деталей. - public static string ToDetailJson(object? details) => JsonSerializer.Serialize(details, DetailJsonOptions); + /// Человекочитаемые изменения параметров события. + /// JSON-строка деталей вида { changes: [...] }. + public static string ToDetailJson(IReadOnlyList changes) => + JsonSerializer.Serialize(new { changes }, DetailJsonOptions); /// /// Актор «пользователь тенанта» по разрешённой сессии diff --git a/src/core/Deal.Modules.Tenants/Application/Services/SuspiciousActivityService.cs b/src/core/Deal.Modules.Tenants/Application/Services/SuspiciousActivityService.cs index 11ba330..2502f99 100644 --- a/src/core/Deal.Modules.Tenants/Application/Services/SuspiciousActivityService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/SuspiciousActivityService.cs @@ -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: Фактическое значение правила. diff --git a/src/core/Deal.Modules.Tenants/Application/Services/TenantAdminService.cs b/src/core/Deal.Modules.Tenants/Application/Services/TenantAdminService.cs index 70ec5f5..c6965b1 100644 --- a/src/core/Deal.Modules.Tenants/Application/Services/TenantAdminService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/TenantAdminService.cs @@ -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).