Нормализовать переводы строк в LF
Решение по TD-STYLE-ANALYZERS: LF — инструменты проекта (Python/Node) пишут LF, CRLF-.sh не работают на Linux CI (sh scripts/ci.sh), большинство файлов уже были LF. Добавлен .gitattributes (* text=auto eol=lf, бинарные исключения), .editorconfig переведён на lf, 1029 файлов конвертированы, git add --renormalize. Из индекса убраны закравшиеся archive/**/__pycache__/*.pyc.
This commit is contained in:
@@ -1,160 +1,160 @@
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Прикладной сервис операторской аналитики
|
||||
/// </summary>
|
||||
public sealed class AnalyticsService(
|
||||
ITenantRepository tenants,
|
||||
AuditService audit,
|
||||
TokenUsageEventService tokenUsage)
|
||||
{
|
||||
/// <summary>
|
||||
/// Размер страницы ленты действий по умолчанию
|
||||
/// </summary>
|
||||
public const int DefaultActivityLimit = AuditService.DefaultQueryLimit;
|
||||
|
||||
/// <summary>
|
||||
/// Верхняя граница размера страницы ленты действий
|
||||
/// </summary>
|
||||
public const int MaxActivityLimit = AuditService.MaxQueryLimit;
|
||||
|
||||
/// <summary>
|
||||
/// Сводка: тенанты
|
||||
/// </summary>
|
||||
/// <param name="from">Начало периода (включительно; null — без границы).</param>
|
||||
/// <param name="to">Конец периода (включительно; null — без границы).</param>
|
||||
/// <returns>Сводка аналитики.</returns>
|
||||
public async Task<AnalyticsOverviewDto> OverviewAsync(
|
||||
DateTimeOffset? from,
|
||||
DateTimeOffset? to,
|
||||
CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<TenantRecordDto> allTenants = await tenants.ListAsync(ct);
|
||||
int active = allTenants.Count(tenant => tenant.Status == TenantStatuses.Active);
|
||||
|
||||
// Токены за период — суммируем по тенантам (агрегат по тенантам: ключ не важен, берём суммы).
|
||||
IReadOnlyList<TokenUsageAggregateDto> perTenant = await tokenUsage.AggregateAsync(
|
||||
new TokenUsageEventQueryDto(null, null, null, null, from, to, TokenUsageGroupBys.Tenant), ct);
|
||||
long promptTokens = perTenant.Sum(row => row.PromptTokens);
|
||||
long completionTokens = perTenant.Sum(row => row.CompletionTokens);
|
||||
long totalTokens = perTenant.Sum(row => row.TotalTokens);
|
||||
long tokenEvents = perTenant.Sum(row => row.EventCount);
|
||||
|
||||
int events = await audit.CountAsync(
|
||||
new AuditQueryDto(null, null, null, from, to, AuditService.DefaultQueryLimit), ct);
|
||||
int logins = await CountEventTypesAsync(
|
||||
from, to, [AuditEvents.TenantLoginOk, AuditEvents.OperatorLoginOk], ct);
|
||||
int logouts = await CountEventTypesAsync(
|
||||
from, to, [AuditEvents.TenantLogout, AuditEvents.OperatorLogout], ct);
|
||||
int failedLogins = await CountEventTypesAsync(
|
||||
from, to, [AuditEvents.TenantLoginFailed, AuditEvents.OperatorLoginFailed], ct);
|
||||
|
||||
return new AnalyticsOverviewDto(
|
||||
TenantsTotal: allTenants.Count,
|
||||
TenantsActive: active,
|
||||
PromptTokens: promptTokens,
|
||||
CompletionTokens: completionTokens,
|
||||
TotalTokens: totalTokens,
|
||||
TokenEvents: tokenEvents,
|
||||
Events: events,
|
||||
Logins: logins,
|
||||
Logouts: logouts,
|
||||
FailedLogins: failedLogins,
|
||||
From: from,
|
||||
To: to);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Агрегаты расхода токенов по группировке и фильтрам
|
||||
/// </summary>
|
||||
/// <param name="groupBy">Группировка day|tenant|provider|model (валидирует HTTP-слой).</param>
|
||||
/// <param name="tenantId">Тенант (равенство; null — все тенанты).</param>
|
||||
/// <param name="from">Начало периода (включительно; null — без границы).</param>
|
||||
/// <param name="to">Конец периода (включительно; null — без границы).</param>
|
||||
/// <returns>Строки агрегатов и итог.</returns>
|
||||
public async Task<AnalyticsTokensDto> TokensAsync(
|
||||
string groupBy,
|
||||
Guid? tenantId,
|
||||
DateTimeOffset? from,
|
||||
DateTimeOffset? to,
|
||||
CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<TokenUsageAggregateDto> items = await tokenUsage.AggregateAsync(
|
||||
new TokenUsageEventQueryDto(tenantId, null, null, null, from, to, groupBy), ct);
|
||||
var total = new TokenUsageAggregateDto(
|
||||
Key: "total",
|
||||
PromptTokens: items.Sum(row => row.PromptTokens),
|
||||
CompletionTokens: items.Sum(row => row.CompletionTokens),
|
||||
TotalTokens: items.Sum(row => row.TotalTokens),
|
||||
EventCount: items.Sum(row => row.EventCount));
|
||||
return new AnalyticsTokensDto(groupBy, from, to, items, total);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Лента действий
|
||||
/// </summary>
|
||||
/// <param name="eventType">Тип события (равенство; null — без фильтра).</param>
|
||||
/// <param name="actorType">Тип актора operator|tenant|system (равенство; null — без фильтра).</param>
|
||||
/// <param name="actorId">Идентификатор актора (равенство; null — без фильтра).</param>
|
||||
/// <param name="tenantId">Тенант (равенство; null — без фильтра).</param>
|
||||
/// <param name="from">Нижняя граница At (включительно; null — без границы).</param>
|
||||
/// <param name="to">Верхняя граница At (включительно; null — без границы).</param>
|
||||
/// <param name="limit">Размер страницы (дефолт 100, кламп 1..500).</param>
|
||||
/// <param name="offset">Смещение страницы (≥0).</param>
|
||||
/// <returns>Страница записей и полное число по фильтру.</returns>
|
||||
public async Task<AnalyticsActivityDto> ActivityAsync(
|
||||
string? eventType,
|
||||
string? actorType,
|
||||
Guid? actorId,
|
||||
Guid? tenantId,
|
||||
DateTimeOffset? from,
|
||||
DateTimeOffset? to,
|
||||
int? limit,
|
||||
int? offset,
|
||||
CancellationToken ct)
|
||||
{
|
||||
int normalizedLimit = NormalizeActivityLimit(limit);
|
||||
int normalizedOffset = Math.Max(0, offset ?? 0);
|
||||
var filter = new AuditQueryDto(
|
||||
eventType, actorType, tenantId, from, to, normalizedLimit, actorId, normalizedOffset);
|
||||
|
||||
IReadOnlyList<AuditRecordDto> items = await audit.QueryAsync(filter, ct);
|
||||
int total = await audit.CountAsync(filter, ct);
|
||||
return new AnalyticsActivityDto(items, total, normalizedLimit, normalizedOffset);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Нормализует limit ленты действий
|
||||
/// </summary>
|
||||
/// <param name="limit">Запрошенный размер (null — не задан).</param>
|
||||
/// <returns>Значение для фильтра.</returns>
|
||||
public static int NormalizeActivityLimit(int? limit) =>
|
||||
limit is null
|
||||
? DefaultActivityLimit
|
||||
: Math.Max(1, Math.Min(MaxActivityLimit, limit.Value));
|
||||
|
||||
// Суммарное число записей аудита по списку типов событий за период.
|
||||
// from: Нижняя граница At (включительно; null — без границы).
|
||||
// to: Верхняя граница At (включительно; null — без границы).
|
||||
// eventTypes: Типы событий (каждый считается отдельным равенством).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Сумма по типам событий.
|
||||
private async Task<int> CountEventTypesAsync(
|
||||
DateTimeOffset? from,
|
||||
DateTimeOffset? to,
|
||||
string[] eventTypes,
|
||||
CancellationToken ct)
|
||||
{
|
||||
int total = 0;
|
||||
foreach (string eventType in eventTypes)
|
||||
{
|
||||
total += await audit.CountAsync(
|
||||
new AuditQueryDto(eventType, null, null, from, to, AuditService.DefaultQueryLimit), ct);
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
}
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Прикладной сервис операторской аналитики
|
||||
/// </summary>
|
||||
public sealed class AnalyticsService(
|
||||
ITenantRepository tenants,
|
||||
AuditService audit,
|
||||
TokenUsageEventService tokenUsage)
|
||||
{
|
||||
/// <summary>
|
||||
/// Размер страницы ленты действий по умолчанию
|
||||
/// </summary>
|
||||
public const int DefaultActivityLimit = AuditService.DefaultQueryLimit;
|
||||
|
||||
/// <summary>
|
||||
/// Верхняя граница размера страницы ленты действий
|
||||
/// </summary>
|
||||
public const int MaxActivityLimit = AuditService.MaxQueryLimit;
|
||||
|
||||
/// <summary>
|
||||
/// Сводка: тенанты
|
||||
/// </summary>
|
||||
/// <param name="from">Начало периода (включительно; null — без границы).</param>
|
||||
/// <param name="to">Конец периода (включительно; null — без границы).</param>
|
||||
/// <returns>Сводка аналитики.</returns>
|
||||
public async Task<AnalyticsOverviewDto> OverviewAsync(
|
||||
DateTimeOffset? from,
|
||||
DateTimeOffset? to,
|
||||
CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<TenantRecordDto> allTenants = await tenants.ListAsync(ct);
|
||||
int active = allTenants.Count(tenant => tenant.Status == TenantStatuses.Active);
|
||||
|
||||
// Токены за период — суммируем по тенантам (агрегат по тенантам: ключ не важен, берём суммы).
|
||||
IReadOnlyList<TokenUsageAggregateDto> perTenant = await tokenUsage.AggregateAsync(
|
||||
new TokenUsageEventQueryDto(null, null, null, null, from, to, TokenUsageGroupBys.Tenant), ct);
|
||||
long promptTokens = perTenant.Sum(row => row.PromptTokens);
|
||||
long completionTokens = perTenant.Sum(row => row.CompletionTokens);
|
||||
long totalTokens = perTenant.Sum(row => row.TotalTokens);
|
||||
long tokenEvents = perTenant.Sum(row => row.EventCount);
|
||||
|
||||
int events = await audit.CountAsync(
|
||||
new AuditQueryDto(null, null, null, from, to, AuditService.DefaultQueryLimit), ct);
|
||||
int logins = await CountEventTypesAsync(
|
||||
from, to, [AuditEvents.TenantLoginOk, AuditEvents.OperatorLoginOk], ct);
|
||||
int logouts = await CountEventTypesAsync(
|
||||
from, to, [AuditEvents.TenantLogout, AuditEvents.OperatorLogout], ct);
|
||||
int failedLogins = await CountEventTypesAsync(
|
||||
from, to, [AuditEvents.TenantLoginFailed, AuditEvents.OperatorLoginFailed], ct);
|
||||
|
||||
return new AnalyticsOverviewDto(
|
||||
TenantsTotal: allTenants.Count,
|
||||
TenantsActive: active,
|
||||
PromptTokens: promptTokens,
|
||||
CompletionTokens: completionTokens,
|
||||
TotalTokens: totalTokens,
|
||||
TokenEvents: tokenEvents,
|
||||
Events: events,
|
||||
Logins: logins,
|
||||
Logouts: logouts,
|
||||
FailedLogins: failedLogins,
|
||||
From: from,
|
||||
To: to);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Агрегаты расхода токенов по группировке и фильтрам
|
||||
/// </summary>
|
||||
/// <param name="groupBy">Группировка day|tenant|provider|model (валидирует HTTP-слой).</param>
|
||||
/// <param name="tenantId">Тенант (равенство; null — все тенанты).</param>
|
||||
/// <param name="from">Начало периода (включительно; null — без границы).</param>
|
||||
/// <param name="to">Конец периода (включительно; null — без границы).</param>
|
||||
/// <returns>Строки агрегатов и итог.</returns>
|
||||
public async Task<AnalyticsTokensDto> TokensAsync(
|
||||
string groupBy,
|
||||
Guid? tenantId,
|
||||
DateTimeOffset? from,
|
||||
DateTimeOffset? to,
|
||||
CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<TokenUsageAggregateDto> items = await tokenUsage.AggregateAsync(
|
||||
new TokenUsageEventQueryDto(tenantId, null, null, null, from, to, groupBy), ct);
|
||||
var total = new TokenUsageAggregateDto(
|
||||
Key: "total",
|
||||
PromptTokens: items.Sum(row => row.PromptTokens),
|
||||
CompletionTokens: items.Sum(row => row.CompletionTokens),
|
||||
TotalTokens: items.Sum(row => row.TotalTokens),
|
||||
EventCount: items.Sum(row => row.EventCount));
|
||||
return new AnalyticsTokensDto(groupBy, from, to, items, total);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Лента действий
|
||||
/// </summary>
|
||||
/// <param name="eventType">Тип события (равенство; null — без фильтра).</param>
|
||||
/// <param name="actorType">Тип актора operator|tenant|system (равенство; null — без фильтра).</param>
|
||||
/// <param name="actorId">Идентификатор актора (равенство; null — без фильтра).</param>
|
||||
/// <param name="tenantId">Тенант (равенство; null — без фильтра).</param>
|
||||
/// <param name="from">Нижняя граница At (включительно; null — без границы).</param>
|
||||
/// <param name="to">Верхняя граница At (включительно; null — без границы).</param>
|
||||
/// <param name="limit">Размер страницы (дефолт 100, кламп 1..500).</param>
|
||||
/// <param name="offset">Смещение страницы (≥0).</param>
|
||||
/// <returns>Страница записей и полное число по фильтру.</returns>
|
||||
public async Task<AnalyticsActivityDto> ActivityAsync(
|
||||
string? eventType,
|
||||
string? actorType,
|
||||
Guid? actorId,
|
||||
Guid? tenantId,
|
||||
DateTimeOffset? from,
|
||||
DateTimeOffset? to,
|
||||
int? limit,
|
||||
int? offset,
|
||||
CancellationToken ct)
|
||||
{
|
||||
int normalizedLimit = NormalizeActivityLimit(limit);
|
||||
int normalizedOffset = Math.Max(0, offset ?? 0);
|
||||
var filter = new AuditQueryDto(
|
||||
eventType, actorType, tenantId, from, to, normalizedLimit, actorId, normalizedOffset);
|
||||
|
||||
IReadOnlyList<AuditRecordDto> items = await audit.QueryAsync(filter, ct);
|
||||
int total = await audit.CountAsync(filter, ct);
|
||||
return new AnalyticsActivityDto(items, total, normalizedLimit, normalizedOffset);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Нормализует limit ленты действий
|
||||
/// </summary>
|
||||
/// <param name="limit">Запрошенный размер (null — не задан).</param>
|
||||
/// <returns>Значение для фильтра.</returns>
|
||||
public static int NormalizeActivityLimit(int? limit) =>
|
||||
limit is null
|
||||
? DefaultActivityLimit
|
||||
: Math.Max(1, Math.Min(MaxActivityLimit, limit.Value));
|
||||
|
||||
// Суммарное число записей аудита по списку типов событий за период.
|
||||
// from: Нижняя граница At (включительно; null — без границы).
|
||||
// to: Верхняя граница At (включительно; null — без границы).
|
||||
// eventTypes: Типы событий (каждый считается отдельным равенством).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Сумма по типам событий.
|
||||
private async Task<int> CountEventTypesAsync(
|
||||
DateTimeOffset? from,
|
||||
DateTimeOffset? to,
|
||||
string[] eventTypes,
|
||||
CancellationToken ct)
|
||||
{
|
||||
int total = 0;
|
||||
foreach (string eventType in eventTypes)
|
||||
{
|
||||
total += await audit.CountAsync(
|
||||
new AuditQueryDto(eventType, null, null, from, to, AuditService.DefaultQueryLimit), ct);
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,73 +1,73 @@
|
||||
using System.Text.Json;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.SharedKernel.Observability;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Прикладной сервис аудита
|
||||
/// </summary>
|
||||
public sealed class AuditService(IAuditLogStore store)
|
||||
{
|
||||
/// <summary>
|
||||
/// Верхняя граница выборки аудита.
|
||||
/// </summary>
|
||||
public const int MaxQueryLimit = 500;
|
||||
|
||||
/// <summary>
|
||||
/// Размер выборки по умолчанию при отсутствии limit в запросе
|
||||
/// </summary>
|
||||
public const int DefaultQueryLimit = 100;
|
||||
|
||||
// Опции JSON деталей: camelCase (конвенция DetailJson/JSON проекта).
|
||||
private static readonly JsonSerializerOptions DetailJsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
/// <summary>
|
||||
/// Записывает событие аудита
|
||||
/// </summary>
|
||||
/// <param name="record">Запись события (At и Id игнорируются: At проставляет сервис, Id — БД).</param>
|
||||
public async Task AppendAsync(AuditRecordDto record, CancellationToken ct)
|
||||
{
|
||||
await store.AppendAsync(record with { At = DateTimeOffset.UtcNow }, ct);
|
||||
DealMetrics.RecordAuditEvent(record.EventType, record.ActorType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Записи по фильтру, новые сверху
|
||||
/// </summary>
|
||||
/// <param name="filter">Фильтр выборки.</param>
|
||||
/// <returns>Записи от новых к старым.</returns>
|
||||
public Task<IReadOnlyList<AuditRecordDto>> QueryAsync(AuditQueryDto filter, CancellationToken ct) =>
|
||||
store.QueryAsync(filter, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Число записей по фильтру
|
||||
/// </summary>
|
||||
/// <param name="filter">Фильтр выборки.</param>
|
||||
/// <returns>Полное число записей по фильтру.</returns>
|
||||
public Task<int> CountAsync(AuditQueryDto filter, CancellationToken ct) => store.CountAsync(filter, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Сериализует детали события в JSON.
|
||||
/// </summary>
|
||||
/// <param name="details">Объект деталей (обычно анонимный: { login =... }).</param>
|
||||
/// <returns>JSON-строка деталей.</returns>
|
||||
public static string ToDetailJson(object? details) => JsonSerializer.Serialize(details, DetailJsonOptions);
|
||||
|
||||
/// <summary>
|
||||
/// Актор «пользователь тенанта» по разрешённой сессии
|
||||
/// </summary>
|
||||
/// <param name="user">Идентичность пользователя тенанта.</param>
|
||||
/// <returns>Кортеж актора для полей записи аудита.</returns>
|
||||
public static (string ActorType, Guid? ActorId, Guid? TenantId) ActorFromUser(UserIdentityDto user) =>
|
||||
(AuditActorTypes.Tenant, user.Id, user.TenantId);
|
||||
|
||||
/// <summary>
|
||||
/// Актор «оператор» по разрешённой операторской сессии
|
||||
/// </summary>
|
||||
/// <param name="operatorIdentity">Идентичность оператора.</param>
|
||||
/// <returns>Кортеж актора для полей записи аудита.</returns>
|
||||
public static (string ActorType, Guid? ActorId, Guid? TenantId) ActorFromOperator(OperatorIdentityDto operatorIdentity) =>
|
||||
(AuditActorTypes.Operator, operatorIdentity.Id, null);
|
||||
}
|
||||
using System.Text.Json;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.SharedKernel.Observability;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Прикладной сервис аудита
|
||||
/// </summary>
|
||||
public sealed class AuditService(IAuditLogStore store)
|
||||
{
|
||||
/// <summary>
|
||||
/// Верхняя граница выборки аудита.
|
||||
/// </summary>
|
||||
public const int MaxQueryLimit = 500;
|
||||
|
||||
/// <summary>
|
||||
/// Размер выборки по умолчанию при отсутствии limit в запросе
|
||||
/// </summary>
|
||||
public const int DefaultQueryLimit = 100;
|
||||
|
||||
// Опции JSON деталей: camelCase (конвенция DetailJson/JSON проекта).
|
||||
private static readonly JsonSerializerOptions DetailJsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
/// <summary>
|
||||
/// Записывает событие аудита
|
||||
/// </summary>
|
||||
/// <param name="record">Запись события (At и Id игнорируются: At проставляет сервис, Id — БД).</param>
|
||||
public async Task AppendAsync(AuditRecordDto record, CancellationToken ct)
|
||||
{
|
||||
await store.AppendAsync(record with { At = DateTimeOffset.UtcNow }, ct);
|
||||
DealMetrics.RecordAuditEvent(record.EventType, record.ActorType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Записи по фильтру, новые сверху
|
||||
/// </summary>
|
||||
/// <param name="filter">Фильтр выборки.</param>
|
||||
/// <returns>Записи от новых к старым.</returns>
|
||||
public Task<IReadOnlyList<AuditRecordDto>> QueryAsync(AuditQueryDto filter, CancellationToken ct) =>
|
||||
store.QueryAsync(filter, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Число записей по фильтру
|
||||
/// </summary>
|
||||
/// <param name="filter">Фильтр выборки.</param>
|
||||
/// <returns>Полное число записей по фильтру.</returns>
|
||||
public Task<int> CountAsync(AuditQueryDto filter, CancellationToken ct) => store.CountAsync(filter, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Сериализует детали события в JSON.
|
||||
/// </summary>
|
||||
/// <param name="details">Объект деталей (обычно анонимный: { login =... }).</param>
|
||||
/// <returns>JSON-строка деталей.</returns>
|
||||
public static string ToDetailJson(object? details) => JsonSerializer.Serialize(details, DetailJsonOptions);
|
||||
|
||||
/// <summary>
|
||||
/// Актор «пользователь тенанта» по разрешённой сессии
|
||||
/// </summary>
|
||||
/// <param name="user">Идентичность пользователя тенанта.</param>
|
||||
/// <returns>Кортеж актора для полей записи аудита.</returns>
|
||||
public static (string ActorType, Guid? ActorId, Guid? TenantId) ActorFromUser(UserIdentityDto user) =>
|
||||
(AuditActorTypes.Tenant, user.Id, user.TenantId);
|
||||
|
||||
/// <summary>
|
||||
/// Актор «оператор» по разрешённой операторской сессии
|
||||
/// </summary>
|
||||
/// <param name="operatorIdentity">Идентичность оператора.</param>
|
||||
/// <returns>Кортеж актора для полей записи аудита.</returns>
|
||||
public static (string ActorType, Guid? ActorId, Guid? TenantId) ActorFromOperator(OperatorIdentityDto operatorIdentity) =>
|
||||
(AuditActorTypes.Operator, operatorIdentity.Id, null);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Isopoh.Cryptography.Argon2;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Реализация <see cref="IPasswordHasher"/> на Argon2id.
|
||||
/// </summary>
|
||||
public sealed class DefaultPasswordHasher : IPasswordHasher
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string Hash(string password) => Argon2.Hash(password);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Verify(string password, string encodedHash) => Argon2.Verify(encodedHash, password);
|
||||
}
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Isopoh.Cryptography.Argon2;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Реализация <see cref="IPasswordHasher"/> на Argon2id.
|
||||
/// </summary>
|
||||
public sealed class DefaultPasswordHasher : IPasswordHasher
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string Hash(string password) => Argon2.Hash(password);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Verify(string password, string encodedHash) => Argon2.Verify(encodedHash, password);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
using Deal.SharedKernel;
|
||||
using Deal.SharedKernel.Utilities;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Генератор кодов приглашений
|
||||
/// </summary>
|
||||
public static class InviteCodeGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Длина кода в символах.
|
||||
/// </summary>
|
||||
public const int CodeLength = 16;
|
||||
|
||||
// Случайные байты одного кода: 12 → ровно 16 символов Base64Url.
|
||||
private const int RandomByteCount = 12;
|
||||
|
||||
/// <summary>
|
||||
/// Новый код приглашения
|
||||
/// </summary>
|
||||
/// <returns>Строка кода длиной <see cref="CodeLength"/> символов.</returns>
|
||||
public static string NewCode() => UrlSafeToken.New(RandomByteCount);
|
||||
}
|
||||
using Deal.SharedKernel;
|
||||
using Deal.SharedKernel.Utilities;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Генератор кодов приглашений
|
||||
/// </summary>
|
||||
public static class InviteCodeGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Длина кода в символах.
|
||||
/// </summary>
|
||||
public const int CodeLength = 16;
|
||||
|
||||
// Случайные байты одного кода: 12 → ровно 16 символов Base64Url.
|
||||
private const int RandomByteCount = 12;
|
||||
|
||||
/// <summary>
|
||||
/// Новый код приглашения
|
||||
/// </summary>
|
||||
/// <returns>Строка кода длиной <see cref="CodeLength"/> символов.</returns>
|
||||
public static string NewCode() => UrlSafeToken.New(RandomByteCount);
|
||||
}
|
||||
|
||||
@@ -1,169 +1,169 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Extensions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Прикладной сервис приглашений
|
||||
/// </summary>
|
||||
public sealed partial class InvitesService(IInviteStore inviteStore)
|
||||
{
|
||||
/// <summary>
|
||||
/// Срок действия приглашения, часов.
|
||||
/// </summary>
|
||||
public const int ExpiryHours = 72;
|
||||
|
||||
private const int MaxEmailLength = 200;
|
||||
|
||||
// Проверяемый формат: один '@', непустые локальная часть и домен с точкой, без пробелов.
|
||||
private const string EmailFormatPattern = @"^[^@\s]+@[^@\s]+\.[^@\s]+$";
|
||||
|
||||
// Компилируется из EmailFormatPattern; вход ограничен 200 символами, паттерн линейный.
|
||||
[GeneratedRegex(EmailFormatPattern, RegexOptions.CultureInvariant)]
|
||||
private static partial Regex EmailFormatRegex();
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт приглашение оператором
|
||||
/// </summary>
|
||||
/// <param name="operatorId">Идентификатор оператора (CreatedById приглашения).</param>
|
||||
/// <param name="email">Email приглашённого (регистр/пробелы не важны — нормализуется).</param>
|
||||
/// <param name="tenantId">Целевой тенант; null — при активации будет создан новый тенант.</param>
|
||||
/// <returns>Ok=true + созданное приглашение, либо код ошибки (текст — HTTP-слой).</returns>
|
||||
public async Task<InviteCreateResultDto> CreateInviteAsync(
|
||||
Guid operatorId,
|
||||
string? email,
|
||||
Guid? tenantId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string normalizedEmail = NormalizeEmail(email);
|
||||
if (!IsValidEmail(normalizedEmail))
|
||||
{
|
||||
return new InviteCreateResultDto(Ok: false, Error: InviteCreateResultDto.ErrorInvalidEmail, Invite: null);
|
||||
}
|
||||
|
||||
var active = await inviteStore.FindActiveByEmailAsync(normalizedEmail, ct);
|
||||
if (active is not null)
|
||||
{
|
||||
if (active.IsExpired())
|
||||
{
|
||||
await inviteStore.UpdateStatusAsync(active.Code, InviteStatuses.Expired, null, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
return new InviteCreateResultDto(Ok: false, Error: InviteCreateResultDto.ErrorDuplicateActive, Invite: null);
|
||||
}
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var invite = new InviteDto(
|
||||
Code: InviteCodeGenerator.NewCode(),
|
||||
Email: normalizedEmail,
|
||||
TenantId: tenantId,
|
||||
Status: InviteStatuses.Pending,
|
||||
ExpiresAt: now.AddHours(ExpiryHours),
|
||||
ActivatedAt: null,
|
||||
CreatedById: operatorId,
|
||||
CreatedAt: now);
|
||||
await inviteStore.CreateAsync(invite, ct);
|
||||
return new InviteCreateResultDto(Ok: true, Error: null, Invite: invite);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Отзывает приглашение
|
||||
/// </summary>
|
||||
/// <param name="code">Код приглашения.</param>
|
||||
/// <returns>Ok=true + отозванное приглашение, либо код ошибки (текст — HTTP-слой).</returns>
|
||||
public async Task<InviteRevokeResultDto> RevokeAsync(string code, CancellationToken ct)
|
||||
{
|
||||
var invite = await inviteStore.GetByCodeAsync(code, ct);
|
||||
if (invite is null)
|
||||
{
|
||||
return new InviteRevokeResultDto(Ok: false, Error: InviteRevokeResultDto.ErrorNotFound, Invite: null);
|
||||
}
|
||||
|
||||
if (invite.Status != InviteStatuses.Pending)
|
||||
{
|
||||
return new InviteRevokeResultDto(Ok: false, Error: InviteRevokeResultDto.ErrorNotPending, Invite: invite);
|
||||
}
|
||||
|
||||
bool updated = await inviteStore.UpdateStatusAsync(code, InviteStatuses.Revoked, null, ct);
|
||||
if (!updated)
|
||||
{
|
||||
// Строка исчезла между чтением и обновлением — считаем «не найдено».
|
||||
return new InviteRevokeResultDto(Ok: false, Error: InviteRevokeResultDto.ErrorNotFound, Invite: null);
|
||||
}
|
||||
|
||||
return new InviteRevokeResultDto(Ok: true, Error: null, Invite: invite with { Status = InviteStatuses.Revoked });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Список приглашений для оператора
|
||||
/// </summary>
|
||||
/// <returns>Приглашения в порядке CreatedAt DESC; протухшие pending приходят со статусом expired.</returns>
|
||||
public async Task<IReadOnlyList<InviteDto>> ListAsync(CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<InviteDto> invites = await inviteStore.ListAsync(ct);
|
||||
var result = new List<InviteDto>(invites.Count);
|
||||
foreach (var invite in invites)
|
||||
{
|
||||
if (invite.Status == InviteStatuses.Pending && invite.ExpiresAt <= DateTimeOffset.UtcNow)
|
||||
{
|
||||
await inviteStore.UpdateStatusAsync(invite.Code, InviteStatuses.Expired, null, ct);
|
||||
result.Add(invite with { Status = InviteStatuses.Expired });
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Add(invite);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Читает приглашение по коду, вычисляя статус expired при чтении.
|
||||
/// </summary>
|
||||
/// <param name="code">Код приглашения.</param>
|
||||
/// <returns>Приглашение (протухшее pending — со статусом expired и сохранённым переходом) или null.</returns>
|
||||
public async Task<InviteDto?> GetByCodeAsync(string code, CancellationToken ct)
|
||||
{
|
||||
var invite = await inviteStore.GetByCodeAsync(code, ct);
|
||||
if (invite is null || invite.Status != InviteStatuses.Pending || !invite.IsExpired())
|
||||
{
|
||||
return invite;
|
||||
}
|
||||
|
||||
await inviteStore.UpdateStatusAsync(code, InviteStatuses.Expired, null, ct);
|
||||
return invite with { Status = InviteStatuses.Expired };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Активирует приглашение CAS-переходом
|
||||
/// </summary>
|
||||
/// <param name="code">Код приглашения.</param>
|
||||
/// <returns>true, если активация выполнена; false — строка не в статусе pending.</returns>
|
||||
public Task<bool> TryActivateAsync(string code, CancellationToken ct) =>
|
||||
inviteStore.TryActivateAsync(code, DateTimeOffset.UtcNow, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Нормализация email
|
||||
/// </summary>
|
||||
/// <param name="email">Входной email (может быть null).</param>
|
||||
/// <returns>Нормализованный email (пустая строка, если вход был пустым).</returns>
|
||||
public static string NormalizeEmail(string? email) => (email ?? string.Empty).Trim().ToLowerInvariant();
|
||||
|
||||
/// <summary>
|
||||
/// Проверка формата email
|
||||
/// </summary>
|
||||
/// <param name="email">Входной email (регистр/пробелы не важны).</param>
|
||||
/// <returns>true, если email выглядит корректно.</returns>
|
||||
public static bool IsValidEmail(string? email)
|
||||
{
|
||||
string normalized = NormalizeEmail(email);
|
||||
return normalized.Length > 0
|
||||
&& normalized.Length <= MaxEmailLength
|
||||
&& EmailFormatRegex().IsMatch(normalized);
|
||||
}
|
||||
}
|
||||
using System.Text.RegularExpressions;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Extensions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Прикладной сервис приглашений
|
||||
/// </summary>
|
||||
public sealed partial class InvitesService(IInviteStore inviteStore)
|
||||
{
|
||||
/// <summary>
|
||||
/// Срок действия приглашения, часов.
|
||||
/// </summary>
|
||||
public const int ExpiryHours = 72;
|
||||
|
||||
private const int MaxEmailLength = 200;
|
||||
|
||||
// Проверяемый формат: один '@', непустые локальная часть и домен с точкой, без пробелов.
|
||||
private const string EmailFormatPattern = @"^[^@\s]+@[^@\s]+\.[^@\s]+$";
|
||||
|
||||
// Компилируется из EmailFormatPattern; вход ограничен 200 символами, паттерн линейный.
|
||||
[GeneratedRegex(EmailFormatPattern, RegexOptions.CultureInvariant)]
|
||||
private static partial Regex EmailFormatRegex();
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт приглашение оператором
|
||||
/// </summary>
|
||||
/// <param name="operatorId">Идентификатор оператора (CreatedById приглашения).</param>
|
||||
/// <param name="email">Email приглашённого (регистр/пробелы не важны — нормализуется).</param>
|
||||
/// <param name="tenantId">Целевой тенант; null — при активации будет создан новый тенант.</param>
|
||||
/// <returns>Ok=true + созданное приглашение, либо код ошибки (текст — HTTP-слой).</returns>
|
||||
public async Task<InviteCreateResultDto> CreateInviteAsync(
|
||||
Guid operatorId,
|
||||
string? email,
|
||||
Guid? tenantId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string normalizedEmail = NormalizeEmail(email);
|
||||
if (!IsValidEmail(normalizedEmail))
|
||||
{
|
||||
return new InviteCreateResultDto(Ok: false, Error: InviteCreateResultDto.ErrorInvalidEmail, Invite: null);
|
||||
}
|
||||
|
||||
var active = await inviteStore.FindActiveByEmailAsync(normalizedEmail, ct);
|
||||
if (active is not null)
|
||||
{
|
||||
if (active.IsExpired())
|
||||
{
|
||||
await inviteStore.UpdateStatusAsync(active.Code, InviteStatuses.Expired, null, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
return new InviteCreateResultDto(Ok: false, Error: InviteCreateResultDto.ErrorDuplicateActive, Invite: null);
|
||||
}
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var invite = new InviteDto(
|
||||
Code: InviteCodeGenerator.NewCode(),
|
||||
Email: normalizedEmail,
|
||||
TenantId: tenantId,
|
||||
Status: InviteStatuses.Pending,
|
||||
ExpiresAt: now.AddHours(ExpiryHours),
|
||||
ActivatedAt: null,
|
||||
CreatedById: operatorId,
|
||||
CreatedAt: now);
|
||||
await inviteStore.CreateAsync(invite, ct);
|
||||
return new InviteCreateResultDto(Ok: true, Error: null, Invite: invite);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Отзывает приглашение
|
||||
/// </summary>
|
||||
/// <param name="code">Код приглашения.</param>
|
||||
/// <returns>Ok=true + отозванное приглашение, либо код ошибки (текст — HTTP-слой).</returns>
|
||||
public async Task<InviteRevokeResultDto> RevokeAsync(string code, CancellationToken ct)
|
||||
{
|
||||
var invite = await inviteStore.GetByCodeAsync(code, ct);
|
||||
if (invite is null)
|
||||
{
|
||||
return new InviteRevokeResultDto(Ok: false, Error: InviteRevokeResultDto.ErrorNotFound, Invite: null);
|
||||
}
|
||||
|
||||
if (invite.Status != InviteStatuses.Pending)
|
||||
{
|
||||
return new InviteRevokeResultDto(Ok: false, Error: InviteRevokeResultDto.ErrorNotPending, Invite: invite);
|
||||
}
|
||||
|
||||
bool updated = await inviteStore.UpdateStatusAsync(code, InviteStatuses.Revoked, null, ct);
|
||||
if (!updated)
|
||||
{
|
||||
// Строка исчезла между чтением и обновлением — считаем «не найдено».
|
||||
return new InviteRevokeResultDto(Ok: false, Error: InviteRevokeResultDto.ErrorNotFound, Invite: null);
|
||||
}
|
||||
|
||||
return new InviteRevokeResultDto(Ok: true, Error: null, Invite: invite with { Status = InviteStatuses.Revoked });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Список приглашений для оператора
|
||||
/// </summary>
|
||||
/// <returns>Приглашения в порядке CreatedAt DESC; протухшие pending приходят со статусом expired.</returns>
|
||||
public async Task<IReadOnlyList<InviteDto>> ListAsync(CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<InviteDto> invites = await inviteStore.ListAsync(ct);
|
||||
var result = new List<InviteDto>(invites.Count);
|
||||
foreach (var invite in invites)
|
||||
{
|
||||
if (invite.Status == InviteStatuses.Pending && invite.ExpiresAt <= DateTimeOffset.UtcNow)
|
||||
{
|
||||
await inviteStore.UpdateStatusAsync(invite.Code, InviteStatuses.Expired, null, ct);
|
||||
result.Add(invite with { Status = InviteStatuses.Expired });
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Add(invite);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Читает приглашение по коду, вычисляя статус expired при чтении.
|
||||
/// </summary>
|
||||
/// <param name="code">Код приглашения.</param>
|
||||
/// <returns>Приглашение (протухшее pending — со статусом expired и сохранённым переходом) или null.</returns>
|
||||
public async Task<InviteDto?> GetByCodeAsync(string code, CancellationToken ct)
|
||||
{
|
||||
var invite = await inviteStore.GetByCodeAsync(code, ct);
|
||||
if (invite is null || invite.Status != InviteStatuses.Pending || !invite.IsExpired())
|
||||
{
|
||||
return invite;
|
||||
}
|
||||
|
||||
await inviteStore.UpdateStatusAsync(code, InviteStatuses.Expired, null, ct);
|
||||
return invite with { Status = InviteStatuses.Expired };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Активирует приглашение CAS-переходом
|
||||
/// </summary>
|
||||
/// <param name="code">Код приглашения.</param>
|
||||
/// <returns>true, если активация выполнена; false — строка не в статусе pending.</returns>
|
||||
public Task<bool> TryActivateAsync(string code, CancellationToken ct) =>
|
||||
inviteStore.TryActivateAsync(code, DateTimeOffset.UtcNow, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Нормализация email
|
||||
/// </summary>
|
||||
/// <param name="email">Входной email (может быть null).</param>
|
||||
/// <returns>Нормализованный email (пустая строка, если вход был пустым).</returns>
|
||||
public static string NormalizeEmail(string? email) => (email ?? string.Empty).Trim().ToLowerInvariant();
|
||||
|
||||
/// <summary>
|
||||
/// Проверка формата email
|
||||
/// </summary>
|
||||
/// <param name="email">Входной email (регистр/пробелы не важны).</param>
|
||||
/// <returns>true, если email выглядит корректно.</returns>
|
||||
public static bool IsValidEmail(string? email)
|
||||
{
|
||||
string normalized = NormalizeEmail(email);
|
||||
return normalized.Length > 0
|
||||
&& normalized.Length <= MaxEmailLength
|
||||
&& EmailFormatRegex().IsMatch(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,131 +1,131 @@
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Прикладной сервис активации инвайта через публичную ручку POST /api/join
|
||||
/// </summary>
|
||||
public sealed class JoinService(
|
||||
InvitesService invitesService,
|
||||
TenantService tenantService,
|
||||
IAuthStore authStore,
|
||||
IPasswordHasher passwordHasher,
|
||||
ITenantRepository tenantRepository)
|
||||
{
|
||||
// Статус создаваемого пользователя (как у seed-пользователя TenantBootstrapService).
|
||||
private const string UserActiveStatus = "active";
|
||||
|
||||
/// <summary>
|
||||
/// Активирует инвайт
|
||||
/// </summary>
|
||||
/// <param name="code">Код приглашения (пробелы по краям не важны).</param>
|
||||
/// <param name="email">Email активирующего; обязан совпасть с email приглашения (регистр/пробелы не важны).</param>
|
||||
/// <param name="name">Имя тенанта при создании нового (TenantId инвайта пуст); null/пустое — имя = email.</param>
|
||||
/// <param name="password">Пароль пользователя (открытым текстом; минимум <see cref="AuthService.MinNewPasswordLength"/>).</param>
|
||||
/// <returns>При успехе — Ok=true, Login (нормализованный email), UserId и TenantId; иначе код ошибки (см. <see cref="JoinResultDto"/>).</returns>
|
||||
public async Task<JoinResultDto> ActivateAsync(
|
||||
string? code,
|
||||
string? email,
|
||||
string? name,
|
||||
string? password,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string normalizedEmail = InvitesService.NormalizeEmail(email);
|
||||
string normalizedCode = code?.Trim() ?? string.Empty;
|
||||
|
||||
var invite = await invitesService.GetByCodeAsync(normalizedCode, ct);
|
||||
if (invite is null)
|
||||
{
|
||||
return Failed(JoinResultDto.ErrorNotFound);
|
||||
}
|
||||
|
||||
if (invite.Status != InviteStatuses.Pending)
|
||||
{
|
||||
return Failed(ErrorFromStatus(invite.Status));
|
||||
}
|
||||
|
||||
if (normalizedEmail != invite.Email)
|
||||
{
|
||||
return Failed(JoinResultDto.ErrorEmailMismatch);
|
||||
}
|
||||
|
||||
// 3. Пароль: минимум AuthService.MinNewPasswordLength (единый источник), текст на HTTP-слое.
|
||||
if (password is null || password.Length < AuthService.MinNewPasswordLength)
|
||||
{
|
||||
return Failed(JoinResultDto.ErrorPasswordTooShort);
|
||||
}
|
||||
|
||||
var existingUser = await authStore.FindUserByLoginAsync(normalizedEmail, ct);
|
||||
if (existingUser is not null)
|
||||
{
|
||||
return Failed(JoinResultDto.ErrorEmailTaken);
|
||||
}
|
||||
|
||||
// 5. Целевой тенант (инвайт с TenantId): обязан существовать и быть активным ДО резервирования кода —
|
||||
// «битый» инвайт (тенант удалён/приостановлен) не должен ни расходовать код, ни создавать пользователя
|
||||
// на несуществующем/замороженном тенанте (Security review).
|
||||
if (invite.TenantId is not null)
|
||||
{
|
||||
var target = await tenantRepository.FindByIdAsync(invite.TenantId.Value, ct);
|
||||
if (target is null)
|
||||
{
|
||||
return Failed(JoinResultDto.ErrorTenantNotFound);
|
||||
}
|
||||
|
||||
if (target.Status == TenantStatuses.Suspended)
|
||||
{
|
||||
return Failed(JoinResultDto.ErrorTenantSuspended);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. CAS-резервирование инвайта (pending → activated). Если параллельно инвайт отозван/активирован —
|
||||
// переход не выполнен: перечитываем фактический статус и отвечаем соответствующей ошибкой без побочных
|
||||
// эффектов (ни тенант, ни пользователь не создаются — создаёт только победитель гонки).
|
||||
if (!await invitesService.TryActivateAsync(normalizedCode, ct))
|
||||
{
|
||||
var current = await invitesService.GetByCodeAsync(normalizedCode, ct);
|
||||
return current is null
|
||||
? Failed(JoinResultDto.ErrorNotFound)
|
||||
: Failed(ErrorFromStatus(current.Status));
|
||||
}
|
||||
|
||||
// 7. Тенант: TenantId инвайта задан — присоединение к существующему (провижининг не нужен — схема уже есть);
|
||||
// пуст — создание нового с явным id (TenantService сам провижинит схему) и именем name ?? email.
|
||||
Guid tenantId = invite.TenantId ?? Guid.NewGuid();
|
||||
if (invite.TenantId is null)
|
||||
{
|
||||
string tenantName = string.IsNullOrWhiteSpace(name) ? normalizedEmail : name.Trim();
|
||||
await tenantService.CreateTenantAsync(tenantName, tenantId, ct);
|
||||
}
|
||||
|
||||
// 8. Пользователь в public.users (login=email, Argon2id-хэш пароля).
|
||||
var userId = Guid.NewGuid();
|
||||
await authStore.CreateUserAsync(
|
||||
new StoredUserDto(
|
||||
Id: userId,
|
||||
Login: normalizedEmail,
|
||||
TenantId: tenantId,
|
||||
Status: UserActiveStatus,
|
||||
PasswordHash: passwordHasher.Hash(password)),
|
||||
ct);
|
||||
|
||||
return new JoinResultDto(Ok: true, Error: null, Login: normalizedEmail, UserId: userId, TenantId: tenantId);
|
||||
}
|
||||
|
||||
// Код ошибки по фактическому статусу приглашения (используется при отказе CAS и при не-pending чтении).
|
||||
// status: Статус из InviteStatuses.
|
||||
// Возвращает: Код JoinResultDto: activated → used, revoked → revoked, иначе expired (для не-pending чтения).
|
||||
private static string ErrorFromStatus(string status) =>
|
||||
status switch
|
||||
{
|
||||
InviteStatuses.Activated => JoinResultDto.ErrorUsed,
|
||||
InviteStatuses.Revoked => JoinResultDto.ErrorRevoked,
|
||||
_ => JoinResultDto.ErrorExpired,
|
||||
};
|
||||
|
||||
// Неуспешный результат без полезной нагрузки.
|
||||
// error: Код ошибки JoinResultDto.
|
||||
// Возвращает: JoinResultDto с Ok=false.
|
||||
private static JoinResultDto Failed(string error) => new(Ok: false, Error: error, Login: null, UserId: null, TenantId: null);
|
||||
}
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Прикладной сервис активации инвайта через публичную ручку POST /api/join
|
||||
/// </summary>
|
||||
public sealed class JoinService(
|
||||
InvitesService invitesService,
|
||||
TenantService tenantService,
|
||||
IAuthStore authStore,
|
||||
IPasswordHasher passwordHasher,
|
||||
ITenantRepository tenantRepository)
|
||||
{
|
||||
// Статус создаваемого пользователя (как у seed-пользователя TenantBootstrapService).
|
||||
private const string UserActiveStatus = "active";
|
||||
|
||||
/// <summary>
|
||||
/// Активирует инвайт
|
||||
/// </summary>
|
||||
/// <param name="code">Код приглашения (пробелы по краям не важны).</param>
|
||||
/// <param name="email">Email активирующего; обязан совпасть с email приглашения (регистр/пробелы не важны).</param>
|
||||
/// <param name="name">Имя тенанта при создании нового (TenantId инвайта пуст); null/пустое — имя = email.</param>
|
||||
/// <param name="password">Пароль пользователя (открытым текстом; минимум <see cref="AuthService.MinNewPasswordLength"/>).</param>
|
||||
/// <returns>При успехе — Ok=true, Login (нормализованный email), UserId и TenantId; иначе код ошибки (см. <see cref="JoinResultDto"/>).</returns>
|
||||
public async Task<JoinResultDto> ActivateAsync(
|
||||
string? code,
|
||||
string? email,
|
||||
string? name,
|
||||
string? password,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string normalizedEmail = InvitesService.NormalizeEmail(email);
|
||||
string normalizedCode = code?.Trim() ?? string.Empty;
|
||||
|
||||
var invite = await invitesService.GetByCodeAsync(normalizedCode, ct);
|
||||
if (invite is null)
|
||||
{
|
||||
return Failed(JoinResultDto.ErrorNotFound);
|
||||
}
|
||||
|
||||
if (invite.Status != InviteStatuses.Pending)
|
||||
{
|
||||
return Failed(ErrorFromStatus(invite.Status));
|
||||
}
|
||||
|
||||
if (normalizedEmail != invite.Email)
|
||||
{
|
||||
return Failed(JoinResultDto.ErrorEmailMismatch);
|
||||
}
|
||||
|
||||
// 3. Пароль: минимум AuthService.MinNewPasswordLength (единый источник), текст на HTTP-слое.
|
||||
if (password is null || password.Length < AuthService.MinNewPasswordLength)
|
||||
{
|
||||
return Failed(JoinResultDto.ErrorPasswordTooShort);
|
||||
}
|
||||
|
||||
var existingUser = await authStore.FindUserByLoginAsync(normalizedEmail, ct);
|
||||
if (existingUser is not null)
|
||||
{
|
||||
return Failed(JoinResultDto.ErrorEmailTaken);
|
||||
}
|
||||
|
||||
// 5. Целевой тенант (инвайт с TenantId): обязан существовать и быть активным ДО резервирования кода —
|
||||
// «битый» инвайт (тенант удалён/приостановлен) не должен ни расходовать код, ни создавать пользователя
|
||||
// на несуществующем/замороженном тенанте (Security review).
|
||||
if (invite.TenantId is not null)
|
||||
{
|
||||
var target = await tenantRepository.FindByIdAsync(invite.TenantId.Value, ct);
|
||||
if (target is null)
|
||||
{
|
||||
return Failed(JoinResultDto.ErrorTenantNotFound);
|
||||
}
|
||||
|
||||
if (target.Status == TenantStatuses.Suspended)
|
||||
{
|
||||
return Failed(JoinResultDto.ErrorTenantSuspended);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. CAS-резервирование инвайта (pending → activated). Если параллельно инвайт отозван/активирован —
|
||||
// переход не выполнен: перечитываем фактический статус и отвечаем соответствующей ошибкой без побочных
|
||||
// эффектов (ни тенант, ни пользователь не создаются — создаёт только победитель гонки).
|
||||
if (!await invitesService.TryActivateAsync(normalizedCode, ct))
|
||||
{
|
||||
var current = await invitesService.GetByCodeAsync(normalizedCode, ct);
|
||||
return current is null
|
||||
? Failed(JoinResultDto.ErrorNotFound)
|
||||
: Failed(ErrorFromStatus(current.Status));
|
||||
}
|
||||
|
||||
// 7. Тенант: TenantId инвайта задан — присоединение к существующему (провижининг не нужен — схема уже есть);
|
||||
// пуст — создание нового с явным id (TenantService сам провижинит схему) и именем name ?? email.
|
||||
Guid tenantId = invite.TenantId ?? Guid.NewGuid();
|
||||
if (invite.TenantId is null)
|
||||
{
|
||||
string tenantName = string.IsNullOrWhiteSpace(name) ? normalizedEmail : name.Trim();
|
||||
await tenantService.CreateTenantAsync(tenantName, tenantId, ct);
|
||||
}
|
||||
|
||||
// 8. Пользователь в public.users (login=email, Argon2id-хэш пароля).
|
||||
var userId = Guid.NewGuid();
|
||||
await authStore.CreateUserAsync(
|
||||
new StoredUserDto(
|
||||
Id: userId,
|
||||
Login: normalizedEmail,
|
||||
TenantId: tenantId,
|
||||
Status: UserActiveStatus,
|
||||
PasswordHash: passwordHasher.Hash(password)),
|
||||
ct);
|
||||
|
||||
return new JoinResultDto(Ok: true, Error: null, Login: normalizedEmail, UserId: userId, TenantId: tenantId);
|
||||
}
|
||||
|
||||
// Код ошибки по фактическому статусу приглашения (используется при отказе CAS и при не-pending чтении).
|
||||
// status: Статус из InviteStatuses.
|
||||
// Возвращает: Код JoinResultDto: activated → used, revoked → revoked, иначе expired (для не-pending чтения).
|
||||
private static string ErrorFromStatus(string status) =>
|
||||
status switch
|
||||
{
|
||||
InviteStatuses.Activated => JoinResultDto.ErrorUsed,
|
||||
InviteStatuses.Revoked => JoinResultDto.ErrorRevoked,
|
||||
_ => JoinResultDto.ErrorExpired,
|
||||
};
|
||||
|
||||
// Неуспешный результат без полезной нагрузки.
|
||||
// error: Код ошибки JoinResultDto.
|
||||
// Возвращает: JoinResultDto с Ok=false.
|
||||
private static JoinResultDto Failed(string error) => new(Ok: false, Error: error, Login: null, UserId: null, TenantId: null);
|
||||
}
|
||||
|
||||
@@ -1,385 +1,385 @@
|
||||
using System.Text.Json;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Детектор подозрительной активности по логам безопасности
|
||||
/// </summary>
|
||||
public sealed class SuspiciousActivityService
|
||||
{
|
||||
/// <summary>
|
||||
/// Размер окна анализа по умолчанию, часов
|
||||
/// </summary>
|
||||
public const int DefaultWindowHours = 24;
|
||||
|
||||
/// <summary>
|
||||
/// Предел разбираемых записей за окно
|
||||
/// </summary>
|
||||
public const int MaxScanRecords = AuditService.MaxQueryLimit;
|
||||
|
||||
/// <summary>
|
||||
/// Порог неудачных входов с одного IP за окно.
|
||||
/// </summary>
|
||||
public const int FailedLoginsPerIpThreshold = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Порог неудачных входов по одному логину за окно.
|
||||
/// </summary>
|
||||
public const int FailedLoginsPerLoginThreshold = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Порог числа разных IP успешных входов одного актора за окно.
|
||||
/// </summary>
|
||||
public const int DistinctIpsPerActorThreshold = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Порог неудачных входов по одному тенанту за окно.
|
||||
/// </summary>
|
||||
public const int AuthFailuresPerTenantThreshold = 20;
|
||||
|
||||
/// <summary>
|
||||
/// Порог числа разных логинов в неудачных входах с одного IP за окно.
|
||||
/// </summary>
|
||||
public const int DistinctLoginsPerIpThreshold = 5;
|
||||
|
||||
// Кратность порога, с которой уровень поднимается до high (2× порог).
|
||||
private const int HighSeverityMultiplier = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Правило: всплеск неудачных входов с одного IP.
|
||||
/// </summary>
|
||||
public const string KindFailedLoginsPerIp = "failed_logins_per_ip";
|
||||
|
||||
/// <summary>
|
||||
/// Правило: всплеск неудачных входов по одному логину.
|
||||
/// </summary>
|
||||
public const string KindFailedLoginsPerLogin = "failed_logins_per_login";
|
||||
|
||||
/// <summary>
|
||||
/// Правило: успешные входы актора с множества IP.
|
||||
/// </summary>
|
||||
public const string KindManyIpsPerActor = "many_ips_per_actor";
|
||||
|
||||
/// <summary>
|
||||
/// Правило: повторные неудачные входы по тенанту.
|
||||
/// </summary>
|
||||
public const string KindAuthFailuresPerTenant = "auth_failures_per_tenant";
|
||||
|
||||
/// <summary>
|
||||
/// Правило: перебор разных логинов с одного IP.
|
||||
/// </summary>
|
||||
public const string KindDistinctLoginsPerIp = "distinct_logins_per_ip";
|
||||
|
||||
/// <summary>
|
||||
/// Уровень находки
|
||||
/// </summary>
|
||||
public const string SeverityHigh = "high";
|
||||
|
||||
/// <summary>
|
||||
/// Уровень находки
|
||||
/// </summary>
|
||||
public const string SeverityMedium = "medium";
|
||||
|
||||
private readonly IAuditLogStore _store;
|
||||
private readonly Func<DateTimeOffset> _clock;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт детектор с системными часами UTC-«сейчас»
|
||||
/// </summary>
|
||||
/// <param name="store">Хранилище аудита (append-only лента).</param>
|
||||
public SuspiciousActivityService(IAuditLogStore store)
|
||||
: this(store, () => DateTimeOffset.UtcNow)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт детектор с инъекцией часов
|
||||
/// </summary>
|
||||
/// <param name="store">Хранилище аудита (append-only лента).</param>
|
||||
/// <param name="clock">Источник «сейчас».</param>
|
||||
public SuspiciousActivityService(IAuditLogStore store, Func<DateTimeOffset> clock)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(store);
|
||||
ArgumentNullException.ThrowIfNull(clock);
|
||||
_store = store;
|
||||
_clock = clock;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Анализирует логи аудита за окно и возвращает находки подозрительной активности.
|
||||
/// </summary>
|
||||
/// <param name="from">Начало окна (включительно); null — «сейчас минус <see cref="DefaultWindowHours"/> часов».</param>
|
||||
/// <param name="to">Конец окна (включительно); null — «сейчас».</param>
|
||||
/// <returns>Сводка: окно, число разобранных записей, признак усечения и список находок.</returns>
|
||||
public async Task<SuspiciousActivityDto> AnalyzeAsync(
|
||||
DateTimeOffset? from,
|
||||
DateTimeOffset? to,
|
||||
CancellationToken ct)
|
||||
{
|
||||
DateTimeOffset windowTo = to ?? _clock();
|
||||
DateTimeOffset windowFrom = from ?? windowTo.AddHours(-DefaultWindowHours);
|
||||
|
||||
var filter = new AuditQueryDto(
|
||||
EventType: null,
|
||||
ActorType: null,
|
||||
TenantId: null,
|
||||
From: windowFrom,
|
||||
To: windowTo,
|
||||
Limit: MaxScanRecords);
|
||||
IReadOnlyList<AuditRecordDto> records = await _store.QueryAsync(filter, ct);
|
||||
int total = await _store.CountAsync(filter, ct);
|
||||
|
||||
var findings = new List<SuspiciousFindingDto>();
|
||||
AddFailedLoginsPerIp(records, findings);
|
||||
AddFailedLoginsPerLogin(records, findings);
|
||||
AddManyIpsPerActor(records, findings);
|
||||
AddAuthFailuresPerTenant(records, findings);
|
||||
AddDistinctLoginsPerIp(records, findings);
|
||||
|
||||
findings.Sort(static (left, right) =>
|
||||
{
|
||||
int severity = SeverityRank(right.Severity).CompareTo(SeverityRank(left.Severity));
|
||||
if (severity != 0)
|
||||
{
|
||||
return severity;
|
||||
}
|
||||
|
||||
int count = right.Count.CompareTo(left.Count);
|
||||
return count != 0 ? count : string.CompareOrdinal(left.Subject, right.Subject);
|
||||
});
|
||||
|
||||
return new SuspiciousActivityDto
|
||||
{
|
||||
From = windowFrom,
|
||||
To = windowTo,
|
||||
Scanned = records.Count,
|
||||
Truncated = total > records.Count,
|
||||
Items = findings,
|
||||
};
|
||||
}
|
||||
|
||||
// Правило «всплеск неудачных входов с одного IP».
|
||||
// records: Записи окна.
|
||||
// findings: Накопитель находок.
|
||||
private static void AddFailedLoginsPerIp(IReadOnlyList<AuditRecordDto> records, List<SuspiciousFindingDto> findings)
|
||||
{
|
||||
Dictionary<string, int> counts = CountBy(records, record => record.IsFailedLogin(), record => record.Ip);
|
||||
foreach ((string ip, int count) in counts)
|
||||
{
|
||||
if (count >= FailedLoginsPerIpThreshold)
|
||||
{
|
||||
findings.Add(new SuspiciousFindingDto(
|
||||
KindFailedLoginsPerIp,
|
||||
SeverityFor(count, FailedLoginsPerIpThreshold),
|
||||
ip,
|
||||
count,
|
||||
$"Неудачных входов с IP {ip}: {count} за окно"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Правило «всплеск неудачных входов по одному логину».
|
||||
// records: Записи окна.
|
||||
// findings: Накопитель находок.
|
||||
private static void AddFailedLoginsPerLogin(IReadOnlyList<AuditRecordDto> records, List<SuspiciousFindingDto> findings)
|
||||
{
|
||||
Dictionary<string, int> counts = CountBy(records, record => record.IsFailedLogin(), ExtractLogin);
|
||||
foreach ((string login, int count) in counts)
|
||||
{
|
||||
if (count >= FailedLoginsPerLoginThreshold)
|
||||
{
|
||||
findings.Add(new SuspiciousFindingDto(
|
||||
KindFailedLoginsPerLogin,
|
||||
SeverityFor(count, FailedLoginsPerLoginThreshold),
|
||||
login,
|
||||
count,
|
||||
$"Неудачных входов по логину «{login}»: {count} за окно"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Правило «успешные входы одного актора с множества IP».
|
||||
// records: Записи окна.
|
||||
// findings: Накопитель находок.
|
||||
private static void AddManyIpsPerActor(IReadOnlyList<AuditRecordDto> records, List<SuspiciousFindingDto> findings)
|
||||
{
|
||||
var ipsByActor = new Dictionary<Guid, HashSet<string>>();
|
||||
foreach (AuditRecordDto record in records)
|
||||
{
|
||||
if (!record.IsSuccessfulLogin() || record.ActorId is not { } actorId || string.IsNullOrWhiteSpace(record.Ip))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ipsByActor.TryGetValue(actorId, out HashSet<string>? ips))
|
||||
{
|
||||
ips = new HashSet<string>(StringComparer.Ordinal);
|
||||
ipsByActor[actorId] = ips;
|
||||
}
|
||||
|
||||
ips.Add(record.Ip);
|
||||
}
|
||||
|
||||
foreach ((Guid actorId, HashSet<string> ips) in ipsByActor)
|
||||
{
|
||||
if (ips.Count >= DistinctIpsPerActorThreshold)
|
||||
{
|
||||
findings.Add(new SuspiciousFindingDto(
|
||||
KindManyIpsPerActor,
|
||||
SeverityFor(ips.Count, DistinctIpsPerActorThreshold),
|
||||
actorId.ToString("D"),
|
||||
ips.Count,
|
||||
$"Входы актора {actorId:D} с {ips.Count} разных IP за окно"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Правило «повторные неудачные входы по одному тенанту».
|
||||
// records: Записи окна.
|
||||
// findings: Накопитель находок.
|
||||
private static void AddAuthFailuresPerTenant(IReadOnlyList<AuditRecordDto> records, List<SuspiciousFindingDto> findings)
|
||||
{
|
||||
var counts = new Dictionary<Guid, int>();
|
||||
foreach (AuditRecordDto record in records)
|
||||
{
|
||||
if (!record.IsFailedLogin() || record.TenantId is not { } tenantId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
counts[tenantId] = counts.TryGetValue(tenantId, out int current) ? current + 1 : 1;
|
||||
}
|
||||
|
||||
foreach ((Guid tenantId, int count) in counts)
|
||||
{
|
||||
if (count >= AuthFailuresPerTenantThreshold)
|
||||
{
|
||||
findings.Add(new SuspiciousFindingDto(
|
||||
KindAuthFailuresPerTenant,
|
||||
SeverityFor(count, AuthFailuresPerTenantThreshold),
|
||||
tenantId.ToString("D"),
|
||||
count,
|
||||
$"Неудачных входов по тенанту {tenantId:D}: {count} за окно"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Правило «перебор разных логинов с одного IP» (credential stuffing).
|
||||
// records: Записи окна.
|
||||
// findings: Накопитель находок.
|
||||
private static void AddDistinctLoginsPerIp(IReadOnlyList<AuditRecordDto> records, List<SuspiciousFindingDto> findings)
|
||||
{
|
||||
var loginsByIp = new Dictionary<string, HashSet<string>>(StringComparer.Ordinal);
|
||||
foreach (AuditRecordDto record in records)
|
||||
{
|
||||
if (!record.IsFailedLogin() || string.IsNullOrWhiteSpace(record.Ip))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string? login = ExtractLogin(record);
|
||||
if (string.IsNullOrWhiteSpace(login))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!loginsByIp.TryGetValue(record.Ip, out HashSet<string>? logins))
|
||||
{
|
||||
logins = new HashSet<string>(StringComparer.Ordinal);
|
||||
loginsByIp[record.Ip] = logins;
|
||||
}
|
||||
|
||||
logins.Add(login);
|
||||
}
|
||||
|
||||
foreach ((string ip, HashSet<string> logins) in loginsByIp)
|
||||
{
|
||||
if (logins.Count >= DistinctLoginsPerIpThreshold)
|
||||
{
|
||||
findings.Add(new SuspiciousFindingDto(
|
||||
KindDistinctLoginsPerIp,
|
||||
SeverityFor(logins.Count, DistinctLoginsPerIpThreshold),
|
||||
ip,
|
||||
logins.Count,
|
||||
$"Разных логинов с IP {ip}: {logins.Count} за окно (перебор)"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Считает записи по ключу-селектору (пустые/неразобранные ключи пропускаются).
|
||||
// records: Записи окна.
|
||||
// predicate: Отбор записей правила.
|
||||
// keySelector: Ключ группировки (null/пусто — запись пропускается).
|
||||
// Возвращает: Словарь ключ → число записей.
|
||||
private static Dictionary<string, int> CountBy(
|
||||
IReadOnlyList<AuditRecordDto> records,
|
||||
Func<AuditRecordDto, bool> predicate,
|
||||
Func<AuditRecordDto, string?> keySelector)
|
||||
{
|
||||
var counts = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
foreach (AuditRecordDto record in records)
|
||||
{
|
||||
if (!predicate(record))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string? key = keySelector(record);
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
counts[key] = counts.TryGetValue(key, out int current) ? current + 1 : 1;
|
||||
}
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
// Извлекает логин из DetailJson записи (поле 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;
|
||||
}
|
||||
|
||||
// Уровень находки по превышению порога (≥2× порог — high, иначе medium).
|
||||
// count: Фактическое значение правила.
|
||||
// threshold: Порог правила.
|
||||
// Возвращает: Константа уровня (SeverityHigh либо SeverityMedium).
|
||||
private static string SeverityFor(int count, int threshold) =>
|
||||
count >= threshold * HighSeverityMultiplier ? SeverityHigh : SeverityMedium;
|
||||
|
||||
// Ранг серьёзности для сортировки (high выше medium; неизвестное — ниже).
|
||||
// severity: Уровень находки.
|
||||
// Возвращает: Числовой ранг.
|
||||
private static int SeverityRank(string severity) => severity switch
|
||||
{
|
||||
SeverityHigh => 2,
|
||||
SeverityMedium => 1,
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
using System.Text.Json;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Детектор подозрительной активности по логам безопасности
|
||||
/// </summary>
|
||||
public sealed class SuspiciousActivityService
|
||||
{
|
||||
/// <summary>
|
||||
/// Размер окна анализа по умолчанию, часов
|
||||
/// </summary>
|
||||
public const int DefaultWindowHours = 24;
|
||||
|
||||
/// <summary>
|
||||
/// Предел разбираемых записей за окно
|
||||
/// </summary>
|
||||
public const int MaxScanRecords = AuditService.MaxQueryLimit;
|
||||
|
||||
/// <summary>
|
||||
/// Порог неудачных входов с одного IP за окно.
|
||||
/// </summary>
|
||||
public const int FailedLoginsPerIpThreshold = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Порог неудачных входов по одному логину за окно.
|
||||
/// </summary>
|
||||
public const int FailedLoginsPerLoginThreshold = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Порог числа разных IP успешных входов одного актора за окно.
|
||||
/// </summary>
|
||||
public const int DistinctIpsPerActorThreshold = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Порог неудачных входов по одному тенанту за окно.
|
||||
/// </summary>
|
||||
public const int AuthFailuresPerTenantThreshold = 20;
|
||||
|
||||
/// <summary>
|
||||
/// Порог числа разных логинов в неудачных входах с одного IP за окно.
|
||||
/// </summary>
|
||||
public const int DistinctLoginsPerIpThreshold = 5;
|
||||
|
||||
// Кратность порога, с которой уровень поднимается до high (2× порог).
|
||||
private const int HighSeverityMultiplier = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Правило: всплеск неудачных входов с одного IP.
|
||||
/// </summary>
|
||||
public const string KindFailedLoginsPerIp = "failed_logins_per_ip";
|
||||
|
||||
/// <summary>
|
||||
/// Правило: всплеск неудачных входов по одному логину.
|
||||
/// </summary>
|
||||
public const string KindFailedLoginsPerLogin = "failed_logins_per_login";
|
||||
|
||||
/// <summary>
|
||||
/// Правило: успешные входы актора с множества IP.
|
||||
/// </summary>
|
||||
public const string KindManyIpsPerActor = "many_ips_per_actor";
|
||||
|
||||
/// <summary>
|
||||
/// Правило: повторные неудачные входы по тенанту.
|
||||
/// </summary>
|
||||
public const string KindAuthFailuresPerTenant = "auth_failures_per_tenant";
|
||||
|
||||
/// <summary>
|
||||
/// Правило: перебор разных логинов с одного IP.
|
||||
/// </summary>
|
||||
public const string KindDistinctLoginsPerIp = "distinct_logins_per_ip";
|
||||
|
||||
/// <summary>
|
||||
/// Уровень находки
|
||||
/// </summary>
|
||||
public const string SeverityHigh = "high";
|
||||
|
||||
/// <summary>
|
||||
/// Уровень находки
|
||||
/// </summary>
|
||||
public const string SeverityMedium = "medium";
|
||||
|
||||
private readonly IAuditLogStore _store;
|
||||
private readonly Func<DateTimeOffset> _clock;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт детектор с системными часами UTC-«сейчас»
|
||||
/// </summary>
|
||||
/// <param name="store">Хранилище аудита (append-only лента).</param>
|
||||
public SuspiciousActivityService(IAuditLogStore store)
|
||||
: this(store, () => DateTimeOffset.UtcNow)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт детектор с инъекцией часов
|
||||
/// </summary>
|
||||
/// <param name="store">Хранилище аудита (append-only лента).</param>
|
||||
/// <param name="clock">Источник «сейчас».</param>
|
||||
public SuspiciousActivityService(IAuditLogStore store, Func<DateTimeOffset> clock)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(store);
|
||||
ArgumentNullException.ThrowIfNull(clock);
|
||||
_store = store;
|
||||
_clock = clock;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Анализирует логи аудита за окно и возвращает находки подозрительной активности.
|
||||
/// </summary>
|
||||
/// <param name="from">Начало окна (включительно); null — «сейчас минус <see cref="DefaultWindowHours"/> часов».</param>
|
||||
/// <param name="to">Конец окна (включительно); null — «сейчас».</param>
|
||||
/// <returns>Сводка: окно, число разобранных записей, признак усечения и список находок.</returns>
|
||||
public async Task<SuspiciousActivityDto> AnalyzeAsync(
|
||||
DateTimeOffset? from,
|
||||
DateTimeOffset? to,
|
||||
CancellationToken ct)
|
||||
{
|
||||
DateTimeOffset windowTo = to ?? _clock();
|
||||
DateTimeOffset windowFrom = from ?? windowTo.AddHours(-DefaultWindowHours);
|
||||
|
||||
var filter = new AuditQueryDto(
|
||||
EventType: null,
|
||||
ActorType: null,
|
||||
TenantId: null,
|
||||
From: windowFrom,
|
||||
To: windowTo,
|
||||
Limit: MaxScanRecords);
|
||||
IReadOnlyList<AuditRecordDto> records = await _store.QueryAsync(filter, ct);
|
||||
int total = await _store.CountAsync(filter, ct);
|
||||
|
||||
var findings = new List<SuspiciousFindingDto>();
|
||||
AddFailedLoginsPerIp(records, findings);
|
||||
AddFailedLoginsPerLogin(records, findings);
|
||||
AddManyIpsPerActor(records, findings);
|
||||
AddAuthFailuresPerTenant(records, findings);
|
||||
AddDistinctLoginsPerIp(records, findings);
|
||||
|
||||
findings.Sort(static (left, right) =>
|
||||
{
|
||||
int severity = SeverityRank(right.Severity).CompareTo(SeverityRank(left.Severity));
|
||||
if (severity != 0)
|
||||
{
|
||||
return severity;
|
||||
}
|
||||
|
||||
int count = right.Count.CompareTo(left.Count);
|
||||
return count != 0 ? count : string.CompareOrdinal(left.Subject, right.Subject);
|
||||
});
|
||||
|
||||
return new SuspiciousActivityDto
|
||||
{
|
||||
From = windowFrom,
|
||||
To = windowTo,
|
||||
Scanned = records.Count,
|
||||
Truncated = total > records.Count,
|
||||
Items = findings,
|
||||
};
|
||||
}
|
||||
|
||||
// Правило «всплеск неудачных входов с одного IP».
|
||||
// records: Записи окна.
|
||||
// findings: Накопитель находок.
|
||||
private static void AddFailedLoginsPerIp(IReadOnlyList<AuditRecordDto> records, List<SuspiciousFindingDto> findings)
|
||||
{
|
||||
Dictionary<string, int> counts = CountBy(records, record => record.IsFailedLogin(), record => record.Ip);
|
||||
foreach ((string ip, int count) in counts)
|
||||
{
|
||||
if (count >= FailedLoginsPerIpThreshold)
|
||||
{
|
||||
findings.Add(new SuspiciousFindingDto(
|
||||
KindFailedLoginsPerIp,
|
||||
SeverityFor(count, FailedLoginsPerIpThreshold),
|
||||
ip,
|
||||
count,
|
||||
$"Неудачных входов с IP {ip}: {count} за окно"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Правило «всплеск неудачных входов по одному логину».
|
||||
// records: Записи окна.
|
||||
// findings: Накопитель находок.
|
||||
private static void AddFailedLoginsPerLogin(IReadOnlyList<AuditRecordDto> records, List<SuspiciousFindingDto> findings)
|
||||
{
|
||||
Dictionary<string, int> counts = CountBy(records, record => record.IsFailedLogin(), ExtractLogin);
|
||||
foreach ((string login, int count) in counts)
|
||||
{
|
||||
if (count >= FailedLoginsPerLoginThreshold)
|
||||
{
|
||||
findings.Add(new SuspiciousFindingDto(
|
||||
KindFailedLoginsPerLogin,
|
||||
SeverityFor(count, FailedLoginsPerLoginThreshold),
|
||||
login,
|
||||
count,
|
||||
$"Неудачных входов по логину «{login}»: {count} за окно"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Правило «успешные входы одного актора с множества IP».
|
||||
// records: Записи окна.
|
||||
// findings: Накопитель находок.
|
||||
private static void AddManyIpsPerActor(IReadOnlyList<AuditRecordDto> records, List<SuspiciousFindingDto> findings)
|
||||
{
|
||||
var ipsByActor = new Dictionary<Guid, HashSet<string>>();
|
||||
foreach (AuditRecordDto record in records)
|
||||
{
|
||||
if (!record.IsSuccessfulLogin() || record.ActorId is not { } actorId || string.IsNullOrWhiteSpace(record.Ip))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ipsByActor.TryGetValue(actorId, out HashSet<string>? ips))
|
||||
{
|
||||
ips = new HashSet<string>(StringComparer.Ordinal);
|
||||
ipsByActor[actorId] = ips;
|
||||
}
|
||||
|
||||
ips.Add(record.Ip);
|
||||
}
|
||||
|
||||
foreach ((Guid actorId, HashSet<string> ips) in ipsByActor)
|
||||
{
|
||||
if (ips.Count >= DistinctIpsPerActorThreshold)
|
||||
{
|
||||
findings.Add(new SuspiciousFindingDto(
|
||||
KindManyIpsPerActor,
|
||||
SeverityFor(ips.Count, DistinctIpsPerActorThreshold),
|
||||
actorId.ToString("D"),
|
||||
ips.Count,
|
||||
$"Входы актора {actorId:D} с {ips.Count} разных IP за окно"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Правило «повторные неудачные входы по одному тенанту».
|
||||
// records: Записи окна.
|
||||
// findings: Накопитель находок.
|
||||
private static void AddAuthFailuresPerTenant(IReadOnlyList<AuditRecordDto> records, List<SuspiciousFindingDto> findings)
|
||||
{
|
||||
var counts = new Dictionary<Guid, int>();
|
||||
foreach (AuditRecordDto record in records)
|
||||
{
|
||||
if (!record.IsFailedLogin() || record.TenantId is not { } tenantId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
counts[tenantId] = counts.TryGetValue(tenantId, out int current) ? current + 1 : 1;
|
||||
}
|
||||
|
||||
foreach ((Guid tenantId, int count) in counts)
|
||||
{
|
||||
if (count >= AuthFailuresPerTenantThreshold)
|
||||
{
|
||||
findings.Add(new SuspiciousFindingDto(
|
||||
KindAuthFailuresPerTenant,
|
||||
SeverityFor(count, AuthFailuresPerTenantThreshold),
|
||||
tenantId.ToString("D"),
|
||||
count,
|
||||
$"Неудачных входов по тенанту {tenantId:D}: {count} за окно"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Правило «перебор разных логинов с одного IP» (credential stuffing).
|
||||
// records: Записи окна.
|
||||
// findings: Накопитель находок.
|
||||
private static void AddDistinctLoginsPerIp(IReadOnlyList<AuditRecordDto> records, List<SuspiciousFindingDto> findings)
|
||||
{
|
||||
var loginsByIp = new Dictionary<string, HashSet<string>>(StringComparer.Ordinal);
|
||||
foreach (AuditRecordDto record in records)
|
||||
{
|
||||
if (!record.IsFailedLogin() || string.IsNullOrWhiteSpace(record.Ip))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string? login = ExtractLogin(record);
|
||||
if (string.IsNullOrWhiteSpace(login))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!loginsByIp.TryGetValue(record.Ip, out HashSet<string>? logins))
|
||||
{
|
||||
logins = new HashSet<string>(StringComparer.Ordinal);
|
||||
loginsByIp[record.Ip] = logins;
|
||||
}
|
||||
|
||||
logins.Add(login);
|
||||
}
|
||||
|
||||
foreach ((string ip, HashSet<string> logins) in loginsByIp)
|
||||
{
|
||||
if (logins.Count >= DistinctLoginsPerIpThreshold)
|
||||
{
|
||||
findings.Add(new SuspiciousFindingDto(
|
||||
KindDistinctLoginsPerIp,
|
||||
SeverityFor(logins.Count, DistinctLoginsPerIpThreshold),
|
||||
ip,
|
||||
logins.Count,
|
||||
$"Разных логинов с IP {ip}: {logins.Count} за окно (перебор)"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Считает записи по ключу-селектору (пустые/неразобранные ключи пропускаются).
|
||||
// records: Записи окна.
|
||||
// predicate: Отбор записей правила.
|
||||
// keySelector: Ключ группировки (null/пусто — запись пропускается).
|
||||
// Возвращает: Словарь ключ → число записей.
|
||||
private static Dictionary<string, int> CountBy(
|
||||
IReadOnlyList<AuditRecordDto> records,
|
||||
Func<AuditRecordDto, bool> predicate,
|
||||
Func<AuditRecordDto, string?> keySelector)
|
||||
{
|
||||
var counts = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
foreach (AuditRecordDto record in records)
|
||||
{
|
||||
if (!predicate(record))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string? key = keySelector(record);
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
counts[key] = counts.TryGetValue(key, out int current) ? current + 1 : 1;
|
||||
}
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
// Извлекает логин из DetailJson записи (поле 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;
|
||||
}
|
||||
|
||||
// Уровень находки по превышению порога (≥2× порог — high, иначе medium).
|
||||
// count: Фактическое значение правила.
|
||||
// threshold: Порог правила.
|
||||
// Возвращает: Константа уровня (SeverityHigh либо SeverityMedium).
|
||||
private static string SeverityFor(int count, int threshold) =>
|
||||
count >= threshold * HighSeverityMultiplier ? SeverityHigh : SeverityMedium;
|
||||
|
||||
// Ранг серьёзности для сортировки (high выше medium; неизвестное — ниже).
|
||||
// severity: Уровень находки.
|
||||
// Возвращает: Числовой ранг.
|
||||
private static int SeverityRank(string severity) => severity switch
|
||||
{
|
||||
SeverityHigh => 2,
|
||||
SeverityMedium => 1,
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,149 +1,149 @@
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.SharedKernel;
|
||||
using Deal.SharedKernel.Utilities;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Прикладной сервис операторского реестра тенантов
|
||||
/// </summary>
|
||||
public sealed class TenantAdminService(
|
||||
ITenantRepository tenantRepository,
|
||||
IAuthStore authStore,
|
||||
TenantService tenantService,
|
||||
IPasswordHasher passwordHasher)
|
||||
{
|
||||
// Случайные байты одноразового пароля владельца: 12 → ровно 16 символов Base64Url (как InviteCodeGenerator).
|
||||
private const int InitialPasswordRandomByteCount = 12;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт тенанта оператором
|
||||
/// </summary>
|
||||
/// <param name="name">Имя тенанта (обязательно; обрезается).</param>
|
||||
/// <param name="email">Email владельца (опционально): создаёт пользователя-владельца сразу, иначе владелец заводится инвайтом.</param>
|
||||
/// <returns>Ok=true — тенант создан (Tenant); при email дополнительно OwnerUserId/OwnerLogin/InitialPassword. Ok=false — код ошибки (имя пусто, email невалиден/уже зарегистрирован); текст — HTTP-слой.</returns>
|
||||
public async Task<TenantCreateResultDto> CreateAsync(
|
||||
string? name,
|
||||
string? email,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string tenantName = name?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrEmpty(tenantName))
|
||||
{
|
||||
return TenantCreateResultDto.Failed(TenantCreateResultDto.ErrorNameRequired);
|
||||
}
|
||||
|
||||
string? normalizedEmail = null;
|
||||
if (!string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
normalizedEmail = InvitesService.NormalizeEmail(email);
|
||||
if (!InvitesService.IsValidEmail(email))
|
||||
{
|
||||
return TenantCreateResultDto.Failed(TenantCreateResultDto.ErrorInvalidEmail);
|
||||
}
|
||||
|
||||
var existingUser = await authStore.FindUserByLoginAsync(normalizedEmail, ct);
|
||||
if (existingUser is not null)
|
||||
{
|
||||
return TenantCreateResultDto.Failed(TenantCreateResultDto.ErrorEmailTaken);
|
||||
}
|
||||
}
|
||||
|
||||
// Создание тенанта — единая операция: строка public.tenants (Status active) + провижининг схемы.
|
||||
var tenantId = Guid.NewGuid();
|
||||
await tenantService.CreateTenantAsync(tenantName, tenantId, ct);
|
||||
var tenant = new TenantRecordDto(tenantId, tenantName, TenantStatuses.Active, DateTimeOffset.UtcNow);
|
||||
|
||||
if (normalizedEmail is null)
|
||||
{
|
||||
return new TenantCreateResultDto(Ok: true, Error: null, Tenant: tenant);
|
||||
}
|
||||
|
||||
// Пользователь-владелец с одноразовым паролем: raw-пароль возвращается один раз (наружу), в хранилище —
|
||||
// только хэш; в аудит/логи не пишется.
|
||||
string initialPassword = GenerateInitialPassword();
|
||||
var ownerUserId = Guid.NewGuid();
|
||||
await authStore.CreateUserAsync(
|
||||
new StoredUserDto(
|
||||
Id: ownerUserId,
|
||||
Login: normalizedEmail,
|
||||
TenantId: tenantId,
|
||||
Status: "active",
|
||||
PasswordHash: passwordHasher.Hash(initialPassword)),
|
||||
ct);
|
||||
|
||||
return new TenantCreateResultDto(
|
||||
Ok: true,
|
||||
Error: null,
|
||||
Tenant: tenant,
|
||||
OwnerUserId: ownerUserId,
|
||||
OwnerLogin: normalizedEmail,
|
||||
InitialPassword: initialPassword);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Список тенантов со счётчиками пользователей.
|
||||
/// </summary>
|
||||
/// <returns>Тенанты в порядке создания (реестр) с числом пользователей каждого.</returns>
|
||||
public async Task<IReadOnlyList<TenantListItemDto>> ListAsync(CancellationToken ct)
|
||||
{
|
||||
var tenants = await tenantRepository.ListAsync(ct);
|
||||
var items = new List<TenantListItemDto>(tenants.Count);
|
||||
foreach (TenantRecordDto tenant in tenants)
|
||||
{
|
||||
IReadOnlyList<UserIdentityDto> users = await authStore.ListUsersByTenantIdAsync(tenant.Id, ct);
|
||||
items.Add(new TenantListItemDto(tenant.Id, tenant.Name, tenant.Status, tenant.CreatedAt, users.Count));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Детали тенанта с пользователями
|
||||
/// </summary>
|
||||
/// <param name="id">Идентификатор тенанта.</param>
|
||||
/// <returns>Детали и пользователи тенанта (по CreatedAt) или null, если тенанта нет.</returns>
|
||||
public async Task<TenantDetailDto?> GetAsync(Guid id, CancellationToken ct)
|
||||
{
|
||||
var tenant = await tenantRepository.FindByIdAsync(id, ct);
|
||||
if (tenant is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
IReadOnlyList<UserIdentityDto> users = await authStore.ListUsersByTenantIdAsync(id, ct);
|
||||
return new TenantDetailDto(tenant.Id, tenant.Name, tenant.Status, tenant.CreatedAt, users);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Меняет статус тенанта
|
||||
/// </summary>
|
||||
/// <param name="id">Идентификатор тенанта.</param>
|
||||
/// <param name="status">Новый статус — константа <c>TenantStatuses</c>.</param>
|
||||
/// <returns>Результат: тенант не найден (404) или применение с признаком реального изменения (Changed).</returns>
|
||||
public async Task<TenantStatusChangeResultDto> ChangeStatusAsync(
|
||||
Guid id,
|
||||
string status,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var tenant = await tenantRepository.FindByIdAsync(id, ct);
|
||||
if (tenant is null)
|
||||
{
|
||||
return new TenantStatusChangeResultDto(Ok: false, Error: TenantStatusChangeResultDto.ErrorNotFound, Changed: false, Tenant: null);
|
||||
}
|
||||
|
||||
// Идемпотентность: повторный suspend уже приостановленного — Ok без изменения (аудит не дублируется).
|
||||
if (tenant.Status == status)
|
||||
{
|
||||
return new TenantStatusChangeResultDto(Ok: true, Error: null, Changed: false, Tenant: tenant);
|
||||
}
|
||||
|
||||
bool updated = await tenantRepository.UpdateStatusAsync(id, status, ct);
|
||||
return new TenantStatusChangeResultDto(Ok: true, Error: null, Changed: updated, Tenant: tenant with { Status = status });
|
||||
}
|
||||
|
||||
// Новый одноразовый пароль владельца: 16 url-safe символов (общий UrlSafeToken, Security review C36).
|
||||
// Возвращает: Строка пароля.
|
||||
private static string GenerateInitialPassword() => UrlSafeToken.New(InitialPasswordRandomByteCount);
|
||||
}
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.SharedKernel;
|
||||
using Deal.SharedKernel.Utilities;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Прикладной сервис операторского реестра тенантов
|
||||
/// </summary>
|
||||
public sealed class TenantAdminService(
|
||||
ITenantRepository tenantRepository,
|
||||
IAuthStore authStore,
|
||||
TenantService tenantService,
|
||||
IPasswordHasher passwordHasher)
|
||||
{
|
||||
// Случайные байты одноразового пароля владельца: 12 → ровно 16 символов Base64Url (как InviteCodeGenerator).
|
||||
private const int InitialPasswordRandomByteCount = 12;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт тенанта оператором
|
||||
/// </summary>
|
||||
/// <param name="name">Имя тенанта (обязательно; обрезается).</param>
|
||||
/// <param name="email">Email владельца (опционально): создаёт пользователя-владельца сразу, иначе владелец заводится инвайтом.</param>
|
||||
/// <returns>Ok=true — тенант создан (Tenant); при email дополнительно OwnerUserId/OwnerLogin/InitialPassword. Ok=false — код ошибки (имя пусто, email невалиден/уже зарегистрирован); текст — HTTP-слой.</returns>
|
||||
public async Task<TenantCreateResultDto> CreateAsync(
|
||||
string? name,
|
||||
string? email,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string tenantName = name?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrEmpty(tenantName))
|
||||
{
|
||||
return TenantCreateResultDto.Failed(TenantCreateResultDto.ErrorNameRequired);
|
||||
}
|
||||
|
||||
string? normalizedEmail = null;
|
||||
if (!string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
normalizedEmail = InvitesService.NormalizeEmail(email);
|
||||
if (!InvitesService.IsValidEmail(email))
|
||||
{
|
||||
return TenantCreateResultDto.Failed(TenantCreateResultDto.ErrorInvalidEmail);
|
||||
}
|
||||
|
||||
var existingUser = await authStore.FindUserByLoginAsync(normalizedEmail, ct);
|
||||
if (existingUser is not null)
|
||||
{
|
||||
return TenantCreateResultDto.Failed(TenantCreateResultDto.ErrorEmailTaken);
|
||||
}
|
||||
}
|
||||
|
||||
// Создание тенанта — единая операция: строка public.tenants (Status active) + провижининг схемы.
|
||||
var tenantId = Guid.NewGuid();
|
||||
await tenantService.CreateTenantAsync(tenantName, tenantId, ct);
|
||||
var tenant = new TenantRecordDto(tenantId, tenantName, TenantStatuses.Active, DateTimeOffset.UtcNow);
|
||||
|
||||
if (normalizedEmail is null)
|
||||
{
|
||||
return new TenantCreateResultDto(Ok: true, Error: null, Tenant: tenant);
|
||||
}
|
||||
|
||||
// Пользователь-владелец с одноразовым паролем: raw-пароль возвращается один раз (наружу), в хранилище —
|
||||
// только хэш; в аудит/логи не пишется.
|
||||
string initialPassword = GenerateInitialPassword();
|
||||
var ownerUserId = Guid.NewGuid();
|
||||
await authStore.CreateUserAsync(
|
||||
new StoredUserDto(
|
||||
Id: ownerUserId,
|
||||
Login: normalizedEmail,
|
||||
TenantId: tenantId,
|
||||
Status: "active",
|
||||
PasswordHash: passwordHasher.Hash(initialPassword)),
|
||||
ct);
|
||||
|
||||
return new TenantCreateResultDto(
|
||||
Ok: true,
|
||||
Error: null,
|
||||
Tenant: tenant,
|
||||
OwnerUserId: ownerUserId,
|
||||
OwnerLogin: normalizedEmail,
|
||||
InitialPassword: initialPassword);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Список тенантов со счётчиками пользователей.
|
||||
/// </summary>
|
||||
/// <returns>Тенанты в порядке создания (реестр) с числом пользователей каждого.</returns>
|
||||
public async Task<IReadOnlyList<TenantListItemDto>> ListAsync(CancellationToken ct)
|
||||
{
|
||||
var tenants = await tenantRepository.ListAsync(ct);
|
||||
var items = new List<TenantListItemDto>(tenants.Count);
|
||||
foreach (TenantRecordDto tenant in tenants)
|
||||
{
|
||||
IReadOnlyList<UserIdentityDto> users = await authStore.ListUsersByTenantIdAsync(tenant.Id, ct);
|
||||
items.Add(new TenantListItemDto(tenant.Id, tenant.Name, tenant.Status, tenant.CreatedAt, users.Count));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Детали тенанта с пользователями
|
||||
/// </summary>
|
||||
/// <param name="id">Идентификатор тенанта.</param>
|
||||
/// <returns>Детали и пользователи тенанта (по CreatedAt) или null, если тенанта нет.</returns>
|
||||
public async Task<TenantDetailDto?> GetAsync(Guid id, CancellationToken ct)
|
||||
{
|
||||
var tenant = await tenantRepository.FindByIdAsync(id, ct);
|
||||
if (tenant is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
IReadOnlyList<UserIdentityDto> users = await authStore.ListUsersByTenantIdAsync(id, ct);
|
||||
return new TenantDetailDto(tenant.Id, tenant.Name, tenant.Status, tenant.CreatedAt, users);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Меняет статус тенанта
|
||||
/// </summary>
|
||||
/// <param name="id">Идентификатор тенанта.</param>
|
||||
/// <param name="status">Новый статус — константа <c>TenantStatuses</c>.</param>
|
||||
/// <returns>Результат: тенант не найден (404) или применение с признаком реального изменения (Changed).</returns>
|
||||
public async Task<TenantStatusChangeResultDto> ChangeStatusAsync(
|
||||
Guid id,
|
||||
string status,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var tenant = await tenantRepository.FindByIdAsync(id, ct);
|
||||
if (tenant is null)
|
||||
{
|
||||
return new TenantStatusChangeResultDto(Ok: false, Error: TenantStatusChangeResultDto.ErrorNotFound, Changed: false, Tenant: null);
|
||||
}
|
||||
|
||||
// Идемпотентность: повторный suspend уже приостановленного — Ok без изменения (аудит не дублируется).
|
||||
if (tenant.Status == status)
|
||||
{
|
||||
return new TenantStatusChangeResultDto(Ok: true, Error: null, Changed: false, Tenant: tenant);
|
||||
}
|
||||
|
||||
bool updated = await tenantRepository.UpdateStatusAsync(id, status, ct);
|
||||
return new TenantStatusChangeResultDto(Ok: true, Error: null, Changed: updated, Tenant: tenant with { Status = status });
|
||||
}
|
||||
|
||||
// Новый одноразовый пароль владельца: 16 url-safe символов (общий UrlSafeToken, Security review C36).
|
||||
// Возвращает: Строка пароля.
|
||||
private static string GenerateInitialPassword() => UrlSafeToken.New(InitialPasswordRandomByteCount);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Прикладной сервис истории расхода токенов
|
||||
/// </summary>
|
||||
public sealed class TokenUsageEventService(ITokenUsageEventStore store)
|
||||
{
|
||||
/// <summary>
|
||||
/// Записывает событие расхода токенов
|
||||
/// </summary>
|
||||
/// <param name="record">Событие (At перезаписывается сервисом).</param>
|
||||
public async Task AppendAsync(TokenUsageEventDto record, CancellationToken ct)
|
||||
{
|
||||
await store.AppendAsync(record with { At = DateTimeOffset.UtcNow }, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Агрегаты расхода по фильтру и группировке
|
||||
/// </summary>
|
||||
/// <param name="query">Фильтр/группировка.</param>
|
||||
/// <returns>Строки агрегатов.</returns>
|
||||
public Task<IReadOnlyList<TokenUsageAggregateDto>> AggregateAsync(TokenUsageEventQueryDto query, CancellationToken ct) =>
|
||||
store.AggregateAsync(query, ct);
|
||||
}
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Прикладной сервис истории расхода токенов
|
||||
/// </summary>
|
||||
public sealed class TokenUsageEventService(ITokenUsageEventStore store)
|
||||
{
|
||||
/// <summary>
|
||||
/// Записывает событие расхода токенов
|
||||
/// </summary>
|
||||
/// <param name="record">Событие (At перезаписывается сервисом).</param>
|
||||
public async Task AppendAsync(TokenUsageEventDto record, CancellationToken ct)
|
||||
{
|
||||
await store.AppendAsync(record with { At = DateTimeOffset.UtcNow }, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Агрегаты расхода по фильтру и группировке
|
||||
/// </summary>
|
||||
/// <param name="query">Фильтр/группировка.</param>
|
||||
/// <returns>Строки агрегатов.</returns>
|
||||
public Task<IReadOnlyList<TokenUsageAggregateDto>> AggregateAsync(TokenUsageEventQueryDto query, CancellationToken ct) =>
|
||||
store.AggregateAsync(query, ct);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user