SaaS-мониторинг Telegram: ядро (модули Cards/Kanban/Pipeline/Tenants/Settings/ Discovery, Api, Infrastructure), сервисы telegram/ai/ml/storage, фронт Vue, контракты и grpc-hosting, деплой-конфиги (dev/prod/observability/CI-раннер), Gitea Actions CI, документация (ТЗ, техдок, api-map, код-стайл, планы, бэклог). Текущее состояние: все этапы роадмапа 0–12 закрыты, сборка 5 sln 0/0, тесты 1340/130/52/38/9 зелёные.
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Порт хранилища аудита
|
||||
/// </summary>
|
||||
public interface IAuditLogStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Добавляет запись аудита
|
||||
/// </summary>
|
||||
/// <param name="record">Запись для сохранения.</param>
|
||||
public Task AppendAsync(AuditRecordDto record, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Записи по фильтру, новые сверху
|
||||
/// </summary>
|
||||
/// <param name="filter">Фильтр выборки.</param>
|
||||
/// <returns>Записи от новых к старым (пусто — записей нет).</returns>
|
||||
public Task<IReadOnlyList<AuditRecordDto>> QueryAsync(AuditQueryDto filter, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Число записей, удовлетворяющих фильтру
|
||||
/// </summary>
|
||||
/// <param name="filter">Фильтр выборки (Limit не влияет на подсчёт).</param>
|
||||
/// <returns>Полное число записей по фильтру.</returns>
|
||||
public Task<int> CountAsync(AuditQueryDto filter, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Удаляет записи старше retention-границы
|
||||
/// </summary>
|
||||
/// <param name="cutoff">Граница: удаляются записи со строгим <c>At < cutoff</c> (UTC).</param>
|
||||
/// <returns>Число удалённых записей.</returns>
|
||||
public Task<int> PurgeOlderThanAsync(DateTimeOffset cutoff, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Порт хранилища аутентификации
|
||||
/// </summary>
|
||||
public interface IAuthStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Ищет пользователя по нормализованному логину
|
||||
/// </summary>
|
||||
/// <param name="login">Нормализованный логин.</param>
|
||||
/// <returns>Пользователь с хэшем пароля или null.</returns>
|
||||
public Task<StoredUserDto?> FindUserByLoginAsync(string login, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт пользователя
|
||||
/// </summary>
|
||||
/// <param name="user">Данные нового пользователя (логин нормализован, хэш пароля готов).</param>
|
||||
public Task CreateUserAsync(StoredUserDto user, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Ищет сессию по SHA-256-хэшу токена; протухшие сессии не возвращает.
|
||||
/// </summary>
|
||||
/// <param name="tokenHash">SHA-256-хэш raw-токена.</param>
|
||||
/// <returns>Сессия или null.</returns>
|
||||
public Task<SessionDto?> FindSessionByTokenHashAsync(string tokenHash, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Ищет пользователя по идентификатору.
|
||||
/// </summary>
|
||||
/// <param name="userId">Идентификатор пользователя.</param>
|
||||
/// <returns>Идентичность пользователя (без хэша пароля) или null.</returns>
|
||||
public Task<UserIdentityDto?> FindUserByIdAsync(Guid userId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Возвращает пользователей тенанта.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Идентификатор тенанта.</param>
|
||||
/// <returns>Пользователи тенанта, упорядоченные по времени создания (пусто — пользователей нет).</returns>
|
||||
public Task<IReadOnlyList<UserIdentityDto>> ListUsersByTenantIdAsync(Guid tenantId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Сохраняет новую сессию.
|
||||
/// </summary>
|
||||
/// <param name="session">Сессия для сохранения.</param>
|
||||
public Task CreateSessionAsync(SessionDto session, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Удаляет сессию по SHA-256-хэшу токена
|
||||
/// </summary>
|
||||
/// <param name="tokenHash">SHA-256-хэш raw-токена.</param>
|
||||
public Task DeleteSessionAsync(string tokenHash, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Удаляет все сессии пользователя
|
||||
/// </summary>
|
||||
/// <param name="userId">Идентификатор пользователя.</param>
|
||||
public Task DeleteSessionsByUserIdAsync(Guid userId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Обновляет хэш пароля пользователя.
|
||||
/// </summary>
|
||||
/// <param name="userId">Идентификатор пользователя.</param>
|
||||
/// <param name="passwordHash">Новая encoded-строка хэша.</param>
|
||||
public Task UpdatePasswordHashAsync(
|
||||
Guid userId,
|
||||
string passwordHash,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Удаляет все сессии со сроком жизни не позднее текущего момента.
|
||||
/// </summary>
|
||||
public Task DeleteExpiredSessionsAsync(CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Порт хранилища приглашений
|
||||
/// </summary>
|
||||
public interface IInviteStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Сохраняет новое приглашение
|
||||
/// </summary>
|
||||
/// <param name="invite">Новое приглашение (код/email/tenantId/срок/автор заданы сервисом).</param>
|
||||
public Task CreateAsync(InviteDto invite, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Приглашение по коду
|
||||
/// </summary>
|
||||
/// <param name="code">Код приглашения.</param>
|
||||
/// <returns>Приглашение или null, если кода нет.</returns>
|
||||
public Task<InviteDto?> GetByCodeAsync(string code, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Все приглашения, новые сверху
|
||||
/// </summary>
|
||||
/// <returns>Список приглашений (пусто — приглашений нет).</returns>
|
||||
public Task<IReadOnlyList<InviteDto>> ListAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Меняет статус приглашения по коду; для активации обновляет и ActivatedAt.
|
||||
/// </summary>
|
||||
/// <param name="code">Код приглашения.</param>
|
||||
/// <param name="status">Новый статус (константа <see cref="InviteStatuses"/>).</param>
|
||||
/// <param name="activatedAt">Момент активации при статусе activated; иначе null.</param>
|
||||
/// <returns>true, если приглашение с таким кодом найдено и статус изменён; false — кода нет.</returns>
|
||||
public Task<bool> UpdateStatusAsync(
|
||||
string code,
|
||||
string status,
|
||||
DateTimeOffset? activatedAt,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Атомарно активирует приглашение
|
||||
/// </summary>
|
||||
/// <param name="code">Код приглашения.</param>
|
||||
/// <param name="activatedAt">Момент активации (UTC) для колонки ActivatedAt.</param>
|
||||
/// <returns>true, если строка была в статусе pending и переведена в activated; false — кода нет или статус уже иной.</returns>
|
||||
public Task<bool> TryActivateAsync(
|
||||
string code,
|
||||
DateTimeOffset activatedAt,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Активное (pending) приглашение на email — антидубль создания.
|
||||
/// </summary>
|
||||
/// <param name="email">Нормализованный email.</param>
|
||||
/// <returns>Pending-приглашение на email (включая протухшее, но ещё не помеченное expired — его переводит сервис) или null.</returns>
|
||||
public Task<InviteDto?> FindActiveByEmailAsync(string email, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Порт хранилища аутентификации оператора
|
||||
/// </summary>
|
||||
public interface IOperatorAuthStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Ищет оператора по нормализованному логину
|
||||
/// </summary>
|
||||
/// <param name="login">Нормализованный логин.</param>
|
||||
/// <returns>Оператор с хэшем пароля или null.</returns>
|
||||
public Task<StoredOperatorDto?> FindByLoginAsync(string login, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт оператора
|
||||
/// </summary>
|
||||
/// <param name="operatorRecord">Данные нового оператора (логин нормализован, хэш пароля готов).</param>
|
||||
public Task CreateAsync(StoredOperatorDto operatorRecord, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Ищет сессию по SHA-256-хэшу токена.
|
||||
/// </summary>
|
||||
/// <param name="tokenHash">SHA-256-хэш raw-токена.</param>
|
||||
/// <returns>Сессия или null.</returns>
|
||||
public Task<OperatorSessionDto?> FindSessionByTokenHashAsync(string tokenHash, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Сохраняет новую сессию оператора.
|
||||
/// </summary>
|
||||
/// <param name="session">Сессия для сохранения.</param>
|
||||
public Task CreateSessionAsync(OperatorSessionDto session, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Удаляет сессию по SHA-256-хэшу токена
|
||||
/// </summary>
|
||||
/// <param name="tokenHash">SHA-256-хэш raw-токена.</param>
|
||||
public Task DeleteSessionAsync(string tokenHash, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Удаляет все сессии со сроком жизни не позднее текущего момента.
|
||||
/// </summary>
|
||||
public Task DeleteExpiredSessionsAsync(CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Deal.Modules.Tenants.Application.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Порт хэширования паролей
|
||||
/// </summary>
|
||||
public interface IPasswordHasher
|
||||
{
|
||||
/// <summary>
|
||||
/// Вычисляет encoded-строку хэша пароля
|
||||
/// </summary>
|
||||
/// <param name="password">Пароль в открытом виде.</param>
|
||||
/// <returns>Encoded-строка для хранения в БД.</returns>
|
||||
public string Hash(string password);
|
||||
|
||||
/// <summary>
|
||||
/// Проверяет пароль против ранее сохранённой encoded-строки.
|
||||
/// </summary>
|
||||
/// <param name="password">Пароль в открытом виде.</param>
|
||||
/// <param name="encodedHash">Encoded-строка из <see cref="Hash"/>.</param>
|
||||
/// <returns>true, если пароль соответствует хэшу; иначе false.</returns>
|
||||
public bool Verify(string password, string encodedHash);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace Deal.Modules.Tenants.Application.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Порт распределённого счётчика фиксированного окна
|
||||
/// </summary>
|
||||
public interface IRateLimitCounterStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Атомарно увеличивает счётчик окна
|
||||
/// </summary>
|
||||
/// <param name="key">Уникальный ключ счётчика (политика/тип + партиция).</param>
|
||||
/// <param name="windowStart">Начало текущего фиксированного окна (UTC).</param>
|
||||
/// <param name="windowEnd">Конец окна (UTC) — срок уборки строки (ExpiresAt).</param>
|
||||
/// <param name="amount">Величина приращения (обычно 1; должно быть ≥0).</param>
|
||||
/// <returns>Значение счётчика в текущем окне после операции.</returns>
|
||||
public Task<int> IncrementAsync(
|
||||
string key,
|
||||
DateTimeOffset windowStart,
|
||||
DateTimeOffset windowEnd,
|
||||
int amount,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Читает счётчик текущего окна
|
||||
/// </summary>
|
||||
/// <param name="key">Уникальный ключ счётчика.</param>
|
||||
/// <param name="windowStart">Начало текущего фиксированного окна (UTC).</param>
|
||||
/// <returns>Значение счётчика в окне <paramref name="windowStart"/> (0, если окно иное/строки нет).</returns>
|
||||
public Task<int> GetCountAsync(
|
||||
string key,
|
||||
DateTimeOffset windowStart,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Удаляет счётчик ключа.
|
||||
/// </summary>
|
||||
/// <param name="key">Уникальный ключ счётчика.</param>
|
||||
public Task ResetAsync(string key, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Удаляет строки завершившихся окон
|
||||
/// </summary>
|
||||
/// <param name="now">Текущий момент (UTC).</param>
|
||||
/// <returns>Число удалённых строк.</returns>
|
||||
public Task<int> DeleteExpiredAsync(DateTimeOffset now, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Порт хранилища лимитов ИИ-бюджета тенанта
|
||||
/// </summary>
|
||||
public interface ITenantLimitStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Возвращает строку лимита тенанта, при отсутствии — лениво создаёт с дефолт-бюджетом
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Тенант (существующая строка public.tenants).</param>
|
||||
/// <param name="defaults">Дефолт-параметры создаваемой строки; null — дефолт адаптера (конфигурация/env).</param>
|
||||
/// <returns>Строка лимита (созданная или существующая).</returns>
|
||||
public Task<TenantLimitDto> GetOrCreateAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken ct,
|
||||
TokenLimitDefaults? defaults = null);
|
||||
|
||||
/// <summary>
|
||||
/// Текущее состояние бюджета тенанта.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Тенант.</param>
|
||||
/// <returns>Состояние бюджета на сейчас.</returns>
|
||||
public Task<BudgetStateDto> GetStateAsync(Guid tenantId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Списывает <paramref name="tokens"/> с бюджета тенанта
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Тенант.</param>
|
||||
/// <param name="tokens">Списываемые токены (usage.Total ответа ai-service; ≤0 — no-op).</param>
|
||||
/// <returns>Состояние бюджета после списания (источник для гейта/сводки usage).</returns>
|
||||
public Task<BudgetStateDto> AddUsageAsync(
|
||||
Guid tenantId,
|
||||
long tokens,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Меняет бюджет/период тенанта и сбрасывает флаги Warned80/ NotifiedExhausted.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Тенант.</param>
|
||||
/// <param name="budgetTokens">Новый бюджет периода (≥0; 0 — ИИ запрещён).</param>
|
||||
/// <param name="period">Новый тип периода (<see cref="TenantLimitPeriods"/>).</param>
|
||||
/// <returns>Состояние бюджета после изменения.</returns>
|
||||
public Task<BudgetStateDto> UpdateBudgetAsync(
|
||||
Guid tenantId,
|
||||
long budgetTokens,
|
||||
string period,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Атомарно выставляет флаг Warned80, если порог 80% достигнут и флаг ещё не стоял.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Тенант.</param>
|
||||
/// <returns>True — флаг только что установлен (нужно публиковать тост); false — уже стоял/порог не достигнут.</returns>
|
||||
public Task<bool> TryMarkWarnedAsync(Guid tenantId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Атомарно выставляет флаг NotifiedExhausted, если бюджет исчерпан и флаг ещё не стоял.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Тенант.</param>
|
||||
/// <returns>True — флаг только что установлен; false — уже стоял/бюджет не исчерпан.</returns>
|
||||
public Task<bool> TryMarkNotifiedExhaustedAsync(Guid tenantId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Авто-очистка накопительных полей прошедших периодов
|
||||
/// </summary>
|
||||
/// <param name="now">Текущий момент (UTC) — источник проверки завершения периода.</param>
|
||||
/// <returns>Число строк, у которых период был сброшен.</returns>
|
||||
public Task<int> ResetExpiredPeriodsAsync(DateTimeOffset now, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using Deal.SharedKernel.Tenants.Models;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Порт провижининга схемы тенанта
|
||||
/// </summary>
|
||||
public interface ITenantProvisioner
|
||||
{
|
||||
/// <summary>
|
||||
/// Провижинит схему тенанта
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Идентификатор тенанта.</param>
|
||||
public Task ProvisionAsync(TenantId tenantId, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Порт реестра тенантов
|
||||
/// </summary>
|
||||
public interface ITenantRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Ищет тенанта по идентификатору.
|
||||
/// </summary>
|
||||
/// <param name="id">Идентификатор тенанта.</param>
|
||||
/// <returns>Запись тенанта или null.</returns>
|
||||
public Task<TenantRecordDto?> FindByIdAsync(Guid id, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Сохраняет нового тенанта.
|
||||
/// </summary>
|
||||
/// <param name="tenant">Запись тенанта.</param>
|
||||
public Task CreateAsync(TenantRecordDto tenant, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Возвращает список всех тенантов.
|
||||
/// </summary>
|
||||
/// <returns>Список тенантов.</returns>
|
||||
public Task<IReadOnlyList<TenantRecordDto>> ListAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Страница реестра тенантов (шардированный обход для 1000+ схем).
|
||||
/// </summary>
|
||||
/// <param name="offset">Сдвиг от начала (устойчивый порядок — CreatedAt, затем Id).</param>
|
||||
/// <param name="limit">Размер страницы (≥1; валидирует потребитель).</param>
|
||||
/// <returns>Записи страницы; пусто — страниц больше нет.</returns>
|
||||
public Task<IReadOnlyList<TenantRecordDto>> ListPageAsync(
|
||||
int offset,
|
||||
int limit,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Устанавливает статус тенанта.
|
||||
/// </summary>
|
||||
/// <param name="id">Идентификатор тенанта.</param>
|
||||
/// <param name="status">Новый статус — константа <c>TenantStatuses</c>.</param>
|
||||
/// <returns>true, если тенант существовал и статус обновлён; false — записи нет (404 на HTTP-слое).</returns>
|
||||
public Task<bool> UpdateStatusAsync(
|
||||
Guid id,
|
||||
string status,
|
||||
CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Порт хранилища истории расхода токенов
|
||||
/// </summary>
|
||||
public interface ITokenUsageEventStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Добавляет событие расхода токенов
|
||||
/// </summary>
|
||||
/// <param name="record">Событие для сохранения.</param>
|
||||
public Task AppendAsync(TokenUsageEventDto record, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Агрегаты по фильтру и способу группировки
|
||||
/// </summary>
|
||||
/// <param name="query">Фильтр/группировка.</param>
|
||||
/// <returns>Строки агрегатов (пусто — событий нет).</returns>
|
||||
public Task<IReadOnlyList<TokenUsageAggregateDto>> AggregateAsync(TokenUsageEventQueryDto query, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
// Расширения записей аудита
|
||||
internal static class AuditRecordDtoExtensions
|
||||
{
|
||||
// События аудита «неудачный вход» (тенант/оператор).
|
||||
private static readonly string[] FailedLoginEvents =
|
||||
{
|
||||
AuditEvents.TenantLoginFailed,
|
||||
AuditEvents.OperatorLoginFailed,
|
||||
};
|
||||
|
||||
// События аудита «успешный вход» (тенант/оператор).
|
||||
private static readonly string[] SuccessfulLoginEvents =
|
||||
{
|
||||
AuditEvents.TenantLoginOk,
|
||||
AuditEvents.OperatorLoginOk,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Неудачный вход
|
||||
/// </summary>
|
||||
/// <param name="record">Запись аудита.</param>
|
||||
/// <returns>True — событие из FailedLoginEvents.</returns>
|
||||
public static bool IsFailedLogin(this AuditRecordDto record) => FailedLoginEvents.Contains(record.EventType);
|
||||
|
||||
/// <summary>
|
||||
/// Успешный вход
|
||||
/// </summary>
|
||||
/// <param name="record">Запись аудита.</param>
|
||||
/// <returns>True — событие из SuccessfulLoginEvents.</returns>
|
||||
public static bool IsSuccessfulLogin(this AuditRecordDto record) => SuccessfulLoginEvents.Contains(record.EventType);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Extensions;
|
||||
|
||||
internal static class InviteDtoExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Истёк ли срок действия приглашения
|
||||
/// </summary>
|
||||
/// <param name="invite">Приглашение.</param>
|
||||
/// <returns>True — срок действия уже прошёл.</returns>
|
||||
public static bool IsExpired(this InviteDto invite) => invite.ExpiresAt <= DateTimeOffset.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Лента действий тенантов/операторов.
|
||||
/// </summary>
|
||||
/// <param name="Items">Записи аудита, новые сверху (в пределах страницы offset/limit).</param>
|
||||
/// <param name="Total">Полное число записей по фильтру (без учёта limit/offset).</param>
|
||||
/// <param name="Limit">Применённый размер страницы (кламп 1..500).</param>
|
||||
/// <param name="Offset">Применённое смещение (≥0).</param>
|
||||
public sealed record AnalyticsActivityDto(
|
||||
IReadOnlyList<AuditRecordDto> Items,
|
||||
int Total,
|
||||
int Limit,
|
||||
int Offset);
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Сводка операторской аналитики.
|
||||
/// </summary>
|
||||
/// <param name="TenantsTotal">Всего тенантов.</param>
|
||||
/// <param name="TenantsActive">Активных тенантов (статус active).</param>
|
||||
/// <param name="PromptTokens">Токены запроса за период (сумма).</param>
|
||||
/// <param name="CompletionTokens">Токены ответа за период (сумма).</param>
|
||||
/// <param name="TotalTokens">Всего токенов за период (сумма).</param>
|
||||
/// <param name="TokenEvents">Число событий расхода токенов за период.</param>
|
||||
/// <param name="Events">Число событий аудита за период.</param>
|
||||
/// <param name="Logins">Успешные входы (tenant+operator) за период.</param>
|
||||
/// <param name="Logouts">Выходы (tenant+operator) за период.</param>
|
||||
/// <param name="FailedLogins">Неудачные входы (tenant+operator) за период.</param>
|
||||
/// <param name="From">Начало периода (включительно; null — без границы).</param>
|
||||
/// <param name="To">Конец периода (включительно; null — без границы).</param>
|
||||
public sealed record AnalyticsOverviewDto(
|
||||
int TenantsTotal,
|
||||
int TenantsActive,
|
||||
long PromptTokens,
|
||||
long CompletionTokens,
|
||||
long TotalTokens,
|
||||
long TokenEvents,
|
||||
int Events,
|
||||
int Logins,
|
||||
int Logouts,
|
||||
int FailedLogins,
|
||||
DateTimeOffset? From,
|
||||
DateTimeOffset? To);
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Агрегаты расхода токенов.
|
||||
/// </summary>
|
||||
/// <param name="GroupBy">Способ группировки: day|tenant|provider|model.</param>
|
||||
/// <param name="From">Начало периода (включительно; null — без границы).</param>
|
||||
/// <param name="To">Конец периода (включительно; null — без границы).</param>
|
||||
/// <param name="Items">Строки агрегатов (day — по возрастанию даты, остальные — по убыванию total).</param>
|
||||
/// <param name="Total">Итог по всем строкам (Key = "total").</param>
|
||||
public sealed record AnalyticsTokensDto(
|
||||
string GroupBy,
|
||||
DateTimeOffset? From,
|
||||
DateTimeOffset? To,
|
||||
IReadOnlyList<TokenUsageAggregateDto> Items,
|
||||
TokenUsageAggregateDto Total);
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Типы акторов аудита.
|
||||
/// </summary>
|
||||
public static class AuditActorTypes
|
||||
{
|
||||
/// <summary>
|
||||
/// Действие пользователя тенанта
|
||||
/// </summary>
|
||||
public const string Tenant = "tenant";
|
||||
|
||||
/// <summary>
|
||||
/// Действие оператора SaaS-контура
|
||||
/// </summary>
|
||||
public const string Operator = "operator";
|
||||
|
||||
/// <summary>
|
||||
/// Системное действие без актора-человека.
|
||||
/// </summary>
|
||||
public const string System = "system";
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Каталог типов событий аудита.
|
||||
/// </summary>
|
||||
public static class AuditEvents
|
||||
{
|
||||
/// <summary>
|
||||
/// Успешный вход пользователя тенанта
|
||||
/// </summary>
|
||||
public const string TenantLoginOk = "tenant_login_ok";
|
||||
|
||||
/// <summary>
|
||||
/// Неудачный вход пользователя тенанта — неверный пароль или заблокированный вход
|
||||
/// </summary>
|
||||
public const string TenantLoginFailed = "tenant_login_failed";
|
||||
|
||||
/// <summary>
|
||||
/// Успешный вход оператора
|
||||
/// </summary>
|
||||
public const string OperatorLoginOk = "operator_login_ok";
|
||||
|
||||
/// <summary>
|
||||
/// Неудачный вход оператора
|
||||
/// </summary>
|
||||
public const string OperatorLoginFailed = "operator_login_failed";
|
||||
|
||||
/// <summary>
|
||||
/// Оператор создал приглашение
|
||||
/// </summary>
|
||||
public const string InviteCreated = "invite_created";
|
||||
|
||||
/// <summary>
|
||||
/// Оператор отозвал приглашение
|
||||
/// </summary>
|
||||
public const string InviteRevoked = "invite_revoked";
|
||||
|
||||
/// <summary>
|
||||
/// Приглашение активировано
|
||||
/// </summary>
|
||||
public const string InviteActivated = "invite_activated";
|
||||
|
||||
/// <summary>
|
||||
/// Оператор создал тенанта
|
||||
/// </summary>
|
||||
public const string TenantCreated = "tenant_created";
|
||||
|
||||
/// <summary>
|
||||
/// Статус тенанта изменён оператором
|
||||
/// </summary>
|
||||
public const string TenantStatusChanged = "tenant_status_changed";
|
||||
|
||||
/// <summary>
|
||||
/// Оператор изменил лимиты тенанта
|
||||
/// </summary>
|
||||
public const string TenantLimitChanged = "tenant_limit_changed";
|
||||
|
||||
/// <summary>
|
||||
/// Оператор начал impersonation пользователя тенанта
|
||||
/// </summary>
|
||||
public const string ImpersonationStarted = "impersonation_started";
|
||||
|
||||
/// <summary>
|
||||
/// Сессия impersonation завершена выходом пользователя
|
||||
/// </summary>
|
||||
public const string ImpersonationStopped = "impersonation_stopped";
|
||||
|
||||
/// <summary>
|
||||
/// Выход пользователя тенанта.
|
||||
/// </summary>
|
||||
public const string TenantLogout = "tenant_logout";
|
||||
|
||||
/// <summary>
|
||||
/// Выход оператора.
|
||||
/// </summary>
|
||||
public const string OperatorLogout = "operator_logout";
|
||||
|
||||
/// <summary>
|
||||
/// Активация инвайта через публичный POST /api/join.
|
||||
/// </summary>
|
||||
public const string InviteJoined = "invite_joined";
|
||||
|
||||
/// <summary>
|
||||
/// Пользователь тенанта создал карточку.
|
||||
/// </summary>
|
||||
public const string CardCreated = "card_created";
|
||||
|
||||
/// <summary>
|
||||
/// Пользователь перенёс карточку между контейнерами.
|
||||
/// </summary>
|
||||
public const string CardMoved = "card_moved";
|
||||
|
||||
/// <summary>
|
||||
/// Пользователь отправил карточку в корзину.
|
||||
/// </summary>
|
||||
public const string CardTrashed = "card_trashed";
|
||||
|
||||
/// <summary>
|
||||
/// Пользователь вернул карточку из корзины/архива.
|
||||
/// </summary>
|
||||
public const string CardRestored = "card_restored";
|
||||
|
||||
/// <summary>
|
||||
/// Пользователь удалил карточку навсегда.
|
||||
/// </summary>
|
||||
public const string CardDeleted = "card_deleted";
|
||||
|
||||
/// <summary>
|
||||
/// Пользователь добавил комментарий к карточке.
|
||||
/// </summary>
|
||||
public const string CardCommentAdded = "card_comment_added";
|
||||
|
||||
/// <summary>
|
||||
/// Пользователь переклассифицировал карточку(и) через ИИ/локальный разбор.
|
||||
/// </summary>
|
||||
public const string CardReclassified = "card_reclassified";
|
||||
|
||||
/// <summary>
|
||||
/// Пользователь создал контейнер/колонку.
|
||||
/// </summary>
|
||||
public const string ContainerCreated = "container_created";
|
||||
|
||||
/// <summary>
|
||||
/// Пользователь изменил контейнер/колонку.
|
||||
/// </summary>
|
||||
public const string ContainerUpdated = "container_updated";
|
||||
|
||||
/// <summary>
|
||||
/// Пользователь удалил контейнер/колонку.
|
||||
/// </summary>
|
||||
public const string ContainerDeleted = "container_deleted";
|
||||
|
||||
/// <summary>
|
||||
/// Пользователь сохранил настройки тенанта.
|
||||
/// </summary>
|
||||
public const string SettingsUpdated = "settings_updated";
|
||||
|
||||
/// <summary>
|
||||
/// Пользователь включил мониторинг канала Telegram.
|
||||
/// </summary>
|
||||
public const string ChannelEnabled = "channel_enabled";
|
||||
|
||||
/// <summary>
|
||||
/// Канал Telegram добавлен в каталог тенанта.
|
||||
/// </summary>
|
||||
public const string ChannelCreated = "channel_created";
|
||||
|
||||
/// <summary>
|
||||
/// Аккаунт Telegram привязан.
|
||||
/// </summary>
|
||||
public const string TelegramLinked = "telegram_linked";
|
||||
|
||||
/// <summary>
|
||||
/// Оператор изменил глобальные ключи Telegram api_id/api_hash.
|
||||
/// </summary>
|
||||
public const string TelegramKeysChanged = "telegram_keys_changed";
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Фильтр выборки аудита.
|
||||
/// </summary>
|
||||
/// <param name="EventType">Тип события (равенство; null — без фильтра).</param>
|
||||
/// <param name="ActorType">Тип актора (равенство; null — без фильтра).</param>
|
||||
/// <param name="TenantId">Тенант (равенство; null — без фильтра).</param>
|
||||
/// <param name="From">Нижняя граница At (включительно; null — без фильтра).</param>
|
||||
/// <param name="To">Верхняя граница At (включительно; null — без фильтра).</param>
|
||||
/// <param name="Limit">Размер выборки; адаптер клампит в 1..<see cref="AuditService.MaxQueryLimit"/>.</param>
|
||||
/// <param name="ActorId">Идентификатор актора (равенство; null — без фильтра).</param>
|
||||
/// <param name="Offset">Смещение страницы (≥0; в CountAsync не учитывается).</param>
|
||||
public sealed record AuditQueryDto(
|
||||
string? EventType,
|
||||
string? ActorType,
|
||||
Guid? TenantId,
|
||||
DateTimeOffset? From,
|
||||
DateTimeOffset? To,
|
||||
int Limit,
|
||||
Guid? ActorId = null,
|
||||
int Offset = 0);
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Запись аудита.
|
||||
/// </summary>
|
||||
/// <param name="EventType">Тип события — константа каталога <c>AuditEvents</c>.</param>
|
||||
/// <param name="ActorType">Тип актора — константа <c>AuditActorTypes</c> (operator|tenant|system).</param>
|
||||
/// <param name="ActorId">Идентификатор актора; null, если актор неизвестен (неудачный вход).</param>
|
||||
/// <param name="TenantId">Тенант события; null для операторских/системных событий.</param>
|
||||
/// <param name="Ip">IP-адрес клиента (без порта); null для серверных действий.</param>
|
||||
/// <param name="DetailJson">Детали события в JSON (camelCase); null, если деталей нет.</param>
|
||||
/// <param name="At">Время события (UTC; проставляет AuditService при записи).</param>
|
||||
/// <param name="Id">Identity-идентификатор строки (0 — ещё не сохранённая запись).</param>
|
||||
public sealed record AuditRecordDto(
|
||||
string EventType,
|
||||
string ActorType,
|
||||
Guid? ActorId,
|
||||
Guid? TenantId,
|
||||
string? Ip,
|
||||
string? DetailJson,
|
||||
DateTimeOffset At = default,
|
||||
long Id = 0);
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Состояние ИИ-бюджета тенанта на момент чтения.
|
||||
/// </summary>
|
||||
/// <param name="TenantId">Тенант, которому принадлежит лимит.</param>
|
||||
/// <param name="BudgetTokens">Бюджет текущего периода в токенах.</param>
|
||||
/// <param name="Period">Тип периода (константа <c>TenantLimitPeriods</c>: month|day).</param>
|
||||
/// <param name="PeriodStart">Начало текущего периода (после ленивого reset — момент reset'а).</param>
|
||||
/// <param name="UsedTokens">Использовано токенов с начала периода.</param>
|
||||
/// <param name="Status">Статус тенанта (константа <c>TenantStatuses</c>: active|suspended).</param>
|
||||
/// <param name="Allowed">True — ИИ-вызовы разрешены: тенант активен и бюджет не исчерпан.</param>
|
||||
/// <param name="Warned80">Флаг: тост о расходе 80% бюджета уже отправлен (один на период).</param>
|
||||
/// <param name="NotifiedExhausted">Флаг: тост об исчерпании бюджета уже отправлен (один на период).</param>
|
||||
public sealed record BudgetStateDto(
|
||||
Guid TenantId,
|
||||
long BudgetTokens,
|
||||
string Period,
|
||||
DateTimeOffset PeriodStart,
|
||||
long UsedTokens,
|
||||
string Status,
|
||||
bool Allowed,
|
||||
bool Warned80,
|
||||
bool NotifiedExhausted);
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Результат смены пароля.
|
||||
/// </summary>
|
||||
/// <param name="Ok">true при успехе; false при ошибке.</param>
|
||||
/// <param name="Error">Код ошибки (см. <see cref="ErrorOldPassword"/>, <see cref="ErrorTooShort"/>) или null при успехе.</param>
|
||||
/// <param name="NewToken">Raw-токен свежей сессии при успехе; иначе null.</param>
|
||||
public sealed record ChangePasswordResultDto(bool Ok, string? Error, string? NewToken)
|
||||
{
|
||||
/// <summary>
|
||||
/// Ошибка: текущий пароль неверен
|
||||
/// </summary>
|
||||
public const string ErrorOldPassword = "oldPassword";
|
||||
|
||||
/// <summary>
|
||||
/// Ошибка: новый пароль короче минимальной длины
|
||||
/// </summary>
|
||||
public const string ErrorTooShort = "tooShort";
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Результат impersonation.
|
||||
/// </summary>
|
||||
/// <param name="Ok">true — сессия impersonation создана.</param>
|
||||
/// <param name="Error">Код ошибки при Ok=false (см. константы); null при успехе.</param>
|
||||
/// <param name="SessionToken">Raw-токен tenant-сессии (значение куки deal_session); null при Ok=false.</param>
|
||||
/// <param name="ExpiresAt">Момент истечения сессии (30 дней от создания, как у обычного входа).</param>
|
||||
/// <param name="Login">Логин пользователя, под которым выполнен вход; null при Ok=false.</param>
|
||||
/// <param name="UserId">Идентификатор пользователя (для аудита); null при Ok=false.</param>
|
||||
/// <param name="TenantId">Тенант пользователя; null при Ok=false.</param>
|
||||
public sealed record ImpersonationResultDto(
|
||||
bool Ok,
|
||||
string? Error,
|
||||
string? SessionToken,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
string? Login,
|
||||
Guid? UserId,
|
||||
Guid? TenantId)
|
||||
{
|
||||
/// <summary>
|
||||
/// Код ошибки: тенант не найден
|
||||
/// </summary>
|
||||
public const string ErrorTenantNotFound = "tenant_not_found";
|
||||
|
||||
/// <summary>
|
||||
/// Код ошибки: в тенанте нет пользователей, а login не указан
|
||||
/// </summary>
|
||||
public const string ErrorTenantHasNoUsers = "tenant_has_no_users";
|
||||
|
||||
/// <summary>
|
||||
/// Код ошибки: пользователь с таким login не найден в тенанте
|
||||
/// </summary>
|
||||
public const string ErrorUserNotFound = "user_not_found";
|
||||
|
||||
/// <summary>
|
||||
/// Неуспешный результат без полезной нагрузки.
|
||||
/// </summary>
|
||||
/// <param name="error">Код ошибки (см. константы).</param>
|
||||
/// <returns>ImpersonationResultDto с Ok=false.</returns>
|
||||
public static ImpersonationResultDto Failed(string error) =>
|
||||
new(Ok: false, Error: error, SessionToken: null, ExpiresAt: null, Login: null, UserId: null, TenantId: null);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Результат создания приглашения оператором; тексты HTTP-ошибок фиксирует слой эндпоинтов.
|
||||
/// </summary>
|
||||
/// <param name="Ok">true — приглашение создано и сохранено (см. <paramref name="Invite"/>); false — ошибка.</param>
|
||||
/// <param name="Error">Код ошибки (<see cref="ErrorInvalidEmail"/> / <see cref="ErrorDuplicateActive"/>) или null при успехе.</param>
|
||||
/// <param name="Invite">Созданное приглашение при Ok=true; иначе null.</param>
|
||||
public sealed record InviteCreateResultDto(bool Ok, string? Error, InviteDto? Invite)
|
||||
{
|
||||
/// <summary>
|
||||
/// Ошибка: email пустой/пробельный или не прошёл проверку формата
|
||||
/// </summary>
|
||||
public const string ErrorInvalidEmail = "invalidEmail";
|
||||
|
||||
/// <summary>
|
||||
/// Ошибка: на email уже есть активное
|
||||
/// </summary>
|
||||
public const string ErrorDuplicateActive = "duplicateActive";
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Приглашение.
|
||||
/// </summary>
|
||||
/// <param name="Code">Одноразовый код приглашения (url-safe, 16 симв.) — первичный ключ.</param>
|
||||
/// <param name="Email">Email приглашённого, нормализованный (нижний регистр).</param>
|
||||
/// <param name="TenantId">Целевой тенант; null — при активации создаётся новый тенант.</param>
|
||||
/// <param name="Status">Статус: pending/activated/revoked/expired.</param>
|
||||
/// <param name="ExpiresAt">Момент истечения (UTC; создание + 72 часа).</param>
|
||||
/// <param name="ActivatedAt">Момент активации; null, пока приглашение не активировано.</param>
|
||||
/// <param name="CreatedById">Идентификатор оператора, создавшего приглашение.</param>
|
||||
/// <param name="CreatedAt">Момент создания (UTC).</param>
|
||||
public sealed record InviteDto(
|
||||
string Code,
|
||||
string Email,
|
||||
Guid? TenantId,
|
||||
string Status,
|
||||
DateTimeOffset ExpiresAt,
|
||||
DateTimeOffset? ActivatedAt,
|
||||
Guid CreatedById,
|
||||
DateTimeOffset CreatedAt);
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Результат отзыва приглашения оператором; тексты HTTP-ошибок фиксирует слой эндпоинтов.
|
||||
/// </summary>
|
||||
/// <param name="Ok">true — приглашение было pending и переведено в revoked.</param>
|
||||
/// <param name="Error">Код ошибки (<see cref="ErrorNotFound"/> / <see cref="ErrorNotPending"/>) или null при успехе.</param>
|
||||
/// <param name="Invite">Состояние приглашения: при Ok — отозванное (для аудита); при ErrorNotPending — фактическое (уже отозвано/использовано/истекло); при ErrorNotFound — null.</param>
|
||||
public sealed record InviteRevokeResultDto(bool Ok, string? Error, InviteDto? Invite)
|
||||
{
|
||||
/// <summary>
|
||||
/// Ошибка: приглашение с таким кодом не найдено.
|
||||
/// </summary>
|
||||
public const string ErrorNotFound = "notFound";
|
||||
|
||||
/// <summary>
|
||||
/// Ошибка: приглашение не в статусе pending — отозвать нельзя.
|
||||
/// </summary>
|
||||
public const string ErrorNotPending = "notPending";
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Статусы приглашения.
|
||||
/// </summary>
|
||||
public static class InviteStatuses
|
||||
{
|
||||
/// <summary>
|
||||
/// Ожидает активации — единственный статус, при котором /api/join может активировать приглашение.
|
||||
/// </summary>
|
||||
public const string Pending = "pending";
|
||||
|
||||
/// <summary>
|
||||
/// Активировано через /api/join
|
||||
/// </summary>
|
||||
public const string Activated = "activated";
|
||||
|
||||
/// <summary>
|
||||
/// Отозвано оператором; email освобождается для нового приглашения
|
||||
/// </summary>
|
||||
public const string Revoked = "revoked";
|
||||
|
||||
/// <summary>
|
||||
/// Срок действия истёк; проставляется лениво при чтении/проверке
|
||||
/// </summary>
|
||||
public const string Expired = "expired";
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Результат активации приглашения через /api/join; тексты HTTP-ошибок фиксирует слой эндпоинтов.
|
||||
/// </summary>
|
||||
/// <param name="Ok">true — пользователь создан, тенант (при необходимости) создан и приглашение активировано.</param>
|
||||
/// <param name="Error">Код ошибки (<see cref="ErrorNotFound"/>/<see cref="ErrorExpired"/>/<see cref="ErrorUsed"/>/ <see cref="ErrorRevoked"/>/<see cref="ErrorEmailMismatch"/>/<see cref="ErrorEmailTaken"/>/ <see cref="ErrorPasswordTooShort"/>) или null при успехе.</param>
|
||||
/// <param name="Login">Нормализованный email (логин пользователя) при Ok=true; иначе null.</param>
|
||||
/// <param name="UserId">Идентификатор созданного пользователя при Ok=true (для аудита invite_activated); иначе null.</param>
|
||||
/// <param name="TenantId">Тенант пользователя при Ok=true (существующий из инвайта или созданный); иначе null.</param>
|
||||
public sealed record JoinResultDto(bool Ok, string? Error, string? Login, Guid? UserId, Guid? TenantId)
|
||||
{
|
||||
/// <summary>
|
||||
/// Ошибка: приглашение с таким кодом не найдено
|
||||
/// </summary>
|
||||
public const string ErrorNotFound = "notFound";
|
||||
|
||||
/// <summary>
|
||||
/// Ошибка: срок действия приглашения истёк
|
||||
/// </summary>
|
||||
public const string ErrorExpired = "expired";
|
||||
|
||||
/// <summary>
|
||||
/// Ошибка: приглашение уже активировано
|
||||
/// </summary>
|
||||
public const string ErrorUsed = "used";
|
||||
|
||||
/// <summary>
|
||||
/// Ошибка: приглашение отозвано оператором.
|
||||
/// </summary>
|
||||
public const string ErrorRevoked = "revoked";
|
||||
|
||||
/// <summary>
|
||||
/// Ошибка: email запроса не совпадает с email приглашения.
|
||||
/// </summary>
|
||||
public const string ErrorEmailMismatch = "emailMismatch";
|
||||
|
||||
/// <summary>
|
||||
/// Ошибка: пользователь с таким email
|
||||
/// </summary>
|
||||
public const string ErrorEmailTaken = "emailTaken";
|
||||
|
||||
/// <summary>
|
||||
/// Ошибка: пароль пустой или короче <see cref="AuthService.MinNewPasswordLength"/> символов.
|
||||
/// </summary>
|
||||
public const string ErrorPasswordTooShort = "passwordTooShort";
|
||||
|
||||
/// <summary>
|
||||
/// Ошибка: целевой тенант инвайта не существует
|
||||
/// </summary>
|
||||
public const string ErrorTenantNotFound = "tenantNotFound";
|
||||
|
||||
/// <summary>
|
||||
/// Ошибка: целевой тенант инвайта приостановлен
|
||||
/// </summary>
|
||||
public const string ErrorTenantSuspended = "tenantSuspended";
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Результат логина
|
||||
/// </summary>
|
||||
/// <param name="Login">Логин пользователя (в нижнем регистре) при успехе; иначе null.</param>
|
||||
/// <param name="Token">Raw-токен сессии при успехе; иначе null.</param>
|
||||
/// <param name="UserId">Идентификатор пользователя при успехе или при заблокированном входе (для аудита); иначе null.</param>
|
||||
/// <param name="TenantId">Тенант пользователя при успехе или при заблокированном входе (для аудита); иначе null.</param>
|
||||
/// <param name="Error">Код ошибки (см. константы типа); null при успехе.</param>
|
||||
public sealed record LoginResultDto(
|
||||
string? Login,
|
||||
string? Token,
|
||||
Guid? UserId = null,
|
||||
Guid? TenantId = null,
|
||||
string? Error = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Код ошибки: тенант пользователя приостановлен — вход заблокирован
|
||||
/// </summary>
|
||||
public const string ErrorTenantSuspended = "tenant_suspended";
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Сведения о завершённой сессии impersonation — возвращает AuthService.LogoutAsync.
|
||||
/// </summary>
|
||||
/// <param name="OperatorId">Идентификатор оператора, создавшего сессию (актор события stopped).</param>
|
||||
/// <param name="Login">Логин пользователя тенанта (денормализован в сессию).</param>
|
||||
/// <param name="UserId">Идентификатор пользователя тенанта.</param>
|
||||
/// <param name="TenantId">Тенант пользователя (для фильтра TenantId в аудите).</param>
|
||||
public sealed record LogoutResultDto(
|
||||
Guid OperatorId,
|
||||
string Login,
|
||||
Guid UserId,
|
||||
Guid TenantId);
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Идентичность оператора — результат разрешения операторской сессии
|
||||
/// </summary>
|
||||
/// <param name="Id">Идентификатор оператора.</param>
|
||||
/// <param name="Login">Логин в нижнем регистре.</param>
|
||||
/// <param name="Status">Статус учётной записи.</param>
|
||||
public sealed record OperatorIdentityDto(
|
||||
Guid Id,
|
||||
string Login,
|
||||
string Status);
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Результат логина оператора
|
||||
/// </summary>
|
||||
/// <param name="Login">Логин оператора (в нижнем регистре) при успехе; иначе null.</param>
|
||||
/// <param name="Token">Raw-токен сессии при успехе; иначе null.</param>
|
||||
/// <param name="OperatorId">Идентификатор оператора при успехе (для аудита operator_login_ok); иначе null.</param>
|
||||
public sealed record OperatorLoginResultDto(string? Login, string? Token, Guid? OperatorId = null);
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Сессия оператора как её видит хранилище
|
||||
/// </summary>
|
||||
/// <param name="TokenHash">SHA-256-хэш raw-токена (первичный ключ таблицы operator_sessions).</param>
|
||||
/// <param name="OperatorId">Идентификатор оператора.</param>
|
||||
/// <param name="Login">Денормализованный логин (для чтения без join).</param>
|
||||
/// <param name="ExpiresAt">Момент истечения сессии (12 часов от создания).</param>
|
||||
public sealed record OperatorSessionDto(
|
||||
string TokenHash,
|
||||
Guid OperatorId,
|
||||
string Login,
|
||||
DateTimeOffset ExpiresAt);
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Сессия пользователя как её видит хранилище
|
||||
/// </summary>
|
||||
/// <param name="TokenHash">SHA-256-хэш raw-токена (первичный ключ таблицы sessions).</param>
|
||||
/// <param name="UserId">Идентификатор пользователя.</param>
|
||||
/// <param name="Login">Денормализованный логин (для чтения без join).</param>
|
||||
/// <param name="ExpiresAt">Момент истечения сессии (30 дней от создания).</param>
|
||||
/// <param name="ImpersonatedByOperatorId">Идентификатор оператора, создавшего сессию impersonation; null — обычная сессия пользователя. Маркер нужен для аудита impersonation_stopped при logout.</param>
|
||||
public sealed record SessionDto(
|
||||
string TokenHash,
|
||||
Guid UserId,
|
||||
string Login,
|
||||
DateTimeOffset ExpiresAt,
|
||||
Guid? ImpersonatedByOperatorId = null);
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Оператор SaaS-контура из хранилища вместе с хэшем пароля
|
||||
/// </summary>
|
||||
/// <param name="Id">Идентификатор оператора.</param>
|
||||
/// <param name="Login">Логин в нижнем регистре (уникален).</param>
|
||||
/// <param name="Status">Статус учётной записи ("active" и т.п.).</param>
|
||||
/// <param name="PasswordHash">Encoded-строка хэша пароля (Argon2id).</param>
|
||||
public sealed record StoredOperatorDto(
|
||||
Guid Id,
|
||||
string Login,
|
||||
string Status,
|
||||
string PasswordHash);
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Пользователь из хранилища вместе с хэшем пароля
|
||||
/// </summary>
|
||||
/// <param name="Id">Идентификатор пользователя.</param>
|
||||
/// <param name="Login">Логин в нижнем регистре.</param>
|
||||
/// <param name="TenantId">Идентификатор тенанта, которому принадлежит пользователь.</param>
|
||||
/// <param name="Status">Статус учётной записи ("active" и т.п.).</param>
|
||||
/// <param name="PasswordHash">Encoded-строка хэша пароля.</param>
|
||||
public sealed record StoredUserDto(
|
||||
Guid Id,
|
||||
string Login,
|
||||
Guid TenantId,
|
||||
string Status,
|
||||
string PasswordHash);
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Сводка детектора подозрительной активности — тело ответа GET /api/operator/analytics/suspicious.
|
||||
/// </summary>
|
||||
public sealed record SuspiciousActivityDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Начало окна анализа
|
||||
/// </summary>
|
||||
public DateTimeOffset From { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Конец окна анализа
|
||||
/// </summary>
|
||||
public DateTimeOffset To { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Сколько записей аудита разобрано в окне.
|
||||
/// </summary>
|
||||
public int Scanned { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// True — в окне было больше записей, чем предел выборки
|
||||
/// </summary>
|
||||
public bool Truncated { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Найденные подозрительные паттерны
|
||||
/// </summary>
|
||||
public IReadOnlyList<SuspiciousFindingDto> Items { get; init; } = Array.Empty<SuspiciousFindingDto>();
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Одна находка детектора подозрительной активности
|
||||
/// </summary>
|
||||
/// <param name="Kind">Правило, сработавшее на логах безопасности (константы <c>SuspiciousActivityService</c>).</param>
|
||||
/// <param name="Severity">Уровень: high | medium (константы сервиса).</param>
|
||||
/// <param name="Subject">Субъект находки: IP, логин, id актора или id тенанта.</param>
|
||||
/// <param name="Count">Число событий, приведших к срабатыванию правила.</param>
|
||||
/// <param name="Detail">Человекочитаемое пояснение для оператора.</param>
|
||||
public sealed record SuspiciousFindingDto(
|
||||
string Kind,
|
||||
string Severity,
|
||||
string Subject,
|
||||
int Count,
|
||||
string Detail);
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Результат операторского создания тенанта.
|
||||
/// </summary>
|
||||
/// <param name="Ok">true — тенант создан (и, при email, пользователь-владелец).</param>
|
||||
/// <param name="Error">Код ошибки при Ok=false (см. константы); null при успехе.</param>
|
||||
/// <param name="Tenant">Созданный тенант (Status active); null при Ok=false.</param>
|
||||
/// <param name="OwnerUserId">Идентификатор созданного пользователя-владельца (email задан); null — не создавался.</param>
|
||||
/// <param name="OwnerLogin">Логин владельца (нормализованный email); null — не создавался.</param>
|
||||
/// <param name="InitialPassword">Одноразовый пароль владельца (показывается один раз); null — email не задан.</param>
|
||||
public sealed record TenantCreateResultDto(
|
||||
bool Ok,
|
||||
string? Error,
|
||||
TenantRecordDto? Tenant,
|
||||
Guid? OwnerUserId = null,
|
||||
string? OwnerLogin = null,
|
||||
string? InitialPassword = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Код ошибки: имя тенанта пустое/пробельное
|
||||
/// </summary>
|
||||
public const string ErrorNameRequired = "name_required";
|
||||
|
||||
/// <summary>
|
||||
/// Код ошибки: email имеет некорректный формат
|
||||
/// </summary>
|
||||
public const string ErrorInvalidEmail = "invalid_email";
|
||||
|
||||
/// <summary>
|
||||
/// Код ошибки: пользователь с таким email уже зарегистрирован.
|
||||
/// </summary>
|
||||
public const string ErrorEmailTaken = "email_taken";
|
||||
|
||||
/// <summary>
|
||||
/// Неуспешный результат без полезной нагрузки.
|
||||
/// </summary>
|
||||
/// <param name="error">Код ошибки (см. константы).</param>
|
||||
/// <returns>TenantCreateResultDto с Ok=false.</returns>
|
||||
public static TenantCreateResultDto Failed(string error) =>
|
||||
new(Ok: false, Error: error, Tenant: null);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Детали тенанта для оператора
|
||||
/// </summary>
|
||||
/// <param name="Id">Идентификатор тенанта.</param>
|
||||
/// <param name="Name">Человекочитаемое имя тенанта.</param>
|
||||
/// <param name="Status">Статус тенанта (константа <c>TenantStatuses</c>).</param>
|
||||
/// <param name="CreatedAt">Момент создания записи.</param>
|
||||
/// <param name="Users">Пользователи тенанта (по времени создания; без хэшей паролей).</param>
|
||||
public sealed record TenantDetailDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string Status,
|
||||
DateTimeOffset CreatedAt,
|
||||
IReadOnlyList<UserIdentityDto> Users);
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Строка лимита ИИ-бюджета тенанта.
|
||||
/// </summary>
|
||||
/// <param name="TenantId">Тенант, которому принадлежит лимит (первичный ключ).</param>
|
||||
/// <param name="BudgetTokens">Бюджет текущего периода в токенах.</param>
|
||||
/// <param name="Period">Тип периода (константа <c>TenantLimitPeriods</c>: month|day).</param>
|
||||
/// <param name="PeriodStart">Начало текущего периода (отсчёт окна для ленивого reset).</param>
|
||||
/// <param name="UsedTokens">Использовано токенов с начала периода.</param>
|
||||
/// <param name="Warned80">Флаг: тост о расходе 80% бюджета уже отправлен.</param>
|
||||
/// <param name="NotifiedExhausted">Флаг: тост об исчерпании бюджета уже отправлен.</param>
|
||||
public sealed record TenantLimitDto(
|
||||
Guid TenantId,
|
||||
long BudgetTokens,
|
||||
string Period,
|
||||
DateTimeOffset PeriodStart,
|
||||
long UsedTokens,
|
||||
bool Warned80,
|
||||
bool NotifiedExhausted);
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Типы периода бюджета тенанта.
|
||||
/// </summary>
|
||||
public static class TenantLimitPeriods
|
||||
{
|
||||
/// <summary>
|
||||
/// Месячный период
|
||||
/// </summary>
|
||||
public const string Month = "month";
|
||||
|
||||
/// <summary>
|
||||
/// Суточный период
|
||||
/// </summary>
|
||||
public const string Day = "day";
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Строка списка тенантов для оператора
|
||||
/// </summary>
|
||||
/// <param name="Id">Идентификатор тенанта.</param>
|
||||
/// <param name="Name">Человекочитаемое имя тенанта.</param>
|
||||
/// <param name="Status">Статус тенанта (константа <c>TenantStatuses</c>).</param>
|
||||
/// <param name="CreatedAt">Момент создания записи.</param>
|
||||
/// <param name="UsersCount">Число пользователей тенанта (public.users, счётчик для оператора).</param>
|
||||
public sealed record TenantListItemDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string Status,
|
||||
DateTimeOffset CreatedAt,
|
||||
int UsersCount);
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Запись реестра тенантов
|
||||
/// </summary>
|
||||
/// <param name="Id">Идентификатор тенанта (Guid).</param>
|
||||
/// <param name="Name">Человекочитаемое имя тенанта.</param>
|
||||
/// <param name="Status">Статус тенанта ("active").</param>
|
||||
/// <param name="CreatedAt">Момент создания записи.</param>
|
||||
public sealed record TenantRecordDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string Status,
|
||||
DateTimeOffset CreatedAt);
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Результат смены статуса тенанта оператором.
|
||||
/// </summary>
|
||||
/// <param name="Ok">true — тенант найден и действие применимо (возможно, без изменения — см. <see cref="Changed"/>).</param>
|
||||
/// <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>
|
||||
public sealed record TenantStatusChangeResultDto(
|
||||
bool Ok,
|
||||
string? Error,
|
||||
bool Changed,
|
||||
TenantRecordDto? Tenant)
|
||||
{
|
||||
/// <summary>
|
||||
/// Код ошибки: тенант не найден
|
||||
/// </summary>
|
||||
public const string ErrorNotFound = "tenant_not_found";
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Статусы тенанта.
|
||||
/// </summary>
|
||||
public static class TenantStatuses
|
||||
{
|
||||
/// <summary>
|
||||
/// Тенант активен: вход пользователей разрешён, лимиты списываются
|
||||
/// </summary>
|
||||
public const string Active = "active";
|
||||
|
||||
/// <summary>
|
||||
/// Тенант приостановлен оператором
|
||||
/// </summary>
|
||||
public const string Suspended = "suspended";
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Дефолт-бюджет нового тенанта.
|
||||
/// </summary>
|
||||
public static class TokenBudgetDefaults
|
||||
{
|
||||
/// <summary>
|
||||
/// Дефолт бюджета, токенов в месяц.
|
||||
/// </summary>
|
||||
public const long DefaultBudgetTokens = 10_000_000;
|
||||
|
||||
/// <summary>
|
||||
/// Дефолт периода нового тенанта.
|
||||
/// </summary>
|
||||
public const string DefaultPeriod = TenantLimitPeriods.Month;
|
||||
|
||||
/// <summary>
|
||||
/// Готовый набор дефолтов
|
||||
/// </summary>
|
||||
public static TokenLimitDefaults Default => new(DefaultBudgetTokens, DefaultPeriod);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Дефолт-параметры новой строки tenant_limits.
|
||||
/// </summary>
|
||||
/// <param name="BudgetTokens">Стартовый бюджет периода в токенах (лениво создаваемая строка).</param>
|
||||
/// <param name="Period">Тип периода (константа <c>TenantLimitPeriods</c>: month|day).</param>
|
||||
public sealed record TokenLimitDefaults(long BudgetTokens, string Period);
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Строка агрегата расхода токенов.
|
||||
/// </summary>
|
||||
/// <param name="Key">Ключ группы: ГГГГ-ММ-ДД (day), Guid (tenant), id провайдера, модель.</param>
|
||||
/// <param name="PromptTokens">Сумма токенов запроса в группе.</param>
|
||||
/// <param name="CompletionTokens">Сумма токенов ответа в группе.</param>
|
||||
/// <param name="TotalTokens">Сумма total токенов в группе.</param>
|
||||
/// <param name="EventCount">Число событий в группе.</param>
|
||||
public sealed record TokenUsageAggregateDto(
|
||||
string Key,
|
||||
long PromptTokens,
|
||||
long CompletionTokens,
|
||||
long TotalTokens,
|
||||
long EventCount);
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Событие расхода токенов — time-series для аналитики.
|
||||
/// </summary>
|
||||
/// <param name="TenantId">Тенант события (Guid строки public.tenants).</param>
|
||||
/// <param name="At">Время события (UTC; проставляет сервис).</param>
|
||||
/// <param name="Provider">Провайдер/источник (deepseek/openai/anthropic/local/ml).</param>
|
||||
/// <param name="Model">Модель провайдера (напр. модель aiConfigs либо "ml" для локальной ML-модели).</param>
|
||||
/// <param name="Kind">Вид вызова — константа <see cref="TokenUsageEventKinds"/> (ai|ml).</param>
|
||||
/// <param name="PromptTokens">Токены запроса (system+user).</param>
|
||||
/// <param name="CompletionTokens">Токены ответа модели.</param>
|
||||
/// <param name="TotalTokens">Суммарно (может отличаться от суммы при подсчёте провайдером — берём как есть).</param>
|
||||
/// <param name="DetailJson">Детали события в JSON (camelCase) без секретов; null — деталей нет.</param>
|
||||
public sealed record TokenUsageEventDto(
|
||||
Guid TenantId,
|
||||
DateTimeOffset At,
|
||||
string Provider,
|
||||
string Model,
|
||||
string Kind,
|
||||
long PromptTokens,
|
||||
long CompletionTokens,
|
||||
long TotalTokens,
|
||||
string? DetailJson);
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Виды вызова расхода токенов.
|
||||
/// </summary>
|
||||
public static class TokenUsageEventKinds
|
||||
{
|
||||
/// <summary>
|
||||
/// Платный вызов LLM через ai-service
|
||||
/// </summary>
|
||||
public const string Ai = "ai";
|
||||
|
||||
/// <summary>
|
||||
/// Локальный вызов ml-service/ML
|
||||
/// </summary>
|
||||
public const string Ml = "ml";
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Фильтр агрегации расхода токенов.
|
||||
/// </summary>
|
||||
/// <param name="TenantId">Тенант (равенство; null — все тенанты).</param>
|
||||
/// <param name="Provider">Провайдер (равенство; null — без фильтра).</param>
|
||||
/// <param name="Model">Модель (равенство; null — без фильтра).</param>
|
||||
/// <param name="Kind">Вид вызова ai|ml (равенство; null — без фильтра).</param>
|
||||
/// <param name="From">Нижняя граница At (включительно; null — без фильтра).</param>
|
||||
/// <param name="To">Верхняя граница At (включительно; null — без фильтра).</param>
|
||||
/// <param name="GroupBy">Способ группировки — константа <see cref="TokenUsageGroupBys"/>.</param>
|
||||
public sealed record TokenUsageEventQueryDto(
|
||||
Guid? TenantId,
|
||||
string? Provider,
|
||||
string? Model,
|
||||
string? Kind,
|
||||
DateTimeOffset? From,
|
||||
DateTimeOffset? To,
|
||||
string GroupBy);
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Способы группировки агрегатов расхода токенов.
|
||||
/// </summary>
|
||||
public static class TokenUsageGroupBys
|
||||
{
|
||||
/// <summary>
|
||||
/// Серия по суткам UTC
|
||||
/// </summary>
|
||||
public const string Day = "day";
|
||||
|
||||
/// <summary>
|
||||
/// Агрегат по тенантам
|
||||
/// </summary>
|
||||
public const string Tenant = "tenant";
|
||||
|
||||
/// <summary>
|
||||
/// Агрегат по провайдерам
|
||||
/// </summary>
|
||||
public const string Provider = "provider";
|
||||
|
||||
/// <summary>
|
||||
/// Агрегат по моделям
|
||||
/// </summary>
|
||||
public const string Model = "model";
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Источники расхода токенов.
|
||||
/// </summary>
|
||||
public static class TokenUsageSources
|
||||
{
|
||||
/// <summary>
|
||||
/// Локальный источник
|
||||
/// </summary>
|
||||
public const string Local = "local";
|
||||
|
||||
/// <summary>
|
||||
/// Модель локального ML-вызова
|
||||
/// </summary>
|
||||
public const string Ml = "ml";
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Идентичность пользователя — ответ разрешения сессии
|
||||
/// </summary>
|
||||
/// <param name="Id">Идентификатор пользователя.</param>
|
||||
/// <param name="Login">Логин в нижнем регистре.</param>
|
||||
/// <param name="TenantId">Идентификатор тенанта, которому принадлежит пользователь.</param>
|
||||
/// <param name="Status">Статус учётной записи.</param>
|
||||
public sealed record UserIdentityDto(
|
||||
Guid Id,
|
||||
string Login,
|
||||
Guid TenantId,
|
||||
string Status);
|
||||
@@ -0,0 +1,31 @@
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Registrars;
|
||||
|
||||
/// <summary>
|
||||
/// DI-регистрация модуля Tenants.
|
||||
/// </summary>
|
||||
public static class TenantModuleRegistrar
|
||||
{
|
||||
/// <summary>
|
||||
/// Регистрирует сервисы модуля Tenants в контейнере.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddTenantsModule(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IPasswordHasher, DefaultPasswordHasher>();
|
||||
services.AddScoped<AuthService>();
|
||||
services.AddScoped<TenantService>();
|
||||
services.AddScoped<OperatorAuthService>();
|
||||
services.AddScoped<OperatorBootstrapService>();
|
||||
services.AddScoped<AuditService>();
|
||||
services.AddScoped<TokenUsageEventService>();
|
||||
services.AddScoped<AnalyticsService>();
|
||||
services.AddScoped<SuspiciousActivityService>();
|
||||
services.AddScoped<InvitesService>();
|
||||
services.AddScoped<JoinService>();
|
||||
services.AddScoped<TenantAdminService>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Прикладной сервис аутентификации
|
||||
/// </summary>
|
||||
public sealed class AuthService(
|
||||
IAuthStore authStore,
|
||||
IPasswordHasher passwordHasher,
|
||||
ITenantRepository tenantRepository)
|
||||
{
|
||||
/// <summary>
|
||||
/// Срок жизни сессии, дней.
|
||||
/// </summary>
|
||||
public const int SessionLifetimeDays = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Минимальная длина нового пароля.
|
||||
/// </summary>
|
||||
public const int MinNewPasswordLength = 8;
|
||||
|
||||
// Статус «активен»: только активного пользователя разрешает ResolveSessionAsync (деактивированный
|
||||
// вручную с живой сессией — null, как у оператора OperatorAuthService).
|
||||
private const string UserActiveStatus = "active";
|
||||
|
||||
/// <summary>
|
||||
/// Вход: при успехе создаёт сессию и возвращает её raw-токен.
|
||||
/// </summary>
|
||||
/// <param name="login">Логин (регистр и пробелы не важны — нормализуется).</param>
|
||||
/// <param name="password">Пароль в открытом виде.</param>
|
||||
/// <returns>При успехе — Login и Token (UserId/TenantId для аудита). Иначе Login/Token null: Error может отличать заблокированный вход приостановленного тенанта (<see cref="LoginResultDto.ErrorTenantSuspended"/>, UserId/TenantId заполнены) от «неверные учётные данные» (Error null, UserId/TenantId пусты).</returns>
|
||||
public async Task<LoginResultDto> LoginAsync(
|
||||
string login,
|
||||
string password,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string normalizedLogin = NormalizeLogin(login);
|
||||
if (string.IsNullOrEmpty(normalizedLogin) || string.IsNullOrEmpty(password))
|
||||
{
|
||||
return new LoginResultDto(null, null);
|
||||
}
|
||||
|
||||
var user = await authStore.FindUserByLoginAsync(normalizedLogin, ct);
|
||||
// Порядок проверок: сначала учётные данные, затем статус тенанта — неверный пароль не раскрывает
|
||||
// приостановку (тот же «Неверный логин или пароль», что и для активного тенанта).
|
||||
if (user is null || !passwordHasher.Verify(password, user.PasswordHash))
|
||||
{
|
||||
return new LoginResultDto(null, null);
|
||||
}
|
||||
|
||||
var tenant = await tenantRepository.FindByIdAsync(user.TenantId, ct);
|
||||
if (tenant is not null && tenant.Status == TenantStatuses.Suspended)
|
||||
{
|
||||
return new LoginResultDto(
|
||||
Login: null,
|
||||
Token: null,
|
||||
UserId: user.Id,
|
||||
TenantId: user.TenantId,
|
||||
Error: LoginResultDto.ErrorTenantSuspended);
|
||||
}
|
||||
|
||||
string token = await CreateSessionForUserAsync(user.Id, user.Login, impersonatedByOperatorId: null, ct);
|
||||
return new LoginResultDto(user.Login, token, user.Id, user.TenantId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Выход: удаляет сессию по raw-токену
|
||||
/// </summary>
|
||||
/// <param name="rawToken">Raw-токен из куки (может отсутствовать — no-op).</param>
|
||||
/// <returns>Не null, если удалённая сессия была impersonation — сведения для аудита impersonation_stopped (актор — оператор по маркеру сессии). Обычный logout и no-op возвращают null.</returns>
|
||||
public async Task<LogoutResultDto?> LogoutAsync(string? rawToken, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawToken))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string tokenHash = SessionTokens.HashToken(rawToken);
|
||||
var session = await authStore.FindSessionByTokenHashAsync(tokenHash, ct);
|
||||
await authStore.DeleteSessionAsync(tokenHash, ct);
|
||||
|
||||
if (session is null || session.ImpersonatedByOperatorId is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Пользователь не удаляется при живой сессии (FK sessions→users Cascade, SessionConfiguration):
|
||||
// TenantId берём из реестра пользователей для поля-фильтра аудита.
|
||||
var user = await authStore.FindUserByIdAsync(session.UserId, ct);
|
||||
return user is null
|
||||
? null
|
||||
: new LogoutResultDto(
|
||||
OperatorId: session.ImpersonatedByOperatorId.Value,
|
||||
Login: session.Login,
|
||||
UserId: user.Id,
|
||||
TenantId: user.TenantId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Смена пароля: инвалидирует все сессии, обновляет хэш и выдаёт свежую сессию.
|
||||
/// </summary>
|
||||
/// <param name="login">Логин пользователя.</param>
|
||||
/// <param name="oldPassword">Текущий пароль.</param>
|
||||
/// <param name="NewPassword">Новый пароль (минимум 8 символов).</param>
|
||||
/// <returns>При успехе — Ok=true и NewToken (raw-токен свежей сессии). Иначе Ok=false и код ошибки: <see cref="ChangePasswordResultDto.ErrorOldPassword"/> или <see cref="ChangePasswordResultDto.ErrorTooShort"/>.</returns>
|
||||
public async Task<ChangePasswordResultDto> ChangePasswordAsync(
|
||||
string login,
|
||||
string oldPassword,
|
||||
string newPassword,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string normalizedLogin = NormalizeLogin(login);
|
||||
var user = await authStore.FindUserByLoginAsync(normalizedLogin, ct);
|
||||
if (user is null || oldPassword is null || !passwordHasher.Verify(oldPassword, user.PasswordHash))
|
||||
{
|
||||
return new ChangePasswordResultDto(Ok: false, Error: ChangePasswordResultDto.ErrorOldPassword, NewToken: null);
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(newPassword) || newPassword.Length < MinNewPasswordLength)
|
||||
{
|
||||
return new ChangePasswordResultDto(Ok: false, Error: ChangePasswordResultDto.ErrorTooShort, NewToken: null);
|
||||
}
|
||||
|
||||
await authStore.DeleteSessionsByUserIdAsync(user.Id, ct);
|
||||
await authStore.UpdatePasswordHashAsync(user.Id, passwordHasher.Hash(newPassword), ct);
|
||||
string token = await CreateSessionForUserAsync(user.Id, user.Login, impersonatedByOperatorId: null, ct);
|
||||
return new ChangePasswordResultDto(Ok: true, Error: null, NewToken: token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Impersonation: tenant-сессия целевого пользователя от имени оператора.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Идентификатор тенанта.</param>
|
||||
/// <param name="targetLogin">Логин пользователя (null/пустой — первый пользователь тенанта).</param>
|
||||
/// <param name="operatorId">Идентификатор оператора, начинающего impersonation (маркер сессии).</param>
|
||||
/// <returns>Результат: Ok=true — SessionToken (raw-токен для куки deal_session) и срок жизни; иначе код ошибки.</returns>
|
||||
public async Task<ImpersonationResultDto> ImpersonateAsync(
|
||||
Guid tenantId,
|
||||
string? targetLogin,
|
||||
Guid operatorId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var tenant = await tenantRepository.FindByIdAsync(tenantId, ct);
|
||||
if (tenant is null)
|
||||
{
|
||||
return ImpersonationResultDto.Failed(ImpersonationResultDto.ErrorTenantNotFound);
|
||||
}
|
||||
|
||||
string normalizedLogin = NormalizeLogin(targetLogin);
|
||||
if (string.IsNullOrEmpty(normalizedLogin))
|
||||
{
|
||||
// login не задан — первый пользователь тенанта (по CreatedAt, порядок ListUsersByTenantIdAsync).
|
||||
var tenantUsers = await authStore.ListUsersByTenantIdAsync(tenantId, ct);
|
||||
if (tenantUsers.Count == 0)
|
||||
{
|
||||
return ImpersonationResultDto.Failed(ImpersonationResultDto.ErrorTenantHasNoUsers);
|
||||
}
|
||||
|
||||
UserIdentityDto firstUser = tenantUsers[0];
|
||||
string rawTokenForFirst = await CreateSessionForUserAsync(firstUser.Id, firstUser.Login, operatorId, ct);
|
||||
return Success(rawTokenForFirst, firstUser.Id, firstUser.Login, firstUser.TenantId);
|
||||
}
|
||||
|
||||
var user = await authStore.FindUserByLoginAsync(normalizedLogin, ct);
|
||||
if (user is null || user.TenantId != tenantId)
|
||||
{
|
||||
// Пользователь не найден или принадлежит другому тенанту — не раскрываем существование логина.
|
||||
return ImpersonationResultDto.Failed(ImpersonationResultDto.ErrorUserNotFound);
|
||||
}
|
||||
|
||||
string rawToken = await CreateSessionForUserAsync(user.Id, user.Login, operatorId, ct);
|
||||
return Success(rawToken, user.Id, user.Login, user.TenantId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Разрешение сессии по raw-токену
|
||||
/// </summary>
|
||||
/// <param name="rawToken">Raw-токен из куки.</param>
|
||||
/// <returns>Идентичность пользователя или null.</returns>
|
||||
public async Task<UserIdentityDto?> ResolveSessionAsync(string? rawToken, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawToken))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string tokenHash = SessionTokens.HashToken(rawToken);
|
||||
var session = await authStore.FindSessionByTokenHashAsync(tokenHash, ct);
|
||||
UserIdentityDto? user = null;
|
||||
if (session is not null && session.ExpiresAt > DateTimeOffset.UtcNow)
|
||||
{
|
||||
user = await authStore.FindUserByIdAsync(session.UserId, ct);
|
||||
// Деактивированный пользователь при живой сессии не получает доступ (Security review):
|
||||
// сессия разрешается только для активной учётки (как операторская в OperatorAuthService).
|
||||
if (user is not null && user.Status != UserActiveStatus)
|
||||
{
|
||||
user = null;
|
||||
}
|
||||
|
||||
if (user is not null)
|
||||
{
|
||||
var tenant = await tenantRepository.FindByIdAsync(user.TenantId, ct);
|
||||
if (tenant is not null && tenant.Status == TenantStatuses.Suspended)
|
||||
{
|
||||
user = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (session is not null)
|
||||
{
|
||||
// Сессия протухла: глобальная очистка протухших сессий выполняется ТОЛЬКО при обнаружении
|
||||
// протухшей (редкий случай), а не на каждом разрешении сессии (hot-path, Security review).
|
||||
await authStore.DeleteExpiredSessionsAsync(ct);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
// Создаёт сессию пользователя: raw-токен наружу, в хранилище — его SHA-256-хэш.
|
||||
// userId: Идентификатор пользователя.
|
||||
// login: Логин (денормализуется в сессию для чтения без join).
|
||||
// impersonatedByOperatorId: Маркер impersonation: оператор, создавший сессию; null — обычный вход.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Raw-токен для выдачи клиенту.
|
||||
private async Task<string> CreateSessionForUserAsync(
|
||||
Guid userId,
|
||||
string login,
|
||||
Guid? impersonatedByOperatorId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string rawToken = SessionTokens.NewToken();
|
||||
var session = new SessionDto(
|
||||
TokenHash: SessionTokens.HashToken(rawToken),
|
||||
UserId: userId,
|
||||
Login: login,
|
||||
ExpiresAt: DateTimeOffset.UtcNow.AddDays(SessionLifetimeDays),
|
||||
ImpersonatedByOperatorId: impersonatedByOperatorId);
|
||||
|
||||
await authStore.CreateSessionAsync(session, ct);
|
||||
return rawToken;
|
||||
}
|
||||
|
||||
// Успешный результат impersonation (токен + пользователь + тенант).
|
||||
// rawToken: Raw-токен созданной сессии.
|
||||
// userId: Идентификатор пользователя.
|
||||
// login: Логин пользователя.
|
||||
// tenantId: Тенант пользователя.
|
||||
// Возвращает: ImpersonationResultDto с Ok=true.
|
||||
private ImpersonationResultDto Success(
|
||||
string rawToken,
|
||||
Guid userId,
|
||||
string login,
|
||||
Guid tenantId) =>
|
||||
new(
|
||||
Ok: true,
|
||||
Error: null,
|
||||
SessionToken: rawToken,
|
||||
ExpiresAt: DateTimeOffset.UtcNow.AddDays(SessionLifetimeDays),
|
||||
Login: login,
|
||||
UserId: userId,
|
||||
TenantId: tenantId);
|
||||
|
||||
// Нормализация логина: нижний регистр и обрезка пробелов (единая форма хранения/сравнения).
|
||||
// login: Входной логин.
|
||||
// Возвращает: Нормализованный логин (пустая строка, если вход был пустым).
|
||||
private static string NormalizeLogin(string? login) => (login ?? string.Empty).ToLowerInvariant().Trim();
|
||||
}
|
||||
@@ -0,0 +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);
|
||||
}
|
||||
@@ -0,0 +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);
|
||||
}
|
||||
@@ -0,0 +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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Прикладной сервис аутентификации оператора
|
||||
/// </summary>
|
||||
public sealed class OperatorAuthService(IOperatorAuthStore operatorAuthStore, IPasswordHasher passwordHasher)
|
||||
{
|
||||
/// <summary>
|
||||
/// Срок жизни сессии оператора, часов.
|
||||
/// </summary>
|
||||
public const int SessionLifetimeHours = 12;
|
||||
|
||||
// Статус «активен»: только активного оператора разрешает ResolveSessionAsync (удалённый/приостановленный — null).
|
||||
private const string ActiveStatus = "active";
|
||||
|
||||
/// <summary>
|
||||
/// Вход оператора: при успехе создаёт сессию и возвращает её raw-токен.
|
||||
/// </summary>
|
||||
/// <param name="login">Логин (регистр и пробелы не важны — нормализуется).</param>
|
||||
/// <param name="password">Пароль в открытом виде.</param>
|
||||
/// <returns>При успехе — Login и Token; иначе оба null (текст «Неверный логин или пароль оператора» фиксирует endpoint).</returns>
|
||||
public async Task<OperatorLoginResultDto> LoginAsync(
|
||||
string login,
|
||||
string password,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string normalizedLogin = NormalizeLogin(login);
|
||||
if (string.IsNullOrEmpty(normalizedLogin) || string.IsNullOrEmpty(password))
|
||||
{
|
||||
return new OperatorLoginResultDto(null, null);
|
||||
}
|
||||
|
||||
var operatorRecord = await operatorAuthStore.FindByLoginAsync(normalizedLogin, ct);
|
||||
if (operatorRecord is null || !passwordHasher.Verify(password, operatorRecord.PasswordHash))
|
||||
{
|
||||
return new OperatorLoginResultDto(null, null);
|
||||
}
|
||||
|
||||
string token = await CreateSessionForOperatorAsync(operatorRecord, ct);
|
||||
return new OperatorLoginResultDto(operatorRecord.Login, token, operatorRecord.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Выход оператора
|
||||
/// </summary>
|
||||
/// <param name="rawToken">Raw-токен из куки (может отсутствовать — no-op).</param>
|
||||
public async Task LogoutAsync(string? rawToken, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawToken))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await operatorAuthStore.DeleteSessionAsync(SessionTokens.HashToken(rawToken), ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Разрешение операторской сессии по raw-токену
|
||||
/// </summary>
|
||||
/// <param name="rawToken">Raw-токен из куки deal_operator_session.</param>
|
||||
/// <returns>Идентичность активного оператора или null.</returns>
|
||||
public async Task<OperatorIdentityDto?> ResolveSessionAsync(string? rawToken, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawToken))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string tokenHash = SessionTokens.HashToken(rawToken);
|
||||
var session = await operatorAuthStore.FindSessionByTokenHashAsync(tokenHash, ct);
|
||||
OperatorIdentityDto? operatorIdentity = null;
|
||||
if (session is not null && session.ExpiresAt > DateTimeOffset.UtcNow)
|
||||
{
|
||||
var operatorRecord = await operatorAuthStore.FindByLoginAsync(session.Login, ct);
|
||||
if (operatorRecord is not null && operatorRecord.Status == ActiveStatus)
|
||||
{
|
||||
operatorIdentity = new OperatorIdentityDto(operatorRecord.Id, operatorRecord.Login, operatorRecord.Status);
|
||||
}
|
||||
}
|
||||
else if (session is not null)
|
||||
{
|
||||
// Очистка протухших сессий — только при обнаружении протухшей (редкий случай), не на каждый запрос
|
||||
// (горячий путь запросов).
|
||||
await operatorAuthStore.DeleteExpiredSessionsAsync(ct);
|
||||
}
|
||||
|
||||
return operatorIdentity;
|
||||
}
|
||||
|
||||
// Создаёт сессию оператора: raw-токен наружу, в хранилище — его SHA-256-хэш (формат как у SessionTokens).
|
||||
// operatorRecord: Оператор (логин денормализуется в сессию).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Raw-токен для выдачи клиенту.
|
||||
private async Task<string> CreateSessionForOperatorAsync(StoredOperatorDto operatorRecord, CancellationToken ct)
|
||||
{
|
||||
string rawToken = SessionTokens.NewToken();
|
||||
var session = new OperatorSessionDto(
|
||||
TokenHash: SessionTokens.HashToken(rawToken),
|
||||
OperatorId: operatorRecord.Id,
|
||||
Login: operatorRecord.Login,
|
||||
ExpiresAt: DateTimeOffset.UtcNow.AddHours(SessionLifetimeHours));
|
||||
|
||||
await operatorAuthStore.CreateSessionAsync(session, ct);
|
||||
return rawToken;
|
||||
}
|
||||
|
||||
// Нормализация логина: нижний регистр и обрезка пробелов (единая форма хранения/сравнения).
|
||||
// login: Входной логин.
|
||||
// Возвращает: Нормализованный логин (пустая строка, если вход был пустым).
|
||||
private static string NormalizeLogin(string? login) => (login ?? string.Empty).ToLowerInvariant().Trim();
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Bootstrap оператора при старте
|
||||
/// </summary>
|
||||
public sealed class OperatorBootstrapService(IOperatorAuthStore operatorAuthStore, IPasswordHasher passwordHasher)
|
||||
{
|
||||
/// <summary>
|
||||
/// Переменная окружения
|
||||
/// </summary>
|
||||
public const string LoginEnvKey = "DEAL_OPERATOR_LOGIN";
|
||||
|
||||
/// <summary>
|
||||
/// Переменная окружения
|
||||
/// </summary>
|
||||
public const string PasswordEnvKey = "DEAL_OPERATOR_PASSWORD";
|
||||
|
||||
/// <summary>
|
||||
/// Дефолтный логин в Development при отсутствии env-кред.
|
||||
/// </summary>
|
||||
public const string DefaultOperatorLogin = "operator";
|
||||
|
||||
/// <summary>
|
||||
/// Дефолтный пароль в Development при отсутствии env-кред.
|
||||
/// </summary>
|
||||
public const string DefaultOperatorPassword = "operator";
|
||||
|
||||
private const string ActiveStatus = "active";
|
||||
|
||||
/// <summary>
|
||||
/// Гарантирует наличие оператора
|
||||
/// </summary>
|
||||
/// <param name="login">Логин из env (<see cref="LoginEnvKey"/>) или null/пусто, если не задан.</param>
|
||||
/// <param name="password">Пароль из env (<see cref="PasswordEnvKey"/>) или null/пусто, если не задан.</param>
|
||||
/// <param name="allowDevelopmentDefaults">true в Development: при отсутствии кред берутся дефолты operator/operator; false (Production) при отсутствии кред — шаг пропускается, хост логирует warning.</param>
|
||||
/// <returns>Логин оператора, присутствующего после шага (созданного или уже существовавшего); null — шаг пропущен.</returns>
|
||||
public async Task<string?> EnsureOperatorAsync(
|
||||
string? login,
|
||||
string? password,
|
||||
bool allowDevelopmentDefaults,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string normalizedLogin = NormalizeLogin(login);
|
||||
string? resolvedPassword = password;
|
||||
if (string.IsNullOrEmpty(normalizedLogin) || string.IsNullOrEmpty(resolvedPassword))
|
||||
{
|
||||
if (!allowDevelopmentDefaults)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
normalizedLogin = DefaultOperatorLogin;
|
||||
resolvedPassword = DefaultOperatorPassword;
|
||||
}
|
||||
|
||||
// Идемпотентность: оператор с таким логином уже есть — пароль не перезаписываем.
|
||||
var existing = await operatorAuthStore.FindByLoginAsync(normalizedLogin, ct);
|
||||
if (existing is not null)
|
||||
{
|
||||
return existing.Login;
|
||||
}
|
||||
|
||||
await operatorAuthStore.CreateAsync(
|
||||
new StoredOperatorDto(
|
||||
Id: Guid.NewGuid(),
|
||||
Login: normalizedLogin,
|
||||
Status: ActiveStatus,
|
||||
PasswordHash: passwordHasher.Hash(resolvedPassword)),
|
||||
ct);
|
||||
return normalizedLogin;
|
||||
}
|
||||
|
||||
// Нормализация логина: нижний регистр и обрезка пробелов (единая форма хранения).
|
||||
// login: Входной логин (может быть null — как «не задан»).
|
||||
// Возвращает: Нормализованный логин (пустая строка, если вход был пустым).
|
||||
private static string NormalizeLogin(string? login) => (login ?? string.Empty).ToLowerInvariant().Trim();
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Deal.SharedKernel;
|
||||
using Deal.SharedKernel.Utilities;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Токены сессий: генерация raw-токена и его SHA-256-хэша для хранения.
|
||||
/// </summary>
|
||||
public static class SessionTokens
|
||||
{
|
||||
// Случайные байты raw-токена (32 → 43 символа Base64Url).
|
||||
private const int RawTokenByteLength = 32;
|
||||
|
||||
/// <summary>
|
||||
/// Новый raw-токен
|
||||
/// </summary>
|
||||
/// <returns>Строка токена длиной 43 символа.</returns>
|
||||
public static string NewToken() => UrlSafeToken.New(RawTokenByteLength);
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256-хэш raw-токена в нижнем регистре
|
||||
/// </summary>
|
||||
/// <param name="rawToken">Raw-токен из <see cref="NewToken"/> (или от клиента).</param>
|
||||
/// <returns>64 hex-символа.</returns>
|
||||
public static string HashToken(string rawToken)
|
||||
{
|
||||
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(rawToken));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +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);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.SharedKernel.Tenants.Models;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Прикладной сервис реестра тенантов
|
||||
/// </summary>
|
||||
public sealed class TenantService(ITenantRepository tenantRepository, ITenantProvisioner tenantProvisioner)
|
||||
{
|
||||
/// <summary>
|
||||
/// Создаёт тенанта
|
||||
/// </summary>
|
||||
/// <param name="name">Имя тенанта.</param>
|
||||
/// <returns>Идентификатор созданного тенанта (он же имя схемы tenant_<id>).</returns>
|
||||
public Task<TenantId> CreateTenantAsync(string name, CancellationToken ct) =>
|
||||
CreateTenantAsync(name, Guid.NewGuid(), ct);
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт тенанта с явным id и провижинит его схему.
|
||||
/// </summary>
|
||||
/// <param name="name">Имя тенанта.</param>
|
||||
/// <param name="id">Идентификатор тенанта (определяет имя схемы).</param>
|
||||
/// <returns>Идентификатор созданного тенанта (он же имя схемы tenant_<id>).</returns>
|
||||
public async Task<TenantId> CreateTenantAsync(
|
||||
string name,
|
||||
Guid id,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await tenantRepository.CreateAsync(
|
||||
new TenantRecordDto(
|
||||
Id: id,
|
||||
Name: name,
|
||||
Status: TenantStatuses.Active,
|
||||
CreatedAt: DateTimeOffset.UtcNow),
|
||||
ct);
|
||||
|
||||
string tenantIdValue = id.ToString("N");
|
||||
await tenantProvisioner.ProvisionAsync(new TenantId(tenantIdValue), ct);
|
||||
return new TenantId(tenantIdValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Возвращает список всех тенантов.
|
||||
/// </summary>
|
||||
/// <returns>Список тенантов.</returns>
|
||||
public Task<IReadOnlyList<TenantRecordDto>> ListTenantsAsync(CancellationToken ct) =>
|
||||
tenantRepository.ListAsync(ct);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Период-математика лимитов ИИ-бюджета
|
||||
/// </summary>
|
||||
public sealed class TokenBudgetService
|
||||
{
|
||||
/// <summary>
|
||||
/// Проверяет, что период истёк
|
||||
/// </summary>
|
||||
/// <param name="periodStart">Начало текущего периода.</param>
|
||||
/// <param name="period">Тип периода (<see cref="TenantLimitPeriods"/>; любое иное значение трактуется как месяц).</param>
|
||||
/// <param name="now">Текущий момент (UTC).</param>
|
||||
/// <returns>True — пора обнулить счётчик и сдвинуть PeriodStart на now.</returns>
|
||||
public bool IsPeriodExpired(
|
||||
DateTimeOffset periodStart,
|
||||
string period,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
DateTimeOffset periodEnd = period == TenantLimitPeriods.Day
|
||||
? periodStart.AddDays(1)
|
||||
: periodStart.AddMonths(1);
|
||||
return now >= periodEnd;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Проверяет порог 80% бюджета.
|
||||
/// </summary>
|
||||
/// <param name="usedTokens">Использовано токенов с начала периода.</param>
|
||||
/// <param name="budgetTokens">Бюджет периода.</param>
|
||||
/// <returns>True — израсходовано ≥80% бюджета.</returns>
|
||||
public bool IsWarned80(long usedTokens, long budgetTokens)
|
||||
{
|
||||
return budgetTokens > 0 && usedTokens >= Warned80ThresholdTokens(budgetTokens);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Проверяет исчерпание бюджета.
|
||||
/// </summary>
|
||||
/// <param name="usedTokens">Использовано токенов с начала периода.</param>
|
||||
/// <param name="budgetTokens">Бюджет периода.</param>
|
||||
/// <returns>True — израсходовано ≥ бюджета (бюджет ≤0 трактуется как исчерпанный — лимит 0 запрещает ИИ).</returns>
|
||||
public bool IsExhausted(long usedTokens, long budgetTokens)
|
||||
{
|
||||
return usedTokens >= budgetTokens;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Остаток бюджета до исчерпания.
|
||||
/// </summary>
|
||||
/// <param name="usedTokens">Использовано токенов с начала периода.</param>
|
||||
/// <param name="budgetTokens">Бюджет периода.</param>
|
||||
/// <returns>budget − used, но не меньше 0.</returns>
|
||||
public long RemainingTokens(long usedTokens, long budgetTokens)
|
||||
{
|
||||
return Math.Max(0, budgetTokens - usedTokens);
|
||||
}
|
||||
|
||||
// Порог 80% бюджета в токенах: floor(0.8·budget) целочисленно (budget − floor(budget/5)), без double.
|
||||
// budgetTokens: Бюджет периода (>0).
|
||||
// Возвращает: Порог, начиная с которого расход ≥80% бюджета.
|
||||
private static long Warned80ThresholdTokens(long budgetTokens)
|
||||
=> budgetTokens - budgetTokens / 5;
|
||||
}
|
||||
@@ -0,0 +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);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Deal.SharedKernel\Deal.SharedKernel.csproj" />
|
||||
<ProjectReference Include="..\Deal.Contracts\Deal.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Isopoh.Cryptography.Argon2" Version="2.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.11" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Deal.Modules.Tenants;
|
||||
|
||||
/// <summary>
|
||||
/// Маркер модуля Tenants
|
||||
/// </summary>
|
||||
public interface ITenantsModule
|
||||
{
|
||||
}
|
||||
Reference in New Issue
Block a user