Дочистить код-стайл: var встроенных типов, приватные доки, доки интерфейсам

Гейт csharp_style_var_for_built_in_types=false:warning (ломает сборку), остаток
выправлен dotnet format IDE0008 по 5 sln (только встроенные типы). Понижено 12
новых XML-доков private/internal, добавлены <summary> членам IContainerRules и
ITenantContext, 3 англоязычных комментария переведены на русский.
This commit is contained in:
Rustam Khalimov
2026-09-11 19:00:35 +03:00
parent dde86fd63c
commit 39c9bdc1b7
51 changed files with 3204 additions and 3215 deletions
+74 -73
View File
@@ -1,73 +1,74 @@
root = true root = true
[*] [*]
charset = utf-8 charset = utf-8
end_of_line = crlf end_of_line = crlf
insert_final_newline = true insert_final_newline = true
indent_style = space indent_style = space
indent_size = 4 indent_size = 4
trim_trailing_whitespace = true trim_trailing_whitespace = true
[*.{cs,vb}] [*.{cs,vb}]
indent_size = 4 indent_size = 4
# Стиль фигурных скобок — Allman (на отдельной строке) # Стиль фигурных скобок — Allman (на отдельной строке)
csharp_new_line_before_open_brace = all csharp_new_line_before_open_brace = all
csharp_new_line_before_else = true csharp_new_line_before_else = true
csharp_new_line_before_catch = true csharp_new_line_before_catch = true
csharp_new_line_before_finally = true csharp_new_line_before_finally = true
# using — в начале файла # using — в начале файла
dotnet_sort_system_directives_first = true dotnet_sort_system_directives_first = true
# Модификаторы доступа — всегда явные # Модификаторы доступа — всегда явные
dotnet_style_require_accessibility_modifiers = always:error dotnet_style_require_accessibility_modifiers = always:error
# Квалификация this. — запрещена (поля, свойства, методы, события) # Квалификация this. — запрещена (поля, свойства, методы, события)
dotnet_style_qualification_for_field = false:warning dotnet_style_qualification_for_field = false:warning
dotnet_style_qualification_for_property = false:warning dotnet_style_qualification_for_property = false:warning
dotnet_style_qualification_for_method = false:warning dotnet_style_qualification_for_method = false:warning
dotnet_style_qualification_for_event = false:warning dotnet_style_qualification_for_event = false:warning
# Члены # Члены
csharp_style_var_for_built_in_types = false:silent # var — запрещён для встроенных типов (ломает сборку), для очевидных/прочих — silent (§4 код-стайла)
csharp_style_var_when_type_is_apparent = false:silent csharp_style_var_for_built_in_types = false:warning
csharp_style_var_elsewhere = false:silent csharp_style_var_when_type_is_apparent = false:silent
csharp_style_var_elsewhere = false:silent
[*.cs]
# Отключить лишние правила IDE, которые конфликтуют с код-стайлом проекта [*.cs]
dotnet_diagnostic.IDE0290.severity = none # Отключить лишние правила IDE, которые конфликтуют с код-стайлом проекта
dotnet_diagnostic.IDE0290.severity = none
# --- Правила именования ---
# Приватные const-поля: PascalCase # --- Правила именования ---
# Приватные static readonly-поля (константоподобные): PascalCase # Приватные const-поля: PascalCase
# Остальные приватные поля: обязательный префикс `_` + camelCase # Приватные static readonly-поля (константоподобные): PascalCase
dotnet_naming_rule.private_const_fields_pascal.severity = warning # Остальные приватные поля: обязательный префикс `_` + camelCase
dotnet_naming_rule.private_const_fields_pascal.symbols = private_const_fields dotnet_naming_rule.private_const_fields_pascal.severity = warning
dotnet_naming_rule.private_const_fields_pascal.style = pascal_case_style dotnet_naming_rule.private_const_fields_pascal.symbols = private_const_fields
dotnet_naming_rule.private_const_fields_pascal.style = pascal_case_style
dotnet_naming_symbols.private_const_fields.applicable_kinds = field
dotnet_naming_symbols.private_const_fields.applicable_accessibilities = private dotnet_naming_symbols.private_const_fields.applicable_kinds = field
dotnet_naming_symbols.private_const_fields.required_modifiers = const dotnet_naming_symbols.private_const_fields.applicable_accessibilities = private
dotnet_naming_symbols.private_const_fields.required_modifiers = const
dotnet_naming_rule.private_static_readonly_fields_pascal.severity = warning
dotnet_naming_rule.private_static_readonly_fields_pascal.symbols = private_static_readonly_fields dotnet_naming_rule.private_static_readonly_fields_pascal.severity = warning
dotnet_naming_rule.private_static_readonly_fields_pascal.style = pascal_case_style dotnet_naming_rule.private_static_readonly_fields_pascal.symbols = private_static_readonly_fields
dotnet_naming_rule.private_static_readonly_fields_pascal.style = pascal_case_style
dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field
dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field
dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = static, readonly dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private
dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = static, readonly
dotnet_naming_style.pascal_case_style.capitalization = pascal_case
dotnet_naming_style.pascal_case_style.capitalization = pascal_case
dotnet_naming_rule.private_fields_underscore_camel.severity = warning
dotnet_naming_rule.private_fields_underscore_camel.symbols = private_fields dotnet_naming_rule.private_fields_underscore_camel.severity = warning
dotnet_naming_rule.private_fields_underscore_camel.style = underscore_camel_style dotnet_naming_rule.private_fields_underscore_camel.symbols = private_fields
dotnet_naming_rule.private_fields_underscore_camel.style = underscore_camel_style
dotnet_naming_symbols.private_fields.applicable_kinds = field
dotnet_naming_symbols.private_fields.applicable_accessibilities = private dotnet_naming_symbols.private_fields.applicable_kinds = field
dotnet_naming_symbols.private_fields.applicable_accessibilities = private
dotnet_naming_style.underscore_camel_style.required_prefix = _
dotnet_naming_style.underscore_camel_style.capitalization = camel_case dotnet_naming_style.underscore_camel_style.required_prefix = _
dotnet_naming_style.underscore_camel_style.capitalization = camel_case
dotnet_diagnostic.IDE1006.severity = warning
dotnet_diagnostic.IDE1006.severity = warning
+193 -193
View File
@@ -1,193 +1,193 @@
using Deal.Api.Extensions; using Deal.Api.Extensions;
using Deal.Api.Middleware; using Deal.Api.Middleware;
using Deal.Api.Models; using Deal.Api.Models;
using Deal.Api.Services; using Deal.Api.Services;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services; using Deal.Modules.Tenants.Application.Services;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом. // Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом.
using CookieOptions = Deal.Api.Configuration.CookieOptions; using CookieOptions = Deal.Api.Configuration.CookieOptions;
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
/// <summary> /// <summary>
/// HTTP-эндпоинты аутентификации /// HTTP-эндпоинты аутентификации
/// </summary> /// </summary>
public static class AuthEndpoints public static class AuthEndpoints
{ {
private const string InvalidCredentialsDetail = "Неверный логин или пароль"; private const string InvalidCredentialsDetail = "Неверный логин или пароль";
private const string WrongOldPasswordDetail = "Текущий пароль неверен"; private const string WrongOldPasswordDetail = "Текущий пароль неверен";
private const string PasswordTooShortDetail = "Пароль слишком короткий (минимум 8 символов)"; private const string PasswordTooShortDetail = "Пароль слишком короткий (минимум 8 символов)";
private const string TenantSuspendedDetail = "Учётная запись приостановлена. Обратитесь к оператору"; private const string TenantSuspendedDetail = "Учётная запись приостановлена. Обратитесь к оператору";
private const string AuthGroupPrefix = "/api/auth"; private const string AuthGroupPrefix = "/api/auth";
private const string AuthOpenApiTag = "auth"; private const string AuthOpenApiTag = "auth";
/// <summary> /// <summary>
/// Регистрирует группу /api/auth /// Регистрирует группу /api/auth
/// </summary> /// </summary>
/// <param name="app">Построитель маршрутов приложения.</param> /// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns> /// <returns>Построитель маршрутов для цепочки вызовов.</returns>
public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder app)
{ {
var group = app.MapGroup(AuthGroupPrefix).WithTags(AuthOpenApiTag); var group = app.MapGroup(AuthGroupPrefix).WithTags(AuthOpenApiTag);
group.MapPost("/login", LoginAsync).RequireRateLimiting(RateLimitPolicies.AuthPolicy); group.MapPost("/login", LoginAsync).RequireRateLimiting(RateLimitPolicies.AuthPolicy);
group.MapPost("/logout", LogoutAsync); group.MapPost("/logout", LogoutAsync);
group.MapGet("/me", MeAsync); group.MapGet("/me", MeAsync);
group.MapPost("/change-password", ChangePasswordAsync); group.MapPost("/change-password", ChangePasswordAsync);
return app; return app;
} }
private static async Task<IResult> LoginAsync( private static async Task<IResult> LoginAsync(
LoginRequest body, LoginRequest body,
AuthService authService, AuthService authService,
AuditService auditService, AuditService auditService,
IOptions<CookieOptions> cookieOptions, IOptions<CookieOptions> cookieOptions,
HttpContext context, HttpContext context,
CancellationToken ct, CancellationToken ct,
LoginAttemptGuard loginAttemptGuard, LoginAttemptGuard loginAttemptGuard,
SuspiciousActivityReporter suspicious) SuspiciousActivityReporter suspicious)
{ {
string? attemptedLogin = NormalizeLogin(body.Login); string? attemptedLogin = NormalizeLogin(body.Login);
if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct)) if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct))
{ {
// Событие подозрительной активности: серия неудачных попыток входа → блокировка ключа. // Событие подозрительной активности: серия неудачных попыток входа → блокировка ключа.
suspicious.Report(SuspiciousActivityReporter.LoginBlockedKind, ClientIp(context)); suspicious.Report(SuspiciousActivityReporter.LoginBlockedKind, ClientIp(context));
return EndpointResults.TooManyRequests(LoginAttemptGuard.BlockedDetail); return EndpointResults.TooManyRequests(LoginAttemptGuard.BlockedDetail);
} }
var result = await authService.LoginAsync(body.Login, body.Password, ct); var result = await authService.LoginAsync(body.Login, body.Password, ct);
if (result.Error == LoginResultDto.ErrorTenantSuspended) if (result.Error == LoginResultDto.ErrorTenantSuspended)
{ {
await auditService.AppendAsync(new AuditRecordDto( await auditService.AppendAsync(new AuditRecordDto(
AuditEvents.TenantLoginFailed, AuditEvents.TenantLoginFailed,
AuditActorTypes.Tenant, AuditActorTypes.Tenant,
ActorId: result.UserId, ActorId: result.UserId,
TenantId: result.TenantId, TenantId: result.TenantId,
Ip: ClientIp(context), Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { login = NormalizeLogin(body.Login) })), ct); DetailJson: AuditService.ToDetailJson(new { login = NormalizeLogin(body.Login) })), ct);
return EndpointResults.Forbidden(TenantSuspendedDetail); return EndpointResults.Forbidden(TenantSuspendedDetail);
} }
if (result.Login is null || result.Token is null) if (result.Login is null || result.Token is null)
{ {
if (attemptedLogin is not null) if (attemptedLogin is not null)
{ {
await loginAttemptGuard.RecordFailureAsync(ClientIp(context), attemptedLogin, ct); await loginAttemptGuard.RecordFailureAsync(ClientIp(context), attemptedLogin, ct);
await auditService.AppendAsync(new AuditRecordDto( await auditService.AppendAsync(new AuditRecordDto(
AuditEvents.TenantLoginFailed, AuditEvents.TenantLoginFailed,
AuditActorTypes.Tenant, AuditActorTypes.Tenant,
ActorId: null, ActorId: null,
TenantId: null, TenantId: null,
Ip: ClientIp(context), Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { login = attemptedLogin })), ct); DetailJson: AuditService.ToDetailJson(new { login = attemptedLogin })), ct);
} }
return EndpointResults.Unauthorized(InvalidCredentialsDetail); return EndpointResults.Unauthorized(InvalidCredentialsDetail);
} }
await loginAttemptGuard.ResetAsync(ClientIp(context), result.Login, ct); await loginAttemptGuard.ResetAsync(ClientIp(context), result.Login, ct);
await auditService.AppendAsync(new AuditRecordDto( await auditService.AppendAsync(new AuditRecordDto(
AuditEvents.TenantLoginOk, AuditEvents.TenantLoginOk,
AuditActorTypes.Tenant, AuditActorTypes.Tenant,
ActorId: result.UserId, ActorId: result.UserId,
TenantId: result.TenantId, TenantId: result.TenantId,
Ip: ClientIp(context), Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { login = result.Login })), ct); DetailJson: AuditService.ToDetailJson(new { login = result.Login })), ct);
SessionCookieWriter.Append(context, cookieOptions.Value, result.Token); SessionCookieWriter.Append(context, cookieOptions.Value, result.Token);
return Results.Ok(new { ok = true, login = result.Login }); return Results.Ok(new { ok = true, login = result.Login });
} }
private static async Task<IResult> LogoutAsync( private static async Task<IResult> LogoutAsync(
AuthService authService, AuthService authService,
AuditService auditService, AuditService auditService,
IOptions<CookieOptions> cookieOptions, IOptions<CookieOptions> cookieOptions,
HttpContext context, HttpContext context,
CancellationToken ct) CancellationToken ct)
{ {
var cookieName = cookieOptions.Value.Name; string cookieName = cookieOptions.Value.Name;
var rawToken = context.Request.Cookies[cookieName]; string? rawToken = context.Request.Cookies[cookieName];
// Пользователь разрешённой сессии — до её удаления (SessionMiddleware наполнил Items на старте запроса). // Пользователь разрешённой сессии — до её удаления (SessionMiddleware наполнил Items на старте запроса).
CurrentUser? user = context.GetCurrentUser(); CurrentUser? user = context.GetCurrentUser();
var logout = await authService.LogoutAsync(rawToken, ct); var logout = await authService.LogoutAsync(rawToken, ct);
if (logout is not null) if (logout is not null)
{ {
// Актор — оператор, начавший impersonation (маркер сессии); тенант — для фильтра TenantId. // Актор — оператор, начавший impersonation (маркер сессии); тенант — для фильтра TenantId.
await auditService.AppendAsync(new AuditRecordDto( await auditService.AppendAsync(new AuditRecordDto(
AuditEvents.ImpersonationStopped, AuditEvents.ImpersonationStopped,
AuditActorTypes.Operator, AuditActorTypes.Operator,
ActorId: logout.OperatorId, ActorId: logout.OperatorId,
TenantId: logout.TenantId, TenantId: logout.TenantId,
Ip: ClientIp(context), Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { login = logout.Login })), ct); DetailJson: AuditService.ToDetailJson(new { login = logout.Login })), ct);
} }
if (user is not null) if (user is not null)
{ {
await AuditAppender.AppendTenantAsync(context, AuditEvents.TenantLogout, new { login = user.Login }, ct); await AuditAppender.AppendTenantAsync(context, AuditEvents.TenantLogout, new { login = user.Login }, ct);
} }
context.Response.Cookies.Delete(cookieName); context.Response.Cookies.Delete(cookieName);
return Results.Ok(new { ok = true }); return Results.Ok(new { ok = true });
} }
// GET /api/auth/me: проверка живой сессии. // GET /api/auth/me: проверка живой сессии.
private static IResult MeAsync(HttpContext context) private static IResult MeAsync(HttpContext context)
{ {
var user = context.GetCurrentUser(); var user = context.GetCurrentUser();
if (user is null) if (user is null)
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
return Results.Ok(new { login = user.Login, ok = true }); return Results.Ok(new { login = user.Login, ok = true });
} }
// POST /api/auth/change-password: смена пароля и перевыпуск куки (свежая сессия). // POST /api/auth/change-password: смена пароля и перевыпуск куки (свежая сессия).
private static async Task<IResult> ChangePasswordAsync( private static async Task<IResult> ChangePasswordAsync(
ChangePasswordRequest body, ChangePasswordRequest body,
AuthService authService, AuthService authService,
IOptions<CookieOptions> cookieOptions, IOptions<CookieOptions> cookieOptions,
HttpContext context, HttpContext context,
CancellationToken ct) CancellationToken ct)
{ {
var user = context.GetCurrentUser(); var user = context.GetCurrentUser();
if (user is null) if (user is null)
{ {
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
} }
var result = await authService.ChangePasswordAsync(user.Login, body.OldPassword, body.NewPassword, ct); var result = await authService.ChangePasswordAsync(user.Login, body.OldPassword, body.NewPassword, ct);
if (!result.Ok || result.NewToken is null) if (!result.Ok || result.NewToken is null)
{ {
var detail = result.Error == ChangePasswordResultDto.ErrorTooShort string detail = result.Error == ChangePasswordResultDto.ErrorTooShort
? PasswordTooShortDetail ? PasswordTooShortDetail
: WrongOldPasswordDetail; : WrongOldPasswordDetail;
return EndpointResults.BadRequest(detail); return EndpointResults.BadRequest(detail);
} }
// Старые сессии удалены внутри сервиса; выдаём клиенту свежую куку. // Старые сессии удалены внутри сервиса; выдаём клиенту свежую куку.
SessionCookieWriter.Append(context, cookieOptions.Value, result.NewToken); SessionCookieWriter.Append(context, cookieOptions.Value, result.NewToken);
return Results.Ok(new { ok = true }); return Results.Ok(new { ok = true });
} }
// Нормализованная попытка логина для аудита (нижний регистр/обрезка, как AuthService); null — писать нечего. // Нормализованная попытка логина для аудита (нижний регистр/обрезка, как AuthService); null — писать нечего.
// login: Логин из тела запроса. // login: Логин из тела запроса.
// Возвращает: Нормализованный логин или null при пустом/пробельном входе. // Возвращает: Нормализованный логин или null при пустом/пробельном входе.
private static string? NormalizeLogin(string? login) private static string? NormalizeLogin(string? login)
{ {
string? normalized = login?.Trim().ToLowerInvariant(); string? normalized = login?.Trim().ToLowerInvariant();
return string.IsNullOrEmpty(normalized) ? null : normalized; return string.IsNullOrEmpty(normalized) ? null : normalized;
} }
// IP-адрес клиента для аудита (без порта; null, если недоступен). // IP-адрес клиента для аудита (без порта; null, если недоступен).
// context: Контекст запроса. // context: Контекст запроса.
// Возвращает: Строковое представление IP или null. // Возвращает: Строковое представление IP или null.
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString(); private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
} }
@@ -1,157 +1,157 @@
using Deal.Api.Extensions; using Deal.Api.Extensions;
using Deal.Api.Middleware; using Deal.Api.Middleware;
using Deal.Api.Models; using Deal.Api.Models;
using Deal.Api.Services; using Deal.Api.Services;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services; using Deal.Modules.Tenants.Application.Services;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using AspNetCoreCookieOptions = Microsoft.AspNetCore.Http.CookieOptions; using AspNetCoreCookieOptions = Microsoft.AspNetCore.Http.CookieOptions;
// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасами. // Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасами.
using OperatorCookieOptions = Deal.Api.Configuration.OperatorCookieOptions; using OperatorCookieOptions = Deal.Api.Configuration.OperatorCookieOptions;
namespace Deal.Api.Endpoints; namespace Deal.Api.Endpoints;
/// <summary> /// <summary>
/// HTTP-эндпоинты аутентификации оператора /// HTTP-эндпоинты аутентификации оператора
/// </summary> /// </summary>
public static class OperatorAuthEndpoints public static class OperatorAuthEndpoints
{ {
private const string InvalidCredentialsDetail = "Неверный логин или пароль оператора"; private const string InvalidCredentialsDetail = "Неверный логин или пароль оператора";
private const string OperatorAuthGroupPrefix = "/api/operator/auth"; private const string OperatorAuthGroupPrefix = "/api/operator/auth";
private const string OperatorAuthOpenApiTag = "operator-auth"; private const string OperatorAuthOpenApiTag = "operator-auth";
/// <summary> /// <summary>
/// Регистрирует группу /api/operator/auth /// Регистрирует группу /api/operator/auth
/// </summary> /// </summary>
/// <param name="app">Построитель маршрутов приложения.</param> /// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns> /// <returns>Построитель маршрутов для цепочки вызовов.</returns>
public static IEndpointRouteBuilder MapOperatorAuthEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapOperatorAuthEndpoints(this IEndpointRouteBuilder app)
{ {
var group = app.MapGroup(OperatorAuthGroupPrefix).WithTags(OperatorAuthOpenApiTag); var group = app.MapGroup(OperatorAuthGroupPrefix).WithTags(OperatorAuthOpenApiTag);
group.MapPost("/login", LoginAsync).RequireRateLimiting(RateLimitPolicies.AuthPolicy); group.MapPost("/login", LoginAsync).RequireRateLimiting(RateLimitPolicies.AuthPolicy);
group.MapPost("/logout", LogoutAsync); group.MapPost("/logout", LogoutAsync);
group.MapGet("/me", MeAsync); group.MapGet("/me", MeAsync);
return app; return app;
} }
private static async Task<IResult> LoginAsync( private static async Task<IResult> LoginAsync(
LoginRequest body, LoginRequest body,
OperatorAuthService operatorAuthService, OperatorAuthService operatorAuthService,
AuditService auditService, AuditService auditService,
IOptions<OperatorCookieOptions> cookieOptions, IOptions<OperatorCookieOptions> cookieOptions,
HttpContext context, HttpContext context,
CancellationToken ct, CancellationToken ct,
LoginAttemptGuard loginAttemptGuard) LoginAttemptGuard loginAttemptGuard)
{ {
string? attemptedLogin = NormalizeLogin(body.Login); string? attemptedLogin = NormalizeLogin(body.Login);
if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct)) if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct))
{ {
return EndpointResults.TooManyRequests(LoginAttemptGuard.BlockedDetail); return EndpointResults.TooManyRequests(LoginAttemptGuard.BlockedDetail);
} }
var result = await operatorAuthService.LoginAsync(body.Login, body.Password, ct); var result = await operatorAuthService.LoginAsync(body.Login, body.Password, ct);
if (result.Login is null || result.Token is null) if (result.Login is null || result.Token is null)
{ {
if (attemptedLogin is not null) if (attemptedLogin is not null)
{ {
await loginAttemptGuard.RecordFailureAsync(ClientIp(context), attemptedLogin, ct); await loginAttemptGuard.RecordFailureAsync(ClientIp(context), attemptedLogin, ct);
await auditService.AppendAsync(new AuditRecordDto( await auditService.AppendAsync(new AuditRecordDto(
AuditEvents.OperatorLoginFailed, AuditEvents.OperatorLoginFailed,
AuditActorTypes.Operator, AuditActorTypes.Operator,
ActorId: null, ActorId: null,
TenantId: null, TenantId: null,
Ip: ClientIp(context), Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { login = attemptedLogin })), ct); DetailJson: AuditService.ToDetailJson(new { login = attemptedLogin })), ct);
} }
return EndpointResults.Unauthorized(InvalidCredentialsDetail); return EndpointResults.Unauthorized(InvalidCredentialsDetail);
} }
await loginAttemptGuard.ResetAsync(ClientIp(context), result.Login, ct); await loginAttemptGuard.ResetAsync(ClientIp(context), result.Login, ct);
await auditService.AppendAsync(new AuditRecordDto( await auditService.AppendAsync(new AuditRecordDto(
AuditEvents.OperatorLoginOk, AuditEvents.OperatorLoginOk,
AuditActorTypes.Operator, AuditActorTypes.Operator,
ActorId: result.OperatorId, ActorId: result.OperatorId,
TenantId: null, TenantId: null,
Ip: ClientIp(context), Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { login = result.Login })), ct); DetailJson: AuditService.ToDetailJson(new { login = result.Login })), ct);
SetOperatorSessionCookie(context, cookieOptions.Value, result.Token); SetOperatorSessionCookie(context, cookieOptions.Value, result.Token);
return Results.Ok(new { ok = true, login = result.Login }); return Results.Ok(new { ok = true, login = result.Login });
} }
// POST /api/operator/auth/logout: удаление операторской сессии по токену из куки и очистка куки (всегда ok). // POST /api/operator/auth/logout: удаление операторской сессии по токену из куки и очистка куки (всегда ok).
private static async Task<IResult> LogoutAsync( private static async Task<IResult> LogoutAsync(
OperatorAuthService operatorAuthService, OperatorAuthService operatorAuthService,
IOptions<OperatorCookieOptions> cookieOptions, IOptions<OperatorCookieOptions> cookieOptions,
HttpContext context, HttpContext context,
CancellationToken ct) CancellationToken ct)
{ {
var cookieName = cookieOptions.Value.Name; string cookieName = cookieOptions.Value.Name;
var rawToken = context.Request.Cookies[cookieName]; string? rawToken = context.Request.Cookies[cookieName];
// Оператор разрешённой сессии — до её удаления (OperatorSessionMiddleware наполнил Items). // Оператор разрешённой сессии — до её удаления (OperatorSessionMiddleware наполнил Items).
CurrentOperator? operatorIdentity = context.GetCurrentOperator(); CurrentOperator? operatorIdentity = context.GetCurrentOperator();
await operatorAuthService.LogoutAsync(rawToken, ct); await operatorAuthService.LogoutAsync(rawToken, ct);
context.Response.Cookies.Delete(cookieName); context.Response.Cookies.Delete(cookieName);
if (operatorIdentity is not null) if (operatorIdentity is not null)
{ {
await AuditAppender.AppendOperatorAsync(context, AuditEvents.OperatorLogout, new { login = operatorIdentity.Login }, ct); await AuditAppender.AppendOperatorAsync(context, AuditEvents.OperatorLogout, new { login = operatorIdentity.Login }, ct);
} }
return Results.Ok(new { ok = true }); return Results.Ok(new { ok = true });
} }
private static IResult MeAsync(HttpContext context) private static IResult MeAsync(HttpContext context)
{ {
var operatorIdentity = context.GetCurrentOperator(); var operatorIdentity = context.GetCurrentOperator();
if (operatorIdentity is null) if (operatorIdentity is null)
{ {
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
} }
return Results.Ok(new { login = operatorIdentity.Login, ok = true }); return Results.Ok(new { login = operatorIdentity.Login, ok = true });
} }
// Выставляет httpOnly-куку сессии оператора: SameSite=Lax, Path=/, MaxAge=Hours, Secure — из конфига. // Выставляет httpOnly-куку сессии оператора: SameSite=Lax, Path=/, MaxAge=Hours, Secure — из конфига.
// context: Контекст запроса. // context: Контекст запроса.
// options: Настройки куки из конфигурации (секция OperatorCookies). // options: Настройки куки из конфигурации (секция OperatorCookies).
// rawToken: Raw-токен операторской сессии. // rawToken: Raw-токен операторской сессии.
private static void SetOperatorSessionCookie( private static void SetOperatorSessionCookie(
HttpContext context, HttpContext context,
OperatorCookieOptions options, OperatorCookieOptions options,
string rawToken) string rawToken)
{ {
// MaxAge — OperatorCookies:Hours; код-дефолт значения ссылается на // MaxAge — OperatorCookies:Hours; код-дефолт значения ссылается на
// OperatorAuthService.SessionLifetimeHours (единый источник «12 часов», см. OperatorCookieOptions). // OperatorAuthService.SessionLifetimeHours (единый источник «12 часов», см. OperatorCookieOptions).
context.Response.Cookies.Append( context.Response.Cookies.Append(
options.Name, options.Name,
rawToken, rawToken,
new AspNetCoreCookieOptions new AspNetCoreCookieOptions
{ {
HttpOnly = true, HttpOnly = true,
SameSite = SameSiteMode.Lax, SameSite = SameSiteMode.Lax,
Path = "/", Path = "/",
MaxAge = TimeSpan.FromHours(options.Hours), MaxAge = TimeSpan.FromHours(options.Hours),
Secure = options.Secure, Secure = options.Secure,
}); });
} }
// Нормализованная попытка логина для аудита (нижний регистр/обрезка); null — писать нечего. // Нормализованная попытка логина для аудита (нижний регистр/обрезка); null — писать нечего.
// login: Логин из тела запроса. // login: Логин из тела запроса.
// Возвращает: Нормализованный логин или null при пустом/пробельном входе. // Возвращает: Нормализованный логин или null при пустом/пробельном входе.
private static string? NormalizeLogin(string? login) private static string? NormalizeLogin(string? login)
{ {
string? normalized = login?.Trim().ToLowerInvariant(); string? normalized = login?.Trim().ToLowerInvariant();
return string.IsNullOrEmpty(normalized) ? null : normalized; return string.IsNullOrEmpty(normalized) ? null : normalized;
} }
// IP-адрес клиента для аудита (без порта; null, если недоступен). // IP-адрес клиента для аудита (без порта; null, если недоступен).
// context: Контекст запроса. // context: Контекст запроса.
// Возвращает: Строковое представление IP или null. // Возвращает: Строковое представление IP или null.
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString(); private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
} }
@@ -1,84 +1,84 @@
using Deal.Modules.Tenants.Application.Services; using Deal.Modules.Tenants.Application.Services;
namespace Deal.Api.Hosting; namespace Deal.Api.Hosting;
/// <summary> /// <summary>
/// Hosted-шаг bootstrap оператора при старте /// Hosted-шаг bootstrap оператора при старте
/// </summary> /// </summary>
public sealed class OperatorBootstrapHostedService( public sealed class OperatorBootstrapHostedService(
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,
IConfiguration configuration, IConfiguration configuration,
IHostEnvironment environment, IHostEnvironment environment,
ILogger<OperatorBootstrapHostedService> logger) : IHostedService ILogger<OperatorBootstrapHostedService> logger) : IHostedService
{ {
/// <inheritdoc /> /// <inheritdoc />
public async Task StartAsync(CancellationToken ct) public async Task StartAsync(CancellationToken ct)
{ {
await using var scope = scopeFactory.CreateAsyncScope(); await using var scope = scopeFactory.CreateAsyncScope();
var operatorBootstrapService = scope.ServiceProvider.GetRequiredService<OperatorBootstrapService>(); var operatorBootstrapService = scope.ServiceProvider.GetRequiredService<OperatorBootstrapService>();
var login = configuration[OperatorBootstrapService.LoginEnvKey]; string? login = configuration[OperatorBootstrapService.LoginEnvKey];
var password = configuration[OperatorBootstrapService.PasswordEnvKey]; string? password = configuration[OperatorBootstrapService.PasswordEnvKey];
bool hasLogin = !string.IsNullOrWhiteSpace(login); bool hasLogin = !string.IsNullOrWhiteSpace(login);
bool hasPassword = !string.IsNullOrWhiteSpace(password); bool hasPassword = !string.IsNullOrWhiteSpace(password);
bool allowDevelopmentDefaults = environment.IsDevelopment(); bool allowDevelopmentDefaults = environment.IsDevelopment();
// Предупреждения о пропуске/частичной конфигурации логируются ДО вызова шага — сам шаг // Предупреждения о пропуске/частичной конфигурации логируются ДО вызова шага — сам шаг
// в этих случаях ничего не создаёт (EnsureOperatorAsync возвращает null, хранилище не тронуто). // в этих случаях ничего не создаёт (EnsureOperatorAsync возвращает null, хранилище не тронуто).
if (!hasLogin || !hasPassword) if (!hasLogin || !hasPassword)
{ {
if (hasLogin != hasPassword) if (hasLogin != hasPassword)
{ {
LogPartialConfigurationWarning(hasLogin, allowDevelopmentDefaults); LogPartialConfigurationWarning(hasLogin, allowDevelopmentDefaults);
} }
else if (!allowDevelopmentDefaults) else if (!allowDevelopmentDefaults)
{ {
logger.LogWarning( logger.LogWarning(
"DEAL_OPERATOR_LOGIN/DEAL_OPERATOR_PASSWORD не заданы (Production) — bootstrap оператора " "DEAL_OPERATOR_LOGIN/DEAL_OPERATOR_PASSWORD не заданы (Production) — bootstrap оператора "
+ "пропущен. Оператор заводится позже: задайте env DEAL_OPERATOR_* и перезапустите хост."); + "пропущен. Оператор заводится позже: задайте env DEAL_OPERATOR_* и перезапустите хост.");
} }
else else
{ {
// Development без кред: штатный dev-дефолт (зеркало dev-seed admin/admin). // Development без кред: штатный dev-дефолт (зеркало dev-seed admin/admin).
logger.LogInformation( logger.LogInformation(
"DEAL_OPERATOR_LOGIN/DEAL_OPERATOR_PASSWORD не заданы (Development) — используется " "DEAL_OPERATOR_LOGIN/DEAL_OPERATOR_PASSWORD не заданы (Development) — используется "
+ "dev-дефолт оператора {DefaultLogin}.", + "dev-дефолт оператора {DefaultLogin}.",
OperatorBootstrapService.DefaultOperatorLogin); OperatorBootstrapService.DefaultOperatorLogin);
} }
} }
var ensuredLogin = await operatorBootstrapService.EnsureOperatorAsync( string? ensuredLogin = await operatorBootstrapService.EnsureOperatorAsync(
login, password, allowDevelopmentDefaults, ct); login, password, allowDevelopmentDefaults, ct);
if (ensuredLogin is not null) if (ensuredLogin is not null)
{ {
logger.LogInformation("Bootstrap оператора: оператор {Login} присутствует.", ensuredLogin); logger.LogInformation("Bootstrap оператора: оператор {Login} присутствует.", ensuredLogin);
} }
} }
/// <inheritdoc /> /// <inheritdoc />
public Task StopAsync(CancellationToken ct) => Task.CompletedTask; public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
// Логирует warning о неполной env-конфигурации (задана одна из двух переменных). // Логирует warning о неполной env-конфигурации (задана одна из двух переменных).
// hasLogin: Задан ли логин (пароль при этом пуст). // hasLogin: Задан ли логин (пароль при этом пуст).
// allowDevelopmentDefaults: Разрешены ли dev-дефолты (Development). // allowDevelopmentDefaults: Разрешены ли dev-дефолты (Development).
private void LogPartialConfigurationWarning(bool hasLogin, bool allowDevelopmentDefaults) private void LogPartialConfigurationWarning(bool hasLogin, bool allowDevelopmentDefaults)
{ {
string missingEnv = hasLogin string missingEnv = hasLogin
? OperatorBootstrapService.PasswordEnvKey ? OperatorBootstrapService.PasswordEnvKey
: OperatorBootstrapService.LoginEnvKey; : OperatorBootstrapService.LoginEnvKey;
if (allowDevelopmentDefaults) if (allowDevelopmentDefaults)
{ {
logger.LogWarning( logger.LogWarning(
"Env-конфигурация оператора неполна: не задан {MissingEnv}. В Development используются " "Env-конфигурация оператора неполна: не задан {MissingEnv}. В Development используются "
+ "dev-дефолты оператора {DefaultLogin}.", + "dev-дефолты оператора {DefaultLogin}.",
missingEnv, OperatorBootstrapService.DefaultOperatorLogin); missingEnv, OperatorBootstrapService.DefaultOperatorLogin);
} }
else else
{ {
logger.LogWarning( logger.LogWarning(
"Env-конфигурация оператора неполна: не задан {MissingEnv}. Bootstrap оператора в Production " "Env-конфигурация оператора неполна: не задан {MissingEnv}. Bootstrap оператора в Production "
+ "пропущен — оператор заводится позже через env DEAL_OPERATOR_* и рестарт хоста.", + "пропущен — оператор заводится позже через env DEAL_OPERATOR_* и рестарт хоста.",
missingEnv); missingEnv);
} }
} }
} }
@@ -1,78 +1,78 @@
using Deal.Infrastructure.Tenancy; using Deal.Infrastructure.Tenancy;
using Deal.Modules.Tenants.Application.Abstractions; using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services; using Deal.Modules.Tenants.Application.Services;
namespace Deal.Api.Hosting; namespace Deal.Api.Hosting;
/// <summary> /// <summary>
/// Bootstrap при старте /// Bootstrap при старте
/// </summary> /// </summary>
public sealed class TenantBootstrapService(IServiceScopeFactory scopeFactory) : IHostedService public sealed class TenantBootstrapService(IServiceScopeFactory scopeFactory) : IHostedService
{ {
private const string DefaultTenantName = "Default"; private const string DefaultTenantName = "Default";
private static readonly Guid DefaultTenantId = Guid.Parse("00000000-0000-0000-0000-000000000001"); private static readonly Guid DefaultTenantId = Guid.Parse("00000000-0000-0000-0000-000000000001");
private const string ActiveStatus = "active"; private const string ActiveStatus = "active";
private const string BootstrapLoginEnvKey = "DEAL_BOOTSTRAP_LOGIN"; private const string BootstrapLoginEnvKey = "DEAL_BOOTSTRAP_LOGIN";
private const string BootstrapPasswordEnvKey = "DEAL_BOOTSTRAP_PASSWORD"; private const string BootstrapPasswordEnvKey = "DEAL_BOOTSTRAP_PASSWORD";
private const string DefaultAdminLogin = "admin"; private const string DefaultAdminLogin = "admin";
private const string DefaultAdminPassword = "admin"; private const string DefaultAdminPassword = "admin";
private const string DefaultTenantBootstrapEnvKey = "DEAL_BOOTSTRAP_DEFAULT_TENANT"; private const string DefaultTenantBootstrapEnvKey = "DEAL_BOOTSTRAP_DEFAULT_TENANT";
private const string DefaultTenantBootstrapEnabledValue = "1"; private const string DefaultTenantBootstrapEnabledValue = "1";
/// <inheritdoc /> /// <inheritdoc />
public async Task StartAsync(CancellationToken ct) public async Task StartAsync(CancellationToken ct)
{ {
await using var scope = scopeFactory.CreateAsyncScope(); await using var scope = scopeFactory.CreateAsyncScope();
var configuration = scope.ServiceProvider.GetRequiredService<IConfiguration>(); var configuration = scope.ServiceProvider.GetRequiredService<IConfiguration>();
var tenantService = scope.ServiceProvider.GetRequiredService<TenantService>(); var tenantService = scope.ServiceProvider.GetRequiredService<TenantService>();
var tenantRepository = scope.ServiceProvider.GetRequiredService<ITenantRepository>(); var tenantRepository = scope.ServiceProvider.GetRequiredService<ITenantRepository>();
var authStore = scope.ServiceProvider.GetRequiredService<IAuthStore>(); var authStore = scope.ServiceProvider.GetRequiredService<IAuthStore>();
var passwordHasher = scope.ServiceProvider.GetRequiredService<IPasswordHasher>(); var passwordHasher = scope.ServiceProvider.GetRequiredService<IPasswordHasher>();
var login = NormalizeLogin(configuration[BootstrapLoginEnvKey] ?? DefaultAdminLogin); string login = NormalizeLogin(configuration[BootstrapLoginEnvKey] ?? DefaultAdminLogin);
var password = configuration[BootstrapPasswordEnvKey] ?? DefaultAdminPassword; string password = configuration[BootstrapPasswordEnvKey] ?? DefaultAdminPassword;
var environment = scope.ServiceProvider.GetRequiredService<IHostEnvironment>(); var environment = scope.ServiceProvider.GetRequiredService<IHostEnvironment>();
var seedDefaultTenant = environment.IsDevelopment() bool seedDefaultTenant = environment.IsDevelopment()
|| configuration[DefaultTenantBootstrapEnvKey] == DefaultTenantBootstrapEnabledValue; || configuration[DefaultTenantBootstrapEnvKey] == DefaultTenantBootstrapEnabledValue;
if (seedDefaultTenant) if (seedDefaultTenant)
{ {
var tenants = await tenantRepository.ListAsync(ct); var tenants = await tenantRepository.ListAsync(ct);
if (tenants.Count == 0) if (tenants.Count == 0)
{ {
// CreateTenantAsync сам провижинит схему дефолтного тенанта (TenantService → ITenantProvisioner). // CreateTenantAsync сам провижинит схему дефолтного тенанта (TenantService → ITenantProvisioner).
await tenantService.CreateTenantAsync(DefaultTenantName, DefaultTenantId, ct); await tenantService.CreateTenantAsync(DefaultTenantName, DefaultTenantId, ct);
} }
// Идемпотентность: пользователь с таким логином уже есть — ничего не делаем. // Идемпотентность: пользователь с таким логином уже есть — ничего не делаем.
var existingUser = await authStore.FindUserByLoginAsync(login, ct); var existingUser = await authStore.FindUserByLoginAsync(login, ct);
if (existingUser is null) if (existingUser is null)
{ {
await authStore.CreateUserAsync( await authStore.CreateUserAsync(
new StoredUserDto( new StoredUserDto(
Id: Guid.NewGuid(), Id: Guid.NewGuid(),
Login: login, Login: login,
TenantId: DefaultTenantId, TenantId: DefaultTenantId,
Status: ActiveStatus, Status: ActiveStatus,
PasswordHash: passwordHasher.Hash(password)), PasswordHash: passwordHasher.Hash(password)),
ct); ct);
} }
} }
var tenantSchemaMigrationService = scope.ServiceProvider.GetRequiredService<TenantSchemaMigrationService>(); var tenantSchemaMigrationService = scope.ServiceProvider.GetRequiredService<TenantSchemaMigrationService>();
await tenantSchemaMigrationService.MigrateAllAsync(ct); await tenantSchemaMigrationService.MigrateAllAsync(ct);
} }
/// <inheritdoc /> /// <inheritdoc />
public Task StopAsync(CancellationToken ct) => Task.CompletedTask; public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
// Нормализация логина: нижний регистр и обрезка пробелов (единая форма хранения). // Нормализация логина: нижний регистр и обрезка пробелов (единая форма хранения).
// login: Входной логин. // login: Входной логин.
// Возвращает: Нормализованный логин. // Возвращает: Нормализованный логин.
private static string NormalizeLogin(string login) => login.Trim().ToLowerInvariant(); private static string NormalizeLogin(string login) => login.Trim().ToLowerInvariant();
} }
@@ -1,45 +1,45 @@
using Deal.Api.Extensions; using Deal.Api.Extensions;
using Deal.Api.Models; using Deal.Api.Models;
using Deal.Modules.Tenants.Application.Services; using Deal.Modules.Tenants.Application.Services;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом. // Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом.
using OperatorCookieOptions = Deal.Api.Configuration.OperatorCookieOptions; using OperatorCookieOptions = Deal.Api.Configuration.OperatorCookieOptions;
namespace Deal.Api.Middleware; namespace Deal.Api.Middleware;
/// <summary> /// <summary>
/// Middleware операторской сессии /// Middleware операторской сессии
/// </summary> /// </summary>
public sealed class OperatorSessionMiddleware public sealed class OperatorSessionMiddleware
{ {
private readonly RequestDelegate _next; private readonly RequestDelegate _next;
private readonly IOptionsMonitor<OperatorCookieOptions> _cookieOptions; private readonly IOptionsMonitor<OperatorCookieOptions> _cookieOptions;
public OperatorSessionMiddleware(RequestDelegate next, IOptionsMonitor<OperatorCookieOptions> cookieOptions) public OperatorSessionMiddleware(RequestDelegate next, IOptionsMonitor<OperatorCookieOptions> cookieOptions)
{ {
_next = next; _next = next;
_cookieOptions = cookieOptions; _cookieOptions = cookieOptions;
} }
/// <summary> /// <summary>
/// Обрабатывает запрос /// Обрабатывает запрос
/// </summary> /// </summary>
public async Task InvokeAsync(HttpContext context) public async Task InvokeAsync(HttpContext context)
{ {
var cookieName = _cookieOptions.CurrentValue.Name; string cookieName = _cookieOptions.CurrentValue.Name;
if (context.Request.Cookies.TryGetValue(cookieName, out var rawToken) if (context.Request.Cookies.TryGetValue(cookieName, out string? rawToken)
&& !string.IsNullOrWhiteSpace(rawToken)) && !string.IsNullOrWhiteSpace(rawToken))
{ {
// OperatorAuthService scoped: создаём scope на запрос через RequestServices. // OperatorAuthService scoped: создаём scope на запрос через RequestServices.
await using var scope = context.RequestServices.CreateAsyncScope(); await using var scope = context.RequestServices.CreateAsyncScope();
var operatorAuthService = scope.ServiceProvider.GetRequiredService<OperatorAuthService>(); var operatorAuthService = scope.ServiceProvider.GetRequiredService<OperatorAuthService>();
var identity = await operatorAuthService.ResolveSessionAsync(rawToken, context.RequestAborted); var identity = await operatorAuthService.ResolveSessionAsync(rawToken, context.RequestAborted);
if (identity is not null) if (identity is not null)
{ {
context.SetCurrentOperator(new CurrentOperator(identity.Id, identity.Login, identity.Status)); context.SetCurrentOperator(new CurrentOperator(identity.Id, identity.Login, identity.Status));
} }
} }
await _next(context); await _next(context);
} }
} }
@@ -1,62 +1,62 @@
using Deal.Api.Extensions; using Deal.Api.Extensions;
using Deal.Api.Models; using Deal.Api.Models;
using Deal.Modules.Tenants.Application.Services; using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants.Abstractions; using Deal.SharedKernel.Tenants.Abstractions;
using Deal.SharedKernel.Tenants.Models; using Deal.SharedKernel.Tenants.Models;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом. // Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом.
using CookieOptions = Deal.Api.Configuration.CookieOptions; using CookieOptions = Deal.Api.Configuration.CookieOptions;
namespace Deal.Api.Middleware; namespace Deal.Api.Middleware;
/// <summary> /// <summary>
/// Middleware сессии /// Middleware сессии
/// </summary> /// </summary>
public sealed class SessionMiddleware public sealed class SessionMiddleware
{ {
private readonly RequestDelegate _next; private readonly RequestDelegate _next;
private readonly IOptionsMonitor<CookieOptions> _cookieOptions; private readonly IOptionsMonitor<CookieOptions> _cookieOptions;
private readonly ITenantContext _tenantContext; private readonly ITenantContext _tenantContext;
public SessionMiddleware( public SessionMiddleware(
RequestDelegate next, RequestDelegate next,
IOptionsMonitor<CookieOptions> cookieOptions, IOptionsMonitor<CookieOptions> cookieOptions,
ITenantContext tenantContext) ITenantContext tenantContext)
{ {
_next = next; _next = next;
_cookieOptions = cookieOptions; _cookieOptions = cookieOptions;
_tenantContext = tenantContext; _tenantContext = tenantContext;
} }
/// <summary> /// <summary>
/// Обрабатывает запрос /// Обрабатывает запрос
/// </summary> /// </summary>
public async Task InvokeAsync(HttpContext context) public async Task InvokeAsync(HttpContext context)
{ {
try try
{ {
var cookieName = _cookieOptions.CurrentValue.Name; string cookieName = _cookieOptions.CurrentValue.Name;
if (context.Request.Cookies.TryGetValue(cookieName, out var rawToken) if (context.Request.Cookies.TryGetValue(cookieName, out string? rawToken)
&& !string.IsNullOrWhiteSpace(rawToken)) && !string.IsNullOrWhiteSpace(rawToken))
{ {
// AuthService scoped: создаём scope на запрос через RequestServices. // AuthService scoped: создаём scope на запрос через RequestServices.
await using var scope = context.RequestServices.CreateAsyncScope(); await using var scope = context.RequestServices.CreateAsyncScope();
var authService = scope.ServiceProvider.GetRequiredService<AuthService>(); var authService = scope.ServiceProvider.GetRequiredService<AuthService>();
var user = await authService.ResolveSessionAsync(rawToken, context.RequestAborted); var user = await authService.ResolveSessionAsync(rawToken, context.RequestAborted);
if (user is not null) if (user is not null)
{ {
context.SetCurrentUser(new CurrentUser(user.Id, user.Login, user.TenantId, user.Status)); context.SetCurrentUser(new CurrentUser(user.Id, user.Login, user.TenantId, user.Status));
// Схема тенанта именуется tenant_&lt;id&gt;, где id — Guid в формате "N" (см. TenantService). // Схема тенанта именуется tenant_&lt;id&gt;, где id — Guid в формате "N" (см. TenantService).
_tenantContext.SetTenant(new TenantId(user.TenantId.ToString("N"))); _tenantContext.SetTenant(new TenantId(user.TenantId.ToString("N")));
} }
} }
await _next(context); await _next(context);
} }
finally finally
{ {
// Контекст AsyncLocal не должен переживать запрос. // Контекст AsyncLocal не должен переживать запрос.
_tenantContext.Reset(); _tenantContext.Reset();
} }
} }
} }
File diff suppressed because it is too large Load Diff
@@ -1,17 +1,15 @@
using Grpc.Core; using Grpc.Core;
namespace Deal.Infrastructure.Integrations.Extensions; namespace Deal.Infrastructure.Integrations.Extensions;
/// <summary> // Расширения классификации gRPC-исключений клиентов автономных сервисов.
/// Расширения классификации gRPC-исключений клиентов автономных сервисов. internal static class RpcExceptionExtensions
/// </summary> {
internal static class RpcExceptionExtensions /// <summary>
{ /// Ошибки коммуникации, при которых сервис считается недоступным
/// <summary> /// </summary>
/// Ошибки коммуникации, при которых сервис считается недоступным /// <param name="exception">Исключение RPC.</param>
/// </summary> /// <returns>True — транспорта/контракта health нет (down); false — прикладной статус (не наша зона).</returns>
/// <param name="exception">Исключение RPC.</param> public static bool IsCommunicationFailure(this RpcException exception) =>
/// <returns>True — транспорта/контракта health нет (down); false — прикладной статус (не наша зона).</returns> exception.StatusCode is StatusCode.Unavailable or StatusCode.DeadlineExceeded or StatusCode.Unimplemented;
public static bool IsCommunicationFailure(this RpcException exception) => }
exception.StatusCode is StatusCode.Unavailable or StatusCode.DeadlineExceeded or StatusCode.Unimplemented;
}
@@ -1,53 +1,51 @@
using System.Net; using System.Net;
using System.Net.Sockets; using System.Net.Sockets;
namespace Deal.Infrastructure.Integrations.Extensions; namespace Deal.Infrastructure.Integrations.Extensions;
/// <summary> // Расширения Uri для SSRF-гейта интеграций
/// Расширения <see cref="Uri"/> для SSRF-гейта интеграций internal static class UriExtensions
/// </summary> {
internal static class UriExtensions /// <summary>
{ /// Проверяет, указывает ли URL на приватный/loopback/link-local адрес
/// <summary> /// </summary>
/// Проверяет, указывает ли URL на приватный/loopback/link-local адрес /// <param name="uri">Абсолютный http(s)-адрес.</param>
/// </summary> /// <returns>True — адрес приватный/локальный (HTTP к нему запрещён).</returns>
/// <param name="uri">Абсолютный http(s)-адрес.</param> public static bool IsPrivateEndpoint(this Uri uri)
/// <returns>True — адрес приватный/локальный (HTTP к нему запрещён).</returns> {
public static bool IsPrivateEndpoint(this Uri uri) string host = uri.Host;
{ if (string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase))
string host = uri.Host; {
if (string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase)) return true;
{ }
return true;
} if (!IPAddress.TryParse(host, out IPAddress? address))
{
if (!IPAddress.TryParse(host, out IPAddress? address)) return false; // DNS-имя — резолв вне этого слоя
{ }
return false; // DNS-имя — резолв вне этого слоя
} if (address.IsIPv4MappedToIPv6)
{
if (address.IsIPv4MappedToIPv6) address = address.MapToIPv4();
{ }
address = address.MapToIPv4();
} if (IPAddress.IsLoopback(address))
{
if (IPAddress.IsLoopback(address)) return true;
{ }
return true;
} if (address.AddressFamily == AddressFamily.InterNetwork)
{
if (address.AddressFamily == AddressFamily.InterNetwork) byte[] bytes = address.GetAddressBytes();
{ return bytes[0] == 10
byte[] bytes = address.GetAddressBytes(); || (bytes[0] == 172 && bytes[1] is >= 16 and <= 31)
return bytes[0] == 10 || (bytes[0] == 192 && bytes[1] == 168)
|| (bytes[0] == 172 && bytes[1] is >= 16 and <= 31) || bytes[0] == 169 && bytes[1] == 254 // link-local (включая 169.254.169.254 metadata)
|| (bytes[0] == 192 && bytes[1] == 168) || bytes[0] == 0;
|| bytes[0] == 169 && bytes[1] == 254 // link-local (включая 169.254.169.254 metadata) }
|| bytes[0] == 0;
} // IPv6: уникальные локальные (fc00::/7) и link-local (fe80::/10).
byte[] v6 = address.GetAddressBytes();
// IPv6: уникальные локальные (fc00::/7) и link-local (fe80::/10). return (v6[0] & 0xFE) == 0xFC || (v6[0] == 0xFE && (v6[1] & 0xC0) == 0x80);
byte[] v6 = address.GetAddressBytes(); }
return (v6[0] & 0xFE) == 0xFC || (v6[0] == 0xFE && (v6[1] & 0xC0) == 0x80); }
}
}
@@ -1,21 +1,19 @@
using Deal.Infrastructure.Integrations.Storage.Options; using Deal.Infrastructure.Integrations.Storage.Options;
namespace Deal.Infrastructure.Integrations.Storage.Extensions; namespace Deal.Infrastructure.Integrations.Storage.Extensions;
/// <summary> // Расширения MinioStorageOptions
/// Расширения <see cref="MinioStorageOptions"/> internal static class MinioStorageOptionsExtensions
/// </summary> {
internal static class MinioStorageOptionsExtensions /// <summary>
{ /// True — секция Minio заполнена настолько, что возможен Minio-адаптер.
/// <summary> /// </summary>
/// True — секция Minio заполнена настолько, что возможен Minio-адаптер. /// <param name="minio">Настройки MinIO из секции Storage:Minio.</param>
/// </summary> /// <returns>True — заданы Endpoint, AccessKey и SecretKey.</returns>
/// <param name="minio">Настройки MinIO из секции Storage:Minio.</param> public static bool IsConfigured(this MinioStorageOptions minio)
/// <returns>True — заданы Endpoint, AccessKey и SecretKey.</returns> {
public static bool IsConfigured(this MinioStorageOptions minio) return !string.IsNullOrWhiteSpace(minio.Endpoint)
{ && !string.IsNullOrWhiteSpace(minio.AccessKey)
return !string.IsNullOrWhiteSpace(minio.Endpoint) && !string.IsNullOrWhiteSpace(minio.SecretKey);
&& !string.IsNullOrWhiteSpace(minio.AccessKey) }
&& !string.IsNullOrWhiteSpace(minio.SecretKey); }
}
}
@@ -1,17 +1,15 @@
namespace Deal.Infrastructure.Integrations.Storage.Extensions; namespace Deal.Infrastructure.Integrations.Storage.Extensions;
/// <summary> // Расширения string для разбора конфигурационных значений.
/// Расширения <see cref="string"/> для разбора конфигурационных значений. internal static class StringExtensions
/// </summary> {
internal static class StringExtensions /// <summary>
{ /// Разбирает строковое значение как булев флаг конфигурации
/// <summary> /// </summary>
/// Разбирает строковое значение как булев флаг конфигурации /// <param name="raw">Сырое значение настройки.</param>
/// </summary> /// <returns>True — значение распознано как включённое.</returns>
/// <param name="raw">Сырое значение настройки.</param> public static bool IsTrue(this string raw)
/// <returns>True — значение распознано как включённое.</returns> {
public static bool IsTrue(this string raw) return string.Equals(raw, "true", StringComparison.OrdinalIgnoreCase) || raw == "1";
{ }
return string.Equals(raw, "true", StringComparison.OrdinalIgnoreCase) || raw == "1"; }
}
}
@@ -10,7 +10,7 @@ public static class TenantSchemaMigrator
/// </summary> /// </summary>
public static string CreateSchemaSql(string schemaName) public static string CreateSchemaSql(string schemaName)
{ {
var escaped = schemaName.Replace("\"", "\"\""); string escaped = schemaName.Replace("\"", "\"\"");
return $"CREATE SCHEMA IF NOT EXISTS \"{escaped}\""; return $"CREATE SCHEMA IF NOT EXISTS \"{escaped}\"";
} }
@@ -1,20 +1,20 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design; using Microsoft.EntityFrameworkCore.Design;
namespace Deal.Infrastructure.Persistence; namespace Deal.Infrastructure.Persistence;
/// <summary> /// <summary>
/// Фабрика для dotnet-ef /// Фабрика для dotnet-ef
/// </summary> /// </summary>
public sealed class DealDbDesignTimeFactory : IDesignTimeDbContextFactory<DealDbContext> public sealed class DealDbDesignTimeFactory : IDesignTimeDbContextFactory<DealDbContext>
{ {
public DealDbContext CreateDbContext(string[] args) public DealDbContext CreateDbContext(string[] args)
{ {
var connectionString = Environment.GetEnvironmentVariable("DEAL_PG_CONNECTION") string connectionString = Environment.GetEnvironmentVariable("DEAL_PG_CONNECTION")
?? "Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password"; ?? "Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password";
var options = new DbContextOptionsBuilder<DealDbContext>() var options = new DbContextOptionsBuilder<DealDbContext>()
.UseNpgsql(connectionString) .UseNpgsql(connectionString)
.Options; .Options;
return new DealDbContext(options); return new DealDbContext(options);
} }
} }
@@ -1,20 +1,20 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design; using Microsoft.EntityFrameworkCore.Design;
namespace Deal.Infrastructure.Persistence; namespace Deal.Infrastructure.Persistence;
/// <summary> /// <summary>
/// Фабрика для dotnet-ef /// Фабрика для dotnet-ef
/// </summary> /// </summary>
public sealed class TenantDbDesignTimeFactory : IDesignTimeDbContextFactory<TenantDbContext> public sealed class TenantDbDesignTimeFactory : IDesignTimeDbContextFactory<TenantDbContext>
{ {
public TenantDbContext CreateDbContext(string[] args) public TenantDbContext CreateDbContext(string[] args)
{ {
var connectionString = Environment.GetEnvironmentVariable("DEAL_PG_CONNECTION") string connectionString = Environment.GetEnvironmentVariable("DEAL_PG_CONNECTION")
?? "Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password"; ?? "Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password";
var options = new DbContextOptionsBuilder<TenantDbContext>() var options = new DbContextOptionsBuilder<TenantDbContext>()
.UseNpgsql(connectionString, npgsql => npgsql.MigrationsHistoryTable("__TenantMigrationsHistory")) .UseNpgsql(connectionString, npgsql => npgsql.MigrationsHistoryTable("__TenantMigrationsHistory"))
.Options; .Options;
return new TenantDbContext(options); return new TenantDbContext(options);
} }
} }
@@ -1,75 +1,75 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using Deal.Infrastructure.Data; using Deal.Infrastructure.Data;
using Deal.Infrastructure.Migrations; using Deal.Infrastructure.Migrations;
using Deal.Infrastructure.Persistence; using Deal.Infrastructure.Persistence;
using Deal.Modules.Tenants.Application.Abstractions; using Deal.Modules.Tenants.Application.Abstractions;
using Deal.SharedKernel.Tenants.Models; using Deal.SharedKernel.Tenants.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Npgsql; using Npgsql;
namespace Deal.Infrastructure.Tenancy; namespace Deal.Infrastructure.Tenancy;
/// <summary> /// <summary>
/// Провижининг схемы тенанта /// Провижининг схемы тенанта
/// </summary> /// </summary>
public sealed class TenantProvisioningService(ConnectionStringProvider connectionStringProvider) : ITenantProvisioner public sealed class TenantProvisioningService(ConnectionStringProvider connectionStringProvider) : ITenantProvisioner
{ {
private const string TenantMigrationsHistoryTable = "__TenantMigrationsHistory"; private const string TenantMigrationsHistoryTable = "__TenantMigrationsHistory";
// Семафор на схему тенанта: сериализует провижининг одного тенанта при параллельных вызовах. // Семафор на схему тенанта: сериализует провижининг одного тенанта при параллельных вызовах.
// Сами операции идемпотентны, но создание схемы обязано предшествовать применению миграций, // Сами операции идемпотентны, но создание схемы обязано предшествовать применению миграций,
// а два одновременных Migrate одной схемы — источник гонки. Статический: живёт дольше scoped-сервиса. // а два одновременных Migrate одной схемы — источник гонки. Статический: живёт дольше scoped-сервиса.
private static readonly ConcurrentDictionary<string, SemaphoreSlim> SchemaProvisionLocks = new(); private static readonly ConcurrentDictionary<string, SemaphoreSlim> SchemaProvisionLocks = new();
/// <inheritdoc /> /// <inheritdoc />
public async Task ProvisionAsync(TenantId tenantId, CancellationToken ct) public async Task ProvisionAsync(TenantId tenantId, CancellationToken ct)
{ {
var schemaName = tenantId.SchemaName; string schemaName = tenantId.SchemaName;
var provisionLock = SchemaProvisionLocks.GetOrAdd(schemaName, static _ => new SemaphoreSlim(1, 1)); var provisionLock = SchemaProvisionLocks.GetOrAdd(schemaName, static _ => new SemaphoreSlim(1, 1));
await provisionLock.WaitAsync(ct); await provisionLock.WaitAsync(ct);
try try
{ {
await CreateSchemaAsync(schemaName, ct); await CreateSchemaAsync(schemaName, ct);
await ApplyTenantMigrationsAsync(tenantId, schemaName, ct); await ApplyTenantMigrationsAsync(tenantId, schemaName, ct);
} }
finally finally
{ {
provisionLock.Release(); provisionLock.Release();
} }
} }
// Создаёт схему тенанта в БД, если её ещё нет (CREATE SCHEMA IF NOT EXISTS). // Создаёт схему тенанта в БД, если её ещё нет (CREATE SCHEMA IF NOT EXISTS).
// schemaName: Имя схемы (DDL-идентификатор экранирует TenantSchemaMigrator). // schemaName: Имя схемы (DDL-идентификатор экранирует TenantSchemaMigrator).
// ct: Токен отмены. // ct: Токен отмены.
private async Task CreateSchemaAsync(string schemaName, CancellationToken ct) private async Task CreateSchemaAsync(string schemaName, CancellationToken ct)
{ {
// Соединение закрывается обязательно (await using). DDL (CREATE SCHEMA) выполняется мигратор-строкой, // Соединение закрывается обязательно (await using). DDL (CREATE SCHEMA) выполняется мигратор-строкой,
// если задана ConnectionStrings:DealMigrator (прод, least privilege); иначе — прикладной (dev). // если задана ConnectionStrings:DealMigrator (прод, least privilege); иначе — прикладной (dev).
await using var connection = new NpgsqlConnection(connectionStringProvider.ForSchemaDdl(null)); await using var connection = new NpgsqlConnection(connectionStringProvider.ForSchemaDdl(null));
await connection.OpenAsync(ct); await connection.OpenAsync(ct);
await using var command = connection.CreateCommand(); await using var command = connection.CreateCommand();
command.CommandText = TenantSchemaMigrator.CreateSchemaSql(schemaName); command.CommandText = TenantSchemaMigrator.CreateSchemaSql(schemaName);
await command.ExecuteNonQueryAsync(ct); await command.ExecuteNonQueryAsync(ct);
} }
// Применяет tenant-миграции: контекст на строке с search_path и историей миграций в схеме тенанта. // Применяет tenant-миграции: контекст на строке с search_path и историей миграций в схеме тенанта.
// tenantId: Идентификатор тенанта. // tenantId: Идентификатор тенанта.
// schemaName: Имя схемы тенанта (для MigrationsHistoryTable). // schemaName: Имя схемы тенанта (для MigrationsHistoryTable).
// ct: Токен отмены. // ct: Токен отмены.
private async Task ApplyTenantMigrationsAsync( private async Task ApplyTenantMigrationsAsync(
TenantId tenantId, TenantId tenantId,
string schemaName, string schemaName,
CancellationToken ct) CancellationToken ct)
{ {
// Строка уже с Search Path=tenant_&lt;id&gt; (ForSchemaDdl) — таблицы бессхемной модели TenantDbContext // Строка уже с Search Path=tenant_&lt;id&gt; (ForSchemaDdl) — таблицы бессхемной модели TenantDbContext
// лягут в схему тенанта. DDL применяется мигратор-ролью при её наличии (см. ConnectionStringProvider). // лягут в схему тенанта. DDL применяется мигратор-ролью при её наличии (см. ConnectionStringProvider).
var connectionString = connectionStringProvider.ForSchemaDdl(tenantId); string connectionString = connectionStringProvider.ForSchemaDdl(tenantId);
var options = new DbContextOptionsBuilder<TenantDbContext>() var options = new DbContextOptionsBuilder<TenantDbContext>()
.UseNpgsql(connectionString, npgsql => npgsql.MigrationsHistoryTable(TenantMigrationsHistoryTable, schemaName)) .UseNpgsql(connectionString, npgsql => npgsql.MigrationsHistoryTable(TenantMigrationsHistoryTable, schemaName))
.Options; .Options;
await using var db = new TenantDbContext(options); await using var db = new TenantDbContext(options);
await db.Database.MigrateAsync(ct); await db.Database.MigrateAsync(ct);
await DefaultContainerProvisioner.EnsureAsync(db, DateTimeOffset.UtcNow, ct); await DefaultContainerProvisioner.EnsureAsync(db, DateTimeOffset.UtcNow, ct);
} }
} }
@@ -12,8 +12,14 @@ public interface IContainerRules
/// </summary> /// </summary>
public string Mode { get; } public string Mode { get; }
/// <summary>
/// Ключевые слова
/// </summary>
public IReadOnlyList<string> Keywords { get; } public IReadOnlyList<string> Keywords { get; }
/// <summary>
/// Стек технологий
/// </summary>
public IReadOnlyList<string> Stack { get; } public IReadOnlyList<string> Stack { get; }
/// <summary> /// <summary>
@@ -1,20 +1,18 @@
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
namespace Deal.Modules.Kanban.Application.ColumnRules; namespace Deal.Modules.Kanban.Application.ColumnRules;
/// <summary> // Расширения бюджетной группы правил колонки.
/// Расширения бюджетной группы правил колонки. internal static class BudgetRangeDtoExtensions
/// </summary> {
internal static class BudgetRangeDtoExtensions /// <summary>
{ /// Активна ли бюджетная группа
/// <summary> /// </summary>
/// Активна ли бюджетная группа /// <param name="budget">Поле budget/prices правил (может быть null).</param>
/// </summary> /// <returns>True — группа бюджета участвует в матчинге.</returns>
/// <param name="budget">Поле budget/prices правил (может быть null).</param> public static bool HasBudget(this BudgetRangeDto? budget)
/// <returns>True — группа бюджета участвует в матчинге.</returns> {
public static bool HasBudget(this BudgetRangeDto? budget) return budget is not null
{ && (budget.From is not null || budget.To is not null || !string.IsNullOrWhiteSpace(budget.Cur));
return budget is not null }
&& (budget.From is not null || budget.To is not null || !string.IsNullOrWhiteSpace(budget.Cur)); }
}
}
@@ -1,30 +1,28 @@
namespace Deal.Modules.Kanban.Application.ColumnRules; namespace Deal.Modules.Kanban.Application.ColumnRules;
/// <summary> // Расширения списков термов правил колонки.
/// Расширения списков термов правил колонки. internal static class TermListExtensions
/// </summary> {
internal static class TermListExtensions /// <summary>
{ /// Есть ли в списке непустой
/// <summary> /// </summary>
/// Есть ли в списке непустой /// <param name="terms">Список термов группы (может быть null).</param>
/// </summary> /// <returns>True — хотя бы один терм непустой.</returns>
/// <param name="terms">Список термов группы (может быть null).</param> public static bool HasAnyTerm(this IReadOnlyList<string>? terms)
/// <returns>True — хотя бы один терм непустой.</returns> {
public static bool HasAnyTerm(this IReadOnlyList<string>? terms) if (terms is null)
{ {
if (terms is null) return false;
{ }
return false;
} foreach (string? raw in terms)
{
foreach (string? raw in terms) if (!string.IsNullOrWhiteSpace(raw))
{ {
if (!string.IsNullOrWhiteSpace(raw)) return true;
{ }
return true; }
}
} return false;
}
return false; }
}
}
@@ -1,15 +1,13 @@
namespace Deal.Modules.Kanban.Application.Extensions; namespace Deal.Modules.Kanban.Application.Extensions;
/// <summary> // Расширения символов для нормализации бюджетной валюты.
/// Расширения символов для нормализации бюджетной валюты. internal static class CharExtensions
/// </summary> {
internal static class CharExtensions /// <summary>
{ /// Буква кода валюты
/// <summary> /// </summary>
/// Буква кода валюты /// <param name="c">Символ (строка уже в верхнем регистре).</param>
/// </summary> /// <returns>True — буква, участвующая в распознавании валюты.</returns>
/// <param name="c">Символ (строка уже в верхнем регистре).</param> public static bool IsCurrencyLetter(this char c) =>
/// <returns>True — буква, участвующая в распознавании валюты.</returns> c is >= 'A' and <= 'Z' or >= 'А' and <= 'Я';
public static bool IsCurrencyLetter(this char c) => }
c is >= 'A' and <= 'Z' or >= 'А' and <= 'Я';
}
@@ -1,18 +1,16 @@
namespace Deal.Modules.Pipeline.Application.Parse; namespace Deal.Modules.Pipeline.Application.Parse;
/// <summary> // Расширения кодовых точек для чистки текста сообщений.
/// Расширения кодовых точек для чистки текста сообщений. internal static class CodePointExtensions
/// </summary> {
internal static class CodePointExtensions /// <summary>
{ /// Входит ли кодовая точка в эмодзи-диапазоны.
/// <summary> /// </summary>
/// Входит ли кодовая точка в эмодзи-диапазоны. /// <param name="codePoint">Кодовая точка (BMP или доп. плоскость).</param>
/// </summary> /// <returns>True — декоративный символ, подлежащий удалению.</returns>
/// <param name="codePoint">Кодовая точка (BMP или доп. плоскость).</param> public static bool IsEmojiCodePoint(this int codePoint) =>
/// <returns>True — декоративный символ, подлежащий удалению.</returns> codePoint is >= 0x1F000 and <= 0x1FAFF
public static bool IsEmojiCodePoint(this int codePoint) => or >= 0x2600 and <= 0x27BF
codePoint is >= 0x1F000 and <= 0x1FAFF or >= 0x2B00 and <= 0x2BFF
or >= 0x2600 and <= 0x27BF or 0xFE0F;
or >= 0x2B00 and <= 0x2BFF }
or 0xFE0F;
}
@@ -1,32 +1,30 @@
namespace Deal.Modules.Pipeline.Application.Parse; namespace Deal.Modules.Pipeline.Application.Parse;
/// <summary> // Расширения строк для разбора текста сообщений.
/// Расширения строк для разбора текста сообщений. internal static class StringExtensions
/// </summary> {
internal static class StringExtensions private static readonly string[] FooterHintsArray =
{ {
private static readonly string[] FooterHintsArray = "откликнуться через", "runello", "больше вакансий", "teletype", "при отклике укажите",
{ "больше заявок", "узнать подробнее", "написать в лс", "пишите в лс",
"откликнуться через", "runello", "больше вакансий", "teletype", "при отклике укажите", };
"больше заявок", "узнать подробнее", "написать в лс", "пишите в лс",
}; /// <summary>
/// Содержит ли текст служебный футер-хинт.
/// <summary> /// </summary>
/// Содержит ли текст служебный футер-хинт. /// <param name="text">Текст (в любом регистре; null трактуется как пустая строка).</param>
/// </summary> /// <returns>True — текст похож на футер агрегатора/служебную строку.</returns>
/// <param name="text">Текст (в любом регистре; null трактуется как пустая строка).</param> public static bool ContainsFooterHint(this string text)
/// <returns>True — текст похож на футер агрегатора/служебную строку.</returns> {
public static bool ContainsFooterHint(this string text) string lower = text.ToLowerInvariant();
{ foreach (string hint in FooterHintsArray)
string lower = text.ToLowerInvariant(); {
foreach (string hint in FooterHintsArray) if (lower.Contains(hint, StringComparison.Ordinal))
{ {
if (lower.Contains(hint, StringComparison.Ordinal)) return true;
{ }
return true; }
}
} return false;
}
return false; }
}
}
@@ -141,7 +141,7 @@ public static class SettingsKeys
public const string ExcludeTypes = "excludeTypes"; public const string ExcludeTypes = "excludeTypes";
// ── Dict / special ── // ── Словари / особые ──
public const string ColState = "colState"; public const string ColState = "colState";
@@ -1,35 +1,33 @@
namespace Deal.Modules.Tenants.Application.Models; namespace Deal.Modules.Tenants.Application.Models;
/// <summary> // Расширения записей аудита
/// Расширения записей аудита internal static class AuditRecordDtoExtensions
/// </summary> {
internal static class AuditRecordDtoExtensions // События аудита «неудачный вход» (тенант/оператор).
{ private static readonly string[] FailedLoginEvents =
// События аудита «неудачный вход» (тенант/оператор). {
private static readonly string[] FailedLoginEvents = AuditEvents.TenantLoginFailed,
{ AuditEvents.OperatorLoginFailed,
AuditEvents.TenantLoginFailed, };
AuditEvents.OperatorLoginFailed,
}; // События аудита «успешный вход» (тенант/оператор).
private static readonly string[] SuccessfulLoginEvents =
// События аудита «успешный вход» (тенант/оператор). {
private static readonly string[] SuccessfulLoginEvents = AuditEvents.TenantLoginOk,
{ AuditEvents.OperatorLoginOk,
AuditEvents.TenantLoginOk, };
AuditEvents.OperatorLoginOk,
}; /// <summary>
/// Неудачный вход
/// <summary> /// </summary>
/// Неудачный вход /// <param name="record">Запись аудита.</param>
/// </summary> /// <returns>True — событие из FailedLoginEvents.</returns>
/// <param name="record">Запись аудита.</param> public static bool IsFailedLogin(this AuditRecordDto record) => FailedLoginEvents.Contains(record.EventType);
/// <returns>True — событие из FailedLoginEvents.</returns>
public static bool IsFailedLogin(this AuditRecordDto record) => FailedLoginEvents.Contains(record.EventType); /// <summary>
/// Успешный вход
/// <summary> /// </summary>
/// Успешный вход /// <param name="record">Запись аудита.</param>
/// </summary> /// <returns>True — событие из SuccessfulLoginEvents.</returns>
/// <param name="record">Запись аудита.</param> public static bool IsSuccessfulLogin(this AuditRecordDto record) => SuccessfulLoginEvents.Contains(record.EventType);
/// <returns>True — событие из SuccessfulLoginEvents.</returns> }
public static bool IsSuccessfulLogin(this AuditRecordDto record) => SuccessfulLoginEvents.Contains(record.EventType);
}
@@ -1,269 +1,269 @@
using Deal.Modules.Tenants.Application.Abstractions; using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
namespace Deal.Modules.Tenants.Application.Services; namespace Deal.Modules.Tenants.Application.Services;
/// <summary> /// <summary>
/// Прикладной сервис аутентификации /// Прикладной сервис аутентификации
/// </summary> /// </summary>
public sealed class AuthService( public sealed class AuthService(
IAuthStore authStore, IAuthStore authStore,
IPasswordHasher passwordHasher, IPasswordHasher passwordHasher,
ITenantRepository tenantRepository) ITenantRepository tenantRepository)
{ {
/// <summary> /// <summary>
/// Срок жизни сессии, дней. /// Срок жизни сессии, дней.
/// </summary> /// </summary>
public const int SessionLifetimeDays = 30; public const int SessionLifetimeDays = 30;
/// <summary> /// <summary>
/// Минимальная длина нового пароля. /// Минимальная длина нового пароля.
/// </summary> /// </summary>
public const int MinNewPasswordLength = 8; public const int MinNewPasswordLength = 8;
// Статус «активен»: только активного пользователя разрешает ResolveSessionAsync (деактивированный // Статус «активен»: только активного пользователя разрешает ResolveSessionAsync (деактивированный
// вручную с живой сессией — null, как у оператора OperatorAuthService). // вручную с живой сессией — null, как у оператора OperatorAuthService).
private const string UserActiveStatus = "active"; private const string UserActiveStatus = "active";
/// <summary> /// <summary>
/// Вход: при успехе создаёт сессию и возвращает её raw-токен. /// Вход: при успехе создаёт сессию и возвращает её raw-токен.
/// </summary> /// </summary>
/// <param name="login">Логин (регистр и пробелы не важны — нормализуется).</param> /// <param name="login">Логин (регистр и пробелы не важны — нормализуется).</param>
/// <param name="password">Пароль в открытом виде.</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> /// <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( public async Task<LoginResultDto> LoginAsync(
string login, string login,
string password, string password,
CancellationToken ct) CancellationToken ct)
{ {
var normalizedLogin = NormalizeLogin(login); string normalizedLogin = NormalizeLogin(login);
if (string.IsNullOrEmpty(normalizedLogin) || string.IsNullOrEmpty(password)) if (string.IsNullOrEmpty(normalizedLogin) || string.IsNullOrEmpty(password))
{ {
return new LoginResultDto(null, null); return new LoginResultDto(null, null);
} }
var user = await authStore.FindUserByLoginAsync(normalizedLogin, ct); var user = await authStore.FindUserByLoginAsync(normalizedLogin, ct);
// Порядок проверок: сначала учётные данные, затем статус тенанта — неверный пароль не раскрывает // Порядок проверок: сначала учётные данные, затем статус тенанта — неверный пароль не раскрывает
// приостановку (тот же «Неверный логин или пароль», что и для активного тенанта). // приостановку (тот же «Неверный логин или пароль», что и для активного тенанта).
if (user is null || !passwordHasher.Verify(password, user.PasswordHash)) if (user is null || !passwordHasher.Verify(password, user.PasswordHash))
{ {
return new LoginResultDto(null, null); return new LoginResultDto(null, null);
} }
var tenant = await tenantRepository.FindByIdAsync(user.TenantId, ct); var tenant = await tenantRepository.FindByIdAsync(user.TenantId, ct);
if (tenant is not null && tenant.Status == TenantStatuses.Suspended) if (tenant is not null && tenant.Status == TenantStatuses.Suspended)
{ {
return new LoginResultDto( return new LoginResultDto(
Login: null, Login: null,
Token: null, Token: null,
UserId: user.Id, UserId: user.Id,
TenantId: user.TenantId, TenantId: user.TenantId,
Error: LoginResultDto.ErrorTenantSuspended); Error: LoginResultDto.ErrorTenantSuspended);
} }
var token = await CreateSessionForUserAsync(user.Id, user.Login, impersonatedByOperatorId: null, ct); string token = await CreateSessionForUserAsync(user.Id, user.Login, impersonatedByOperatorId: null, ct);
return new LoginResultDto(user.Login, token, user.Id, user.TenantId); return new LoginResultDto(user.Login, token, user.Id, user.TenantId);
} }
/// <summary> /// <summary>
/// Выход: удаляет сессию по raw-токену /// Выход: удаляет сессию по raw-токену
/// </summary> /// </summary>
/// <param name="rawToken">Raw-токен из куки (может отсутствовать — no-op).</param> /// <param name="rawToken">Raw-токен из куки (может отсутствовать — no-op).</param>
/// <returns>Не null, если удалённая сессия была impersonation — сведения для аудита impersonation_stopped (актор — оператор по маркеру сессии). Обычный logout и no-op возвращают null.</returns> /// <returns>Не null, если удалённая сессия была impersonation — сведения для аудита impersonation_stopped (актор — оператор по маркеру сессии). Обычный logout и no-op возвращают null.</returns>
public async Task<LogoutResultDto?> LogoutAsync(string? rawToken, CancellationToken ct) public async Task<LogoutResultDto?> LogoutAsync(string? rawToken, CancellationToken ct)
{ {
if (string.IsNullOrWhiteSpace(rawToken)) if (string.IsNullOrWhiteSpace(rawToken))
{ {
return null; return null;
} }
var tokenHash = SessionTokens.HashToken(rawToken); string tokenHash = SessionTokens.HashToken(rawToken);
var session = await authStore.FindSessionByTokenHashAsync(tokenHash, ct); var session = await authStore.FindSessionByTokenHashAsync(tokenHash, ct);
await authStore.DeleteSessionAsync(tokenHash, ct); await authStore.DeleteSessionAsync(tokenHash, ct);
if (session is null || session.ImpersonatedByOperatorId is null) if (session is null || session.ImpersonatedByOperatorId is null)
{ {
return null; return null;
} }
// Пользователь не удаляется при живой сессии (FK sessions→users Cascade, SessionConfiguration): // Пользователь не удаляется при живой сессии (FK sessions→users Cascade, SessionConfiguration):
// TenantId берём из реестра пользователей для поля-фильтра аудита. // TenantId берём из реестра пользователей для поля-фильтра аудита.
var user = await authStore.FindUserByIdAsync(session.UserId, ct); var user = await authStore.FindUserByIdAsync(session.UserId, ct);
return user is null return user is null
? null ? null
: new LogoutResultDto( : new LogoutResultDto(
OperatorId: session.ImpersonatedByOperatorId.Value, OperatorId: session.ImpersonatedByOperatorId.Value,
Login: session.Login, Login: session.Login,
UserId: user.Id, UserId: user.Id,
TenantId: user.TenantId); TenantId: user.TenantId);
} }
/// <summary> /// <summary>
/// Смена пароля: инвалидирует все сессии, обновляет хэш и выдаёт свежую сессию. /// Смена пароля: инвалидирует все сессии, обновляет хэш и выдаёт свежую сессию.
/// </summary> /// </summary>
/// <param name="login">Логин пользователя.</param> /// <param name="login">Логин пользователя.</param>
/// <param name="oldPassword">Текущий пароль.</param> /// <param name="oldPassword">Текущий пароль.</param>
/// <param name="NewPassword">Новый пароль (минимум 8 символов).</param> /// <param name="NewPassword">Новый пароль (минимум 8 символов).</param>
/// <returns>При успехе — Ok=true и NewToken (raw-токен свежей сессии). Иначе Ok=false и код ошибки: <see cref="ChangePasswordResultDto.ErrorOldPassword"/> или <see cref="ChangePasswordResultDto.ErrorTooShort"/>.</returns> /// <returns>При успехе — Ok=true и NewToken (raw-токен свежей сессии). Иначе Ok=false и код ошибки: <see cref="ChangePasswordResultDto.ErrorOldPassword"/> или <see cref="ChangePasswordResultDto.ErrorTooShort"/>.</returns>
public async Task<ChangePasswordResultDto> ChangePasswordAsync( public async Task<ChangePasswordResultDto> ChangePasswordAsync(
string login, string login,
string oldPassword, string oldPassword,
string newPassword, string newPassword,
CancellationToken ct) CancellationToken ct)
{ {
var normalizedLogin = NormalizeLogin(login); string normalizedLogin = NormalizeLogin(login);
var user = await authStore.FindUserByLoginAsync(normalizedLogin, ct); var user = await authStore.FindUserByLoginAsync(normalizedLogin, ct);
if (user is null || oldPassword is null || !passwordHasher.Verify(oldPassword, user.PasswordHash)) if (user is null || oldPassword is null || !passwordHasher.Verify(oldPassword, user.PasswordHash))
{ {
return new ChangePasswordResultDto(Ok: false, Error: ChangePasswordResultDto.ErrorOldPassword, NewToken: null); return new ChangePasswordResultDto(Ok: false, Error: ChangePasswordResultDto.ErrorOldPassword, NewToken: null);
} }
if (string.IsNullOrEmpty(newPassword) || newPassword.Length < MinNewPasswordLength) if (string.IsNullOrEmpty(newPassword) || newPassword.Length < MinNewPasswordLength)
{ {
return new ChangePasswordResultDto(Ok: false, Error: ChangePasswordResultDto.ErrorTooShort, NewToken: null); return new ChangePasswordResultDto(Ok: false, Error: ChangePasswordResultDto.ErrorTooShort, NewToken: null);
} }
await authStore.DeleteSessionsByUserIdAsync(user.Id, ct); await authStore.DeleteSessionsByUserIdAsync(user.Id, ct);
await authStore.UpdatePasswordHashAsync(user.Id, passwordHasher.Hash(newPassword), ct); await authStore.UpdatePasswordHashAsync(user.Id, passwordHasher.Hash(newPassword), ct);
var token = await CreateSessionForUserAsync(user.Id, user.Login, impersonatedByOperatorId: null, ct); string token = await CreateSessionForUserAsync(user.Id, user.Login, impersonatedByOperatorId: null, ct);
return new ChangePasswordResultDto(Ok: true, Error: null, NewToken: token); return new ChangePasswordResultDto(Ok: true, Error: null, NewToken: token);
} }
/// <summary> /// <summary>
/// Impersonation: tenant-сессия целевого пользователя от имени оператора. /// Impersonation: tenant-сессия целевого пользователя от имени оператора.
/// </summary> /// </summary>
/// <param name="tenantId">Идентификатор тенанта.</param> /// <param name="tenantId">Идентификатор тенанта.</param>
/// <param name="targetLogin">Логин пользователя (null/пустой — первый пользователь тенанта).</param> /// <param name="targetLogin">Логин пользователя (null/пустой — первый пользователь тенанта).</param>
/// <param name="operatorId">Идентификатор оператора, начинающего impersonation (маркер сессии).</param> /// <param name="operatorId">Идентификатор оператора, начинающего impersonation (маркер сессии).</param>
/// <returns>Результат: Ok=true — SessionToken (raw-токен для куки deal_session) и срок жизни; иначе код ошибки.</returns> /// <returns>Результат: Ok=true — SessionToken (raw-токен для куки deal_session) и срок жизни; иначе код ошибки.</returns>
public async Task<ImpersonationResultDto> ImpersonateAsync( public async Task<ImpersonationResultDto> ImpersonateAsync(
Guid tenantId, Guid tenantId,
string? targetLogin, string? targetLogin,
Guid operatorId, Guid operatorId,
CancellationToken ct) CancellationToken ct)
{ {
var tenant = await tenantRepository.FindByIdAsync(tenantId, ct); var tenant = await tenantRepository.FindByIdAsync(tenantId, ct);
if (tenant is null) if (tenant is null)
{ {
return ImpersonationResultDto.Failed(ImpersonationResultDto.ErrorTenantNotFound); return ImpersonationResultDto.Failed(ImpersonationResultDto.ErrorTenantNotFound);
} }
var normalizedLogin = NormalizeLogin(targetLogin); string normalizedLogin = NormalizeLogin(targetLogin);
if (string.IsNullOrEmpty(normalizedLogin)) if (string.IsNullOrEmpty(normalizedLogin))
{ {
// login не задан — первый пользователь тенанта (по CreatedAt, порядок ListUsersByTenantIdAsync). // login не задан — первый пользователь тенанта (по CreatedAt, порядок ListUsersByTenantIdAsync).
var tenantUsers = await authStore.ListUsersByTenantIdAsync(tenantId, ct); var tenantUsers = await authStore.ListUsersByTenantIdAsync(tenantId, ct);
if (tenantUsers.Count == 0) if (tenantUsers.Count == 0)
{ {
return ImpersonationResultDto.Failed(ImpersonationResultDto.ErrorTenantHasNoUsers); return ImpersonationResultDto.Failed(ImpersonationResultDto.ErrorTenantHasNoUsers);
} }
UserIdentityDto firstUser = tenantUsers[0]; UserIdentityDto firstUser = tenantUsers[0];
var rawTokenForFirst = await CreateSessionForUserAsync(firstUser.Id, firstUser.Login, operatorId, ct); string rawTokenForFirst = await CreateSessionForUserAsync(firstUser.Id, firstUser.Login, operatorId, ct);
return Success(rawTokenForFirst, firstUser.Id, firstUser.Login, firstUser.TenantId); return Success(rawTokenForFirst, firstUser.Id, firstUser.Login, firstUser.TenantId);
} }
var user = await authStore.FindUserByLoginAsync(normalizedLogin, ct); var user = await authStore.FindUserByLoginAsync(normalizedLogin, ct);
if (user is null || user.TenantId != tenantId) if (user is null || user.TenantId != tenantId)
{ {
// Пользователь не найден или принадлежит другому тенанту — не раскрываем существование логина. // Пользователь не найден или принадлежит другому тенанту — не раскрываем существование логина.
return ImpersonationResultDto.Failed(ImpersonationResultDto.ErrorUserNotFound); return ImpersonationResultDto.Failed(ImpersonationResultDto.ErrorUserNotFound);
} }
var rawToken = await CreateSessionForUserAsync(user.Id, user.Login, operatorId, ct); string rawToken = await CreateSessionForUserAsync(user.Id, user.Login, operatorId, ct);
return Success(rawToken, user.Id, user.Login, user.TenantId); return Success(rawToken, user.Id, user.Login, user.TenantId);
} }
/// <summary> /// <summary>
/// Разрешение сессии по raw-токену /// Разрешение сессии по raw-токену
/// </summary> /// </summary>
/// <param name="rawToken">Raw-токен из куки.</param> /// <param name="rawToken">Raw-токен из куки.</param>
/// <returns>Идентичность пользователя или null.</returns> /// <returns>Идентичность пользователя или null.</returns>
public async Task<UserIdentityDto?> ResolveSessionAsync(string? rawToken, CancellationToken ct) public async Task<UserIdentityDto?> ResolveSessionAsync(string? rawToken, CancellationToken ct)
{ {
if (string.IsNullOrWhiteSpace(rawToken)) if (string.IsNullOrWhiteSpace(rawToken))
{ {
return null; return null;
} }
var tokenHash = SessionTokens.HashToken(rawToken); string tokenHash = SessionTokens.HashToken(rawToken);
var session = await authStore.FindSessionByTokenHashAsync(tokenHash, ct); var session = await authStore.FindSessionByTokenHashAsync(tokenHash, ct);
UserIdentityDto? user = null; UserIdentityDto? user = null;
if (session is not null && session.ExpiresAt > DateTimeOffset.UtcNow) if (session is not null && session.ExpiresAt > DateTimeOffset.UtcNow)
{ {
user = await authStore.FindUserByIdAsync(session.UserId, ct); user = await authStore.FindUserByIdAsync(session.UserId, ct);
// Деактивированный пользователь при живой сессии не получает доступ (Security review): // Деактивированный пользователь при живой сессии не получает доступ (Security review):
// сессия разрешается только для активной учётки (как операторская в OperatorAuthService). // сессия разрешается только для активной учётки (как операторская в OperatorAuthService).
if (user is not null && user.Status != UserActiveStatus) if (user is not null && user.Status != UserActiveStatus)
{ {
user = null; user = null;
} }
if (user is not null) if (user is not null)
{ {
var tenant = await tenantRepository.FindByIdAsync(user.TenantId, ct); var tenant = await tenantRepository.FindByIdAsync(user.TenantId, ct);
if (tenant is not null && tenant.Status == TenantStatuses.Suspended) if (tenant is not null && tenant.Status == TenantStatuses.Suspended)
{ {
user = null; user = null;
} }
} }
} }
else if (session is not null) else if (session is not null)
{ {
// Сессия протухла: глобальная очистка протухших сессий выполняется ТОЛЬКО при обнаружении // Сессия протухла: глобальная очистка протухших сессий выполняется ТОЛЬКО при обнаружении
// протухшей (редкий случай), а не на каждом разрешении сессии (hot-path, Security review). // протухшей (редкий случай), а не на каждом разрешении сессии (hot-path, Security review).
await authStore.DeleteExpiredSessionsAsync(ct); await authStore.DeleteExpiredSessionsAsync(ct);
} }
return user; return user;
} }
// Создаёт сессию пользователя: raw-токен наружу, в хранилище — его SHA-256-хэш. // Создаёт сессию пользователя: raw-токен наружу, в хранилище — его SHA-256-хэш.
// userId: Идентификатор пользователя. // userId: Идентификатор пользователя.
// login: Логин (денормализуется в сессию для чтения без join). // login: Логин (денормализуется в сессию для чтения без join).
// impersonatedByOperatorId: Маркер impersonation: оператор, создавший сессию; null — обычный вход. // impersonatedByOperatorId: Маркер impersonation: оператор, создавший сессию; null — обычный вход.
// ct: Токен отмены. // ct: Токен отмены.
// Возвращает: Raw-токен для выдачи клиенту. // Возвращает: Raw-токен для выдачи клиенту.
private async Task<string> CreateSessionForUserAsync( private async Task<string> CreateSessionForUserAsync(
Guid userId, Guid userId,
string login, string login,
Guid? impersonatedByOperatorId, Guid? impersonatedByOperatorId,
CancellationToken ct) CancellationToken ct)
{ {
var rawToken = SessionTokens.NewToken(); string rawToken = SessionTokens.NewToken();
var session = new SessionDto( var session = new SessionDto(
TokenHash: SessionTokens.HashToken(rawToken), TokenHash: SessionTokens.HashToken(rawToken),
UserId: userId, UserId: userId,
Login: login, Login: login,
ExpiresAt: DateTimeOffset.UtcNow.AddDays(SessionLifetimeDays), ExpiresAt: DateTimeOffset.UtcNow.AddDays(SessionLifetimeDays),
ImpersonatedByOperatorId: impersonatedByOperatorId); ImpersonatedByOperatorId: impersonatedByOperatorId);
await authStore.CreateSessionAsync(session, ct); await authStore.CreateSessionAsync(session, ct);
return rawToken; return rawToken;
} }
// Успешный результат impersonation (токен + пользователь + тенант). // Успешный результат impersonation (токен + пользователь + тенант).
// rawToken: Raw-токен созданной сессии. // rawToken: Raw-токен созданной сессии.
// userId: Идентификатор пользователя. // userId: Идентификатор пользователя.
// login: Логин пользователя. // login: Логин пользователя.
// tenantId: Тенант пользователя. // tenantId: Тенант пользователя.
// Возвращает: ImpersonationResultDto с Ok=true. // Возвращает: ImpersonationResultDto с Ok=true.
private ImpersonationResultDto Success( private ImpersonationResultDto Success(
string rawToken, string rawToken,
Guid userId, Guid userId,
string login, string login,
Guid tenantId) => Guid tenantId) =>
new( new(
Ok: true, Ok: true,
Error: null, Error: null,
SessionToken: rawToken, SessionToken: rawToken,
ExpiresAt: DateTimeOffset.UtcNow.AddDays(SessionLifetimeDays), ExpiresAt: DateTimeOffset.UtcNow.AddDays(SessionLifetimeDays),
Login: login, Login: login,
UserId: userId, UserId: userId,
TenantId: tenantId); TenantId: tenantId);
// Нормализация логина: нижний регистр и обрезка пробелов (единая форма хранения/сравнения). // Нормализация логина: нижний регистр и обрезка пробелов (единая форма хранения/сравнения).
// login: Входной логин. // login: Входной логин.
// Возвращает: Нормализованный логин (пустая строка, если вход был пустым). // Возвращает: Нормализованный логин (пустая строка, если вход был пустым).
private static string NormalizeLogin(string? login) => (login ?? string.Empty).ToLowerInvariant().Trim(); private static string NormalizeLogin(string? login) => (login ?? string.Empty).ToLowerInvariant().Trim();
} }
@@ -1,114 +1,114 @@
using Deal.Modules.Tenants.Application.Abstractions; using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
namespace Deal.Modules.Tenants.Application.Services; namespace Deal.Modules.Tenants.Application.Services;
/// <summary> /// <summary>
/// Прикладной сервис аутентификации оператора /// Прикладной сервис аутентификации оператора
/// </summary> /// </summary>
public sealed class OperatorAuthService(IOperatorAuthStore operatorAuthStore, IPasswordHasher passwordHasher) public sealed class OperatorAuthService(IOperatorAuthStore operatorAuthStore, IPasswordHasher passwordHasher)
{ {
/// <summary> /// <summary>
/// Срок жизни сессии оператора, часов. /// Срок жизни сессии оператора, часов.
/// </summary> /// </summary>
public const int SessionLifetimeHours = 12; public const int SessionLifetimeHours = 12;
// Статус «активен»: только активного оператора разрешает ResolveSessionAsync (удалённый/приостановленный — null). // Статус «активен»: только активного оператора разрешает ResolveSessionAsync (удалённый/приостановленный — null).
private const string ActiveStatus = "active"; private const string ActiveStatus = "active";
/// <summary> /// <summary>
/// Вход оператора: при успехе создаёт сессию и возвращает её raw-токен. /// Вход оператора: при успехе создаёт сессию и возвращает её raw-токен.
/// </summary> /// </summary>
/// <param name="login">Логин (регистр и пробелы не важны — нормализуется).</param> /// <param name="login">Логин (регистр и пробелы не важны — нормализуется).</param>
/// <param name="password">Пароль в открытом виде.</param> /// <param name="password">Пароль в открытом виде.</param>
/// <returns>При успехе — Login и Token; иначе оба null (текст «Неверный логин или пароль оператора» фиксирует endpoint).</returns> /// <returns>При успехе — Login и Token; иначе оба null (текст «Неверный логин или пароль оператора» фиксирует endpoint).</returns>
public async Task<OperatorLoginResultDto> LoginAsync( public async Task<OperatorLoginResultDto> LoginAsync(
string login, string login,
string password, string password,
CancellationToken ct) CancellationToken ct)
{ {
var normalizedLogin = NormalizeLogin(login); string normalizedLogin = NormalizeLogin(login);
if (string.IsNullOrEmpty(normalizedLogin) || string.IsNullOrEmpty(password)) if (string.IsNullOrEmpty(normalizedLogin) || string.IsNullOrEmpty(password))
{ {
return new OperatorLoginResultDto(null, null); return new OperatorLoginResultDto(null, null);
} }
var operatorRecord = await operatorAuthStore.FindByLoginAsync(normalizedLogin, ct); var operatorRecord = await operatorAuthStore.FindByLoginAsync(normalizedLogin, ct);
if (operatorRecord is null || !passwordHasher.Verify(password, operatorRecord.PasswordHash)) if (operatorRecord is null || !passwordHasher.Verify(password, operatorRecord.PasswordHash))
{ {
return new OperatorLoginResultDto(null, null); return new OperatorLoginResultDto(null, null);
} }
var token = await CreateSessionForOperatorAsync(operatorRecord, ct); string token = await CreateSessionForOperatorAsync(operatorRecord, ct);
return new OperatorLoginResultDto(operatorRecord.Login, token, operatorRecord.Id); return new OperatorLoginResultDto(operatorRecord.Login, token, operatorRecord.Id);
} }
/// <summary> /// <summary>
/// Выход оператора /// Выход оператора
/// </summary> /// </summary>
/// <param name="rawToken">Raw-токен из куки (может отсутствовать — no-op).</param> /// <param name="rawToken">Raw-токен из куки (может отсутствовать — no-op).</param>
public async Task LogoutAsync(string? rawToken, CancellationToken ct) public async Task LogoutAsync(string? rawToken, CancellationToken ct)
{ {
if (string.IsNullOrWhiteSpace(rawToken)) if (string.IsNullOrWhiteSpace(rawToken))
{ {
return; return;
} }
await operatorAuthStore.DeleteSessionAsync(SessionTokens.HashToken(rawToken), ct); await operatorAuthStore.DeleteSessionAsync(SessionTokens.HashToken(rawToken), ct);
} }
/// <summary> /// <summary>
/// Разрешение операторской сессии по raw-токену /// Разрешение операторской сессии по raw-токену
/// </summary> /// </summary>
/// <param name="rawToken">Raw-токен из куки deal_operator_session.</param> /// <param name="rawToken">Raw-токен из куки deal_operator_session.</param>
/// <returns>Идентичность активного оператора или null.</returns> /// <returns>Идентичность активного оператора или null.</returns>
public async Task<OperatorIdentityDto?> ResolveSessionAsync(string? rawToken, CancellationToken ct) public async Task<OperatorIdentityDto?> ResolveSessionAsync(string? rawToken, CancellationToken ct)
{ {
if (string.IsNullOrWhiteSpace(rawToken)) if (string.IsNullOrWhiteSpace(rawToken))
{ {
return null; return null;
} }
var tokenHash = SessionTokens.HashToken(rawToken); string tokenHash = SessionTokens.HashToken(rawToken);
var session = await operatorAuthStore.FindSessionByTokenHashAsync(tokenHash, ct); var session = await operatorAuthStore.FindSessionByTokenHashAsync(tokenHash, ct);
OperatorIdentityDto? operatorIdentity = null; OperatorIdentityDto? operatorIdentity = null;
if (session is not null && session.ExpiresAt > DateTimeOffset.UtcNow) if (session is not null && session.ExpiresAt > DateTimeOffset.UtcNow)
{ {
var operatorRecord = await operatorAuthStore.FindByLoginAsync(session.Login, ct); var operatorRecord = await operatorAuthStore.FindByLoginAsync(session.Login, ct);
if (operatorRecord is not null && operatorRecord.Status == ActiveStatus) if (operatorRecord is not null && operatorRecord.Status == ActiveStatus)
{ {
operatorIdentity = new OperatorIdentityDto(operatorRecord.Id, operatorRecord.Login, operatorRecord.Status); operatorIdentity = new OperatorIdentityDto(operatorRecord.Id, operatorRecord.Login, operatorRecord.Status);
} }
} }
else if (session is not null) else if (session is not null)
{ {
// Очистка протухших сессий — только при обнаружении протухшей (редкий случай), не на каждый запрос // Очистка протухших сессий — только при обнаружении протухшей (редкий случай), не на каждый запрос
// (hot-path, Security review). // (горячий путь запросов).
await operatorAuthStore.DeleteExpiredSessionsAsync(ct); await operatorAuthStore.DeleteExpiredSessionsAsync(ct);
} }
return operatorIdentity; return operatorIdentity;
} }
// Создаёт сессию оператора: raw-токен наружу, в хранилище — его SHA-256-хэш (формат как у SessionTokens). // Создаёт сессию оператора: raw-токен наружу, в хранилище — его SHA-256-хэш (формат как у SessionTokens).
// operatorRecord: Оператор (логин денормализуется в сессию). // operatorRecord: Оператор (логин денормализуется в сессию).
// ct: Токен отмены. // ct: Токен отмены.
// Возвращает: Raw-токен для выдачи клиенту. // Возвращает: Raw-токен для выдачи клиенту.
private async Task<string> CreateSessionForOperatorAsync(StoredOperatorDto operatorRecord, CancellationToken ct) private async Task<string> CreateSessionForOperatorAsync(StoredOperatorDto operatorRecord, CancellationToken ct)
{ {
var rawToken = SessionTokens.NewToken(); string rawToken = SessionTokens.NewToken();
var session = new OperatorSessionDto( var session = new OperatorSessionDto(
TokenHash: SessionTokens.HashToken(rawToken), TokenHash: SessionTokens.HashToken(rawToken),
OperatorId: operatorRecord.Id, OperatorId: operatorRecord.Id,
Login: operatorRecord.Login, Login: operatorRecord.Login,
ExpiresAt: DateTimeOffset.UtcNow.AddHours(SessionLifetimeHours)); ExpiresAt: DateTimeOffset.UtcNow.AddHours(SessionLifetimeHours));
await operatorAuthStore.CreateSessionAsync(session, ct); await operatorAuthStore.CreateSessionAsync(session, ct);
return rawToken; return rawToken;
} }
// Нормализация логина: нижний регистр и обрезка пробелов (единая форма хранения/сравнения). // Нормализация логина: нижний регистр и обрезка пробелов (единая форма хранения/сравнения).
// login: Входной логин. // login: Входной логин.
// Возвращает: Нормализованный логин (пустая строка, если вход был пустым). // Возвращает: Нормализованный логин (пустая строка, если вход был пустым).
private static string NormalizeLogin(string? login) => (login ?? string.Empty).ToLowerInvariant().Trim(); private static string NormalizeLogin(string? login) => (login ?? string.Empty).ToLowerInvariant().Trim();
} }
@@ -1,80 +1,80 @@
using Deal.Modules.Tenants.Application.Abstractions; using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
namespace Deal.Modules.Tenants.Application.Services; namespace Deal.Modules.Tenants.Application.Services;
/// <summary> /// <summary>
/// Bootstrap оператора при старте /// Bootstrap оператора при старте
/// </summary> /// </summary>
public sealed class OperatorBootstrapService(IOperatorAuthStore operatorAuthStore, IPasswordHasher passwordHasher) public sealed class OperatorBootstrapService(IOperatorAuthStore operatorAuthStore, IPasswordHasher passwordHasher)
{ {
/// <summary> /// <summary>
/// Переменная окружения /// Переменная окружения
/// </summary> /// </summary>
public const string LoginEnvKey = "DEAL_OPERATOR_LOGIN"; public const string LoginEnvKey = "DEAL_OPERATOR_LOGIN";
/// <summary> /// <summary>
/// Переменная окружения /// Переменная окружения
/// </summary> /// </summary>
public const string PasswordEnvKey = "DEAL_OPERATOR_PASSWORD"; public const string PasswordEnvKey = "DEAL_OPERATOR_PASSWORD";
/// <summary> /// <summary>
/// Дефолтный логин в Development при отсутствии env-кред. /// Дефолтный логин в Development при отсутствии env-кред.
/// </summary> /// </summary>
public const string DefaultOperatorLogin = "operator"; public const string DefaultOperatorLogin = "operator";
/// <summary> /// <summary>
/// Дефолтный пароль в Development при отсутствии env-кред. /// Дефолтный пароль в Development при отсутствии env-кред.
/// </summary> /// </summary>
public const string DefaultOperatorPassword = "operator"; public const string DefaultOperatorPassword = "operator";
private const string ActiveStatus = "active"; private const string ActiveStatus = "active";
/// <summary> /// <summary>
/// Гарантирует наличие оператора /// Гарантирует наличие оператора
/// </summary> /// </summary>
/// <param name="login">Логин из env (<see cref="LoginEnvKey"/>) или null/пусто, если не задан.</param> /// <param name="login">Логин из env (<see cref="LoginEnvKey"/>) или null/пусто, если не задан.</param>
/// <param name="password">Пароль из env (<see cref="PasswordEnvKey"/>) или null/пусто, если не задан.</param> /// <param name="password">Пароль из env (<see cref="PasswordEnvKey"/>) или null/пусто, если не задан.</param>
/// <param name="allowDevelopmentDefaults">true в Development: при отсутствии кред берутся дефолты operator/operator; false (Production) при отсутствии кред — шаг пропускается, хост логирует warning.</param> /// <param name="allowDevelopmentDefaults">true в Development: при отсутствии кред берутся дефолты operator/operator; false (Production) при отсутствии кред — шаг пропускается, хост логирует warning.</param>
/// <returns>Логин оператора, присутствующего после шага (созданного или уже существовавшего); null — шаг пропущен.</returns> /// <returns>Логин оператора, присутствующего после шага (созданного или уже существовавшего); null — шаг пропущен.</returns>
public async Task<string?> EnsureOperatorAsync( public async Task<string?> EnsureOperatorAsync(
string? login, string? login,
string? password, string? password,
bool allowDevelopmentDefaults, bool allowDevelopmentDefaults,
CancellationToken ct) CancellationToken ct)
{ {
var normalizedLogin = NormalizeLogin(login); string normalizedLogin = NormalizeLogin(login);
var resolvedPassword = password; string? resolvedPassword = password;
if (string.IsNullOrEmpty(normalizedLogin) || string.IsNullOrEmpty(resolvedPassword)) if (string.IsNullOrEmpty(normalizedLogin) || string.IsNullOrEmpty(resolvedPassword))
{ {
if (!allowDevelopmentDefaults) if (!allowDevelopmentDefaults)
{ {
return null; return null;
} }
normalizedLogin = DefaultOperatorLogin; normalizedLogin = DefaultOperatorLogin;
resolvedPassword = DefaultOperatorPassword; resolvedPassword = DefaultOperatorPassword;
} }
// Идемпотентность: оператор с таким логином уже есть — пароль не перезаписываем. // Идемпотентность: оператор с таким логином уже есть — пароль не перезаписываем.
var existing = await operatorAuthStore.FindByLoginAsync(normalizedLogin, ct); var existing = await operatorAuthStore.FindByLoginAsync(normalizedLogin, ct);
if (existing is not null) if (existing is not null)
{ {
return existing.Login; return existing.Login;
} }
await operatorAuthStore.CreateAsync( await operatorAuthStore.CreateAsync(
new StoredOperatorDto( new StoredOperatorDto(
Id: Guid.NewGuid(), Id: Guid.NewGuid(),
Login: normalizedLogin, Login: normalizedLogin,
Status: ActiveStatus, Status: ActiveStatus,
PasswordHash: passwordHasher.Hash(resolvedPassword)), PasswordHash: passwordHasher.Hash(resolvedPassword)),
ct); ct);
return normalizedLogin; return normalizedLogin;
} }
// Нормализация логина: нижний регистр и обрезка пробелов (единая форма хранения). // Нормализация логина: нижний регистр и обрезка пробелов (единая форма хранения).
// login: Входной логин (может быть null — как «не задан»). // login: Входной логин (может быть null — как «не задан»).
// Возвращает: Нормализованный логин (пустая строка, если вход был пустым). // Возвращает: Нормализованный логин (пустая строка, если вход был пустым).
private static string NormalizeLogin(string? login) => (login ?? string.Empty).ToLowerInvariant().Trim(); private static string NormalizeLogin(string? login) => (login ?? string.Empty).ToLowerInvariant().Trim();
} }
@@ -1,32 +1,32 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using Deal.SharedKernel; using Deal.SharedKernel;
using Deal.SharedKernel.Utilities; using Deal.SharedKernel.Utilities;
namespace Deal.Modules.Tenants.Application.Services; namespace Deal.Modules.Tenants.Application.Services;
/// <summary> /// <summary>
/// Токены сессий: генерация raw-токена и его SHA-256-хэша для хранения. /// Токены сессий: генерация raw-токена и его SHA-256-хэша для хранения.
/// </summary> /// </summary>
public static class SessionTokens public static class SessionTokens
{ {
// Случайные байты raw-токена (32 → 43 символа Base64Url). // Случайные байты raw-токена (32 → 43 символа Base64Url).
private const int RawTokenByteLength = 32; private const int RawTokenByteLength = 32;
/// <summary> /// <summary>
/// Новый raw-токен /// Новый raw-токен
/// </summary> /// </summary>
/// <returns>Строка токена длиной 43 символа.</returns> /// <returns>Строка токена длиной 43 символа.</returns>
public static string NewToken() => UrlSafeToken.New(RawTokenByteLength); public static string NewToken() => UrlSafeToken.New(RawTokenByteLength);
/// <summary> /// <summary>
/// SHA-256-хэш raw-токена в нижнем регистре /// SHA-256-хэш raw-токена в нижнем регистре
/// </summary> /// </summary>
/// <param name="rawToken">Raw-токен из <see cref="NewToken"/> (или от клиента).</param> /// <param name="rawToken">Raw-токен из <see cref="NewToken"/> (или от клиента).</param>
/// <returns>64 hex-символа.</returns> /// <returns>64 hex-символа.</returns>
public static string HashToken(string rawToken) public static string HashToken(string rawToken)
{ {
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(rawToken)); byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(rawToken));
return Convert.ToHexString(hash).ToLowerInvariant(); return Convert.ToHexString(hash).ToLowerInvariant();
} }
} }
@@ -1,50 +1,50 @@
using Deal.Modules.Tenants.Application.Abstractions; using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Deal.SharedKernel.Tenants.Models; using Deal.SharedKernel.Tenants.Models;
namespace Deal.Modules.Tenants.Application.Services; namespace Deal.Modules.Tenants.Application.Services;
/// <summary> /// <summary>
/// Прикладной сервис реестра тенантов /// Прикладной сервис реестра тенантов
/// </summary> /// </summary>
public sealed class TenantService(ITenantRepository tenantRepository, ITenantProvisioner tenantProvisioner) public sealed class TenantService(ITenantRepository tenantRepository, ITenantProvisioner tenantProvisioner)
{ {
/// <summary> /// <summary>
/// Создаёт тенанта /// Создаёт тенанта
/// </summary> /// </summary>
/// <param name="name">Имя тенанта.</param> /// <param name="name">Имя тенанта.</param>
/// <returns>Идентификатор созданного тенанта (он же имя схемы tenant_&lt;id&gt).</returns> /// <returns>Идентификатор созданного тенанта (он же имя схемы tenant_&lt;id&gt).</returns>
public Task<TenantId> CreateTenantAsync(string name, CancellationToken ct) => public Task<TenantId> CreateTenantAsync(string name, CancellationToken ct) =>
CreateTenantAsync(name, Guid.NewGuid(), ct); CreateTenantAsync(name, Guid.NewGuid(), ct);
/// <summary> /// <summary>
/// Создаёт тенанта с явным id и провижинит его схему. /// Создаёт тенанта с явным id и провижинит его схему.
/// </summary> /// </summary>
/// <param name="name">Имя тенанта.</param> /// <param name="name">Имя тенанта.</param>
/// <param name="id">Идентификатор тенанта (определяет имя схемы).</param> /// <param name="id">Идентификатор тенанта (определяет имя схемы).</param>
/// <returns>Идентификатор созданного тенанта (он же имя схемы tenant_&lt;id&gt).</returns> /// <returns>Идентификатор созданного тенанта (он же имя схемы tenant_&lt;id&gt).</returns>
public async Task<TenantId> CreateTenantAsync( public async Task<TenantId> CreateTenantAsync(
string name, string name,
Guid id, Guid id,
CancellationToken ct) CancellationToken ct)
{ {
await tenantRepository.CreateAsync( await tenantRepository.CreateAsync(
new TenantRecordDto( new TenantRecordDto(
Id: id, Id: id,
Name: name, Name: name,
Status: TenantStatuses.Active, Status: TenantStatuses.Active,
CreatedAt: DateTimeOffset.UtcNow), CreatedAt: DateTimeOffset.UtcNow),
ct); ct);
var tenantIdValue = id.ToString("N"); string tenantIdValue = id.ToString("N");
await tenantProvisioner.ProvisionAsync(new TenantId(tenantIdValue), ct); await tenantProvisioner.ProvisionAsync(new TenantId(tenantIdValue), ct);
return new TenantId(tenantIdValue); return new TenantId(tenantIdValue);
} }
/// <summary> /// <summary>
/// Возвращает список всех тенантов. /// Возвращает список всех тенантов.
/// </summary> /// </summary>
/// <returns>Список тенантов.</returns> /// <returns>Список тенантов.</returns>
public Task<IReadOnlyList<TenantRecordDto>> ListTenantsAsync(CancellationToken ct) => public Task<IReadOnlyList<TenantRecordDto>> ListTenantsAsync(CancellationToken ct) =>
tenantRepository.ListAsync(ct); tenantRepository.ListAsync(ct);
} }
@@ -7,8 +7,14 @@ namespace Deal.SharedKernel.Tenants.Abstractions;
/// </summary> /// </summary>
public interface ITenantContext public interface ITenantContext
{ {
/// <summary>
/// Идентификатор текущего тенанта или null для системного контекста
/// </summary>
public TenantId? TenantId { get; } public TenantId? TenantId { get; }
/// <summary>
/// Признак наличия текущего тенанта
/// </summary>
public bool HasTenant { get; } public bool HasTenant { get; }
/// <summary> /// <summary>
@@ -18,7 +18,7 @@ public static class UrlSafeToken
{ {
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(byteCount); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(byteCount);
var bytes = new byte[byteCount]; byte[] bytes = new byte[byteCount];
RandomNumberGenerator.Fill(bytes); RandomNumberGenerator.Fill(bytes);
return Convert.ToBase64String(bytes) return Convert.ToBase64String(bytes)
.TrimEnd('=') .TrimEnd('=')
@@ -1,80 +1,80 @@
using Deal.Infrastructure.Persistence; using Deal.Infrastructure.Persistence;
using Deal.Infrastructure.Persistence.Repositories; using Deal.Infrastructure.Persistence.Repositories;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Deal.Tests.Unit.Infrastructure; namespace Deal.Tests.Unit.Infrastructure;
/// <summary> /// <summary>
/// Юнит-тесты EF-адаптера <see cref="AuthStore"/> на InMemory-провайдере /// Юнит-тесты EF-адаптера <see cref="AuthStore"/> на InMemory-провайдере
/// </summary> /// </summary>
public sealed class AuthStoreTests public sealed class AuthStoreTests
{ {
[Fact] [Fact]
public async Task ListUsersByTenantIdAsync_ReturnsOnlyTenantUsers() public async Task ListUsersByTenantIdAsync_ReturnsOnlyTenantUsers()
{ {
var store = CreateStore(); var store = CreateStore();
var tenantId = Guid.NewGuid(); var tenantId = Guid.NewGuid();
var otherTenantId = Guid.NewGuid(); var otherTenantId = Guid.NewGuid();
await CreateUserAsync(store, tenantId, "first@example.com"); await CreateUserAsync(store, tenantId, "first@example.com");
await CreateUserAsync(store, tenantId, "second@example.com"); await CreateUserAsync(store, tenantId, "second@example.com");
await CreateUserAsync(store, otherTenantId, "other@example.com"); await CreateUserAsync(store, otherTenantId, "other@example.com");
IReadOnlyList<UserIdentityDto> users = await store.ListUsersByTenantIdAsync(tenantId, CancellationToken.None); IReadOnlyList<UserIdentityDto> users = await store.ListUsersByTenantIdAsync(tenantId, CancellationToken.None);
Assert.Equal(2, users.Count); Assert.Equal(2, users.Count);
// Порядок EF: CreatedAt, затем Login — «первый пользователь» для impersonation без login детерминирован. // Порядок EF: CreatedAt, затем Login — «первый пользователь» для impersonation без login детерминирован.
Assert.Equal("first@example.com", users[0].Login); Assert.Equal("first@example.com", users[0].Login);
Assert.Equal("second@example.com", users[1].Login); Assert.Equal("second@example.com", users[1].Login);
Assert.All(users, u => Assert.Equal(tenantId, u.TenantId)); Assert.All(users, u => Assert.Equal(tenantId, u.TenantId));
Assert.DoesNotContain(users, u => u.Login == "other@example.com"); Assert.DoesNotContain(users, u => u.Login == "other@example.com");
} }
[Fact] [Fact]
public async Task CreateSession_ThenFindByTokenHash_ReturnsImpersonationMarker() public async Task CreateSession_ThenFindByTokenHash_ReturnsImpersonationMarker()
{ {
var store = CreateStore(); var store = CreateStore();
var userId = Guid.NewGuid(); var userId = Guid.NewGuid();
await CreateUserAsync(store, Guid.NewGuid(), "user@example.com"); await CreateUserAsync(store, Guid.NewGuid(), "user@example.com");
var operatorId = Guid.NewGuid(); var operatorId = Guid.NewGuid();
var tokenHash = "token-hash-impersonation"; string tokenHash = "token-hash-impersonation";
await store.CreateSessionAsync( await store.CreateSessionAsync(
new SessionDto(tokenHash, userId, "user@example.com", DateTimeOffset.UtcNow.AddDays(30), operatorId), new SessionDto(tokenHash, userId, "user@example.com", DateTimeOffset.UtcNow.AddDays(30), operatorId),
CancellationToken.None); CancellationToken.None);
SessionDto? found = await store.FindSessionByTokenHashAsync(tokenHash, CancellationToken.None); SessionDto? found = await store.FindSessionByTokenHashAsync(tokenHash, CancellationToken.None);
Assert.NotNull(found); Assert.NotNull(found);
Assert.Equal(operatorId, found!.ImpersonatedByOperatorId); Assert.Equal(operatorId, found!.ImpersonatedByOperatorId);
Assert.Equal(userId, found.UserId); Assert.Equal(userId, found.UserId);
// Обычная сессия (без маркера) читается как ImpersonatedByOperatorId=null. // Обычная сессия (без маркера) читается как ImpersonatedByOperatorId=null.
var plainHash = "token-hash-plain"; string plainHash = "token-hash-plain";
await store.CreateSessionAsync( await store.CreateSessionAsync(
new SessionDto(plainHash, userId, "user@example.com", DateTimeOffset.UtcNow.AddDays(30)), new SessionDto(plainHash, userId, "user@example.com", DateTimeOffset.UtcNow.AddDays(30)),
CancellationToken.None); CancellationToken.None);
SessionDto? plain = await store.FindSessionByTokenHashAsync(plainHash, CancellationToken.None); SessionDto? plain = await store.FindSessionByTokenHashAsync(plainHash, CancellationToken.None);
Assert.NotNull(plain); Assert.NotNull(plain);
Assert.Null(plain!.ImpersonatedByOperatorId); Assert.Null(plain!.ImpersonatedByOperatorId);
} }
// Создаёт пользователя тенанта (хэш пароля — заглушка, для чтения не нужен). // Создаёт пользователя тенанта (хэш пароля — заглушка, для чтения не нужен).
private static async Task CreateUserAsync( private static async Task CreateUserAsync(
AuthStore store, AuthStore store,
Guid tenantId, Guid tenantId,
string login) string login)
{ {
await store.CreateUserAsync( await store.CreateUserAsync(
new StoredUserDto(Guid.NewGuid(), login, tenantId, "active", "fake-argon2-encoded-hash"), new StoredUserDto(Guid.NewGuid(), login, tenantId, "active", "fake-argon2-encoded-hash"),
CancellationToken.None); CancellationToken.None);
} }
// Создаёт адаптер на уникальной InMemory-БД (контексты тестов не пересекаются). // Создаёт адаптер на уникальной InMemory-БД (контексты тестов не пересекаются).
private static AuthStore CreateStore() private static AuthStore CreateStore()
{ {
var options = new DbContextOptionsBuilder<DealDbContext>() var options = new DbContextOptionsBuilder<DealDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString("N")) .UseInMemoryDatabase(Guid.NewGuid().ToString("N"))
.Options; .Options;
return new AuthStore(new DealDbContext(options)); return new AuthStore(new DealDbContext(options));
} }
} }
@@ -1,194 +1,194 @@
using Deal.Infrastructure.Tenancy; using Deal.Infrastructure.Tenancy;
using Deal.Modules.Tenants.Application.Abstractions; using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Deal.Tests.Unit.Modules.Tenants; using Deal.Tests.Unit.Modules.Tenants;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
namespace Deal.Tests.Unit.Infrastructure; namespace Deal.Tests.Unit.Infrastructure;
/// <summary> /// <summary>
/// Тесты пакетной миграции схем тенантов /// Тесты пакетной миграции схем тенантов
/// </summary> /// </summary>
public sealed class TenantSchemaMigrationServiceTests public sealed class TenantSchemaMigrationServiceTests
{ {
// Тенант сценария A. // Тенант сценария A.
private static readonly Guid TenantA = Guid.Parse("11111111-1111-1111-1111-111111111111"); private static readonly Guid TenantA = Guid.Parse("11111111-1111-1111-1111-111111111111");
// Тенант сценария B. // Тенант сценария B.
private static readonly Guid TenantB = Guid.Parse("22222222-2222-2222-2222-222222222222"); private static readonly Guid TenantB = Guid.Parse("22222222-2222-2222-2222-222222222222");
[Fact] [Fact]
public async Task MigrateAllAsync_EmptyRegistry_ReturnsEmptySummary() public async Task MigrateAllAsync_EmptyRegistry_ReturnsEmptySummary()
{ {
var service = NewService(new FakeTenantRepository(), new FakeTenantProvisioner()); var service = NewService(new FakeTenantRepository(), new FakeTenantProvisioner());
TenantMigrationSummary summary = await service.MigrateAllAsync(CancellationToken.None); TenantMigrationSummary summary = await service.MigrateAllAsync(CancellationToken.None);
Assert.True(summary.Ok); Assert.True(summary.Ok);
Assert.Equal(0, summary.Total); Assert.Equal(0, summary.Total);
Assert.Equal(0, summary.Migrated); Assert.Equal(0, summary.Migrated);
Assert.Empty(summary.FailedSchemas); Assert.Empty(summary.FailedSchemas);
} }
[Fact] [Fact]
public async Task MigrateAllAsync_ProvisionsEveryTenantSchema() public async Task MigrateAllAsync_ProvisionsEveryTenantSchema()
{ {
var provisioner = new FakeTenantProvisioner(); var provisioner = new FakeTenantProvisioner();
var repository = new FakeTenantRepository( var repository = new FakeTenantRepository(
TenantRecord(TenantA, "A"), TenantRecord(TenantA, "A"),
TenantRecord(TenantB, "B")); TenantRecord(TenantB, "B"));
var service = NewService(repository, provisioner); var service = NewService(repository, provisioner);
TenantMigrationSummary summary = await service.MigrateAllAsync(CancellationToken.None); TenantMigrationSummary summary = await service.MigrateAllAsync(CancellationToken.None);
Assert.True(summary.Ok); Assert.True(summary.Ok);
Assert.Equal(2, summary.Total); Assert.Equal(2, summary.Total);
Assert.Equal(2, summary.Migrated); Assert.Equal(2, summary.Migrated);
Assert.Equal(0, summary.Failed); Assert.Equal(0, summary.Failed);
Assert.Equal( Assert.Equal(
new[] { $"tenant_{TenantA:N}", $"tenant_{TenantB:N}" }.OrderBy(name => name, StringComparer.Ordinal), new[] { $"tenant_{TenantA:N}", $"tenant_{TenantB:N}" }.OrderBy(name => name, StringComparer.Ordinal),
provisioner.ProvisionedSchemaNames.OrderBy(name => name, StringComparer.Ordinal)); provisioner.ProvisionedSchemaNames.OrderBy(name => name, StringComparer.Ordinal));
} }
[Fact] [Fact]
public async Task MigrateAllAsync_OneSchemaFails_ContinuesAndReportsFailed() public async Task MigrateAllAsync_OneSchemaFails_ContinuesAndReportsFailed()
{ {
var failingSchema = $"tenant_{TenantA:N}"; string failingSchema = $"tenant_{TenantA:N}";
var provisioner = new FailingTenantProvisioner(failingSchema); var provisioner = new FailingTenantProvisioner(failingSchema);
var repository = new FakeTenantRepository( var repository = new FakeTenantRepository(
TenantRecord(TenantA, "A"), TenantRecord(TenantA, "A"),
TenantRecord(TenantB, "B")); TenantRecord(TenantB, "B"));
var service = NewService(repository, provisioner); var service = NewService(repository, provisioner);
TenantMigrationSummary summary = await service.MigrateAllAsync(CancellationToken.None); TenantMigrationSummary summary = await service.MigrateAllAsync(CancellationToken.None);
Assert.False(summary.Ok); Assert.False(summary.Ok);
Assert.Equal(2, summary.Total); Assert.Equal(2, summary.Total);
Assert.Equal(1, summary.Migrated); Assert.Equal(1, summary.Migrated);
Assert.Equal(1, summary.Failed); Assert.Equal(1, summary.Failed);
Assert.Equal(new[] { failingSchema }, summary.FailedSchemas); Assert.Equal(new[] { failingSchema }, summary.FailedSchemas);
// Уцелевшая схема мигрирована, сбойная — не попала в список успешных. // Уцелевшая схема мигрирована, сбойная — не попала в список успешных.
Assert.Equal(new[] { $"tenant_{TenantB:N}" }, provisioner.ProvisionedSchemaNames); Assert.Equal(new[] { $"tenant_{TenantB:N}" }, provisioner.ProvisionedSchemaNames);
} }
[Fact] [Fact]
public async Task MigrateAllAsync_MultiplePages_ReadsEveryTenant() public async Task MigrateAllAsync_MultiplePages_ReadsEveryTenant()
{ {
var provisioner = new FakeTenantProvisioner(); var provisioner = new FakeTenantProvisioner();
var repository = new FakeTenantRepository(Records(5)); var repository = new FakeTenantRepository(Records(5));
var service = NewService(repository, provisioner); var service = NewService(repository, provisioner);
TenantMigrationSummary summary = await service.MigrateAllAsync(4, 2, CancellationToken.None); TenantMigrationSummary summary = await service.MigrateAllAsync(4, 2, CancellationToken.None);
Assert.True(summary.Ok); Assert.True(summary.Ok);
Assert.Equal(5, summary.Total); Assert.Equal(5, summary.Total);
Assert.Equal(5, summary.Migrated); Assert.Equal(5, summary.Migrated);
Assert.Equal(0, summary.Failed); Assert.Equal(0, summary.Failed);
Assert.Equal(5, provisioner.ProvisionedSchemaNames.Count); Assert.Equal(5, provisioner.ProvisionedSchemaNames.Count);
Assert.Equal(5, provisioner.ProvisionedSchemaNames.Distinct().Count()); Assert.Equal(5, provisioner.ProvisionedSchemaNames.Distinct().Count());
// Страницы запрошены последовательно с устойчивым offset: 0, 2, 4 (страница на 4 неполная — обход остановлен). // Страницы запрошены последовательно с устойчивым offset: 0, 2, 4 (страница на 4 неполная — обход остановлен).
Assert.Equal( Assert.Equal(
new (int Offset, int Limit)[] { (0, 2), (2, 2), (4, 2) }, new (int Offset, int Limit)[] { (0, 2), (2, 2), (4, 2) },
repository.PageRequests); repository.PageRequests);
} }
[Fact] [Fact]
public async Task MigrateAllAsync_PageSizeOne_ReadsEveryTenant() public async Task MigrateAllAsync_PageSizeOne_ReadsEveryTenant()
{ {
var provisioner = new FakeTenantProvisioner(); var provisioner = new FakeTenantProvisioner();
var repository = new FakeTenantRepository(Records(4)); var repository = new FakeTenantRepository(Records(4));
var service = NewService(repository, provisioner); var service = NewService(repository, provisioner);
TenantMigrationSummary summary = await service.MigrateAllAsync(2, 1, CancellationToken.None); TenantMigrationSummary summary = await service.MigrateAllAsync(2, 1, CancellationToken.None);
Assert.True(summary.Ok); Assert.True(summary.Ok);
Assert.Equal(4, summary.Total); Assert.Equal(4, summary.Total);
Assert.Equal(4, summary.Migrated); Assert.Equal(4, summary.Migrated);
Assert.Equal(4, provisioner.ProvisionedSchemaNames.Count); Assert.Equal(4, provisioner.ProvisionedSchemaNames.Count);
// По одной записи на страницу: 0,1,2,3, затем пустая страница завершает обход. // По одной записи на страницу: 0,1,2,3, затем пустая страница завершает обход.
Assert.Equal(5, repository.PageRequests.Count); Assert.Equal(5, repository.PageRequests.Count);
Assert.Equal(0, repository.PageRequests[0].Offset); Assert.Equal(0, repository.PageRequests[0].Offset);
Assert.Equal(3, repository.PageRequests[3].Offset); Assert.Equal(3, repository.PageRequests[3].Offset);
} }
[Fact] [Fact]
public async Task MigrateAllAsync_SchemaFailsWithinPage_ContinuesOthers() public async Task MigrateAllAsync_SchemaFailsWithinPage_ContinuesOthers()
{ {
TenantRecordDto[] records = Records(3); TenantRecordDto[] records = Records(3);
var failingSchema = $"tenant_{records[0].Id:N}"; string failingSchema = $"tenant_{records[0].Id:N}";
var provisioner = new FailingTenantProvisioner(failingSchema); var provisioner = new FailingTenantProvisioner(failingSchema);
var repository = new FakeTenantRepository(records); var repository = new FakeTenantRepository(records);
var service = NewService(repository, provisioner); var service = NewService(repository, provisioner);
TenantMigrationSummary summary = await service.MigrateAllAsync(1, 2, CancellationToken.None); TenantMigrationSummary summary = await service.MigrateAllAsync(1, 2, CancellationToken.None);
Assert.False(summary.Ok); Assert.False(summary.Ok);
Assert.Equal(3, summary.Total); Assert.Equal(3, summary.Total);
Assert.Equal(2, summary.Migrated); Assert.Equal(2, summary.Migrated);
Assert.Equal(1, summary.Failed); Assert.Equal(1, summary.Failed);
Assert.Equal(new[] { failingSchema }, summary.FailedSchemas); Assert.Equal(new[] { failingSchema }, summary.FailedSchemas);
// Уцелевшие схемы обеих страниц провижинены, сбойная — нет. // Уцелевшие схемы обеих страниц провижинены, сбойная — нет.
Assert.Equal(2, provisioner.ProvisionedSchemaNames.Count); Assert.Equal(2, provisioner.ProvisionedSchemaNames.Count);
} }
[Fact] [Fact]
public async Task MigrateAllAsync_NonPositiveParameters_ClampToMinimum() public async Task MigrateAllAsync_NonPositiveParameters_ClampToMinimum()
{ {
var provisioner = new FakeTenantProvisioner(); var provisioner = new FakeTenantProvisioner();
var repository = new FakeTenantRepository(Records(3)); var repository = new FakeTenantRepository(Records(3));
var service = NewService(repository, provisioner); var service = NewService(repository, provisioner);
TenantMigrationSummary summary = await service.MigrateAllAsync(0, 0, CancellationToken.None); TenantMigrationSummary summary = await service.MigrateAllAsync(0, 0, CancellationToken.None);
Assert.True(summary.Ok); Assert.True(summary.Ok);
Assert.Equal(3, summary.Total); Assert.Equal(3, summary.Total);
Assert.Equal(3, summary.Migrated); Assert.Equal(3, summary.Migrated);
// Кламп pageSize → 1: по одной записи на страницу (плюс пустая на завершение). // Кламп pageSize → 1: по одной записи на страницу (плюс пустая на завершение).
Assert.All(repository.PageRequests, request => Assert.Equal(1, request.Limit)); Assert.All(repository.PageRequests, request => Assert.Equal(1, request.Limit));
} }
[Fact] [Fact]
public async Task MigrateAllAsync_WithExplicitParallelism_ProvisionsEveryTenant() public async Task MigrateAllAsync_WithExplicitParallelism_ProvisionsEveryTenant()
{ {
var provisioner = new FakeTenantProvisioner(); var provisioner = new FakeTenantProvisioner();
var repository = new FakeTenantRepository( var repository = new FakeTenantRepository(
TenantRecord(TenantA, "A"), TenantRecord(TenantA, "A"),
TenantRecord(TenantB, "B")); TenantRecord(TenantB, "B"));
var service = NewService(repository, provisioner); var service = NewService(repository, provisioner);
TenantMigrationSummary summary = await service.MigrateAllAsync(2, CancellationToken.None); TenantMigrationSummary summary = await service.MigrateAllAsync(2, CancellationToken.None);
Assert.True(summary.Ok); Assert.True(summary.Ok);
Assert.Equal(2, summary.Total); Assert.Equal(2, summary.Total);
Assert.Equal(2, summary.Migrated); Assert.Equal(2, summary.Migrated);
} }
// Создаёт сервис на фейках (логирование не проверяется). // Создаёт сервис на фейках (логирование не проверяется).
// repository: Реестр тенантов. // repository: Реестр тенантов.
// provisioner: Провижинер схем. // provisioner: Провижинер схем.
// Возвращает: Тестируемый сервис. // Возвращает: Тестируемый сервис.
private static TenantSchemaMigrationService NewService(ITenantRepository repository, ITenantProvisioner provisioner) private static TenantSchemaMigrationService NewService(ITenantRepository repository, ITenantProvisioner provisioner)
=> new(repository, provisioner, NullLogger<TenantSchemaMigrationService>.Instance); => new(repository, provisioner, NullLogger<TenantSchemaMigrationService>.Instance);
// Строка реестра тенантов для сценария. // Строка реестра тенантов для сценария.
// id: Id тенанта. // id: Id тенанта.
// name: Имя тенанта. // name: Имя тенанта.
// Возвращает: Запись реестра. // Возвращает: Запись реестра.
private static TenantRecordDto TenantRecord(Guid id, string name) private static TenantRecordDto TenantRecord(Guid id, string name)
=> new(id, name, TenantStatuses.Active, DateTimeOffset.UtcNow); => new(id, name, TenantStatuses.Active, DateTimeOffset.UtcNow);
// Последовательность тенантов с детерминированными Id (для проверки шардирования). // Последовательность тенантов с детерминированными Id (для проверки шардирования).
// count: Сколько записей создать. // count: Сколько записей создать.
// Возвращает: Записи реестра в порядке обхода. // Возвращает: Записи реестра в порядке обхода.
private static TenantRecordDto[] Records(int count) private static TenantRecordDto[] Records(int count)
{ {
var records = new TenantRecordDto[count]; var records = new TenantRecordDto[count];
for (int index = 0; index < count; index++) for (int index = 0; index < count; index++)
{ {
var id = Guid.Parse($"33333333-3333-3333-3333-{index + 1:D12}"); var id = Guid.Parse($"33333333-3333-3333-3333-{index + 1:D12}");
records[index] = TenantRecord(id, $"T{index + 1}"); records[index] = TenantRecord(id, $"T{index + 1}");
} }
return records; return records;
} }
} }
@@ -7,7 +7,7 @@ public sealed class TenantSchemaMigratorTests
[Fact] [Fact]
public void CreateSchemaSql_IsEscaped() public void CreateSchemaSql_IsEscaped()
{ {
var sql = TenantSchemaMigrator.CreateSchemaSql("tenant_abc"); string sql = TenantSchemaMigrator.CreateSchemaSql("tenant_abc");
Assert.Contains("CREATE SCHEMA IF NOT EXISTS \"tenant_abc\"", sql); Assert.Contains("CREATE SCHEMA IF NOT EXISTS \"tenant_abc\"", sql);
Assert.DoesNotContain("; DROP", sql); Assert.DoesNotContain("; DROP", sql);
} }
@@ -15,7 +15,7 @@ public sealed class TenantSchemaMigratorTests
[Fact] [Fact]
public void CreateSchemaSql_EscapesQuotes() public void CreateSchemaSql_EscapesQuotes()
{ {
var sql = TenantSchemaMigrator.CreateSchemaSql("tenant_a\"b"); string sql = TenantSchemaMigrator.CreateSchemaSql("tenant_a\"b");
Assert.Contains("\"tenant_a\"\"b\"", sql); Assert.Contains("\"tenant_a\"\"b\"", sql);
Assert.DoesNotContain("\"tenant_a\"b\"", sql); Assert.DoesNotContain("\"tenant_a\"b\"", sql);
} }
@@ -91,7 +91,7 @@ public sealed class PromptDefaultsTests
[Fact] [Fact]
public void Fill_MoreThanSixtyKeywords_TakesFirstSixty() public void Fill_MoreThanSixtyKeywords_TakesFirstSixty()
{ {
var keywords = Enumerable.Range(1, 65).Select(index => $"слово-{index}").ToArray(); string[] keywords = Enumerable.Range(1, 65).Select(index => $"слово-{index}").ToArray();
string result = PromptFiller.Fill("{keywords}", "сфера", keywords); string result = PromptFiller.Fill("{keywords}", "сфера", keywords);
@@ -125,7 +125,7 @@ public sealed class AuthServiceTests
[Fact] [Fact]
public async Task ResolveSessionAsync_WithExpiredSession_ReturnsNull() public async Task ResolveSessionAsync_WithExpiredSession_ReturnsNull()
{ {
var rawToken = "expired-session-raw-token"; string rawToken = "expired-session-raw-token";
_store.AddSession(new SessionDto( _store.AddSession(new SessionDto(
SessionTokens.HashToken(rawToken), UserId, UserLogin, DateTimeOffset.UtcNow.AddDays(-1))); SessionTokens.HashToken(rawToken), UserId, UserLogin, DateTimeOffset.UtcNow.AddDays(-1)));
@@ -140,7 +140,7 @@ public sealed class AuthServiceTests
[Fact] [Fact]
public async Task ResolveSessionAsync_WithValidSession_ReturnsUser() public async Task ResolveSessionAsync_WithValidSession_ReturnsUser()
{ {
var rawToken = "valid-session-raw-token"; string rawToken = "valid-session-raw-token";
_store.AddSession(new SessionDto( _store.AddSession(new SessionDto(
SessionTokens.HashToken(rawToken), UserId, UserLogin, DateTimeOffset.UtcNow.AddDays(30))); SessionTokens.HashToken(rawToken), UserId, UserLogin, DateTimeOffset.UtcNow.AddDays(30)));
@@ -157,7 +157,7 @@ public sealed class AuthServiceTests
[Fact] [Fact]
public async Task ResolveSessionAsync_WhenTenantSuspended_ReturnsNullImmediately() public async Task ResolveSessionAsync_WhenTenantSuspended_ReturnsNullImmediately()
{ {
var rawToken = "suspended-tenant-session"; string rawToken = "suspended-tenant-session";
_store.AddSession(new SessionDto( _store.AddSession(new SessionDto(
SessionTokens.HashToken(rawToken), UserId, UserLogin, DateTimeOffset.UtcNow.AddDays(30))); SessionTokens.HashToken(rawToken), UserId, UserLogin, DateTimeOffset.UtcNow.AddDays(30)));
await _tenantStore.UpdateStatusAsync(UserTenantId, TenantStatuses.Suspended, CancellationToken.None); await _tenantStore.UpdateStatusAsync(UserTenantId, TenantStatuses.Suspended, CancellationToken.None);
@@ -181,8 +181,8 @@ public sealed class AuthServiceTests
[Fact] [Fact]
public async Task LogoutAsync_WithToken_DeletesSession() public async Task LogoutAsync_WithToken_DeletesSession()
{ {
var rawToken = "logout-session-raw-token"; string rawToken = "logout-session-raw-token";
var tokenHash = SessionTokens.HashToken(rawToken); string tokenHash = SessionTokens.HashToken(rawToken);
_store.AddSession(new SessionDto(tokenHash, UserId, UserLogin, DateTimeOffset.UtcNow.AddDays(30))); _store.AddSession(new SessionDto(tokenHash, UserId, UserLogin, DateTimeOffset.UtcNow.AddDays(30)));
await _service.LogoutAsync(rawToken, CancellationToken.None); await _service.LogoutAsync(rawToken, CancellationToken.None);
@@ -330,8 +330,8 @@ public sealed class AuthServiceTests
public async Task LogoutAsync_ForImpersonationSession_ReturnsStopInfoAndDeletesSession() public async Task LogoutAsync_ForImpersonationSession_ReturnsStopInfoAndDeletesSession()
{ {
var operatorId = Guid.NewGuid(); var operatorId = Guid.NewGuid();
var rawToken = "impersonation-session-raw-token"; string rawToken = "impersonation-session-raw-token";
var tokenHash = SessionTokens.HashToken(rawToken); string tokenHash = SessionTokens.HashToken(rawToken);
_store.AddSession(new SessionDto(tokenHash, UserId, UserLogin, DateTimeOffset.UtcNow.AddDays(30), operatorId)); _store.AddSession(new SessionDto(tokenHash, UserId, UserLogin, DateTimeOffset.UtcNow.AddDays(30), operatorId));
var logout = await _service.LogoutAsync(rawToken, CancellationToken.None); var logout = await _service.LogoutAsync(rawToken, CancellationToken.None);
@@ -347,8 +347,8 @@ public sealed class AuthServiceTests
[Fact] [Fact]
public async Task LogoutAsync_ForNormalSession_ReturnsNull() public async Task LogoutAsync_ForNormalSession_ReturnsNull()
{ {
var rawToken = "normal-session-raw-token"; string rawToken = "normal-session-raw-token";
var tokenHash = SessionTokens.HashToken(rawToken); string tokenHash = SessionTokens.HashToken(rawToken);
_store.AddSession(new SessionDto(tokenHash, UserId, UserLogin, DateTimeOffset.UtcNow.AddDays(30))); _store.AddSession(new SessionDto(tokenHash, UserId, UserLogin, DateTimeOffset.UtcNow.AddDays(30)));
var logout = await _service.LogoutAsync(rawToken, CancellationToken.None); var logout = await _service.LogoutAsync(rawToken, CancellationToken.None);
@@ -102,7 +102,7 @@ public sealed class FakeAuthStore : IAuthStore
string passwordHash, string passwordHash,
CancellationToken ct) CancellationToken ct)
{ {
var index = _users.FindIndex(u => u.Id == userId); int index = _users.FindIndex(u => u.Id == userId);
if (index >= 0) if (index >= 0)
{ {
var user = _users[index]; var user = _users[index];
@@ -71,7 +71,7 @@ public sealed class OperatorAuthServiceTests
[Fact] [Fact]
public async Task ResolveSessionAsync_WithValidSession_ReturnsOperatorIdentity() public async Task ResolveSessionAsync_WithValidSession_ReturnsOperatorIdentity()
{ {
var rawToken = "valid-operator-session-raw-token"; string rawToken = "valid-operator-session-raw-token";
_store.AddSession(new OperatorSessionDto( _store.AddSession(new OperatorSessionDto(
SessionTokens.HashToken(rawToken), OperatorId, OperatorLogin, DateTimeOffset.UtcNow.AddHours(12))); SessionTokens.HashToken(rawToken), OperatorId, OperatorLogin, DateTimeOffset.UtcNow.AddHours(12)));
@@ -89,7 +89,7 @@ public sealed class OperatorAuthServiceTests
[Fact] [Fact]
public async Task ResolveSessionAsync_WithExpiredSession_ReturnsNull() public async Task ResolveSessionAsync_WithExpiredSession_ReturnsNull()
{ {
var rawToken = "expired-operator-session-raw-token"; string rawToken = "expired-operator-session-raw-token";
_store.AddSession(new OperatorSessionDto( _store.AddSession(new OperatorSessionDto(
SessionTokens.HashToken(rawToken), OperatorId, OperatorLogin, DateTimeOffset.UtcNow.AddHours(-1))); SessionTokens.HashToken(rawToken), OperatorId, OperatorLogin, DateTimeOffset.UtcNow.AddHours(-1)));
@@ -104,7 +104,7 @@ public sealed class OperatorAuthServiceTests
[Fact] [Fact]
public async Task ResolveSessionAsync_WhenOperatorWasDeleted_ReturnsNull() public async Task ResolveSessionAsync_WhenOperatorWasDeleted_ReturnsNull()
{ {
var rawToken = "orphan-operator-session-raw-token"; string rawToken = "orphan-operator-session-raw-token";
_store.AddSession(new OperatorSessionDto( _store.AddSession(new OperatorSessionDto(
SessionTokens.HashToken(rawToken), Guid.NewGuid(), "deleted-operator", DateTimeOffset.UtcNow.AddHours(12))); SessionTokens.HashToken(rawToken), Guid.NewGuid(), "deleted-operator", DateTimeOffset.UtcNow.AddHours(12)));
@@ -119,7 +119,7 @@ public sealed class OperatorAuthServiceTests
const string suspendedLogin = "suspended-operator"; const string suspendedLogin = "suspended-operator";
var passwordHasher = new FakePasswordHasher(); var passwordHasher = new FakePasswordHasher();
_store.AddOperator(new StoredOperatorDto(Guid.NewGuid(), suspendedLogin, "suspended", passwordHasher.Hash("x"))); _store.AddOperator(new StoredOperatorDto(Guid.NewGuid(), suspendedLogin, "suspended", passwordHasher.Hash("x")));
var rawToken = "suspended-operator-session-raw-token"; string rawToken = "suspended-operator-session-raw-token";
_store.AddSession(new OperatorSessionDto( _store.AddSession(new OperatorSessionDto(
SessionTokens.HashToken(rawToken), Guid.NewGuid(), suspendedLogin, DateTimeOffset.UtcNow.AddHours(12))); SessionTokens.HashToken(rawToken), Guid.NewGuid(), suspendedLogin, DateTimeOffset.UtcNow.AddHours(12)));
@@ -141,8 +141,8 @@ public sealed class OperatorAuthServiceTests
[Fact] [Fact]
public async Task LogoutAsync_WithToken_DeletesSession() public async Task LogoutAsync_WithToken_DeletesSession()
{ {
var rawToken = "logout-operator-session-raw-token"; string rawToken = "logout-operator-session-raw-token";
var tokenHash = SessionTokens.HashToken(rawToken); string tokenHash = SessionTokens.HashToken(rawToken);
_store.AddSession(new OperatorSessionDto(tokenHash, OperatorId, OperatorLogin, DateTimeOffset.UtcNow.AddHours(12))); _store.AddSession(new OperatorSessionDto(tokenHash, OperatorId, OperatorLogin, DateTimeOffset.UtcNow.AddHours(12)));
await _service.LogoutAsync(rawToken, CancellationToken.None); await _service.LogoutAsync(rawToken, CancellationToken.None);
@@ -22,7 +22,7 @@ public sealed class OperatorBootstrapServiceTests
[Fact] [Fact]
public async Task InDevelopment_WithoutEnvCredentials_SeedsDefaultOperator() public async Task InDevelopment_WithoutEnvCredentials_SeedsDefaultOperator()
{ {
var login = await _service.EnsureOperatorAsync(null, null, allowDevelopmentDefaults: true, CancellationToken.None); string? login = await _service.EnsureOperatorAsync(null, null, allowDevelopmentDefaults: true, CancellationToken.None);
Assert.Equal(OperatorBootstrapService.DefaultOperatorLogin, login); Assert.Equal(OperatorBootstrapService.DefaultOperatorLogin, login);
@@ -36,7 +36,7 @@ public sealed class OperatorBootstrapServiceTests
[Fact] [Fact]
public async Task InDevelopment_WithEmptyEnvCredentials_SeedsDefaultOperator() public async Task InDevelopment_WithEmptyEnvCredentials_SeedsDefaultOperator()
{ {
var login = await _service.EnsureOperatorAsync("", "", allowDevelopmentDefaults: true, CancellationToken.None); string? login = await _service.EnsureOperatorAsync("", "", allowDevelopmentDefaults: true, CancellationToken.None);
Assert.Equal(OperatorBootstrapService.DefaultOperatorLogin, login); Assert.Equal(OperatorBootstrapService.DefaultOperatorLogin, login);
Assert.Single(_store.Operators); Assert.Single(_store.Operators);
@@ -45,8 +45,8 @@ public sealed class OperatorBootstrapServiceTests
[Fact] [Fact]
public async Task RepeatedBootstrap_IsIdempotent_KeepsSingleOperator() public async Task RepeatedBootstrap_IsIdempotent_KeepsSingleOperator()
{ {
var first = await _service.EnsureOperatorAsync(null, null, allowDevelopmentDefaults: true, CancellationToken.None); string? first = await _service.EnsureOperatorAsync(null, null, allowDevelopmentDefaults: true, CancellationToken.None);
var second = await _service.EnsureOperatorAsync(null, null, allowDevelopmentDefaults: true, CancellationToken.None); string? second = await _service.EnsureOperatorAsync(null, null, allowDevelopmentDefaults: true, CancellationToken.None);
Assert.Equal(OperatorBootstrapService.DefaultOperatorLogin, first); Assert.Equal(OperatorBootstrapService.DefaultOperatorLogin, first);
Assert.Equal(OperatorBootstrapService.DefaultOperatorLogin, second); Assert.Equal(OperatorBootstrapService.DefaultOperatorLogin, second);
@@ -58,7 +58,7 @@ public sealed class OperatorBootstrapServiceTests
[Fact] [Fact]
public async Task Production_WithoutEnvCredentials_SkipsBootstrap() public async Task Production_WithoutEnvCredentials_SkipsBootstrap()
{ {
var login = await _service.EnsureOperatorAsync(null, null, allowDevelopmentDefaults: false, CancellationToken.None); string? login = await _service.EnsureOperatorAsync(null, null, allowDevelopmentDefaults: false, CancellationToken.None);
Assert.Null(login); Assert.Null(login);
Assert.Empty(_store.Operators); Assert.Empty(_store.Operators);
@@ -68,7 +68,7 @@ public sealed class OperatorBootstrapServiceTests
[Fact] [Fact]
public async Task Production_WithEnvCredentials_CreatesOperatorWithNormalizedLogin() public async Task Production_WithEnvCredentials_CreatesOperatorWithNormalizedLogin()
{ {
var login = await _service.EnsureOperatorAsync( string? login = await _service.EnsureOperatorAsync(
" Root-Admin ", "root-password", allowDevelopmentDefaults: false, CancellationToken.None); " Root-Admin ", "root-password", allowDevelopmentDefaults: false, CancellationToken.None);
Assert.Equal("root-admin", login); Assert.Equal("root-admin", login);
@@ -83,7 +83,7 @@ public sealed class OperatorBootstrapServiceTests
{ {
_store.AddOperator(new StoredOperatorDto(Guid.NewGuid(), "existing-op", "active", _passwordHasher.Hash("old-password"))); _store.AddOperator(new StoredOperatorDto(Guid.NewGuid(), "existing-op", "active", _passwordHasher.Hash("old-password")));
var login = await _service.EnsureOperatorAsync("EXISTING-OP", "new-password", allowDevelopmentDefaults: false, CancellationToken.None); string? login = await _service.EnsureOperatorAsync("EXISTING-OP", "new-password", allowDevelopmentDefaults: false, CancellationToken.None);
Assert.Equal("existing-op", login); Assert.Equal("existing-op", login);
Assert.Single(_store.Operators); Assert.Single(_store.Operators);
@@ -13,7 +13,7 @@ public sealed class PasswordHasherTests
[Fact] [Fact]
public void Hash_ReturnsArgon2idEncodedStringDifferentFromPassword() public void Hash_ReturnsArgon2idEncodedStringDifferentFromPassword()
{ {
var hash = _hasher.Hash("correct horse battery staple"); string hash = _hasher.Hash("correct horse battery staple");
Assert.False(string.IsNullOrWhiteSpace(hash)); Assert.False(string.IsNullOrWhiteSpace(hash));
Assert.StartsWith("$argon2id$", hash); Assert.StartsWith("$argon2id$", hash);
@@ -23,7 +23,7 @@ public sealed class PasswordHasherTests
[Fact] [Fact]
public void Verify_WithCorrectPassword_ReturnsTrue() public void Verify_WithCorrectPassword_ReturnsTrue()
{ {
var hash = _hasher.Hash("secret42"); string hash = _hasher.Hash("secret42");
Assert.True(_hasher.Verify("secret42", hash)); Assert.True(_hasher.Verify("secret42", hash));
} }
@@ -31,7 +31,7 @@ public sealed class PasswordHasherTests
[Fact] [Fact]
public void Verify_WithWrongPassword_ReturnsFalse() public void Verify_WithWrongPassword_ReturnsFalse()
{ {
var hash = _hasher.Hash("secret42"); string hash = _hasher.Hash("secret42");
Assert.False(_hasher.Verify("wrong-password", hash)); Assert.False(_hasher.Verify("wrong-password", hash));
} }
@@ -39,8 +39,8 @@ public sealed class PasswordHasherTests
[Fact] [Fact]
public void Hash_SamePasswordTwice_DifferentHashesBecauseOfRandomSalt() public void Hash_SamePasswordTwice_DifferentHashesBecauseOfRandomSalt()
{ {
var first = _hasher.Hash("same-password"); string first = _hasher.Hash("same-password");
var second = _hasher.Hash("same-password"); string second = _hasher.Hash("same-password");
Assert.NotEqual(first, second); Assert.NotEqual(first, second);
Assert.True(_hasher.Verify("same-password", first)); Assert.True(_hasher.Verify("same-password", first));
@@ -10,8 +10,8 @@ public sealed class SessionTokensTests
[Fact] [Fact]
public void NewToken_ReturnsUniqueLongEnoughTokens() public void NewToken_ReturnsUniqueLongEnoughTokens()
{ {
var first = SessionTokens.NewToken(); string first = SessionTokens.NewToken();
var second = SessionTokens.NewToken(); string second = SessionTokens.NewToken();
Assert.NotEqual(first, second); Assert.NotEqual(first, second);
Assert.True(first.Length > 32); Assert.True(first.Length > 32);
@@ -21,7 +21,7 @@ public sealed class SessionTokensTests
[Fact] [Fact]
public void NewToken_UsesBase64UrlAlphabetWithoutPadding() public void NewToken_UsesBase64UrlAlphabetWithoutPadding()
{ {
var token = SessionTokens.NewToken(); string token = SessionTokens.NewToken();
// 32 байта в Base64Url без padding: 43 символа, без '=' и без небезопасных '+'/ '/'. // 32 байта в Base64Url без padding: 43 символа, без '=' и без небезопасных '+'/ '/'.
Assert.Equal(43, token.Length); Assert.Equal(43, token.Length);
@@ -33,8 +33,8 @@ public sealed class SessionTokensTests
[Fact] [Fact]
public void HashToken_IsDeterministicHexSha256() public void HashToken_IsDeterministicHexSha256()
{ {
var first = SessionTokens.HashToken("raw-token-value"); string first = SessionTokens.HashToken("raw-token-value");
var second = SessionTokens.HashToken("raw-token-value"); string second = SessionTokens.HashToken("raw-token-value");
Assert.Equal(first, second); Assert.Equal(first, second);
Assert.Equal(64, first.Length); Assert.Equal(64, first.Length);
@@ -43,8 +43,8 @@ public sealed class SessionTokensTests
[Fact] [Fact]
public void HashToken_DifferentTokensProduceDifferentHashes() public void HashToken_DifferentTokensProduceDifferentHashes()
{ {
var first = SessionTokens.HashToken("raw-token-1"); string first = SessionTokens.HashToken("raw-token-1");
var second = SessionTokens.HashToken("raw-token-2"); string second = SessionTokens.HashToken("raw-token-2");
Assert.NotEqual(first, second); Assert.NotEqual(first, second);
} }
@@ -41,7 +41,7 @@ public sealed class ConversionRecomputerTests
int updated = await service.RecomputeAsync(CancellationToken.None); int updated = await service.RecomputeAsync(CancellationToken.None);
// 100 USD → EUR: 100 * 92.5 / 99.9 = 92.5925… → round 2. // 100 USD → EUR: 100 * 92.5 / 99.9 = 92.5925… → округление до 2 знаков.
Assert.Equal(1, updated); Assert.Equal(1, updated);
AssertConverted(store, "l_board", convFrom: 92.59, convTo: 92.59, "EUR"); AssertConverted(store, "l_board", convFrom: 92.59, convTo: 92.59, "EUR");
} }
@@ -1,138 +1,138 @@
using System.Diagnostics; using System.Diagnostics;
using Grpc.Core; using Deal.Grpc.Hosting.Models;
using Grpc.Core.Interceptors; using Deal.Grpc.Hosting.Options;
using Microsoft.Extensions.Logging; using Deal.Grpc.Hosting.Services;
using Deal.Grpc.Hosting.Models; using Grpc.Core;
using Deal.Grpc.Hosting.Options; using Grpc.Core.Interceptors;
using Deal.Grpc.Hosting.Services; using Microsoft.Extensions.Logging;
namespace Deal.Grpc.Hosting.Interceptors; namespace Deal.Grpc.Hosting.Interceptors;
/// <summary> /// <summary>
/// Access-лог RPC Deal-сервисов /// Access-лог RPC Deal-сервисов
/// </summary> /// </summary>
public sealed class RpcCallLoggingInterceptor : Interceptor public sealed class RpcCallLoggingInterceptor : Interceptor
{ {
// Префикс методов стандартного gRPC-health — не логируется (инфраструктурный liveness). // Префикс методов стандартного gRPC-health — не логируется (инфраструктурный liveness).
private const string HealthMethodPrefix = "/grpc.health.v1.Health/"; private const string HealthMethodPrefix = "/grpc.health.v1.Health/";
// Деталь RpcException для сбоя реализации (фиксированный текст; детали ошибки не наружу). // Деталь RpcException для сбоя реализации (фиксированный текст; детали ошибки не наружу).
private const string UnknownFailureDetail = "Внутренняя ошибка сервиса"; private const string UnknownFailureDetail = "Внутренняя ошибка сервиса";
private readonly ILogger<RpcCallLoggingInterceptor> _logger; private readonly ILogger<RpcCallLoggingInterceptor> _logger;
/// <summary> /// <summary>
/// Создаёт интерцептор access-лога gRPC-вызовов. /// Создаёт интерцептор access-лога gRPC-вызовов.
/// </summary> /// </summary>
/// <param name="logger">Логгер.</param> /// <param name="logger">Логгер.</param>
public RpcCallLoggingInterceptor(ILogger<RpcCallLoggingInterceptor> logger) public RpcCallLoggingInterceptor(ILogger<RpcCallLoggingInterceptor> logger)
{ {
ArgumentNullException.ThrowIfNull(logger); ArgumentNullException.ThrowIfNull(logger);
_logger = logger; _logger = logger;
} }
/// <summary> /// <summary>
/// Логирует unary-RPC /// Логирует unary-RPC
/// </summary> /// </summary>
public override Task<TResponse> UnaryServerHandler<TRequest, TResponse>( public override Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request, TRequest request,
ServerCallContext context, ServerCallContext context,
UnaryServerMethod<TRequest, TResponse> continuation) UnaryServerMethod<TRequest, TResponse> continuation)
=> LogAsync(context, () => continuation(request, context)); => LogAsync(context, () => continuation(request, context));
/// <summary> /// <summary>
/// Логирует client-streaming-RPC /// Логирует client-streaming-RPC
/// </summary> /// </summary>
public override Task<TResponse> ClientStreamingServerHandler<TRequest, TResponse>( public override Task<TResponse> ClientStreamingServerHandler<TRequest, TResponse>(
IAsyncStreamReader<TRequest> requestStream, IAsyncStreamReader<TRequest> requestStream,
ServerCallContext context, ServerCallContext context,
ClientStreamingServerMethod<TRequest, TResponse> continuation) ClientStreamingServerMethod<TRequest, TResponse> continuation)
=> LogAsync(context, () => continuation(requestStream, context)); => LogAsync(context, () => continuation(requestStream, context));
/// <summary> /// <summary>
/// Логирует server-streaming-RPC /// Логирует server-streaming-RPC
/// </summary> /// </summary>
public override Task ServerStreamingServerHandler<TRequest, TResponse>( public override Task ServerStreamingServerHandler<TRequest, TResponse>(
TRequest request, TRequest request,
IServerStreamWriter<TResponse> responseStream, IServerStreamWriter<TResponse> responseStream,
ServerCallContext context, ServerCallContext context,
ServerStreamingServerMethod<TRequest, TResponse> continuation) ServerStreamingServerMethod<TRequest, TResponse> continuation)
=> LogAsync(context, () => continuation(request, responseStream, context)); => LogAsync(context, () => continuation(request, responseStream, context));
/// <summary> /// <summary>
/// Логирует дуплексный RPC /// Логирует дуплексный RPC
/// </summary> /// </summary>
public override Task DuplexStreamingServerHandler<TRequest, TResponse>( public override Task DuplexStreamingServerHandler<TRequest, TResponse>(
IAsyncStreamReader<TRequest> requestStream, IAsyncStreamReader<TRequest> requestStream,
IServerStreamWriter<TResponse> responseStream, IServerStreamWriter<TResponse> responseStream,
ServerCallContext context, ServerCallContext context,
DuplexStreamingServerMethod<TRequest, TResponse> continuation) DuplexStreamingServerMethod<TRequest, TResponse> continuation)
=> LogAsync(context, () => continuation(requestStream, responseStream, context)); => LogAsync(context, () => continuation(requestStream, responseStream, context));
// Исполняет вызов под access-логом: health пропускается; успех — OK, отмена клиента — Cancelled, // Исполняет вызов под access-логом: health пропускается; успех — OK, отмена клиента — Cancelled,
// RpcException — код статуса исключения, прочие сбои реализации — Unknown + RpcException. // RpcException — код статуса исключения, прочие сбои реализации — Unknown + RpcException.
// TResult: Тип результата вызова. // TResult: Тип результата вызова.
// context: Контекст вызова (метод — context.Method). // context: Контекст вызова (метод — context.Method).
// invoke: Вызов нижестоящего обработчика. // invoke: Вызов нижестоящего обработчика.
private async Task<TResult> LogAsync<TResult>(ServerCallContext context, Func<Task<TResult>> invoke) private async Task<TResult> LogAsync<TResult>(ServerCallContext context, Func<Task<TResult>> invoke)
{ {
if (context.Method.StartsWith(HealthMethodPrefix, StringComparison.Ordinal)) if (context.Method.StartsWith(HealthMethodPrefix, StringComparison.Ordinal))
{ {
return await invoke().ConfigureAwait(false); return await invoke().ConfigureAwait(false);
} }
long startedAt = Stopwatch.GetTimestamp(); long startedAt = Stopwatch.GetTimestamp();
try try
{ {
TResult response = await invoke().ConfigureAwait(false); TResult response = await invoke().ConfigureAwait(false);
LogCall(context, startedAt, null); LogCall(context, startedAt, null);
return response; return response;
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
// Клиент отменил вызов (дисконнект/дедлайн) — статус Cancelled. // Клиент отменил вызов (дисконнект/дедлайн) — статус Cancelled.
LogCall(context, startedAt, StatusCode.Cancelled); LogCall(context, startedAt, StatusCode.Cancelled);
throw; throw;
} }
catch (RpcException rpcException) catch (RpcException rpcException)
{ {
LogCall(context, startedAt, rpcException.Status.StatusCode); LogCall(context, startedAt, rpcException.Status.StatusCode);
throw; throw;
} }
catch (Exception exception) catch (Exception exception)
{ {
// «Прочие» сбои реализации gRPC показал бы клиенту как UNKNOWN мимо access-лога: логируем // «Прочие» сбои реализации gRPC показал бы клиенту как UNKNOWN мимо access-лога: логируем
// строку со статусом Unknown, пишем детали сбоя и переводим в RpcException (текст фиксирован). // строку со статусом Unknown, пишем детали сбоя и переводим в RpcException (текст фиксирован).
_logger.LogError(exception, "gRPC {RpcMethod}: необработанный сбой реализации", context.Method); _logger.LogError(exception, "gRPC {RpcMethod}: необработанный сбой реализации", context.Method);
LogCall(context, startedAt, StatusCode.Unknown); LogCall(context, startedAt, StatusCode.Unknown);
throw new RpcException(new Status(StatusCode.Unknown, UnknownFailureDetail)); throw new RpcException(new Status(StatusCode.Unknown, UnknownFailureDetail));
} }
} }
// Обёртка для handler-ов, возвращающих Task (server-streaming/дуплексный). // Обёртка для handler-ов, возвращающих Task (server-streaming/дуплексный).
// context: Контекст вызова (метод — context.Method). // context: Контекст вызова (метод — context.Method).
// invoke: Вызов нижестоящего обработчика. // invoke: Вызов нижестоящего обработчика.
private Task LogAsync(ServerCallContext context, Func<Task> invoke) private Task LogAsync(ServerCallContext context, Func<Task> invoke)
=> LogAsync(context, async () => => LogAsync(context, async () =>
{ {
await invoke().ConfigureAwait(false); await invoke().ConfigureAwait(false);
return true; return true;
}); });
// Пишет одну строку access-лога: полное имя RPC-метода, статус, длительность. // Пишет одну строку access-лога: полное имя RPC-метода, статус, длительность.
// context: Контекст вызова (метод). // context: Контекст вызова (метод).
// startedAt: Метка времени старта вызова (Stopwatch.GetTimestamp). // startedAt: Метка времени старта вызова (Stopwatch.GetTimestamp).
// statusCode: Итоговый gRPC-статус; null — успех (OK). // statusCode: Итоговый gRPC-статус; null — успех (OK).
private void LogCall( private void LogCall(
ServerCallContext context, ServerCallContext context,
long startedAt, long startedAt,
StatusCode? statusCode) StatusCode? statusCode)
{ {
long elapsedMs = (long)Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds; long elapsedMs = (long)Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds;
_logger.LogInformation( _logger.LogInformation(
"gRPC {RpcMethod}: {GrpcStatus} за {DurationMs} мс", "gRPC {RpcMethod}: {GrpcStatus} за {DurationMs} мс",
context.Method, context.Method,
statusCode?.ToString() ?? StatusCode.OK.ToString(), statusCode?.ToString() ?? StatusCode.OK.ToString(),
elapsedMs); elapsedMs);
} }
} }
@@ -1,130 +1,130 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using Grpc.Core; using Deal.Grpc.Hosting.Models;
using Grpc.Core.Interceptors; using Deal.Grpc.Hosting.Options;
using Microsoft.Extensions.Configuration; using Deal.Grpc.Hosting.Services;
using Deal.Grpc.Hosting.Models; using Grpc.Core;
using Deal.Grpc.Hosting.Options; using Grpc.Core.Interceptors;
using Deal.Grpc.Hosting.Services; using Microsoft.Extensions.Configuration;
namespace Deal.Grpc.Hosting.Interceptors; namespace Deal.Grpc.Hosting.Interceptors;
/// <summary> /// <summary>
/// Серверный интерцептор service-token. /// Серверный интерцептор service-token.
/// </summary> /// </summary>
public sealed class ServiceTokenInterceptor : Interceptor public sealed class ServiceTokenInterceptor : Interceptor
{ {
public const string ServiceTokenMetadataKey = "service-token"; public const string ServiceTokenMetadataKey = "service-token";
// Префикс методов стандартного gRPC-health, освобождённых от проверки токена. // Префикс методов стандартного gRPC-health, освобождённых от проверки токена.
private const string HealthMethodPrefix = "/grpc.health.v1.Health/"; private const string HealthMethodPrefix = "/grpc.health.v1.Health/";
private const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; private const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN";
private const string RejectionDetail = "service-token отсутствует или неверен"; private const string RejectionDetail = "service-token отсутствует или неверен";
private readonly byte[] _expectedTokenBytes; private readonly byte[] _expectedTokenBytes;
/// <summary> /// <summary>
/// Создаёт интерцептор. /// Создаёт интерцептор.
/// </summary> /// </summary>
/// <param name="configuration">Конфигурация хоста (env-провайдер WebApplicationBuilder).</param> /// <param name="configuration">Конфигурация хоста (env-провайдер WebApplicationBuilder).</param>
public ServiceTokenInterceptor(IConfiguration configuration) public ServiceTokenInterceptor(IConfiguration configuration)
{ {
ArgumentNullException.ThrowIfNull(configuration); ArgumentNullException.ThrowIfNull(configuration);
_expectedTokenBytes = Encoding.UTF8.GetBytes(configuration[ServiceTokenEnvKey] ?? string.Empty); _expectedTokenBytes = Encoding.UTF8.GetBytes(configuration[ServiceTokenEnvKey] ?? string.Empty);
} }
/// <summary> /// <summary>
/// Проверяет токен для unary-RPC и передаёт вызов дальше. /// Проверяет токен для unary-RPC и передаёт вызов дальше.
/// </summary> /// </summary>
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>( public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request, TRequest request,
ServerCallContext context, ServerCallContext context,
UnaryServerMethod<TRequest, TResponse> continuation) UnaryServerMethod<TRequest, TResponse> continuation)
{ {
EnsureAuthorized(context); EnsureAuthorized(context);
return await continuation(request, context).ConfigureAwait(false); return await continuation(request, context).ConfigureAwait(false);
} }
/// <summary> /// <summary>
/// Проверяет токен для client-streaming-RPC и передаёт вызов дальше. /// Проверяет токен для client-streaming-RPC и передаёт вызов дальше.
/// </summary> /// </summary>
public override async Task<TResponse> ClientStreamingServerHandler<TRequest, TResponse>( public override async Task<TResponse> ClientStreamingServerHandler<TRequest, TResponse>(
IAsyncStreamReader<TRequest> requestStream, IAsyncStreamReader<TRequest> requestStream,
ServerCallContext context, ServerCallContext context,
ClientStreamingServerMethod<TRequest, TResponse> continuation) ClientStreamingServerMethod<TRequest, TResponse> continuation)
{ {
EnsureAuthorized(context); EnsureAuthorized(context);
return await continuation(requestStream, context).ConfigureAwait(false); return await continuation(requestStream, context).ConfigureAwait(false);
} }
/// <summary> /// <summary>
/// Проверяет токен для server-streaming-RPC и передаёт вызов дальше. /// Проверяет токен для server-streaming-RPC и передаёт вызов дальше.
/// </summary> /// </summary>
public override async Task ServerStreamingServerHandler<TRequest, TResponse>( public override async Task ServerStreamingServerHandler<TRequest, TResponse>(
TRequest request, TRequest request,
IServerStreamWriter<TResponse> responseStream, IServerStreamWriter<TResponse> responseStream,
ServerCallContext context, ServerCallContext context,
ServerStreamingServerMethod<TRequest, TResponse> continuation) ServerStreamingServerMethod<TRequest, TResponse> continuation)
{ {
EnsureAuthorized(context); EnsureAuthorized(context);
await continuation(request, responseStream, context).ConfigureAwait(false); await continuation(request, responseStream, context).ConfigureAwait(false);
} }
/// <summary> /// <summary>
/// Проверяет токен для дуплексного RPC и передаёт вызов дальше. /// Проверяет токен для дуплексного RPC и передаёт вызов дальше.
/// </summary> /// </summary>
public override async Task DuplexStreamingServerHandler<TRequest, TResponse>( public override async Task DuplexStreamingServerHandler<TRequest, TResponse>(
IAsyncStreamReader<TRequest> requestStream, IAsyncStreamReader<TRequest> requestStream,
IServerStreamWriter<TResponse> responseStream, IServerStreamWriter<TResponse> responseStream,
ServerCallContext context, ServerCallContext context,
DuplexStreamingServerMethod<TRequest, TResponse> continuation) DuplexStreamingServerMethod<TRequest, TResponse> continuation)
{ {
EnsureAuthorized(context); EnsureAuthorized(context);
await continuation(requestStream, responseStream, context).ConfigureAwait(false); await continuation(requestStream, responseStream, context).ConfigureAwait(false);
} }
// Проверка токена для любого вида RPC: сначала пропускаются методы gRPC-health (безопасны), затем // Проверка токена для любого вида RPC: сначала пропускаются методы gRPC-health (безопасны), затем
// сверяется metadata «service-token» с ожидаемым значением; несовпадение — UNAUTHENTICATED. // сверяется metadata «service-token» с ожидаемым значением; несовпадение — UNAUTHENTICATED.
// context: Контекст вызова (метод и metadata из заголовков). // context: Контекст вызова (метод и metadata из заголовков).
private void EnsureAuthorized(ServerCallContext context) private void EnsureAuthorized(ServerCallContext context)
{ {
if (context.Method.StartsWith(HealthMethodPrefix, StringComparison.Ordinal)) if (context.Method.StartsWith(HealthMethodPrefix, StringComparison.Ordinal))
{ {
return; return;
} }
// Fail-closed: env-токен не задан — Deal-RPC отклоняется, даже если запрос нёс «пустой» токен // Fail-closed: env-токен не задан — Deal-RPC отклоняется, даже если запрос нёс «пустой» токен
// (иначе «» == «» прошло бы сравнение ниже). Health уже пропущен выше — остаётся живым. // (иначе «» == «» прошло бы сравнение ниже). Health уже пропущен выше — остаётся живым.
if (_expectedTokenBytes.Length == 0) if (_expectedTokenBytes.Length == 0)
{ {
throw Rejection(); throw Rejection();
} }
string? actualToken = context.RequestHeaders.GetValue(ServiceTokenMetadataKey); string? actualToken = context.RequestHeaders.GetValue(ServiceTokenMetadataKey);
if (!TokenMatches(actualToken, _expectedTokenBytes)) if (!TokenMatches(actualToken, _expectedTokenBytes))
{ {
throw Rejection(); throw Rejection();
} }
} }
// Сравнивает токен с ожидаемым constant-time (FixedTimeEquals по UTF-8-байтам): раннего выхода по // Сравнивает токен с ожидаемым constant-time (FixedTimeEquals по UTF-8-байтам): раннего выхода по
// содержимому нет — время сравнения не зависит от совпадения префикса (замечание code-review). // содержимому нет — время сравнения не зависит от совпадения префикса (замечание code-review).
// actualToken: Токен из metadata (null — заголовка нет). // actualToken: Токен из metadata (null — заголовка нет).
// expectedTokenBytes: Ожидаемый токен в UTF-8-байтах. // expectedTokenBytes: Ожидаемый токен в UTF-8-байтах.
private static bool TokenMatches(string? actualToken, byte[] expectedTokenBytes) private static bool TokenMatches(string? actualToken, byte[] expectedTokenBytes)
{ {
if (actualToken is null) if (actualToken is null)
{ {
return false; return false;
} }
byte[] actualTokenBytes = Encoding.UTF8.GetBytes(actualToken); byte[] actualTokenBytes = Encoding.UTF8.GetBytes(actualToken);
return CryptographicOperations.FixedTimeEquals(actualTokenBytes, expectedTokenBytes); return CryptographicOperations.FixedTimeEquals(actualTokenBytes, expectedTokenBytes);
} }
// Создаёт отказ UNAUTHENTICATED с общим текстом детали. // Создаёт отказ UNAUTHENTICATED с общим текстом детали.
private static RpcException Rejection() private static RpcException Rejection()
=> new(new Status(StatusCode.Unauthenticated, RejectionDetail)); => new(new Status(StatusCode.Unauthenticated, RejectionDetail));
} }
@@ -1,104 +1,104 @@
using Microsoft.Extensions.Configuration; using Deal.Grpc.Hosting.Interceptors;
using Deal.Grpc.Hosting.Interceptors; using Deal.Grpc.Hosting.Models;
using Deal.Grpc.Hosting.Models; using Deal.Grpc.Hosting.Services;
using Deal.Grpc.Hosting.Services; using Microsoft.Extensions.Configuration;
namespace Deal.Grpc.Hosting.Options; namespace Deal.Grpc.Hosting.Options;
/// <summary> /// <summary>
/// Конфигурация mTLS-транспорта внутреннего gRPC. /// Конфигурация mTLS-транспорта внутреннего gRPC.
/// </summary> /// </summary>
public sealed class MtlsOptions public sealed class MtlsOptions
{ {
/// <summary> /// <summary>
/// Env-ключ флага: 1/true включает mTLS /// Env-ключ флага: 1/true включает mTLS
/// </summary> /// </summary>
public const string EnabledEnvKey = "DEAL_MTLS_ENABLED"; public const string EnabledEnvKey = "DEAL_MTLS_ENABLED";
/// <summary> /// <summary>
/// Env-ключ пути к PFX серверного сертификата процесса /// Env-ключ пути к PFX серверного сертификата процесса
/// </summary> /// </summary>
public const string ServerCertPfxEnvKey = "DEAL_MTLS_SERVER_CERT_PFX"; public const string ServerCertPfxEnvKey = "DEAL_MTLS_SERVER_CERT_PFX";
/// <summary> /// <summary>
/// Env-ключ пароля серверного PFX. /// Env-ключ пароля серверного PFX.
/// </summary> /// </summary>
public const string ServerCertPasswordEnvKey = "DEAL_MTLS_SERVER_CERT_PASSWORD"; public const string ServerCertPasswordEnvKey = "DEAL_MTLS_SERVER_CERT_PASSWORD";
/// <summary> /// <summary>
/// Env-ключ пути к PFX клиентского сертификата /// Env-ключ пути к PFX клиентского сертификата
/// </summary> /// </summary>
public const string ClientCertPfxEnvKey = "DEAL_MTLS_CLIENT_CERT_PFX"; public const string ClientCertPfxEnvKey = "DEAL_MTLS_CLIENT_CERT_PFX";
/// <summary> /// <summary>
/// Env-ключ пароля клиентского PFX. /// Env-ключ пароля клиентского PFX.
/// </summary> /// </summary>
public const string ClientCertPasswordEnvKey = "DEAL_MTLS_CLIENT_CERT_PASSWORD"; public const string ClientCertPasswordEnvKey = "DEAL_MTLS_CLIENT_CERT_PASSWORD";
/// <summary> /// <summary>
/// Env-ключ пути к PEM dev-CA /// Env-ключ пути к PEM dev-CA
/// </summary> /// </summary>
public const string CaPemEnvKey = "DEAL_MTLS_CA_PEM"; public const string CaPemEnvKey = "DEAL_MTLS_CA_PEM";
/// <summary> /// <summary>
/// True — транспорт внутренних gRPC-эндпоинтов и исходящих каналов под mTLS. /// True — транспорт внутренних gRPC-эндпоинтов и исходящих каналов под mTLS.
/// </summary> /// </summary>
public bool Enabled { get; init; } public bool Enabled { get; init; }
/// <summary> /// <summary>
/// Путь к PFX серверного сертификата процесса /// Путь к PFX серверного сертификата процесса
/// </summary> /// </summary>
public string ServerCertPfx { get; init; } = string.Empty; public string ServerCertPfx { get; init; } = string.Empty;
/// <summary> /// <summary>
/// Пароль серверного PFX /// Пароль серверного PFX
/// </summary> /// </summary>
public string ServerCertPassword { get; init; } = string.Empty; public string ServerCertPassword { get; init; } = string.Empty;
/// <summary> /// <summary>
/// Путь к PFX клиентского сертификата /// Путь к PFX клиентского сертификата
/// </summary> /// </summary>
public string ClientCertPfx { get; init; } = string.Empty; public string ClientCertPfx { get; init; } = string.Empty;
/// <summary> /// <summary>
/// Пароль клиентского PFX /// Пароль клиентского PFX
/// </summary> /// </summary>
public string ClientCertPassword { get; init; } = string.Empty; public string ClientCertPassword { get; init; } = string.Empty;
/// <summary> /// <summary>
/// Путь к PEM-файлу dev-CA /// Путь к PEM-файлу dev-CA
/// </summary> /// </summary>
public string CaPem { get; init; } = string.Empty; public string CaPem { get; init; } = string.Empty;
/// <summary> /// <summary>
/// Читает опции из конфигурации хоста. /// Читает опции из конфигурации хоста.
/// </summary> /// </summary>
/// <param name="configuration">Конфигурация хоста (env-провайдер WebApplicationBuilder).</param> /// <param name="configuration">Конфигурация хоста (env-провайдер WebApplicationBuilder).</param>
/// <returns>Опции mTLS (флаг выключен — остальные поля пустые).</returns> /// <returns>Опции mTLS (флаг выключен — остальные поля пустые).</returns>
public static MtlsOptions FromConfiguration(IConfiguration configuration) public static MtlsOptions FromConfiguration(IConfiguration configuration)
{ {
ArgumentNullException.ThrowIfNull(configuration); ArgumentNullException.ThrowIfNull(configuration);
return new MtlsOptions return new MtlsOptions
{ {
Enabled = IsEnabled(configuration[EnabledEnvKey]), Enabled = IsEnabled(configuration[EnabledEnvKey]),
ServerCertPfx = Trimmed(configuration[ServerCertPfxEnvKey]), ServerCertPfx = Trimmed(configuration[ServerCertPfxEnvKey]),
ServerCertPassword = configuration[ServerCertPasswordEnvKey] ?? string.Empty, ServerCertPassword = configuration[ServerCertPasswordEnvKey] ?? string.Empty,
ClientCertPfx = Trimmed(configuration[ClientCertPfxEnvKey]), ClientCertPfx = Trimmed(configuration[ClientCertPfxEnvKey]),
ClientCertPassword = configuration[ClientCertPasswordEnvKey] ?? string.Empty, ClientCertPassword = configuration[ClientCertPasswordEnvKey] ?? string.Empty,
CaPem = Trimmed(configuration[CaPemEnvKey]), CaPem = Trimmed(configuration[CaPemEnvKey]),
}; };
} }
/// <summary> /// <summary>
/// Разбирает значение флага DEAL_MTLS_ENABLED /// Разбирает значение флага DEAL_MTLS_ENABLED
/// </summary> /// </summary>
/// <param name="rawValue">Сырое значение env (null/пусто — выключено).</param> /// <param name="rawValue">Сырое значение env (null/пусто — выключено).</param>
public static bool IsEnabled(string? rawValue) public static bool IsEnabled(string? rawValue)
=> string.Equals(rawValue, "1", StringComparison.Ordinal) => string.Equals(rawValue, "1", StringComparison.Ordinal)
|| string.Equals(rawValue, "true", StringComparison.OrdinalIgnoreCase); || string.Equals(rawValue, "true", StringComparison.OrdinalIgnoreCase);
// Обрезает путь конфигурации (env-значения с пробелами/кавычками не передаются в файловые API). // Обрезает путь конфигурации (env-значения с пробелами/кавычками не передаются в файловые API).
// rawValue: Сырое значение env. // rawValue: Сырое значение env.
private static string Trimmed(string? rawValue) private static string Trimmed(string? rawValue)
=> rawValue is null ? string.Empty : rawValue.Trim(); => rawValue is null ? string.Empty : rawValue.Trim();
} }
@@ -1,109 +1,109 @@
using Microsoft.AspNetCore.Builder; using Deal.Grpc.Hosting.Interceptors;
using Microsoft.Extensions.Configuration; using Deal.Grpc.Hosting.Models;
using Microsoft.Extensions.Hosting; using Deal.Grpc.Hosting.Options;
using Serilog; using Microsoft.AspNetCore.Builder;
using Serilog.Events; using Microsoft.Extensions.Configuration;
using Serilog.Formatting.Compact; using Microsoft.Extensions.Hosting;
using Deal.Grpc.Hosting.Interceptors; using Serilog;
using Deal.Grpc.Hosting.Models; using Serilog.Events;
using Deal.Grpc.Hosting.Options; using Serilog.Formatting.Compact;
namespace Deal.Grpc.Hosting.Services; namespace Deal.Grpc.Hosting.Services;
/// <summary> /// <summary>
/// Serilog-конфигурация процесса Deal-сервиса. /// Serilog-конфигурация процесса Deal-сервиса.
/// </summary> /// </summary>
public static class DealLogging public static class DealLogging
{ {
// Env-ключ минимального уровня Serilog (Debug/Information/Warning/Error; дефолт Information). // Env-ключ минимального уровня Serilog (Debug/Information/Warning/Error; дефолт Information).
private const string MinimumLevelEnvKey = "DEAL_LOG_LEVEL"; private const string MinimumLevelEnvKey = "DEAL_LOG_LEVEL";
// Env-ключ каталога rolling-файлов (дефолт data/logs под ContentRoot). // Env-ключ каталога rolling-файлов (дефолт data/logs под ContentRoot).
private const string LogsDirectoryEnvKey = "DEAL_LOGS_DIR"; private const string LogsDirectoryEnvKey = "DEAL_LOGS_DIR";
// Каталог логов по умолчанию (относительно ContentRoot). // Каталог логов по умолчанию (относительно ContentRoot).
private const string DefaultLogsSubdirectory = "data/logs"; private const string DefaultLogsSubdirectory = "data/logs";
// Шаблон имени rolling-файла (Serilog добавляет дату): deal-telegram-20260908.json. // Шаблон имени rolling-файла (Serilog добавляет дату): deal-telegram-20260908.json.
private const string LogFileNameTemplate = "deal-{0}-.json"; private const string LogFileNameTemplate = "deal-{0}-.json";
// Сколько rolling-файлов хранится (суток). // Сколько rolling-файлов хранится (суток).
private const int RetainedFileCount = 30; private const int RetainedFileCount = 30;
// Текстовая разметка консоли в Development (цвета — дефолтной темой Serilog). // Текстовая разметка консоли в Development (цвета — дефолтной темой Serilog).
private const string DevelopmentConsoleTemplate = private const string DevelopmentConsoleTemplate =
"{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}"; "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}";
// Категория Grpc.AspNetCore: не ниже Information (внутренние Debug-события вызовов не дублируют access-лог). // Категория Grpc.AspNetCore: не ниже Information (внутренние Debug-события вызовов не дублируют access-лог).
private const string GrpcCategory = "Grpc"; private const string GrpcCategory = "Grpc";
// Дефолтный уровень при пустом/невалидном env DEAL_LOG_LEVEL. // Дефолтный уровень при пустом/невалидном env DEAL_LOG_LEVEL.
private const LogEventLevel DefaultMinimumLevel = LogEventLevel.Information; private const LogEventLevel DefaultMinimumLevel = LogEventLevel.Information;
/// <summary> /// <summary>
/// Подключает Serilog к хосту /// Подключает Serilog к хосту
/// </summary> /// </summary>
/// <param name="builder">Билдер WebApplication процесса (до Build).</param> /// <param name="builder">Билдер WebApplication процесса (до Build).</param>
/// <param name="processName">Имя процесса для имени файла-лога (telegram/ai/ml/…).</param> /// <param name="processName">Имя процесса для имени файла-лога (telegram/ai/ml/…).</param>
public static void Configure(WebApplicationBuilder builder, string processName) public static void Configure(WebApplicationBuilder builder, string processName)
{ {
ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrWhiteSpace(processName); ArgumentException.ThrowIfNullOrWhiteSpace(processName);
builder.Host.UseSerilog((context, loggerConfiguration) => builder.Host.UseSerilog((context, loggerConfiguration) =>
Apply(loggerConfiguration, context.HostingEnvironment, context.Configuration, processName)); Apply(loggerConfiguration, context.HostingEnvironment, context.Configuration, processName));
} }
// Собирает LoggerConfiguration процесса: уровень/фильтры, rolling-файл, консоль. // Собирает LoggerConfiguration процесса: уровень/фильтры, rolling-файл, консоль.
// loggerConfiguration: Конфигурация Serilog (до CreateLogger). // loggerConfiguration: Конфигурация Serilog (до CreateLogger).
// environment: Окружение хоста (Development — текстовая консоль). // environment: Окружение хоста (Development — текстовая консоль).
// configuration: Конфигурация хоста (env DEAL_LOG_*). // configuration: Конфигурация хоста (env DEAL_LOG_*).
// processName: Имя процесса (суффикс имени rolling-файла). // processName: Имя процесса (суффикс имени rolling-файла).
private static void Apply( private static void Apply(
LoggerConfiguration loggerConfiguration, LoggerConfiguration loggerConfiguration,
IHostEnvironment environment, IHostEnvironment environment,
IConfiguration configuration, IConfiguration configuration,
string processName) string processName)
{ {
loggerConfiguration loggerConfiguration
.MinimumLevel.Is(ParseMinimumLevel(configuration[MinimumLevelEnvKey])) .MinimumLevel.Is(ParseMinimumLevel(configuration[MinimumLevelEnvKey]))
.MinimumLevel.Override(GrpcCategory, LogEventLevel.Information) .MinimumLevel.Override(GrpcCategory, LogEventLevel.Information)
.Enrich.FromLogContext(); .Enrich.FromLogContext();
string logsDirectory = ResolveLogsDirectory(environment.ContentRootPath, configuration[LogsDirectoryEnvKey]); string logsDirectory = ResolveLogsDirectory(environment.ContentRootPath, configuration[LogsDirectoryEnvKey]);
Directory.CreateDirectory(logsDirectory); Directory.CreateDirectory(logsDirectory);
string logFilePath = Path.Combine( string logFilePath = Path.Combine(
logsDirectory, logsDirectory,
string.Format(LogFileNameTemplate, processName)); string.Format(LogFileNameTemplate, processName));
loggerConfiguration.WriteTo.File( loggerConfiguration.WriteTo.File(
new CompactJsonFormatter(), new CompactJsonFormatter(),
logFilePath, logFilePath,
rollingInterval: RollingInterval.Day, rollingInterval: RollingInterval.Day,
retainedFileCountLimit: RetainedFileCount); retainedFileCountLimit: RetainedFileCount);
if (environment.IsDevelopment()) if (environment.IsDevelopment())
{ {
loggerConfiguration.WriteTo.Console(outputTemplate: DevelopmentConsoleTemplate); loggerConfiguration.WriteTo.Console(outputTemplate: DevelopmentConsoleTemplate);
} }
else else
{ {
loggerConfiguration.WriteTo.Console(new CompactJsonFormatter()); loggerConfiguration.WriteTo.Console(new CompactJsonFormatter());
} }
} }
// Каталог rolling-файлов: env DEAL_LOGS_DIR либо data/logs под ContentRoot процесса. // Каталог rolling-файлов: env DEAL_LOGS_DIR либо data/logs под ContentRoot процесса.
// contentRootPath: ContentRoot хоста (/app в контейнере). // contentRootPath: ContentRoot хоста (/app в контейнере).
// configuredDirectory: Значение env DEAL_LOGS_DIR (null/пусто — дефолт). // configuredDirectory: Значение env DEAL_LOGS_DIR (null/пусто — дефолт).
// Возвращает: Абсолютный путь каталога логов. // Возвращает: Абсолютный путь каталога логов.
private static string ResolveLogsDirectory(string contentRootPath, string? configuredDirectory) private static string ResolveLogsDirectory(string contentRootPath, string? configuredDirectory)
=> string.IsNullOrWhiteSpace(configuredDirectory) => string.IsNullOrWhiteSpace(configuredDirectory)
? Path.Combine(contentRootPath, DefaultLogsSubdirectory) ? Path.Combine(contentRootPath, DefaultLogsSubdirectory)
: configuredDirectory.Trim(); : configuredDirectory.Trim();
// Разбирает env DEAL_LOG_LEVEL; пустое/невалидное значение — DefaultMinimumLevel. // Разбирает env DEAL_LOG_LEVEL; пустое/невалидное значение — DefaultMinimumLevel.
// rawValue: Сырое значение env. // rawValue: Сырое значение env.
// Возвращает: Уровень Serilog. // Возвращает: Уровень Serilog.
private static LogEventLevel ParseMinimumLevel(string? rawValue) private static LogEventLevel ParseMinimumLevel(string? rawValue)
=> Enum.TryParse(rawValue, ignoreCase: true, out LogEventLevel parsedLevel) => Enum.TryParse(rawValue, ignoreCase: true, out LogEventLevel parsedLevel)
? parsedLevel ? parsedLevel
: DefaultMinimumLevel; : DefaultMinimumLevel;
} }
@@ -1,78 +1,78 @@
using Microsoft.AspNetCore.Builder; using Deal.Grpc.Hosting.Interceptors;
using Microsoft.AspNetCore.Hosting; using Deal.Grpc.Hosting.Models;
using Microsoft.AspNetCore.Server.Kestrel.Core; using Deal.Grpc.Hosting.Options;
using Microsoft.Extensions.DependencyInjection; using Microsoft.AspNetCore.Builder;
using OpenTelemetry.Metrics; using Microsoft.AspNetCore.Hosting;
using Deal.Grpc.Hosting.Interceptors; using Microsoft.AspNetCore.Server.Kestrel.Core;
using Deal.Grpc.Hosting.Models; using Microsoft.Extensions.DependencyInjection;
using Deal.Grpc.Hosting.Options; using OpenTelemetry.Metrics;
namespace Deal.Grpc.Hosting.Services; namespace Deal.Grpc.Hosting.Services;
/// <summary> /// <summary>
/// Общая настройка метрик Deal-сервисов /// Общая настройка метрик Deal-сервисов
/// </summary> /// </summary>
public static class DealMetricsHosting public static class DealMetricsHosting
{ {
/// <summary> /// <summary>
/// Имя meter'а прикладных метрик Deal /// Имя meter'а прикладных метрик Deal
/// </summary> /// </summary>
public const string MeterName = "Deal"; public const string MeterName = "Deal";
/// <summary> /// <summary>
/// Порт эндпоинта <c>/metrics</c> по умолчанию /// Порт эндпоинта <c>/metrics</c> по умолчанию
/// </summary> /// </summary>
public const int DefaultMetricsPort = 9464; public const int DefaultMetricsPort = 9464;
// Env-ключ порта метрик (переопределяет DefaultMetricsPort). // Env-ключ порта метрик (переопределяет DefaultMetricsPort).
private const string MetricsPortEnvKey = "METRICS_PORT"; private const string MetricsPortEnvKey = "METRICS_PORT";
/// <summary> /// <summary>
/// Порт эндпоинта метрик /// Порт эндпоинта метрик
/// </summary> /// </summary>
/// <param name="defaultPort">Дефолтный порт (обычно <see cref="DefaultMetricsPort"/>).</param> /// <param name="defaultPort">Дефолтный порт (обычно <see cref="DefaultMetricsPort"/>).</param>
/// <returns>Порт HTTP/1.1-эндпоинта метрик.</returns> /// <returns>Порт HTTP/1.1-эндпоинта метрик.</returns>
public static int ResolveMetricsPort(int defaultPort) public static int ResolveMetricsPort(int defaultPort)
=> int.TryParse(Environment.GetEnvironmentVariable(MetricsPortEnvKey), out int port) && port > 0 => int.TryParse(Environment.GetEnvironmentVariable(MetricsPortEnvKey), out int port) && port > 0
? port ? port
: defaultPort; : defaultPort;
/// <summary> /// <summary>
/// Регистрирует OTel-метрики и Kestrel-эндпоинт метрик /// Регистрирует OTel-метрики и Kestrel-эндпоинт метрик
/// </summary> /// </summary>
/// <param name="builder">Билдер хоста сервиса.</param> /// <param name="builder">Билдер хоста сервиса.</param>
/// <param name="metricsPort">Порт HTTP/1.1-эндпоинта метрик.</param> /// <param name="metricsPort">Порт HTTP/1.1-эндпоинта метрик.</param>
public static void AddDealMetrics(WebApplicationBuilder builder, int metricsPort) public static void AddDealMetrics(WebApplicationBuilder builder, int metricsPort)
{ {
ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(builder);
// Отдельный HTTP/1.1-эндпоинт для scrape: gRPC-порт остаётся строго HTTP/2 (см. remarks класса). // Отдельный HTTP/1.1-эндпоинт для scrape: gRPC-порт остаётся строго HTTP/2 (см. remarks класса).
builder.WebHost.ConfigureKestrel(kestrel => builder.WebHost.ConfigureKestrel(kestrel =>
{ {
kestrel.ListenAnyIP(metricsPort, listen => listen.Protocols = HttpProtocols.Http1); kestrel.ListenAnyIP(metricsPort, listen => listen.Protocols = HttpProtocols.Http1);
}); });
// Инструментация входящих запросов (http.server.*: RPS/латентность/ошибки по route) и исходящих // Инструментация входящих запросов (http.server.*: RPS/латентность/ошибки по route) и исходящих
// HTTP-клиентов (http.client.*) + экспортёр Prometheus. AddMeter — прикладные метрики. // HTTP-клиентов (http.client.*) + экспортёр Prometheus. AddMeter — прикладные метрики.
// Исходящие gRPC-вызовы (пакет Instrumentation.GrpcNetClient) дают трейс-инструментацию, а не // Исходящие gRPC-вызовы (пакет Instrumentation.GrpcNetClient) дают трейс-инструментацию, а не
// метрики — в MeterProviderBuilder не добавляются (для клиентских метрик gRPC-хопа хватает // метрики — в MeterProviderBuilder не добавляются (для клиентских метрик gRPC-хопа хватает
// серверной стороны соответствующего сервиса). // серверной стороны соответствующего сервиса).
builder.Services builder.Services
.AddOpenTelemetry() .AddOpenTelemetry()
.WithMetrics(metrics => metrics .WithMetrics(metrics => metrics
.AddMeter(MeterName) .AddMeter(MeterName)
.AddAspNetCoreInstrumentation() .AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation() .AddHttpClientInstrumentation()
.AddPrometheusExporter()); .AddPrometheusExporter());
} }
/// <summary> /// <summary>
/// Мапит эндпоинт <c>/metrics</c> /// Мапит эндпоинт <c>/metrics</c>
/// </summary> /// </summary>
/// <param name="app">Собранное приложение сервиса.</param> /// <param name="app">Собранное приложение сервиса.</param>
public static void MapDealMetrics(WebApplication app) public static void MapDealMetrics(WebApplication app)
{ {
ArgumentNullException.ThrowIfNull(app); ArgumentNullException.ThrowIfNull(app);
app.MapPrometheusScrapingEndpoint(); app.MapPrometheusScrapingEndpoint();
} }
} }
@@ -1,106 +1,106 @@
using System.Net; using System.Net;
using Microsoft.AspNetCore.Builder; using Deal.Grpc.Hosting.Interceptors;
using Microsoft.AspNetCore.Hosting; using Deal.Grpc.Hosting.Models;
using Microsoft.AspNetCore.Server.Kestrel.Core; using Deal.Grpc.Hosting.Options;
using Microsoft.AspNetCore.Server.Kestrel.Https; using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection; using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.AspNetCore.Server.Kestrel.Core;
using Deal.Grpc.Hosting.Interceptors; using Microsoft.AspNetCore.Server.Kestrel.Https;
using Deal.Grpc.Hosting.Models; using Microsoft.Extensions.DependencyInjection;
using Deal.Grpc.Hosting.Options; using Microsoft.Extensions.Diagnostics.HealthChecks;
namespace Deal.Grpc.Hosting.Services; namespace Deal.Grpc.Hosting.Services;
/// <summary> /// <summary>
/// Общие серверные блоки gRPC-хостов Deal-сервисов /// Общие серверные блоки gRPC-хостов Deal-сервисов
/// </summary> /// </summary>
public static class GrpcServer public static class GrpcServer
{ {
// Имя gRPC-health-проверки готовности (без неё health-сервис отвечает UNKNOWN, а не SERVING). // Имя gRPC-health-проверки готовности (без неё health-сервис отвечает UNKNOWN, а не SERVING).
private const string ReadyHealthCheckName = "ready"; private const string ReadyHealthCheckName = "ready";
/// <summary> /// <summary>
/// Потолок входящего gRPC-сообщения /// Потолок входящего gRPC-сообщения
/// </summary> /// </summary>
public const int DefaultMaxReceiveMessageSize = 4 * 1024 * 1024; public const int DefaultMaxReceiveMessageSize = 4 * 1024 * 1024;
/// <summary> /// <summary>
/// Загружает сертификаты mTLS из env и регистрирует набор в DI /// Загружает сертификаты mTLS из env и регистрирует набор в DI
/// </summary> /// </summary>
/// <param name="builder">Билдер хоста (конфигурация env + DI).</param> /// <param name="builder">Билдер хоста (конфигурация env + DI).</param>
/// <returns>Набор сертификатов либо null (mTLS выключен).</returns> /// <returns>Набор сертификатов либо null (mTLS выключен).</returns>
public static MtlsCertificates? LoadMtlsCertificates(WebApplicationBuilder builder) public static MtlsCertificates? LoadMtlsCertificates(WebApplicationBuilder builder)
{ {
ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(builder);
MtlsCertificates? mtlsCertificates = MtlsCertificates.Load( MtlsCertificates? mtlsCertificates = MtlsCertificates.Load(
MtlsOptions.FromConfiguration(builder.Configuration)); MtlsOptions.FromConfiguration(builder.Configuration));
if (mtlsCertificates is not null) if (mtlsCertificates is not null)
{ {
builder.Services.AddSingleton(mtlsCertificates); builder.Services.AddSingleton(mtlsCertificates);
} }
return mtlsCertificates; return mtlsCertificates;
} }
/// <summary> /// <summary>
/// Настраивает единственный Kestrel-эндпоинт HTTP/2 на 0.0.0.0:grpcPort /// Настраивает единственный Kestrel-эндпоинт HTTP/2 на 0.0.0.0:grpcPort
/// </summary> /// </summary>
/// <param name="builder">Билдер хоста (WebHost для ConfigureKestrel).</param> /// <param name="builder">Билдер хоста (WebHost для ConfigureKestrel).</param>
/// <param name="grpcPort">TCP-порт Kestrel.</param> /// <param name="grpcPort">TCP-порт Kestrel.</param>
/// <param name="mtlsCertificates">Набор сертификатов mTLS (null — plaintext).</param> /// <param name="mtlsCertificates">Набор сертификатов mTLS (null — plaintext).</param>
public static void ConfigureKestrelHttp2Endpoint( public static void ConfigureKestrelHttp2Endpoint(
WebApplicationBuilder builder, WebApplicationBuilder builder,
int grpcPort, int grpcPort,
MtlsCertificates? mtlsCertificates) MtlsCertificates? mtlsCertificates)
{ {
ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(builder);
builder.WebHost.ConfigureKestrel(kestrel => builder.WebHost.ConfigureKestrel(kestrel =>
{ {
kestrel.Listen(IPAddress.Any, grpcPort, listen => kestrel.Listen(IPAddress.Any, grpcPort, listen =>
{ {
listen.Protocols = HttpProtocols.Http2; listen.Protocols = HttpProtocols.Http2;
if (mtlsCertificates is not null) if (mtlsCertificates is not null)
{ {
listen.UseHttps(https => listen.UseHttps(https =>
{ {
https.ServerCertificate = mtlsCertificates.ServerCertificate; https.ServerCertificate = mtlsCertificates.ServerCertificate;
https.ClientCertificateMode = ClientCertificateMode.RequireCertificate; https.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
https.ClientCertificateValidation = mtlsCertificates.ValidateClientCertificate; https.ClientCertificateValidation = mtlsCertificates.ValidateClientCertificate;
}); });
} }
}); });
}); });
} }
/// <summary> /// <summary>
/// Регистрирует AddGrpc с общей серверной обвязкой /// Регистрирует AddGrpc с общей серверной обвязкой
/// </summary> /// </summary>
/// <param name="services">DI сервисов хоста.</param> /// <param name="services">DI сервисов хоста.</param>
public static IServiceCollection AddDealGrpcServer(this IServiceCollection services) public static IServiceCollection AddDealGrpcServer(this IServiceCollection services)
{ {
ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(services);
services.AddGrpc(grpc => services.AddGrpc(grpc =>
{ {
grpc.MaxReceiveMessageSize = DefaultMaxReceiveMessageSize; grpc.MaxReceiveMessageSize = DefaultMaxReceiveMessageSize;
grpc.Interceptors.Add<RpcCallLoggingInterceptor>(); grpc.Interceptors.Add<RpcCallLoggingInterceptor>();
grpc.Interceptors.Add<ServiceTokenInterceptor>(); grpc.Interceptors.Add<ServiceTokenInterceptor>();
}); });
return services; return services;
} }
/// <summary> /// <summary>
/// Регистрирует стандартный gRPC-health /// Регистрирует стандартный gRPC-health
/// </summary> /// </summary>
/// <param name="services">DI сервисов хоста.</param> /// <param name="services">DI сервисов хоста.</param>
/// <param name="readyDetail">Текст готовности проверки (имя хоста в логах healthcheck).</param> /// <param name="readyDetail">Текст готовности проверки (имя хоста в логах healthcheck).</param>
public static IServiceCollection AddReadyHealthCheck(this IServiceCollection services, string readyDetail) public static IServiceCollection AddReadyHealthCheck(this IServiceCollection services, string readyDetail)
{ {
ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(services);
ArgumentException.ThrowIfNullOrWhiteSpace(readyDetail); ArgumentException.ThrowIfNullOrWhiteSpace(readyDetail);
services services
.AddGrpcHealthChecks() .AddGrpcHealthChecks()
.AddCheck(ReadyHealthCheckName, () => HealthCheckResult.Healthy(readyDetail)); .AddCheck(ReadyHealthCheckName, () => HealthCheckResult.Healthy(readyDetail));
return services; return services;
} }
} }
@@ -1,19 +1,17 @@
using Deal.Ml.Model; using Deal.Ml.Model;
namespace Deal.Ml.Extensions; namespace Deal.Ml.Extensions;
/// <summary> // Расширения string для меток классов ML-модели.
/// Расширения <see cref="string"/> для меток классов ML-модели. internal static class LabelExtensions
/// </summary> {
internal static class LabelExtensions /// <summary>
{ /// Метка внутреннего типа заявки
/// <summary> /// </summary>
/// Метка внутреннего типа заявки /// <param name="label">Метка класса.</param>
/// </summary> /// <returns>True — метка является внутренним типом заявки (префикс t).</returns>
/// <param name="label">Метка класса.</param> public static bool IsTypeLabel(this string label)
/// <returns>True — метка является внутренним типом заявки (префикс t).</returns> {
public static bool IsTypeLabel(this string label) return label.StartsWith(ModelConstants.TypeLabelPrefix, StringComparison.Ordinal);
{ }
return label.StartsWith(ModelConstants.TypeLabelPrefix, StringComparison.Ordinal); }
}
}
@@ -1,53 +1,53 @@
using Deal.Telegram.Dialogs; using Deal.Telegram.Dialogs;
namespace Deal.Telegram.Tests.Telegram; namespace Deal.Telegram.Tests.Telegram;
/// <summary> /// <summary>
/// Unit-тесты цвета диалога. /// Unit-тесты цвета диалога.
/// </summary> /// </summary>
public sealed class DialogHueTests public sealed class DialogHueTests
{ {
/// <summary> /// <summary>
/// Цвет по паре /// Цвет по паре
/// </summary> /// </summary>
[Theory] [Theory]
[InlineData("-1001234567890", "IT Канал", "#a78bfa")] [InlineData("-1001234567890", "IT Канал", "#a78bfa")]
[InlineData("-123456", "Уютный чат", "#34d399")] [InlineData("-123456", "Уютный чат", "#34d399")]
[InlineData("+79990001122", "Иван Петров", "#fb7185")] [InlineData("+79990001122", "Иван Петров", "#fb7185")]
[InlineData("-100999", "", "#34d399")] [InlineData("-100999", "", "#34d399")]
public void Compute_MatchesPythonPrototype( public void Compute_MatchesPythonPrototype(
string dialogId, string dialogId,
string name, string name,
string expectedHue) string expectedHue)
{ {
Assert.Equal(expectedHue, DialogHue.Compute(dialogId, name)); Assert.Equal(expectedHue, DialogHue.Compute(dialogId, name));
} }
/// <summary> /// <summary>
/// Цвет детерминирован /// Цвет детерминирован
/// </summary> /// </summary>
[Fact] [Fact]
public void Compute_IsDeterministic() public void Compute_IsDeterministic()
{ {
string first = DialogHue.Compute("-1001234567890", "IT Канал"); string first = DialogHue.Compute("-1001234567890", "IT Канал");
Assert.Equal(first, DialogHue.Compute("-1001234567890", "IT Канал")); Assert.Equal(first, DialogHue.Compute("-1001234567890", "IT Канал"));
} }
/// <summary> /// <summary>
/// Разные источники распределяются по палитре /// Разные источники распределяются по палитре
/// </summary> /// </summary>
[Fact] [Fact]
public void Compute_DifferentSources_DistributeAcrossPalette() public void Compute_DifferentSources_DistributeAcrossPalette()
{ {
var hues = new[] string[] hues = new[]
{ {
DialogHue.Compute("-100111", "Канал А"), DialogHue.Compute("-100111", "Канал А"),
DialogHue.Compute("-100222", "Канал Б"), DialogHue.Compute("-100222", "Канал Б"),
DialogHue.Compute("+79990001122", "Иван Петров"), DialogHue.Compute("+79990001122", "Иван Петров"),
DialogHue.Compute("-5", "Работа"), DialogHue.Compute("-5", "Работа"),
}; };
Assert.Equal(3, hues.Distinct().Count()); Assert.Equal(3, hues.Distinct().Count());
} }
} }
@@ -1,19 +1,17 @@
using Grpc.Core; using Grpc.Core;
namespace Deal.Telegram.Extensions; namespace Deal.Telegram.Extensions;
/// <summary> // Расширения Exception для классификации сбоев telegram-service.
/// Расширения <see cref="Exception"/> для классификации сбоев telegram-service. internal static class ExceptionExtensions
/// </summary> {
internal static class ExceptionExtensions /// <summary>
{ /// Истинно транспортные/сетевые причины — только они дают UNAVAILABLE
/// <summary> /// </summary>
/// Истинно транспортные/сетевые причины — только они дают UNAVAILABLE /// <param name="exception">Исключение для классификации.</param>
/// </summary> /// <returns>True — исключение транспортного/сетевого характера.</returns>
/// <param name="exception">Исключение для классификации.</param> public static bool IsTransportFailure(this Exception exception)
/// <returns>True — исключение транспортного/сетевого характера.</returns> {
public static bool IsTransportFailure(this Exception exception) return exception is HttpRequestException or IOException or TimeoutException or RpcException;
{ }
return exception is HttpRequestException or IOException or TimeoutException or RpcException; }
}
}