Добавить контракт изменений деталей аудита
Детали событий описываются списком изменений «параметр: было → стало» (AuditChangeDto, AuditDetails), а не произвольным JSON. Запись везде идёт через конструктор AuditDetails; разбор старого плоского формата сохранён. Операторские ручки аудита отдают AuditRecordViewDto с changes и именем пользователя (батч-резолв через ITenantRepository.FindNamesByIdsAsync). Детектор подозрительной активности читает логин через новый разбор.
This commit is contained in:
@@ -68,7 +68,7 @@ public static class AuthEndpoints
|
|||||||
ActorId: result.UserId,
|
ActorId: result.UserId,
|
||||||
TenantId: result.TenantId,
|
TenantId: result.TenantId,
|
||||||
Ip: ClientIp(context),
|
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);
|
return EndpointResults.Forbidden(TenantSuspendedDetail);
|
||||||
}
|
}
|
||||||
@@ -84,7 +84,7 @@ public static class AuthEndpoints
|
|||||||
ActorId: null,
|
ActorId: null,
|
||||||
TenantId: null,
|
TenantId: null,
|
||||||
Ip: ClientIp(context),
|
Ip: ClientIp(context),
|
||||||
DetailJson: AuditService.ToDetailJson(new { login = attemptedLogin })), ct);
|
DetailJson: AuditService.ToDetailJson([AuditDetails.Set("login", attemptedLogin)])), ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
return EndpointResults.Unauthorized(InvalidCredentialsDetail);
|
return EndpointResults.Unauthorized(InvalidCredentialsDetail);
|
||||||
@@ -98,7 +98,7 @@ public static class AuthEndpoints
|
|||||||
ActorId: result.UserId,
|
ActorId: result.UserId,
|
||||||
TenantId: result.TenantId,
|
TenantId: result.TenantId,
|
||||||
Ip: ClientIp(context),
|
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);
|
SessionCookieWriter.Append(context, cookieOptions.Value, result.Token);
|
||||||
return Results.Ok(new { ok = true, login = result.Login });
|
return Results.Ok(new { ok = true, login = result.Login });
|
||||||
@@ -125,12 +125,12 @@ public static class AuthEndpoints
|
|||||||
ActorId: logout.OperatorId,
|
ActorId: logout.OperatorId,
|
||||||
TenantId: logout.TenantId,
|
TenantId: logout.TenantId,
|
||||||
Ip: ClientIp(context),
|
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)
|
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);
|
context.Response.Cookies.Delete(cookieName);
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ public static class CardDetailsEndpoints
|
|||||||
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
||||||
CardDto created = await service.CreateLocalCardAsync(ToCreateLocalDto(body), ct);
|
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);
|
return await ReadCardAsync(context, created.Id, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ public static class CardsEndpoints
|
|||||||
return EndpointResults.BadRequest(outcome.Error);
|
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>();
|
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||||
CardDto unified = await cardsService.GetCardAsync(cardId, ct);
|
CardDto unified = await cardsService.GetCardAsync(cardId, ct);
|
||||||
@@ -202,7 +202,7 @@ public static class CardsEndpoints
|
|||||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||||
await cardsService.TrashCardAsync(cardId, ct);
|
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 });
|
return Results.Ok(new { ok = true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,7 +219,7 @@ public static class CardsEndpoints
|
|||||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||||
string col = await cardsService.RestoreCardAsync(cardId, ct);
|
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 });
|
return Results.Ok(new { ok = true, col });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,7 +240,7 @@ public static class CardsEndpoints
|
|||||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
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 });
|
return Results.Ok(new { ok = true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,7 +284,7 @@ public static class CardsEndpoints
|
|||||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
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 });
|
return Results.Ok(new { comments = result.Comments });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,7 +401,12 @@ public static class CardsEndpoints
|
|||||||
return AuditAppender.AppendTenantAsync(
|
return AuditAppender.AppendTenantAsync(
|
||||||
context,
|
context,
|
||||||
AuditEvents.CardReclassified,
|
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);
|
ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ public static class ContainersEndpoints
|
|||||||
Note: body.Note ?? string.Empty),
|
Note: body.Note ?? string.Empty),
|
||||||
ct);
|
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 });
|
return Results.Ok(new { id = created.Id });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,7 +153,7 @@ public static class ContainersEndpoints
|
|||||||
patchBody.Policy),
|
patchBody.Policy),
|
||||||
ct);
|
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 });
|
return Results.Ok(new { id = updated.Id });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +171,7 @@ public static class ContainersEndpoints
|
|||||||
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
|
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
|
||||||
ContainerDto accepted = await containers.AcceptSuggestedAsync(containerId, ct);
|
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);
|
return Results.Ok(accepted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +189,7 @@ public static class ContainersEndpoints
|
|||||||
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
|
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
|
||||||
int moved = await containers.DeleteAsync(containerId, ct);
|
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 });
|
return Results.Ok(new { ok = true, movedToInbox = moved });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,8 @@ public static class JoinEndpoint
|
|||||||
TenantId: result.TenantId,
|
TenantId: result.TenantId,
|
||||||
Ip: ClientIp(context),
|
Ip: ClientIp(context),
|
||||||
// Код инвайта — capability-токен: в аудит пишется только SHA-256-хэш (Security review).
|
// Код инвайта — 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 });
|
return Results.Ok(new { ok = true, login = result.Login });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Deal.Api.Extensions;
|
using Deal.Api.Extensions;
|
||||||
using Deal.Api.Services;
|
using Deal.Api.Services;
|
||||||
|
using Deal.Modules.Tenants.Application.Abstractions;
|
||||||
using Deal.Modules.Tenants.Application.Models;
|
using Deal.Modules.Tenants.Application.Models;
|
||||||
using Deal.Modules.Tenants.Application.Services;
|
using Deal.Modules.Tenants.Application.Services;
|
||||||
|
|
||||||
@@ -132,6 +133,7 @@ public static class OperatorAnalyticsEndpoints
|
|||||||
int? offset,
|
int? offset,
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
AnalyticsService analyticsService,
|
AnalyticsService analyticsService,
|
||||||
|
ITenantRepository tenantRepository,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (context.GetCurrentOperator() is null)
|
if (context.GetCurrentOperator() is null)
|
||||||
@@ -141,7 +143,8 @@ public static class OperatorAnalyticsEndpoints
|
|||||||
|
|
||||||
AnalyticsActivityDto activity = await analyticsService.ActivityAsync(
|
AnalyticsActivityDto activity = await analyticsService.ActivityAsync(
|
||||||
eventType, actorType, actorId, tenantId, from, to, limit, offset, ct);
|
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>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Deal.Api.Extensions;
|
using Deal.Api.Extensions;
|
||||||
using Deal.Api.Services;
|
using Deal.Api.Services;
|
||||||
|
using Deal.Modules.Tenants.Application.Abstractions;
|
||||||
using Deal.Modules.Tenants.Application.Models;
|
using Deal.Modules.Tenants.Application.Models;
|
||||||
using Deal.Modules.Tenants.Application.Services;
|
using Deal.Modules.Tenants.Application.Services;
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@ public static class OperatorAuditEndpoints
|
|||||||
int? offset,
|
int? offset,
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
AuditService auditService,
|
AuditService auditService,
|
||||||
|
ITenantRepository tenantRepository,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var operatorIdentity = context.GetCurrentOperator();
|
var operatorIdentity = context.GetCurrentOperator();
|
||||||
@@ -51,7 +53,8 @@ public static class OperatorAuditEndpoints
|
|||||||
eventType, actorType, tenantId, from, to, NormalizeLimit(limit), actorId, NormalizeOffset(offset));
|
eventType, actorType, tenantId, from, to, NormalizeLimit(limit), actorId, NormalizeOffset(offset));
|
||||||
IReadOnlyList<AuditRecordDto> items = await auditService.QueryAsync(filter, ct);
|
IReadOnlyList<AuditRecordDto> items = await auditService.QueryAsync(filter, ct);
|
||||||
int total = await auditService.CountAsync(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>
|
/// <summary>
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ public static class OperatorAuthEndpoints
|
|||||||
ActorId: null,
|
ActorId: null,
|
||||||
TenantId: null,
|
TenantId: null,
|
||||||
Ip: ClientIp(context),
|
Ip: ClientIp(context),
|
||||||
DetailJson: AuditService.ToDetailJson(new { login = attemptedLogin })), ct);
|
DetailJson: AuditService.ToDetailJson([AuditDetails.Set("login", attemptedLogin)])), ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
return EndpointResults.Unauthorized(InvalidCredentialsDetail);
|
return EndpointResults.Unauthorized(InvalidCredentialsDetail);
|
||||||
@@ -78,7 +78,7 @@ public static class OperatorAuthEndpoints
|
|||||||
ActorId: result.OperatorId,
|
ActorId: result.OperatorId,
|
||||||
TenantId: null,
|
TenantId: null,
|
||||||
Ip: ClientIp(context),
|
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);
|
SetOperatorSessionCookie(context, cookieOptions.Value, result.Token);
|
||||||
return Results.Ok(new { ok = true, login = result.Login });
|
return Results.Ok(new { ok = true, login = result.Login });
|
||||||
@@ -100,7 +100,7 @@ public static class OperatorAuthEndpoints
|
|||||||
|
|
||||||
if (operatorIdentity is not null)
|
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 });
|
return Results.Ok(new { ok = true });
|
||||||
|
|||||||
@@ -92,7 +92,8 @@ public static class OperatorInvitesEndpoints
|
|||||||
Ip: ClientIp(context),
|
Ip: ClientIp(context),
|
||||||
// Код инвайта — capability-токен (по нему активируется приглашение): в аудит пишется
|
// Код инвайта — capability-токен (по нему активируется приглашение): в аудит пишется
|
||||||
// только его SHA-256-хэш, чтобы утечка ленты не давала рабочие коды (Security review).
|
// только его 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;
|
InviteDto invite = result.Invite;
|
||||||
return Results.Ok(new { invite.Code, invite.Email, invite.TenantId, invite.ExpiresAt, invite.Status });
|
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,
|
ActorId: operatorIdentity.OperatorId,
|
||||||
TenantId: null,
|
TenantId: null,
|
||||||
Ip: ClientIp(context),
|
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 });
|
return Results.Ok(new { ok = true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,14 +167,8 @@ public static class OperatorLimitsEndpoints
|
|||||||
ActorId: operatorIdentity.OperatorId,
|
ActorId: operatorIdentity.OperatorId,
|
||||||
TenantId: id,
|
TenantId: id,
|
||||||
Ip: ClientIp(context),
|
Ip: ClientIp(context),
|
||||||
DetailJson: AuditService.ToDetailJson(new
|
DetailJson: AuditService.ToDetailJson(
|
||||||
{
|
[AuditDetails.Change("budget", current.BudgetTokens, newBudget), AuditDetails.Change("period", current.Period, newPeriod)])), ct);
|
||||||
tenantId = id,
|
|
||||||
oldBudget = current.BudgetTokens,
|
|
||||||
oldPeriod = current.Period,
|
|
||||||
budgetTokens = newBudget,
|
|
||||||
period = newPeriod,
|
|
||||||
})), ct);
|
|
||||||
|
|
||||||
return Results.Ok(BuildDetailDto(tenant.Name, updated));
|
return Results.Ok(BuildDetailDto(tenant.Name, updated));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,7 +128,8 @@ public static class OperatorSettingsEndpoints
|
|||||||
ActorId: operatorIdentity.OperatorId,
|
ActorId: operatorIdentity.OperatorId,
|
||||||
TenantId: null,
|
TenantId: null,
|
||||||
Ip: ClientIp(context),
|
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);
|
TelegramKeysMaskedDto snapshot = await keys.GetMaskedAsync(ct);
|
||||||
return Results.Ok(snapshot);
|
return Results.Ok(snapshot);
|
||||||
|
|||||||
@@ -113,7 +113,8 @@ public static class OperatorTenantsEndpoints
|
|||||||
ActorId: operatorIdentity.OperatorId,
|
ActorId: operatorIdentity.OperatorId,
|
||||||
TenantId: createdTenant.Id,
|
TenantId: createdTenant.Id,
|
||||||
Ip: ClientIp(context),
|
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)
|
if (result.OwnerLogin is not null)
|
||||||
{
|
{
|
||||||
@@ -202,7 +203,8 @@ public static class OperatorTenantsEndpoints
|
|||||||
ActorId: operatorIdentity.OperatorId,
|
ActorId: operatorIdentity.OperatorId,
|
||||||
TenantId: result.Tenant.Id,
|
TenantId: result.Tenant.Id,
|
||||||
Ip: ClientIp(context),
|
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 });
|
return Results.Ok(new { ok = true, status = result.Tenant.Status });
|
||||||
@@ -242,7 +244,7 @@ public static class OperatorTenantsEndpoints
|
|||||||
ActorId: operatorIdentity.OperatorId,
|
ActorId: operatorIdentity.OperatorId,
|
||||||
TenantId: result.TenantId,
|
TenantId: result.TenantId,
|
||||||
Ip: ClientIp(context),
|
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-сессия (как после /api/auth/login): СТАВИМ ту же httpOnly-куку deal_session
|
||||||
// на ответ, чтобы браузер оператора сразу получил tenant-сессию (JS не может записать httpOnly-куку).
|
// на ответ, чтобы браузер оператора сразу получил tenant-сессию (JS не может записать httpOnly-куку).
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ public static class SettingsEndpoints
|
|||||||
SettingsService settingsService = context.RequestServices.GetRequiredService<SettingsService>();
|
SettingsService settingsService = context.RequestServices.GetRequiredService<SettingsService>();
|
||||||
PublicSettingsDto result = await settingsService.ApplyPatchAsync(body, ct);
|
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))
|
if (ShouldScheduleRatesRefresh(body))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ public static class TelegramEndpoints
|
|||||||
TelegramAuthResultDto result = await gateway.StartQrAsync(apiId, keys.ApiHash, ct);
|
TelegramAuthResultDto result = await gateway.StartQrAsync(apiId, keys.ApiHash, ct);
|
||||||
if (result.Phase == ReadyPhase)
|
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 });
|
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);
|
string phase = await gateway.SendCodeAsync((body.Code ?? string.Empty).Trim(), ct);
|
||||||
if (phase == ReadyPhase)
|
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 });
|
return Results.Ok(new { phase });
|
||||||
@@ -224,7 +224,7 @@ public static class TelegramEndpoints
|
|||||||
string phase = await gateway.SendPasswordAsync(body.Password ?? string.Empty, ct);
|
string phase = await gateway.SendPasswordAsync(body.Password ?? string.Empty, ct);
|
||||||
if (phase == ReadyPhase)
|
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 });
|
return Results.Ok(new { phase });
|
||||||
@@ -343,7 +343,7 @@ public static class TelegramEndpoints
|
|||||||
|
|
||||||
if (body.Enabled)
|
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 });
|
return Results.Ok(new { ok = true, count = result.Count, enabled = body.Enabled });
|
||||||
@@ -393,7 +393,7 @@ public static class TelegramEndpoints
|
|||||||
|
|
||||||
if (result.Enabled)
|
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 });
|
return Results.Ok(new { ok = true, enabled = result.Enabled });
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ public static class AuditAppender
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Пишет событие действия пользователя тенанта
|
/// Пишет событие действия пользователя тенанта
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="context">Контекст запроса (сессия пользователя, IP).</param>
|
||||||
/// <param name="eventType">Тип события — константа <see cref="AuditEvents"/>.</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(
|
public static async Task AppendTenantAsync(
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
string eventType,
|
string eventType,
|
||||||
object? details,
|
IReadOnlyList<AuditChangeDto> changes,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
CurrentUser? user = context.GetCurrentUser();
|
CurrentUser? user = context.GetCurrentUser();
|
||||||
@@ -35,19 +37,21 @@ public static class AuditAppender
|
|||||||
ActorId: user.UserId,
|
ActorId: user.UserId,
|
||||||
TenantId: user.TenantId,
|
TenantId: user.TenantId,
|
||||||
Ip: ClientIp(context),
|
Ip: ClientIp(context),
|
||||||
DetailJson: DetailJson(details)),
|
DetailJson: DetailJson(changes)),
|
||||||
ct);
|
ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Пишет событие действия оператора
|
/// Пишет событие действия оператора
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="context">Контекст запроса (операторская сессия, IP).</param>
|
||||||
/// <param name="eventType">Тип события — константа <see cref="AuditEvents"/>.</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(
|
public static async Task AppendOperatorAsync(
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
string eventType,
|
string eventType,
|
||||||
object? details,
|
IReadOnlyList<AuditChangeDto> changes,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
CurrentOperator? operatorIdentity = context.GetCurrentOperator();
|
CurrentOperator? operatorIdentity = context.GetCurrentOperator();
|
||||||
@@ -64,18 +68,16 @@ public static class AuditAppender
|
|||||||
ActorId: operatorIdentity.OperatorId,
|
ActorId: operatorIdentity.OperatorId,
|
||||||
TenantId: null,
|
TenantId: null,
|
||||||
Ip: ClientIp(context),
|
Ip: ClientIp(context),
|
||||||
DetailJson: DetailJson(details)),
|
DetailJson: DetailJson(changes)),
|
||||||
ct);
|
ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Сериализует детали события (null — деталей нет).
|
// Сериализует детали события (пустой список — деталей нет).
|
||||||
// details: Объект деталей или null.
|
// changes: Изменения параметров события.
|
||||||
// Возвращает: JSON деталей (camelCase) или null.
|
// Возвращает: JSON деталей или null.
|
||||||
private static string? DetailJson(object? details) =>
|
private static string? DetailJson(IReadOnlyList<AuditChangeDto> changes) =>
|
||||||
details is null ? null : AuditService.ToDetailJson(details);
|
changes.Count == 0 ? null : AuditService.ToDetailJson(changes);
|
||||||
|
|
||||||
// IP-адрес клиента для аудита (без порта; null, если недоступен).
|
// IP-адрес клиента для аудита (без порта; null, если недоступен).
|
||||||
// context: Контекст запроса.
|
|
||||||
// Возвращает: Строковое представление IP или null.
|
|
||||||
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
|
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;
|
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 />
|
/// <inheritdoc />
|
||||||
public async Task<bool> UpdateStatusAsync(
|
public async Task<bool> UpdateStatusAsync(
|
||||||
Guid id,
|
Guid id,
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ public interface ITenantRepository
|
|||||||
public Task<IReadOnlyList<TenantRecordDto>> ListAsync(CancellationToken ct);
|
public Task<IReadOnlyList<TenantRecordDto>> ListAsync(CancellationToken ct);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Страница реестра тенантов (шардированный обход для 1000+ схем).
|
/// Возвращает страницу реестра тенантов (шардированный обход для 1000+ схем).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="offset">Сдвиг от начала (устойчивый порядок — CreatedAt, затем Id).</param>
|
/// <param name="offset">Сдвиг от начала (устойчивый порядок — CreatedAt, затем Id).</param>
|
||||||
/// <param name="limit">Размер страницы (≥1; валидирует потребитель).</param>
|
/// <param name="limit">Размер страницы (≥1; валидирует потребитель).</param>
|
||||||
@@ -37,6 +37,16 @@ public interface ITenantRepository
|
|||||||
int limit,
|
int limit,
|
||||||
CancellationToken ct);
|
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>
|
||||||
/// Устанавливает статус тенанта.
|
/// Устанавливает статус тенанта.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -1,8 +1,22 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace Deal.Modules.Tenants.Application.Models;
|
namespace Deal.Modules.Tenants.Application.Models;
|
||||||
|
|
||||||
// Расширения записей аудита
|
/// <summary>
|
||||||
internal static class AuditRecordDtoExtensions
|
/// Расширения записей аудита
|
||||||
|
/// </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 =
|
private static readonly string[] FailedLoginEvents =
|
||||||
{
|
{
|
||||||
@@ -30,4 +44,130 @@ internal static class AuditRecordDtoExtensions
|
|||||||
/// <param name="record">Запись аудита.</param>
|
/// <param name="record">Запись аудита.</param>
|
||||||
/// <returns>True — событие из SuccessfulLoginEvents.</returns>
|
/// <returns>True — событие из SuccessfulLoginEvents.</returns>
|
||||||
public static bool IsSuccessfulLogin(this AuditRecordDto record) => SuccessfulLoginEvents.Contains(record.EventType);
|
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="Error">Код ошибки при Ok=false (см. константы); null при успехе.</param>
|
||||||
/// <param name="Changed">true — статус реально изменён (пишется аудит tenant_status_changed); false — уже был таким.</param>
|
/// <param name="Changed">true — статус реально изменён (пишется аудит tenant_status_changed); false — уже был таким.</param>
|
||||||
/// <param name="Tenant">Запись тенанта (актуальный статус — в <see cref="TenantRecordDto.Status"/>); null при Ok=false.</param>
|
/// <param name="Tenant">Запись тенанта (актуальный статус — в <see cref="TenantRecordDto.Status"/>); null при Ok=false.</param>
|
||||||
|
/// <param name="PreviousStatus">Статус до изменения; null при Ok=false.</param>
|
||||||
public sealed record TenantStatusChangeResultDto(
|
public sealed record TenantStatusChangeResultDto(
|
||||||
bool Ok,
|
bool Ok,
|
||||||
string? Error,
|
string? Error,
|
||||||
bool Changed,
|
bool Changed,
|
||||||
TenantRecordDto? Tenant)
|
TenantRecordDto? Tenant,
|
||||||
|
string? PreviousStatus = null)
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Код ошибки: тенант не найден
|
/// Код ошибки: тенант не найден
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
using Deal.Modules.Tenants.Application.Abstractions;
|
using Deal.Modules.Tenants.Application.Abstractions;
|
||||||
using Deal.Modules.Tenants.Application.Models;
|
using Deal.Modules.Tenants.Application.Models;
|
||||||
using Deal.SharedKernel.Observability;
|
using Deal.SharedKernel.Observability;
|
||||||
@@ -20,8 +21,11 @@ public sealed class AuditService(IAuditLogStore store)
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public const int DefaultQueryLimit = 100;
|
public const int DefaultQueryLimit = 100;
|
||||||
|
|
||||||
// Опции JSON деталей: camelCase (конвенция DetailJson/JSON проекта).
|
// Опции JSON деталей: camelCase (конвенция DetailJson/JSON проекта), без null-полей.
|
||||||
private static readonly JsonSerializerOptions DetailJsonOptions = new(JsonSerializerDefaults.Web);
|
private static readonly JsonSerializerOptions DetailJsonOptions = new(JsonSerializerDefaults.Web)
|
||||||
|
{
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Записывает событие аудита
|
/// Записывает событие аудита
|
||||||
@@ -49,11 +53,12 @@ public sealed class AuditService(IAuditLogStore store)
|
|||||||
public Task<int> CountAsync(AuditQueryDto filter, CancellationToken ct) => store.CountAsync(filter, ct);
|
public Task<int> CountAsync(AuditQueryDto filter, CancellationToken ct) => store.CountAsync(filter, ct);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Сериализует детали события в JSON.
|
/// Сериализует изменения деталей события в JSON
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="details">Объект деталей (обычно анонимный: { login =... }).</param>
|
/// <param name="changes">Человекочитаемые изменения параметров события.</param>
|
||||||
/// <returns>JSON-строка деталей.</returns>
|
/// <returns>JSON-строка деталей вида <c>{ changes: [...] }</c>.</returns>
|
||||||
public static string ToDetailJson(object? details) => JsonSerializer.Serialize(details, DetailJsonOptions);
|
public static string ToDetailJson(IReadOnlyList<AuditChangeDto> changes) =>
|
||||||
|
JsonSerializer.Serialize(new { changes }, DetailJsonOptions);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Актор «пользователь тенанта» по разрешённой сессии
|
/// Актор «пользователь тенанта» по разрешённой сессии
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
using System.Text.Json;
|
|
||||||
using Deal.Modules.Tenants.Application.Abstractions;
|
using Deal.Modules.Tenants.Application.Abstractions;
|
||||||
using Deal.Modules.Tenants.Application.Models;
|
using Deal.Modules.Tenants.Application.Models;
|
||||||
|
|
||||||
@@ -338,33 +337,10 @@ public sealed class SuspiciousActivityService
|
|||||||
return counts;
|
return counts;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Извлекает логин из DetailJson записи (поле login); сбой разбора — null.
|
// Извлекает логин из деталей записи (параметр login); повреждённые детали — null.
|
||||||
// record: Запись аудита.
|
// record: Запись аудита.
|
||||||
// Возвращает: Логин либо null (деталей нет/не строка/повреждённый JSON).
|
// Возвращает: Логин либо null (деталей нет/параметр не задан).
|
||||||
private static string? ExtractLogin(AuditRecordDto record)
|
private static string? ExtractLogin(AuditRecordDto record) => record.DetailValue("login");
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Уровень находки по превышению порога (≥2× порог — high, иначе medium).
|
// Уровень находки по превышению порога (≥2× порог — high, иначе medium).
|
||||||
// count: Фактическое значение правила.
|
// count: Фактическое значение правила.
|
||||||
|
|||||||
@@ -138,11 +138,16 @@ public sealed class TenantAdminService(
|
|||||||
// Идемпотентность: повторный suspend уже приостановленного — Ok без изменения (аудит не дублируется).
|
// Идемпотентность: повторный suspend уже приостановленного — Ok без изменения (аудит не дублируется).
|
||||||
if (tenant.Status == status)
|
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);
|
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).
|
// Новый одноразовый пароль владельца: 16 url-safe символов (общий UrlSafeToken, Security review C36).
|
||||||
|
|||||||
Reference in New Issue
Block a user