Добавить контракт изменений деталей аудита
Детали событий описываются списком изменений «параметр: было → стало» (AuditChangeDto, AuditDetails), а не произвольным JSON. Запись везде идёт через конструктор AuditDetails; разбор старого плоского формата сохранён. Операторские ручки аудита отдают AuditRecordViewDto с changes и именем пользователя (батч-резолв через ITenantRepository.FindNamesByIdsAsync). Детектор подозрительной активности читает логин через новый разбор.
This commit is contained in:
@@ -68,7 +68,7 @@ public static class AuthEndpoints
|
||||
ActorId: result.UserId,
|
||||
TenantId: result.TenantId,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: AuditService.ToDetailJson(new { login = NormalizeLogin(body.Login) })), ct);
|
||||
DetailJson: AuditService.ToDetailJson([AuditDetails.Set("login", NormalizeLogin(body.Login))])), ct);
|
||||
|
||||
return EndpointResults.Forbidden(TenantSuspendedDetail);
|
||||
}
|
||||
@@ -84,7 +84,7 @@ public static class AuthEndpoints
|
||||
ActorId: null,
|
||||
TenantId: null,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: AuditService.ToDetailJson(new { login = attemptedLogin })), ct);
|
||||
DetailJson: AuditService.ToDetailJson([AuditDetails.Set("login", attemptedLogin)])), ct);
|
||||
}
|
||||
|
||||
return EndpointResults.Unauthorized(InvalidCredentialsDetail);
|
||||
@@ -98,7 +98,7 @@ public static class AuthEndpoints
|
||||
ActorId: result.UserId,
|
||||
TenantId: result.TenantId,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: AuditService.ToDetailJson(new { login = result.Login })), ct);
|
||||
DetailJson: AuditService.ToDetailJson([AuditDetails.Set("login", result.Login)])), ct);
|
||||
|
||||
SessionCookieWriter.Append(context, cookieOptions.Value, result.Token);
|
||||
return Results.Ok(new { ok = true, login = result.Login });
|
||||
@@ -125,12 +125,12 @@ public static class AuthEndpoints
|
||||
ActorId: logout.OperatorId,
|
||||
TenantId: logout.TenantId,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: AuditService.ToDetailJson(new { login = logout.Login })), ct);
|
||||
DetailJson: AuditService.ToDetailJson([AuditDetails.Set("login", logout.Login)])), ct);
|
||||
}
|
||||
|
||||
if (user is not null)
|
||||
{
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.TenantLogout, new { login = user.Login }, ct);
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.TenantLogout, [AuditDetails.Set("login", user.Login)], ct);
|
||||
}
|
||||
|
||||
context.Response.Cookies.Delete(cookieName);
|
||||
|
||||
@@ -120,7 +120,7 @@ public static class CardDetailsEndpoints
|
||||
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardDto created = await service.CreateLocalCardAsync(ToCreateLocalDto(body), ct);
|
||||
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCreated, new { cardId = created.Id }, ct);
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCreated, [AuditDetails.Set("cardId", created.Id)], ct);
|
||||
return await ReadCardAsync(context, created.Id, ct);
|
||||
}
|
||||
|
||||
|
||||
@@ -182,7 +182,7 @@ public static class CardsEndpoints
|
||||
return EndpointResults.BadRequest(outcome.Error);
|
||||
}
|
||||
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardMoved, new { cardId, to = body.To }, ct);
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardMoved, [AuditDetails.Set("cardId", cardId), AuditDetails.Set("to", body.To)], ct);
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardDto unified = await cardsService.GetCardAsync(cardId, ct);
|
||||
@@ -202,7 +202,7 @@ public static class CardsEndpoints
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
await cardsService.TrashCardAsync(cardId, ct);
|
||||
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardTrashed, new { cardId }, ct);
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardTrashed, [AuditDetails.Set("cardId", cardId)], ct);
|
||||
return Results.Ok(new { ok = true });
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ public static class CardsEndpoints
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
string col = await cardsService.RestoreCardAsync(cardId, ct);
|
||||
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardRestored, new { cardId, col }, ct);
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardRestored, [AuditDetails.Set("cardId", cardId), AuditDetails.Set("col", col)], ct);
|
||||
return Results.Ok(new { ok = true, col });
|
||||
}
|
||||
|
||||
@@ -240,7 +240,7 @@ public static class CardsEndpoints
|
||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardDeleted, new { cardId }, ct);
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardDeleted, [AuditDetails.Set("cardId", cardId)], ct);
|
||||
return Results.Ok(new { ok = true });
|
||||
}
|
||||
|
||||
@@ -284,7 +284,7 @@ public static class CardsEndpoints
|
||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCommentAdded, new { cardId }, ct);
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCommentAdded, [AuditDetails.Set("cardId", cardId)], ct);
|
||||
return Results.Ok(new { comments = result.Comments });
|
||||
}
|
||||
|
||||
@@ -401,7 +401,12 @@ public static class CardsEndpoints
|
||||
return AuditAppender.AppendTenantAsync(
|
||||
context,
|
||||
AuditEvents.CardReclassified,
|
||||
new { attempted = result.Attempted, reclassified = result.Reclassified, moved = result.Moved, trashed = result.Trashed },
|
||||
[
|
||||
AuditDetails.Set("attempted", result.Attempted),
|
||||
AuditDetails.Set("reclassified", result.Reclassified),
|
||||
AuditDetails.Set("moved", result.Moved),
|
||||
AuditDetails.Set("trashed", result.Trashed),
|
||||
],
|
||||
ct);
|
||||
}
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ public static class ContainersEndpoints
|
||||
Note: body.Note ?? string.Empty),
|
||||
ct);
|
||||
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerCreated, new { id = created.Id, name = created.Name }, ct);
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerCreated, [AuditDetails.Set("containerId", created.Id), AuditDetails.Set("name", created.Name)], ct);
|
||||
return Results.Ok(new { id = created.Id });
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ public static class ContainersEndpoints
|
||||
patchBody.Policy),
|
||||
ct);
|
||||
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = updated.Id }, ct);
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, [AuditDetails.Set("containerId", updated.Id)], ct);
|
||||
return Results.Ok(new { id = updated.Id });
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ public static class ContainersEndpoints
|
||||
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
|
||||
ContainerDto accepted = await containers.AcceptSuggestedAsync(containerId, ct);
|
||||
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = accepted.Id }, ct);
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, [AuditDetails.Set("containerId", accepted.Id)], ct);
|
||||
return Results.Ok(accepted);
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ public static class ContainersEndpoints
|
||||
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
|
||||
int moved = await containers.DeleteAsync(containerId, ct);
|
||||
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerDeleted, new { id = containerId }, ct);
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerDeleted, [AuditDetails.Set("containerId", containerId)], ct);
|
||||
return Results.Ok(new { ok = true, movedToInbox = moved });
|
||||
}
|
||||
|
||||
|
||||
@@ -69,7 +69,8 @@ public static class JoinEndpoint
|
||||
TenantId: result.TenantId,
|
||||
Ip: ClientIp(context),
|
||||
// Код инвайта — capability-токен: в аудит пишется только SHA-256-хэш (Security review).
|
||||
DetailJson: AuditService.ToDetailJson(new { email = result.Login, codeHash = SessionTokens.HashToken(body.Code?.Trim() ?? string.Empty) })), ct);
|
||||
DetailJson: AuditService.ToDetailJson(
|
||||
[AuditDetails.Set("email", result.Login), AuditDetails.Set("codeHash", SessionTokens.HashToken(body.Code?.Trim() ?? string.Empty))])), ct);
|
||||
|
||||
return Results.Ok(new { ok = true, login = result.Login });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
@@ -132,6 +133,7 @@ public static class OperatorAnalyticsEndpoints
|
||||
int? offset,
|
||||
HttpContext context,
|
||||
AnalyticsService analyticsService,
|
||||
ITenantRepository tenantRepository,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (context.GetCurrentOperator() is null)
|
||||
@@ -141,7 +143,8 @@ public static class OperatorAnalyticsEndpoints
|
||||
|
||||
AnalyticsActivityDto activity = await analyticsService.ActivityAsync(
|
||||
eventType, actorType, actorId, tenantId, from, to, limit, offset, ct);
|
||||
return Results.Ok(activity);
|
||||
IReadOnlyList<AuditRecordViewDto> view = await AuditViewFactory.ProjectAsync(activity.Items, tenantRepository, ct);
|
||||
return Results.Ok(new { items = view, total = activity.Total, limit = activity.Limit, offset = activity.Offset });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
@@ -39,6 +40,7 @@ public static class OperatorAuditEndpoints
|
||||
int? offset,
|
||||
HttpContext context,
|
||||
AuditService auditService,
|
||||
ITenantRepository tenantRepository,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var operatorIdentity = context.GetCurrentOperator();
|
||||
@@ -51,7 +53,8 @@ public static class OperatorAuditEndpoints
|
||||
eventType, actorType, tenantId, from, to, NormalizeLimit(limit), actorId, NormalizeOffset(offset));
|
||||
IReadOnlyList<AuditRecordDto> items = await auditService.QueryAsync(filter, ct);
|
||||
int total = await auditService.CountAsync(filter, ct);
|
||||
return Results.Ok(new { items, total });
|
||||
IReadOnlyList<AuditRecordViewDto> view = await AuditViewFactory.ProjectAsync(items, tenantRepository, ct);
|
||||
return Results.Ok(new { items = view, total });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -64,7 +64,7 @@ public static class OperatorAuthEndpoints
|
||||
ActorId: null,
|
||||
TenantId: null,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: AuditService.ToDetailJson(new { login = attemptedLogin })), ct);
|
||||
DetailJson: AuditService.ToDetailJson([AuditDetails.Set("login", attemptedLogin)])), ct);
|
||||
}
|
||||
|
||||
return EndpointResults.Unauthorized(InvalidCredentialsDetail);
|
||||
@@ -78,7 +78,7 @@ public static class OperatorAuthEndpoints
|
||||
ActorId: result.OperatorId,
|
||||
TenantId: null,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: AuditService.ToDetailJson(new { login = result.Login })), ct);
|
||||
DetailJson: AuditService.ToDetailJson([AuditDetails.Set("login", result.Login)])), ct);
|
||||
|
||||
SetOperatorSessionCookie(context, cookieOptions.Value, result.Token);
|
||||
return Results.Ok(new { ok = true, login = result.Login });
|
||||
@@ -100,7 +100,7 @@ public static class OperatorAuthEndpoints
|
||||
|
||||
if (operatorIdentity is not null)
|
||||
{
|
||||
await AuditAppender.AppendOperatorAsync(context, AuditEvents.OperatorLogout, new { login = operatorIdentity.Login }, ct);
|
||||
await AuditAppender.AppendOperatorAsync(context, AuditEvents.OperatorLogout, [AuditDetails.Set("login", operatorIdentity.Login)], ct);
|
||||
}
|
||||
|
||||
return Results.Ok(new { ok = true });
|
||||
|
||||
@@ -92,7 +92,8 @@ public static class OperatorInvitesEndpoints
|
||||
Ip: ClientIp(context),
|
||||
// Код инвайта — capability-токен (по нему активируется приглашение): в аудит пишется
|
||||
// только его SHA-256-хэш, чтобы утечка ленты не давала рабочие коды (Security review).
|
||||
DetailJson: AuditService.ToDetailJson(new { email = result.Invite.Email, codeHash = SessionTokens.HashToken(result.Invite.Code) })), ct);
|
||||
DetailJson: AuditService.ToDetailJson(
|
||||
[AuditDetails.Set("email", result.Invite.Email), AuditDetails.Set("codeHash", SessionTokens.HashToken(result.Invite.Code))])), ct);
|
||||
|
||||
InviteDto invite = result.Invite;
|
||||
return Results.Ok(new { invite.Code, invite.Email, invite.TenantId, invite.ExpiresAt, invite.Status });
|
||||
@@ -126,7 +127,8 @@ public static class OperatorInvitesEndpoints
|
||||
ActorId: operatorIdentity.OperatorId,
|
||||
TenantId: null,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: AuditService.ToDetailJson(new { email = result.Invite!.Email, codeHash = SessionTokens.HashToken(result.Invite.Code) })), ct);
|
||||
DetailJson: AuditService.ToDetailJson(
|
||||
[AuditDetails.Set("email", result.Invite!.Email), AuditDetails.Set("codeHash", SessionTokens.HashToken(result.Invite.Code))])), ct);
|
||||
|
||||
return Results.Ok(new { ok = true });
|
||||
}
|
||||
|
||||
@@ -167,14 +167,8 @@ public static class OperatorLimitsEndpoints
|
||||
ActorId: operatorIdentity.OperatorId,
|
||||
TenantId: id,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: AuditService.ToDetailJson(new
|
||||
{
|
||||
tenantId = id,
|
||||
oldBudget = current.BudgetTokens,
|
||||
oldPeriod = current.Period,
|
||||
budgetTokens = newBudget,
|
||||
period = newPeriod,
|
||||
})), ct);
|
||||
DetailJson: AuditService.ToDetailJson(
|
||||
[AuditDetails.Change("budget", current.BudgetTokens, newBudget), AuditDetails.Change("period", current.Period, newPeriod)])), ct);
|
||||
|
||||
return Results.Ok(BuildDetailDto(tenant.Name, updated));
|
||||
}
|
||||
|
||||
@@ -128,7 +128,8 @@ public static class OperatorSettingsEndpoints
|
||||
ActorId: operatorIdentity.OperatorId,
|
||||
TenantId: null,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: AuditService.ToDetailJson(new { apiId = effectiveApiId, apiHashSet = true })), ct);
|
||||
DetailJson: AuditService.ToDetailJson(
|
||||
[AuditDetails.Set("apiId", effectiveApiId), AuditDetails.Set("apiHashSet", true)])), ct);
|
||||
|
||||
TelegramKeysMaskedDto snapshot = await keys.GetMaskedAsync(ct);
|
||||
return Results.Ok(snapshot);
|
||||
|
||||
@@ -113,7 +113,8 @@ public static class OperatorTenantsEndpoints
|
||||
ActorId: operatorIdentity.OperatorId,
|
||||
TenantId: createdTenant.Id,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: AuditService.ToDetailJson(new { tenantId = createdTenant.Id, name = createdTenant.Name, email = result.OwnerLogin })), ct);
|
||||
DetailJson: AuditService.ToDetailJson(
|
||||
[AuditDetails.Set("name", createdTenant.Name), AuditDetails.Set("email", result.OwnerLogin)])), ct);
|
||||
|
||||
if (result.OwnerLogin is not null)
|
||||
{
|
||||
@@ -202,7 +203,8 @@ public static class OperatorTenantsEndpoints
|
||||
ActorId: operatorIdentity.OperatorId,
|
||||
TenantId: result.Tenant.Id,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: AuditService.ToDetailJson(new { tenantId = result.Tenant.Id, status = result.Tenant.Status })), ct);
|
||||
DetailJson: AuditService.ToDetailJson(
|
||||
[AuditDetails.Change("status", result.PreviousStatus, result.Tenant.Status)])), ct);
|
||||
}
|
||||
|
||||
return Results.Ok(new { ok = true, status = result.Tenant.Status });
|
||||
@@ -242,7 +244,7 @@ public static class OperatorTenantsEndpoints
|
||||
ActorId: operatorIdentity.OperatorId,
|
||||
TenantId: result.TenantId,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: AuditService.ToDetailJson(new { targetLogin = result.Login, tenantId = result.TenantId })), ct);
|
||||
DetailJson: AuditService.ToDetailJson([AuditDetails.Set("targetLogin", result.Login)])), ct);
|
||||
|
||||
// Токен — это tenant-сессия (как после /api/auth/login): СТАВИМ ту же httpOnly-куку deal_session
|
||||
// на ответ, чтобы браузер оператора сразу получил tenant-сессию (JS не может записать httpOnly-куку).
|
||||
|
||||
@@ -74,7 +74,7 @@ public static class SettingsEndpoints
|
||||
SettingsService settingsService = context.RequestServices.GetRequiredService<SettingsService>();
|
||||
PublicSettingsDto result = await settingsService.ApplyPatchAsync(body, ct);
|
||||
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.SettingsUpdated, new { fields = body.Keys }, ct);
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.SettingsUpdated, [AuditDetails.Set("fields", string.Join(", ", body.Keys))], ct);
|
||||
|
||||
if (ShouldScheduleRatesRefresh(body))
|
||||
{
|
||||
|
||||
@@ -170,7 +170,7 @@ public static class TelegramEndpoints
|
||||
TelegramAuthResultDto result = await gateway.StartQrAsync(apiId, keys.ApiHash, ct);
|
||||
if (result.Phase == ReadyPhase)
|
||||
{
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, new { phase = result.Phase }, ct);
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, [AuditDetails.Set("phase", result.Phase)], ct);
|
||||
}
|
||||
|
||||
return Results.Ok(new { phase = result.Phase, qrUrl = result.QrUrl ?? string.Empty });
|
||||
@@ -197,7 +197,7 @@ public static class TelegramEndpoints
|
||||
string phase = await gateway.SendCodeAsync((body.Code ?? string.Empty).Trim(), ct);
|
||||
if (phase == ReadyPhase)
|
||||
{
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, new { phase }, ct);
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, [AuditDetails.Set("phase", phase)], ct);
|
||||
}
|
||||
|
||||
return Results.Ok(new { phase });
|
||||
@@ -224,7 +224,7 @@ public static class TelegramEndpoints
|
||||
string phase = await gateway.SendPasswordAsync(body.Password ?? string.Empty, ct);
|
||||
if (phase == ReadyPhase)
|
||||
{
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, new { phase }, ct);
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, [AuditDetails.Set("phase", phase)], ct);
|
||||
}
|
||||
|
||||
return Results.Ok(new { phase });
|
||||
@@ -343,7 +343,7 @@ public static class TelegramEndpoints
|
||||
|
||||
if (body.Enabled)
|
||||
{
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.ChannelEnabled, new { all = true, count = result.Count }, ct);
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.ChannelEnabled, [AuditDetails.Set("all", true), AuditDetails.Set("count", result.Count)], ct);
|
||||
}
|
||||
|
||||
return Results.Ok(new { ok = true, count = result.Count, enabled = body.Enabled });
|
||||
@@ -393,7 +393,7 @@ public static class TelegramEndpoints
|
||||
|
||||
if (result.Enabled)
|
||||
{
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.ChannelEnabled, new { dialogId = dialog_id }, ct);
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.ChannelEnabled, [AuditDetails.Set("dialogId", dialog_id)], ct);
|
||||
}
|
||||
|
||||
return Results.Ok(new { ok = true, enabled = result.Enabled });
|
||||
|
||||
@@ -13,12 +13,14 @@ public static class AuditAppender
|
||||
/// <summary>
|
||||
/// Пишет событие действия пользователя тенанта
|
||||
/// </summary>
|
||||
/// <param name="context">Контекст запроса (сессия пользователя, IP).</param>
|
||||
/// <param name="eventType">Тип события — константа <see cref="AuditEvents"/>.</param>
|
||||
/// <param name="details">Минимальные детали события (обычно анонимный объект) или null.</param>
|
||||
/// <param name="changes">Изменения параметров события.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
public static async Task AppendTenantAsync(
|
||||
HttpContext context,
|
||||
string eventType,
|
||||
object? details,
|
||||
IReadOnlyList<AuditChangeDto> changes,
|
||||
CancellationToken ct)
|
||||
{
|
||||
CurrentUser? user = context.GetCurrentUser();
|
||||
@@ -35,19 +37,21 @@ public static class AuditAppender
|
||||
ActorId: user.UserId,
|
||||
TenantId: user.TenantId,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: DetailJson(details)),
|
||||
DetailJson: DetailJson(changes)),
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Пишет событие действия оператора
|
||||
/// </summary>
|
||||
/// <param name="context">Контекст запроса (операторская сессия, IP).</param>
|
||||
/// <param name="eventType">Тип события — константа <see cref="AuditEvents"/>.</param>
|
||||
/// <param name="details">Минимальные детали события (обычно анонимный объект) или null.</param>
|
||||
/// <param name="changes">Изменения параметров события.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
public static async Task AppendOperatorAsync(
|
||||
HttpContext context,
|
||||
string eventType,
|
||||
object? details,
|
||||
IReadOnlyList<AuditChangeDto> changes,
|
||||
CancellationToken ct)
|
||||
{
|
||||
CurrentOperator? operatorIdentity = context.GetCurrentOperator();
|
||||
@@ -64,18 +68,16 @@ public static class AuditAppender
|
||||
ActorId: operatorIdentity.OperatorId,
|
||||
TenantId: null,
|
||||
Ip: ClientIp(context),
|
||||
DetailJson: DetailJson(details)),
|
||||
DetailJson: DetailJson(changes)),
|
||||
ct);
|
||||
}
|
||||
|
||||
// Сериализует детали события (null — деталей нет).
|
||||
// details: Объект деталей или null.
|
||||
// Возвращает: JSON деталей (camelCase) или null.
|
||||
private static string? DetailJson(object? details) =>
|
||||
details is null ? null : AuditService.ToDetailJson(details);
|
||||
// Сериализует детали события (пустой список — деталей нет).
|
||||
// changes: Изменения параметров события.
|
||||
// Возвращает: JSON деталей или null.
|
||||
private static string? DetailJson(IReadOnlyList<AuditChangeDto> changes) =>
|
||||
changes.Count == 0 ? null : AuditService.ToDetailJson(changes);
|
||||
|
||||
// IP-адрес клиента для аудита (без порта; null, если недоступен).
|
||||
// context: Контекст запроса.
|
||||
// Возвращает: Строковое представление IP или null.
|
||||
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
namespace Deal.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Проекция записей аудита для операторской консоли
|
||||
/// </summary>
|
||||
public static class AuditViewFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Дополняет записи аудита именами пользователей и разбирает изменения деталей
|
||||
/// </summary>
|
||||
/// <param name="records">Записи аудита.</param>
|
||||
/// <param name="tenantRepository">Реестр пользователей для разрешения имён.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Записи для чтения оператором.</returns>
|
||||
public static async Task<IReadOnlyList<AuditRecordViewDto>> ProjectAsync(
|
||||
IReadOnlyList<AuditRecordDto> records,
|
||||
ITenantRepository tenantRepository,
|
||||
CancellationToken ct)
|
||||
{
|
||||
IReadOnlyDictionary<Guid, string> names = await ResolveNamesAsync(records, tenantRepository, ct);
|
||||
var view = new List<AuditRecordViewDto>(records.Count);
|
||||
foreach (AuditRecordDto record in records)
|
||||
{
|
||||
string? tenantName = record.TenantId is { } tenantId && names.TryGetValue(tenantId, out string? name)
|
||||
? name
|
||||
: null;
|
||||
view.Add(new AuditRecordViewDto(
|
||||
record.EventType,
|
||||
record.ActorType,
|
||||
record.ActorId,
|
||||
record.TenantId,
|
||||
tenantName,
|
||||
record.Ip,
|
||||
record.AuditChanges(),
|
||||
record.DetailJson,
|
||||
record.At,
|
||||
record.Id));
|
||||
}
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
// Разрешает имена пользователей одной выборкой по уникальным идентификаторам.
|
||||
// records: Записи аудита.
|
||||
// tenantRepository: Реестр пользователей.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Словарь id → имя.
|
||||
private static async Task<IReadOnlyDictionary<Guid, string>> ResolveNamesAsync(
|
||||
IReadOnlyList<AuditRecordDto> records,
|
||||
ITenantRepository tenantRepository,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Guid[] ids = records
|
||||
.Select(record => record.TenantId)
|
||||
.Where(id => id is not null)
|
||||
.Select(id => id!.Value)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
return ids.Length == 0
|
||||
? new Dictionary<Guid, string>()
|
||||
: await tenantRepository.FindNamesByIdsAsync(ids, ct);
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,23 @@ public sealed class TenantRepository(DealDbContext dbContext) : ITenantRepositor
|
||||
return entities;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<Guid, string>> FindNamesByIdsAsync(
|
||||
IReadOnlyCollection<Guid> ids,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (ids.Count == 0)
|
||||
{
|
||||
return new Dictionary<Guid, string>();
|
||||
}
|
||||
|
||||
var rows = await dbContext.Tenants
|
||||
.AsNoTracking()
|
||||
.Where(t => ids.Contains(t.Id))
|
||||
.Select(t => new { t.Id, t.Name })
|
||||
.ToListAsync(ct);
|
||||
return rows.ToDictionary(row => row.Id, row => row.Name);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> UpdateStatusAsync(
|
||||
Guid id,
|
||||
|
||||
@@ -27,7 +27,7 @@ public interface ITenantRepository
|
||||
public Task<IReadOnlyList<TenantRecordDto>> ListAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Страница реестра тенантов (шардированный обход для 1000+ схем).
|
||||
/// Возвращает страницу реестра тенантов (шардированный обход для 1000+ схем).
|
||||
/// </summary>
|
||||
/// <param name="offset">Сдвиг от начала (устойчивый порядок — CreatedAt, затем Id).</param>
|
||||
/// <param name="limit">Размер страницы (≥1; валидирует потребитель).</param>
|
||||
@@ -37,6 +37,16 @@ public interface ITenantRepository
|
||||
int limit,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Имена тенантов по идентификаторам
|
||||
/// </summary>
|
||||
/// <param name="ids">Идентификаторы тенантов.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Словарь id → имя; отсутствующие не включаются.</returns>
|
||||
public Task<IReadOnlyDictionary<Guid, string>> FindNamesByIdsAsync(
|
||||
IReadOnlyCollection<Guid> ids,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Устанавливает статус тенанта.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
// Расширения записей аудита
|
||||
internal static class AuditRecordDtoExtensions
|
||||
/// <summary>
|
||||
/// Расширения записей аудита
|
||||
/// </summary>
|
||||
public static class AuditRecordDtoExtensions
|
||||
{
|
||||
// Ключ служебного поля-контейнера изменений в деталях события.
|
||||
private const string ChangesProperty = "changes";
|
||||
|
||||
// Параметр события, дублирующий TenantId самой записи, — в изменения не попадает.
|
||||
private const string TenantIdProperty = "tenantId";
|
||||
|
||||
// Префиксы парных ключей «было»/«стало» в устаревшем плоском формате деталей.
|
||||
private const string OldPrefix = "old";
|
||||
private const string NewPrefix = "new";
|
||||
|
||||
// События аудита «неудачный вход» (тенант/оператор).
|
||||
private static readonly string[] FailedLoginEvents =
|
||||
{
|
||||
@@ -30,4 +44,130 @@ internal static class AuditRecordDtoExtensions
|
||||
/// <param name="record">Запись аудита.</param>
|
||||
/// <returns>True — событие из SuccessfulLoginEvents.</returns>
|
||||
public static bool IsSuccessfulLogin(this AuditRecordDto record) => SuccessfulLoginEvents.Contains(record.EventType);
|
||||
|
||||
/// <summary>
|
||||
/// Человекочитаемые изменения параметров события
|
||||
/// </summary>
|
||||
/// <param name="record">Запись аудита.</param>
|
||||
/// <returns>Изменения в порядке записи; пусто — деталей нет или они не разобраны.</returns>
|
||||
public static IReadOnlyList<AuditChangeDto> AuditChanges(this AuditRecordDto record)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(record.DetailJson))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(record.DetailJson);
|
||||
if (document.RootElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return document.RootElement.TryGetProperty(ChangesProperty, out JsonElement changes)
|
||||
&& changes.ValueKind == JsonValueKind.Array
|
||||
? ParseChanges(changes)
|
||||
: ParseFlatDetails(document.RootElement);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Повреждённые детали — изменений нет; запись остаётся читаемой.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Значение параметра события
|
||||
/// </summary>
|
||||
/// <param name="record">Запись аудита.</param>
|
||||
/// <param name="field">Код параметра.</param>
|
||||
/// <returns>Значение «стало» параметра; при отсутствии — значение «было»; иначе null.</returns>
|
||||
public static string? DetailValue(this AuditRecordDto record, string field)
|
||||
{
|
||||
foreach (AuditChangeDto change in record.AuditChanges())
|
||||
{
|
||||
if (string.Equals(change.Field, field, StringComparison.Ordinal))
|
||||
{
|
||||
return change.To ?? change.From;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Разбирает контейнер изменений нового формата («changes»).
|
||||
// changes: Массив объектов { field, from, to }.
|
||||
// Возвращает: Изменения; некорректные элементы пропускаются.
|
||||
private static List<AuditChangeDto> ParseChanges(JsonElement changes)
|
||||
{
|
||||
var result = new List<AuditChangeDto>(changes.GetArrayLength());
|
||||
foreach (JsonElement item in changes.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind != JsonValueKind.Object
|
||||
|| !item.TryGetProperty("field", out JsonElement field)
|
||||
|| field.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string? from = item.TryGetProperty("from", out JsonElement fromElement) ? Stringify(fromElement) : null;
|
||||
string? to = item.TryGetProperty("to", out JsonElement toElement) ? Stringify(toElement) : null;
|
||||
result.Add(new AuditChangeDto(field.GetString()!, from, to));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Разбирает устаревший плоский формат деталей: пары old*/new* → «было → стало», прочее — «задано».
|
||||
// root: Корневой объект деталей.
|
||||
// Возвращает: Изменения в порядке свойств.
|
||||
private static List<AuditChangeDto> ParseFlatDetails(JsonElement root)
|
||||
{
|
||||
List<JsonProperty> properties = root.EnumerateObject().ToList();
|
||||
var consumed = new HashSet<string>(StringComparer.Ordinal);
|
||||
var result = new List<AuditChangeDto>(properties.Count);
|
||||
|
||||
foreach (JsonProperty property in properties)
|
||||
{
|
||||
if (!consumed.Add(property.Name) || property.Name == TenantIdProperty)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string? pairedNewName = property.Name.StartsWith(OldPrefix, StringComparison.Ordinal)
|
||||
&& property.Name.Length > OldPrefix.Length
|
||||
? NewPrefix + property.Name[OldPrefix.Length..]
|
||||
: null;
|
||||
if (pairedNewName is not null && root.TryGetProperty(pairedNewName, out JsonElement newValue))
|
||||
{
|
||||
consumed.Add(pairedNewName);
|
||||
result.Add(new AuditChangeDto(
|
||||
LowerFirst(property.Name[OldPrefix.Length..]),
|
||||
Stringify(property.Value),
|
||||
Stringify(newValue)));
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Add(new AuditChangeDto(property.Name, null, Stringify(property.Value)));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Приводит значение JSON к строке отображения.
|
||||
// element: Значение JSON.
|
||||
// Возвращает: Строковое представление либо null для JSON null.
|
||||
private static string? Stringify(JsonElement element) => element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Null or JsonValueKind.Undefined => null,
|
||||
JsonValueKind.String => element.GetString(),
|
||||
_ => element.GetRawText(),
|
||||
};
|
||||
|
||||
// Понижает регистр первой буквы имени параметра (oldBudget → budget).
|
||||
// value: Имя параметра.
|
||||
// Возвращает: Имя с первой строчной буквой.
|
||||
private static string LowerFirst(string value) =>
|
||||
value.Length == 0 ? value : char.ToLowerInvariant(value[0]) + value[1..];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Изменение параметра в деталях события аудита
|
||||
/// </summary>
|
||||
/// <param name="Field">Код параметра (человекочитаемое имя — в ресурсах интерфейса).</param>
|
||||
/// <param name="From">Значение до изменения; null — параметр задан впервые.</param>
|
||||
/// <param name="To">Значение после изменения.</param>
|
||||
public sealed record AuditChangeDto(
|
||||
string Field,
|
||||
string? From,
|
||||
string? To);
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Конструктор деталей события аудита
|
||||
/// </summary>
|
||||
public static class AuditDetails
|
||||
{
|
||||
/// <summary>
|
||||
/// Задаёт параметр события
|
||||
/// </summary>
|
||||
/// <param name="field">Код параметра.</param>
|
||||
/// <param name="value">Значение параметра.</param>
|
||||
/// <returns>Изменение «параметр задан».</returns>
|
||||
public static AuditChangeDto Set(string field, object? value) =>
|
||||
new(field, null, Format(value));
|
||||
|
||||
/// <summary>
|
||||
/// Задаёт изменение параметра «было → стало»
|
||||
/// </summary>
|
||||
/// <param name="field">Код параметра.</param>
|
||||
/// <param name="from">Значение до изменения.</param>
|
||||
/// <param name="to">Значение после изменения.</param>
|
||||
/// <returns>Изменение «было → стало».</returns>
|
||||
public static AuditChangeDto Change(string field, object? from, object? to) =>
|
||||
new(field, Format(from), Format(to));
|
||||
|
||||
// Приводит значение к строке отображения (инвариантная культура).
|
||||
// value: Значение параметра.
|
||||
// Возвращает: Строковое представление либо null.
|
||||
private static string? Format(object? value) => value switch
|
||||
{
|
||||
null => null,
|
||||
string text => text,
|
||||
bool flag => flag ? "true" : "false",
|
||||
DateTimeOffset moment => moment.ToString("O", CultureInfo.InvariantCulture),
|
||||
IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture),
|
||||
_ => value.ToString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Запись аудита для чтения оператором
|
||||
/// </summary>
|
||||
/// <param name="EventType">Тип события — константа каталога <c>AuditEvents</c>.</param>
|
||||
/// <param name="ActorType">Тип актора — константа <c>AuditActorTypes</c>.</param>
|
||||
/// <param name="ActorId">Идентификатор актора; null, если актор неизвестен.</param>
|
||||
/// <param name="TenantId">Идентификатор пользователя события; null для операторских/системных событий.</param>
|
||||
/// <param name="TenantName">Имя пользователя события; null, если не разрешено.</param>
|
||||
/// <param name="Ip">IP-адрес клиента (без порта); null для серверных действий.</param>
|
||||
/// <param name="Changes">Человекочитаемые изменения параметров события.</param>
|
||||
/// <param name="DetailJson">Сырые детали события в JSON; null, если деталей нет.</param>
|
||||
/// <param name="At">Время события (UTC).</param>
|
||||
/// <param name="Id">Identity-идентификатор строки.</param>
|
||||
public sealed record AuditRecordViewDto(
|
||||
string EventType,
|
||||
string ActorType,
|
||||
Guid? ActorId,
|
||||
Guid? TenantId,
|
||||
string? TenantName,
|
||||
string? Ip,
|
||||
IReadOnlyList<AuditChangeDto> Changes,
|
||||
string? DetailJson,
|
||||
DateTimeOffset At,
|
||||
long Id);
|
||||
@@ -7,11 +7,13 @@ namespace Deal.Modules.Tenants.Application.Models;
|
||||
/// <param name="Error">Код ошибки при Ok=false (см. константы); null при успехе.</param>
|
||||
/// <param name="Changed">true — статус реально изменён (пишется аудит tenant_status_changed); false — уже был таким.</param>
|
||||
/// <param name="Tenant">Запись тенанта (актуальный статус — в <see cref="TenantRecordDto.Status"/>); null при Ok=false.</param>
|
||||
/// <param name="PreviousStatus">Статус до изменения; null при Ok=false.</param>
|
||||
public sealed record TenantStatusChangeResultDto(
|
||||
bool Ok,
|
||||
string? Error,
|
||||
bool Changed,
|
||||
TenantRecordDto? Tenant)
|
||||
TenantRecordDto? Tenant,
|
||||
string? PreviousStatus = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Код ошибки: тенант не найден
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.SharedKernel.Observability;
|
||||
@@ -20,8 +21,11 @@ public sealed class AuditService(IAuditLogStore store)
|
||||
/// </summary>
|
||||
public const int DefaultQueryLimit = 100;
|
||||
|
||||
// Опции JSON деталей: camelCase (конвенция DetailJson/JSON проекта).
|
||||
private static readonly JsonSerializerOptions DetailJsonOptions = new(JsonSerializerDefaults.Web);
|
||||
// Опции JSON деталей: camelCase (конвенция DetailJson/JSON проекта), без null-полей.
|
||||
private static readonly JsonSerializerOptions DetailJsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Записывает событие аудита
|
||||
@@ -49,11 +53,12 @@ public sealed class AuditService(IAuditLogStore store)
|
||||
public Task<int> CountAsync(AuditQueryDto filter, CancellationToken ct) => store.CountAsync(filter, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Сериализует детали события в JSON.
|
||||
/// Сериализует изменения деталей события в JSON
|
||||
/// </summary>
|
||||
/// <param name="details">Объект деталей (обычно анонимный: { login =... }).</param>
|
||||
/// <returns>JSON-строка деталей.</returns>
|
||||
public static string ToDetailJson(object? details) => JsonSerializer.Serialize(details, DetailJsonOptions);
|
||||
/// <param name="changes">Человекочитаемые изменения параметров события.</param>
|
||||
/// <returns>JSON-строка деталей вида <c>{ changes: [...] }</c>.</returns>
|
||||
public static string ToDetailJson(IReadOnlyList<AuditChangeDto> changes) =>
|
||||
JsonSerializer.Serialize(new { changes }, DetailJsonOptions);
|
||||
|
||||
/// <summary>
|
||||
/// Актор «пользователь тенанта» по разрешённой сессии
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using System.Text.Json;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
@@ -338,33 +337,10 @@ public sealed class SuspiciousActivityService
|
||||
return counts;
|
||||
}
|
||||
|
||||
// Извлекает логин из DetailJson записи (поле login); сбой разбора — null.
|
||||
// Извлекает логин из деталей записи (параметр login); повреждённые детали — null.
|
||||
// record: Запись аудита.
|
||||
// Возвращает: Логин либо null (деталей нет/не строка/повреждённый JSON).
|
||||
private static string? ExtractLogin(AuditRecordDto record)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(record.DetailJson))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(record.DetailJson);
|
||||
if (document.RootElement.ValueKind == JsonValueKind.Object
|
||||
&& document.RootElement.TryGetProperty("login", out JsonElement login)
|
||||
&& login.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return login.GetString();
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Повреждённые детали — логин неизвестен; запись в правиле не участвует.
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
// Возвращает: Логин либо null (деталей нет/параметр не задан).
|
||||
private static string? ExtractLogin(AuditRecordDto record) => record.DetailValue("login");
|
||||
|
||||
// Уровень находки по превышению порога (≥2× порог — high, иначе medium).
|
||||
// count: Фактическое значение правила.
|
||||
|
||||
@@ -138,11 +138,16 @@ public sealed class TenantAdminService(
|
||||
// Идемпотентность: повторный suspend уже приостановленного — Ok без изменения (аудит не дублируется).
|
||||
if (tenant.Status == status)
|
||||
{
|
||||
return new TenantStatusChangeResultDto(Ok: true, Error: null, Changed: false, Tenant: tenant);
|
||||
return new TenantStatusChangeResultDto(Ok: true, Error: null, Changed: false, Tenant: tenant, PreviousStatus: tenant.Status);
|
||||
}
|
||||
|
||||
bool updated = await tenantRepository.UpdateStatusAsync(id, status, ct);
|
||||
return new TenantStatusChangeResultDto(Ok: true, Error: null, Changed: updated, Tenant: tenant with { Status = status });
|
||||
return new TenantStatusChangeResultDto(
|
||||
Ok: true,
|
||||
Error: null,
|
||||
Changed: updated,
|
||||
Tenant: tenant with { Status = status },
|
||||
PreviousStatus: tenant.Status);
|
||||
}
|
||||
|
||||
// Новый одноразовый пароль владельца: 16 url-safe символов (общий UrlSafeToken, Security review C36).
|
||||
|
||||
Reference in New Issue
Block a user