diff --git a/.editorconfig b/.editorconfig
index 6789563..b2bec5b 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -1,73 +1,74 @@
-root = true
-
-[*]
-charset = utf-8
-end_of_line = crlf
-insert_final_newline = true
-indent_style = space
-indent_size = 4
-trim_trailing_whitespace = true
-
-[*.{cs,vb}]
-indent_size = 4
-
-# Стиль фигурных скобок — Allman (на отдельной строке)
-csharp_new_line_before_open_brace = all
-csharp_new_line_before_else = true
-csharp_new_line_before_catch = true
-csharp_new_line_before_finally = true
-
-# using — в начале файла
-dotnet_sort_system_directives_first = true
-
-# Модификаторы доступа — всегда явные
-dotnet_style_require_accessibility_modifiers = always:error
-
-# Квалификация this. — запрещена (поля, свойства, методы, события)
-dotnet_style_qualification_for_field = false:warning
-dotnet_style_qualification_for_property = false:warning
-dotnet_style_qualification_for_method = false:warning
-dotnet_style_qualification_for_event = false:warning
-
-# Члены
-csharp_style_var_for_built_in_types = false:silent
-csharp_style_var_when_type_is_apparent = false:silent
-csharp_style_var_elsewhere = false:silent
-
-[*.cs]
-# Отключить лишние правила IDE, которые конфликтуют с код-стайлом проекта
-dotnet_diagnostic.IDE0290.severity = none
-
-# --- Правила именования ---
-# Приватные const-поля: PascalCase
-# Приватные static readonly-поля (константоподобные): PascalCase
-# Остальные приватные поля: обязательный префикс `_` + camelCase
-dotnet_naming_rule.private_const_fields_pascal.severity = warning
-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.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.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.required_modifiers = static, readonly
-
-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.style = underscore_camel_style
-
-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_diagnostic.IDE1006.severity = warning
+root = true
+
+[*]
+charset = utf-8
+end_of_line = crlf
+insert_final_newline = true
+indent_style = space
+indent_size = 4
+trim_trailing_whitespace = true
+
+[*.{cs,vb}]
+indent_size = 4
+
+# Стиль фигурных скобок — Allman (на отдельной строке)
+csharp_new_line_before_open_brace = all
+csharp_new_line_before_else = true
+csharp_new_line_before_catch = true
+csharp_new_line_before_finally = true
+
+# using — в начале файла
+dotnet_sort_system_directives_first = true
+
+# Модификаторы доступа — всегда явные
+dotnet_style_require_accessibility_modifiers = always:error
+
+# Квалификация this. — запрещена (поля, свойства, методы, события)
+dotnet_style_qualification_for_field = false:warning
+dotnet_style_qualification_for_property = false:warning
+dotnet_style_qualification_for_method = false:warning
+dotnet_style_qualification_for_event = false:warning
+
+# Члены
+# var — запрещён для встроенных типов (ломает сборку), для очевидных/прочих — silent (§4 код-стайла)
+csharp_style_var_for_built_in_types = false:warning
+csharp_style_var_when_type_is_apparent = false:silent
+csharp_style_var_elsewhere = false:silent
+
+[*.cs]
+# Отключить лишние правила IDE, которые конфликтуют с код-стайлом проекта
+dotnet_diagnostic.IDE0290.severity = none
+
+# --- Правила именования ---
+# Приватные const-поля: PascalCase
+# Приватные static readonly-поля (константоподобные): PascalCase
+# Остальные приватные поля: обязательный префикс `_` + camelCase
+dotnet_naming_rule.private_const_fields_pascal.severity = warning
+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.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.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.required_modifiers = static, readonly
+
+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.style = underscore_camel_style
+
+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_diagnostic.IDE1006.severity = warning
diff --git a/src/core/Deal.Api/Endpoints/AuthEndpoints.cs b/src/core/Deal.Api/Endpoints/AuthEndpoints.cs
index ce9f6df..3410fe1 100644
--- a/src/core/Deal.Api/Endpoints/AuthEndpoints.cs
+++ b/src/core/Deal.Api/Endpoints/AuthEndpoints.cs
@@ -1,193 +1,193 @@
-using Deal.Api.Extensions;
-using Deal.Api.Middleware;
-using Deal.Api.Models;
-using Deal.Api.Services;
-using Deal.Modules.Tenants.Application.Models;
-using Deal.Modules.Tenants.Application.Services;
-using Microsoft.Extensions.Options;
-// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом.
-using CookieOptions = Deal.Api.Configuration.CookieOptions;
-
-namespace Deal.Api.Endpoints;
-
-///
-/// HTTP-эндпоинты аутентификации
-///
-public static class AuthEndpoints
-{
- private const string InvalidCredentialsDetail = "Неверный логин или пароль";
- private const string WrongOldPasswordDetail = "Текущий пароль неверен";
- private const string PasswordTooShortDetail = "Пароль слишком короткий (минимум 8 символов)";
- private const string TenantSuspendedDetail = "Учётная запись приостановлена. Обратитесь к оператору";
- private const string AuthGroupPrefix = "/api/auth";
- private const string AuthOpenApiTag = "auth";
-
- ///
- /// Регистрирует группу /api/auth
- ///
- /// Построитель маршрутов приложения.
- /// Построитель маршрутов для цепочки вызовов.
- public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder app)
- {
- var group = app.MapGroup(AuthGroupPrefix).WithTags(AuthOpenApiTag);
-
- group.MapPost("/login", LoginAsync).RequireRateLimiting(RateLimitPolicies.AuthPolicy);
- group.MapPost("/logout", LogoutAsync);
- group.MapGet("/me", MeAsync);
- group.MapPost("/change-password", ChangePasswordAsync);
-
- return app;
- }
-
- private static async Task LoginAsync(
- LoginRequest body,
- AuthService authService,
- AuditService auditService,
- IOptions cookieOptions,
- HttpContext context,
- CancellationToken ct,
- LoginAttemptGuard loginAttemptGuard,
- SuspiciousActivityReporter suspicious)
- {
- string? attemptedLogin = NormalizeLogin(body.Login);
-
- if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct))
- {
- // Событие подозрительной активности: серия неудачных попыток входа → блокировка ключа.
- suspicious.Report(SuspiciousActivityReporter.LoginBlockedKind, ClientIp(context));
- return EndpointResults.TooManyRequests(LoginAttemptGuard.BlockedDetail);
- }
-
- var result = await authService.LoginAsync(body.Login, body.Password, ct);
-
- if (result.Error == LoginResultDto.ErrorTenantSuspended)
- {
- await auditService.AppendAsync(new AuditRecordDto(
- AuditEvents.TenantLoginFailed,
- AuditActorTypes.Tenant,
- ActorId: result.UserId,
- TenantId: result.TenantId,
- Ip: ClientIp(context),
- DetailJson: AuditService.ToDetailJson(new { login = NormalizeLogin(body.Login) })), ct);
-
- return EndpointResults.Forbidden(TenantSuspendedDetail);
- }
-
- if (result.Login is null || result.Token is null)
- {
- if (attemptedLogin is not null)
- {
- await loginAttemptGuard.RecordFailureAsync(ClientIp(context), attemptedLogin, ct);
- await auditService.AppendAsync(new AuditRecordDto(
- AuditEvents.TenantLoginFailed,
- AuditActorTypes.Tenant,
- ActorId: null,
- TenantId: null,
- Ip: ClientIp(context),
- DetailJson: AuditService.ToDetailJson(new { login = attemptedLogin })), ct);
- }
-
- return EndpointResults.Unauthorized(InvalidCredentialsDetail);
- }
-
- await loginAttemptGuard.ResetAsync(ClientIp(context), result.Login, ct);
-
- await auditService.AppendAsync(new AuditRecordDto(
- AuditEvents.TenantLoginOk,
- AuditActorTypes.Tenant,
- ActorId: result.UserId,
- TenantId: result.TenantId,
- Ip: ClientIp(context),
- DetailJson: AuditService.ToDetailJson(new { login = result.Login })), ct);
-
- SessionCookieWriter.Append(context, cookieOptions.Value, result.Token);
- return Results.Ok(new { ok = true, login = result.Login });
- }
-
- private static async Task LogoutAsync(
- AuthService authService,
- AuditService auditService,
- IOptions cookieOptions,
- HttpContext context,
- CancellationToken ct)
- {
- var cookieName = cookieOptions.Value.Name;
- var rawToken = context.Request.Cookies[cookieName];
- // Пользователь разрешённой сессии — до её удаления (SessionMiddleware наполнил Items на старте запроса).
- CurrentUser? user = context.GetCurrentUser();
- var logout = await authService.LogoutAsync(rawToken, ct);
- if (logout is not null)
- {
- // Актор — оператор, начавший impersonation (маркер сессии); тенант — для фильтра TenantId.
- await auditService.AppendAsync(new AuditRecordDto(
- AuditEvents.ImpersonationStopped,
- AuditActorTypes.Operator,
- ActorId: logout.OperatorId,
- TenantId: logout.TenantId,
- Ip: ClientIp(context),
- DetailJson: AuditService.ToDetailJson(new { login = logout.Login })), ct);
- }
-
- if (user is not null)
- {
- await AuditAppender.AppendTenantAsync(context, AuditEvents.TenantLogout, new { login = user.Login }, ct);
- }
-
- context.Response.Cookies.Delete(cookieName);
- return Results.Ok(new { ok = true });
- }
-
- // GET /api/auth/me: проверка живой сессии.
- private static IResult MeAsync(HttpContext context)
- {
- var user = context.GetCurrentUser();
- if (user is null)
- {
- return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
- }
-
- return Results.Ok(new { login = user.Login, ok = true });
- }
-
- // POST /api/auth/change-password: смена пароля и перевыпуск куки (свежая сессия).
- private static async Task ChangePasswordAsync(
- ChangePasswordRequest body,
- AuthService authService,
- IOptions cookieOptions,
- HttpContext context,
- CancellationToken ct)
- {
- var user = context.GetCurrentUser();
- if (user is null)
- {
- return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
- }
-
- var result = await authService.ChangePasswordAsync(user.Login, body.OldPassword, body.NewPassword, ct);
- if (!result.Ok || result.NewToken is null)
- {
- var detail = result.Error == ChangePasswordResultDto.ErrorTooShort
- ? PasswordTooShortDetail
- : WrongOldPasswordDetail;
- return EndpointResults.BadRequest(detail);
- }
-
- // Старые сессии удалены внутри сервиса; выдаём клиенту свежую куку.
- SessionCookieWriter.Append(context, cookieOptions.Value, result.NewToken);
- return Results.Ok(new { ok = true });
- }
-
- // Нормализованная попытка логина для аудита (нижний регистр/обрезка, как AuthService); null — писать нечего.
- // login: Логин из тела запроса.
- // Возвращает: Нормализованный логин или null при пустом/пробельном входе.
- private static string? NormalizeLogin(string? login)
- {
- string? normalized = login?.Trim().ToLowerInvariant();
- return string.IsNullOrEmpty(normalized) ? null : normalized;
- }
-
- // IP-адрес клиента для аудита (без порта; null, если недоступен).
- // context: Контекст запроса.
- // Возвращает: Строковое представление IP или null.
- private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
-}
+using Deal.Api.Extensions;
+using Deal.Api.Middleware;
+using Deal.Api.Models;
+using Deal.Api.Services;
+using Deal.Modules.Tenants.Application.Models;
+using Deal.Modules.Tenants.Application.Services;
+using Microsoft.Extensions.Options;
+// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом.
+using CookieOptions = Deal.Api.Configuration.CookieOptions;
+
+namespace Deal.Api.Endpoints;
+
+///
+/// HTTP-эндпоинты аутентификации
+///
+public static class AuthEndpoints
+{
+ private const string InvalidCredentialsDetail = "Неверный логин или пароль";
+ private const string WrongOldPasswordDetail = "Текущий пароль неверен";
+ private const string PasswordTooShortDetail = "Пароль слишком короткий (минимум 8 символов)";
+ private const string TenantSuspendedDetail = "Учётная запись приостановлена. Обратитесь к оператору";
+ private const string AuthGroupPrefix = "/api/auth";
+ private const string AuthOpenApiTag = "auth";
+
+ ///
+ /// Регистрирует группу /api/auth
+ ///
+ /// Построитель маршрутов приложения.
+ /// Построитель маршрутов для цепочки вызовов.
+ public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder app)
+ {
+ var group = app.MapGroup(AuthGroupPrefix).WithTags(AuthOpenApiTag);
+
+ group.MapPost("/login", LoginAsync).RequireRateLimiting(RateLimitPolicies.AuthPolicy);
+ group.MapPost("/logout", LogoutAsync);
+ group.MapGet("/me", MeAsync);
+ group.MapPost("/change-password", ChangePasswordAsync);
+
+ return app;
+ }
+
+ private static async Task LoginAsync(
+ LoginRequest body,
+ AuthService authService,
+ AuditService auditService,
+ IOptions cookieOptions,
+ HttpContext context,
+ CancellationToken ct,
+ LoginAttemptGuard loginAttemptGuard,
+ SuspiciousActivityReporter suspicious)
+ {
+ string? attemptedLogin = NormalizeLogin(body.Login);
+
+ if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct))
+ {
+ // Событие подозрительной активности: серия неудачных попыток входа → блокировка ключа.
+ suspicious.Report(SuspiciousActivityReporter.LoginBlockedKind, ClientIp(context));
+ return EndpointResults.TooManyRequests(LoginAttemptGuard.BlockedDetail);
+ }
+
+ var result = await authService.LoginAsync(body.Login, body.Password, ct);
+
+ if (result.Error == LoginResultDto.ErrorTenantSuspended)
+ {
+ await auditService.AppendAsync(new AuditRecordDto(
+ AuditEvents.TenantLoginFailed,
+ AuditActorTypes.Tenant,
+ ActorId: result.UserId,
+ TenantId: result.TenantId,
+ Ip: ClientIp(context),
+ DetailJson: AuditService.ToDetailJson(new { login = NormalizeLogin(body.Login) })), ct);
+
+ return EndpointResults.Forbidden(TenantSuspendedDetail);
+ }
+
+ if (result.Login is null || result.Token is null)
+ {
+ if (attemptedLogin is not null)
+ {
+ await loginAttemptGuard.RecordFailureAsync(ClientIp(context), attemptedLogin, ct);
+ await auditService.AppendAsync(new AuditRecordDto(
+ AuditEvents.TenantLoginFailed,
+ AuditActorTypes.Tenant,
+ ActorId: null,
+ TenantId: null,
+ Ip: ClientIp(context),
+ DetailJson: AuditService.ToDetailJson(new { login = attemptedLogin })), ct);
+ }
+
+ return EndpointResults.Unauthorized(InvalidCredentialsDetail);
+ }
+
+ await loginAttemptGuard.ResetAsync(ClientIp(context), result.Login, ct);
+
+ await auditService.AppendAsync(new AuditRecordDto(
+ AuditEvents.TenantLoginOk,
+ AuditActorTypes.Tenant,
+ ActorId: result.UserId,
+ TenantId: result.TenantId,
+ Ip: ClientIp(context),
+ DetailJson: AuditService.ToDetailJson(new { login = result.Login })), ct);
+
+ SessionCookieWriter.Append(context, cookieOptions.Value, result.Token);
+ return Results.Ok(new { ok = true, login = result.Login });
+ }
+
+ private static async Task LogoutAsync(
+ AuthService authService,
+ AuditService auditService,
+ IOptions cookieOptions,
+ HttpContext context,
+ CancellationToken ct)
+ {
+ string cookieName = cookieOptions.Value.Name;
+ string? rawToken = context.Request.Cookies[cookieName];
+ // Пользователь разрешённой сессии — до её удаления (SessionMiddleware наполнил Items на старте запроса).
+ CurrentUser? user = context.GetCurrentUser();
+ var logout = await authService.LogoutAsync(rawToken, ct);
+ if (logout is not null)
+ {
+ // Актор — оператор, начавший impersonation (маркер сессии); тенант — для фильтра TenantId.
+ await auditService.AppendAsync(new AuditRecordDto(
+ AuditEvents.ImpersonationStopped,
+ AuditActorTypes.Operator,
+ ActorId: logout.OperatorId,
+ TenantId: logout.TenantId,
+ Ip: ClientIp(context),
+ DetailJson: AuditService.ToDetailJson(new { login = logout.Login })), ct);
+ }
+
+ if (user is not null)
+ {
+ await AuditAppender.AppendTenantAsync(context, AuditEvents.TenantLogout, new { login = user.Login }, ct);
+ }
+
+ context.Response.Cookies.Delete(cookieName);
+ return Results.Ok(new { ok = true });
+ }
+
+ // GET /api/auth/me: проверка живой сессии.
+ private static IResult MeAsync(HttpContext context)
+ {
+ var user = context.GetCurrentUser();
+ if (user is null)
+ {
+ return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
+ }
+
+ return Results.Ok(new { login = user.Login, ok = true });
+ }
+
+ // POST /api/auth/change-password: смена пароля и перевыпуск куки (свежая сессия).
+ private static async Task ChangePasswordAsync(
+ ChangePasswordRequest body,
+ AuthService authService,
+ IOptions cookieOptions,
+ HttpContext context,
+ CancellationToken ct)
+ {
+ var user = context.GetCurrentUser();
+ if (user is null)
+ {
+ return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
+ }
+
+ var result = await authService.ChangePasswordAsync(user.Login, body.OldPassword, body.NewPassword, ct);
+ if (!result.Ok || result.NewToken is null)
+ {
+ string detail = result.Error == ChangePasswordResultDto.ErrorTooShort
+ ? PasswordTooShortDetail
+ : WrongOldPasswordDetail;
+ return EndpointResults.BadRequest(detail);
+ }
+
+ // Старые сессии удалены внутри сервиса; выдаём клиенту свежую куку.
+ SessionCookieWriter.Append(context, cookieOptions.Value, result.NewToken);
+ return Results.Ok(new { ok = true });
+ }
+
+ // Нормализованная попытка логина для аудита (нижний регистр/обрезка, как AuthService); null — писать нечего.
+ // login: Логин из тела запроса.
+ // Возвращает: Нормализованный логин или null при пустом/пробельном входе.
+ private static string? NormalizeLogin(string? login)
+ {
+ string? normalized = login?.Trim().ToLowerInvariant();
+ return string.IsNullOrEmpty(normalized) ? null : normalized;
+ }
+
+ // IP-адрес клиента для аудита (без порта; null, если недоступен).
+ // context: Контекст запроса.
+ // Возвращает: Строковое представление IP или null.
+ private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
+}
diff --git a/src/core/Deal.Api/Endpoints/OperatorAuthEndpoints.cs b/src/core/Deal.Api/Endpoints/OperatorAuthEndpoints.cs
index ba3095f..0b530bf 100644
--- a/src/core/Deal.Api/Endpoints/OperatorAuthEndpoints.cs
+++ b/src/core/Deal.Api/Endpoints/OperatorAuthEndpoints.cs
@@ -1,157 +1,157 @@
-using Deal.Api.Extensions;
-using Deal.Api.Middleware;
-using Deal.Api.Models;
-using Deal.Api.Services;
-using Deal.Modules.Tenants.Application.Models;
-using Deal.Modules.Tenants.Application.Services;
-using Microsoft.Extensions.Options;
-using AspNetCoreCookieOptions = Microsoft.AspNetCore.Http.CookieOptions;
-// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасами.
-using OperatorCookieOptions = Deal.Api.Configuration.OperatorCookieOptions;
-
-namespace Deal.Api.Endpoints;
-
-///
-/// HTTP-эндпоинты аутентификации оператора
-///
-public static class OperatorAuthEndpoints
-{
- private const string InvalidCredentialsDetail = "Неверный логин или пароль оператора";
- private const string OperatorAuthGroupPrefix = "/api/operator/auth";
- private const string OperatorAuthOpenApiTag = "operator-auth";
-
- ///
- /// Регистрирует группу /api/operator/auth
- ///
- /// Построитель маршрутов приложения.
- /// Построитель маршрутов для цепочки вызовов.
- public static IEndpointRouteBuilder MapOperatorAuthEndpoints(this IEndpointRouteBuilder app)
- {
- var group = app.MapGroup(OperatorAuthGroupPrefix).WithTags(OperatorAuthOpenApiTag);
-
- group.MapPost("/login", LoginAsync).RequireRateLimiting(RateLimitPolicies.AuthPolicy);
- group.MapPost("/logout", LogoutAsync);
- group.MapGet("/me", MeAsync);
-
- return app;
- }
-
- private static async Task LoginAsync(
- LoginRequest body,
- OperatorAuthService operatorAuthService,
- AuditService auditService,
- IOptions cookieOptions,
- HttpContext context,
- CancellationToken ct,
- LoginAttemptGuard loginAttemptGuard)
- {
- string? attemptedLogin = NormalizeLogin(body.Login);
-
- if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct))
- {
- return EndpointResults.TooManyRequests(LoginAttemptGuard.BlockedDetail);
- }
-
- var result = await operatorAuthService.LoginAsync(body.Login, body.Password, ct);
- if (result.Login is null || result.Token is null)
- {
- if (attemptedLogin is not null)
- {
- await loginAttemptGuard.RecordFailureAsync(ClientIp(context), attemptedLogin, ct);
- await auditService.AppendAsync(new AuditRecordDto(
- AuditEvents.OperatorLoginFailed,
- AuditActorTypes.Operator,
- ActorId: null,
- TenantId: null,
- Ip: ClientIp(context),
- DetailJson: AuditService.ToDetailJson(new { login = attemptedLogin })), ct);
- }
-
- return EndpointResults.Unauthorized(InvalidCredentialsDetail);
- }
-
- await loginAttemptGuard.ResetAsync(ClientIp(context), result.Login, ct);
-
- await auditService.AppendAsync(new AuditRecordDto(
- AuditEvents.OperatorLoginOk,
- AuditActorTypes.Operator,
- ActorId: result.OperatorId,
- TenantId: null,
- Ip: ClientIp(context),
- DetailJson: AuditService.ToDetailJson(new { login = result.Login })), ct);
-
- SetOperatorSessionCookie(context, cookieOptions.Value, result.Token);
- return Results.Ok(new { ok = true, login = result.Login });
- }
-
- // POST /api/operator/auth/logout: удаление операторской сессии по токену из куки и очистка куки (всегда ok).
- private static async Task LogoutAsync(
- OperatorAuthService operatorAuthService,
- IOptions cookieOptions,
- HttpContext context,
- CancellationToken ct)
- {
- var cookieName = cookieOptions.Value.Name;
- var rawToken = context.Request.Cookies[cookieName];
- // Оператор разрешённой сессии — до её удаления (OperatorSessionMiddleware наполнил Items).
- CurrentOperator? operatorIdentity = context.GetCurrentOperator();
- await operatorAuthService.LogoutAsync(rawToken, ct);
- context.Response.Cookies.Delete(cookieName);
-
- if (operatorIdentity is not null)
- {
- await AuditAppender.AppendOperatorAsync(context, AuditEvents.OperatorLogout, new { login = operatorIdentity.Login }, ct);
- }
-
- return Results.Ok(new { ok = true });
- }
-
- private static IResult MeAsync(HttpContext context)
- {
- var operatorIdentity = context.GetCurrentOperator();
- if (operatorIdentity is null)
- {
- return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
- }
-
- return Results.Ok(new { login = operatorIdentity.Login, ok = true });
- }
-
- // Выставляет httpOnly-куку сессии оператора: SameSite=Lax, Path=/, MaxAge=Hours, Secure — из конфига.
- // context: Контекст запроса.
- // options: Настройки куки из конфигурации (секция OperatorCookies).
- // rawToken: Raw-токен операторской сессии.
- private static void SetOperatorSessionCookie(
- HttpContext context,
- OperatorCookieOptions options,
- string rawToken)
- {
- // MaxAge — OperatorCookies:Hours; код-дефолт значения ссылается на
- // OperatorAuthService.SessionLifetimeHours (единый источник «12 часов», см. OperatorCookieOptions).
- context.Response.Cookies.Append(
- options.Name,
- rawToken,
- new AspNetCoreCookieOptions
- {
- HttpOnly = true,
- SameSite = SameSiteMode.Lax,
- Path = "/",
- MaxAge = TimeSpan.FromHours(options.Hours),
- Secure = options.Secure,
- });
- }
-
- // Нормализованная попытка логина для аудита (нижний регистр/обрезка); null — писать нечего.
- // login: Логин из тела запроса.
- // Возвращает: Нормализованный логин или null при пустом/пробельном входе.
- private static string? NormalizeLogin(string? login)
- {
- string? normalized = login?.Trim().ToLowerInvariant();
- return string.IsNullOrEmpty(normalized) ? null : normalized;
- }
-
- // IP-адрес клиента для аудита (без порта; null, если недоступен).
- // context: Контекст запроса.
- // Возвращает: Строковое представление IP или null.
- private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
-}
+using Deal.Api.Extensions;
+using Deal.Api.Middleware;
+using Deal.Api.Models;
+using Deal.Api.Services;
+using Deal.Modules.Tenants.Application.Models;
+using Deal.Modules.Tenants.Application.Services;
+using Microsoft.Extensions.Options;
+using AspNetCoreCookieOptions = Microsoft.AspNetCore.Http.CookieOptions;
+// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасами.
+using OperatorCookieOptions = Deal.Api.Configuration.OperatorCookieOptions;
+
+namespace Deal.Api.Endpoints;
+
+///
+/// HTTP-эндпоинты аутентификации оператора
+///
+public static class OperatorAuthEndpoints
+{
+ private const string InvalidCredentialsDetail = "Неверный логин или пароль оператора";
+ private const string OperatorAuthGroupPrefix = "/api/operator/auth";
+ private const string OperatorAuthOpenApiTag = "operator-auth";
+
+ ///
+ /// Регистрирует группу /api/operator/auth
+ ///
+ /// Построитель маршрутов приложения.
+ /// Построитель маршрутов для цепочки вызовов.
+ public static IEndpointRouteBuilder MapOperatorAuthEndpoints(this IEndpointRouteBuilder app)
+ {
+ var group = app.MapGroup(OperatorAuthGroupPrefix).WithTags(OperatorAuthOpenApiTag);
+
+ group.MapPost("/login", LoginAsync).RequireRateLimiting(RateLimitPolicies.AuthPolicy);
+ group.MapPost("/logout", LogoutAsync);
+ group.MapGet("/me", MeAsync);
+
+ return app;
+ }
+
+ private static async Task LoginAsync(
+ LoginRequest body,
+ OperatorAuthService operatorAuthService,
+ AuditService auditService,
+ IOptions cookieOptions,
+ HttpContext context,
+ CancellationToken ct,
+ LoginAttemptGuard loginAttemptGuard)
+ {
+ string? attemptedLogin = NormalizeLogin(body.Login);
+
+ if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct))
+ {
+ return EndpointResults.TooManyRequests(LoginAttemptGuard.BlockedDetail);
+ }
+
+ var result = await operatorAuthService.LoginAsync(body.Login, body.Password, ct);
+ if (result.Login is null || result.Token is null)
+ {
+ if (attemptedLogin is not null)
+ {
+ await loginAttemptGuard.RecordFailureAsync(ClientIp(context), attemptedLogin, ct);
+ await auditService.AppendAsync(new AuditRecordDto(
+ AuditEvents.OperatorLoginFailed,
+ AuditActorTypes.Operator,
+ ActorId: null,
+ TenantId: null,
+ Ip: ClientIp(context),
+ DetailJson: AuditService.ToDetailJson(new { login = attemptedLogin })), ct);
+ }
+
+ return EndpointResults.Unauthorized(InvalidCredentialsDetail);
+ }
+
+ await loginAttemptGuard.ResetAsync(ClientIp(context), result.Login, ct);
+
+ await auditService.AppendAsync(new AuditRecordDto(
+ AuditEvents.OperatorLoginOk,
+ AuditActorTypes.Operator,
+ ActorId: result.OperatorId,
+ TenantId: null,
+ Ip: ClientIp(context),
+ DetailJson: AuditService.ToDetailJson(new { login = result.Login })), ct);
+
+ SetOperatorSessionCookie(context, cookieOptions.Value, result.Token);
+ return Results.Ok(new { ok = true, login = result.Login });
+ }
+
+ // POST /api/operator/auth/logout: удаление операторской сессии по токену из куки и очистка куки (всегда ok).
+ private static async Task LogoutAsync(
+ OperatorAuthService operatorAuthService,
+ IOptions cookieOptions,
+ HttpContext context,
+ CancellationToken ct)
+ {
+ string cookieName = cookieOptions.Value.Name;
+ string? rawToken = context.Request.Cookies[cookieName];
+ // Оператор разрешённой сессии — до её удаления (OperatorSessionMiddleware наполнил Items).
+ CurrentOperator? operatorIdentity = context.GetCurrentOperator();
+ await operatorAuthService.LogoutAsync(rawToken, ct);
+ context.Response.Cookies.Delete(cookieName);
+
+ if (operatorIdentity is not null)
+ {
+ await AuditAppender.AppendOperatorAsync(context, AuditEvents.OperatorLogout, new { login = operatorIdentity.Login }, ct);
+ }
+
+ return Results.Ok(new { ok = true });
+ }
+
+ private static IResult MeAsync(HttpContext context)
+ {
+ var operatorIdentity = context.GetCurrentOperator();
+ if (operatorIdentity is null)
+ {
+ return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
+ }
+
+ return Results.Ok(new { login = operatorIdentity.Login, ok = true });
+ }
+
+ // Выставляет httpOnly-куку сессии оператора: SameSite=Lax, Path=/, MaxAge=Hours, Secure — из конфига.
+ // context: Контекст запроса.
+ // options: Настройки куки из конфигурации (секция OperatorCookies).
+ // rawToken: Raw-токен операторской сессии.
+ private static void SetOperatorSessionCookie(
+ HttpContext context,
+ OperatorCookieOptions options,
+ string rawToken)
+ {
+ // MaxAge — OperatorCookies:Hours; код-дефолт значения ссылается на
+ // OperatorAuthService.SessionLifetimeHours (единый источник «12 часов», см. OperatorCookieOptions).
+ context.Response.Cookies.Append(
+ options.Name,
+ rawToken,
+ new AspNetCoreCookieOptions
+ {
+ HttpOnly = true,
+ SameSite = SameSiteMode.Lax,
+ Path = "/",
+ MaxAge = TimeSpan.FromHours(options.Hours),
+ Secure = options.Secure,
+ });
+ }
+
+ // Нормализованная попытка логина для аудита (нижний регистр/обрезка); null — писать нечего.
+ // login: Логин из тела запроса.
+ // Возвращает: Нормализованный логин или null при пустом/пробельном входе.
+ private static string? NormalizeLogin(string? login)
+ {
+ string? normalized = login?.Trim().ToLowerInvariant();
+ return string.IsNullOrEmpty(normalized) ? null : normalized;
+ }
+
+ // IP-адрес клиента для аудита (без порта; null, если недоступен).
+ // context: Контекст запроса.
+ // Возвращает: Строковое представление IP или null.
+ private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
+}
diff --git a/src/core/Deal.Api/Hosting/OperatorBootstrapHostedService.cs b/src/core/Deal.Api/Hosting/OperatorBootstrapHostedService.cs
index 9d5c156..096816f 100644
--- a/src/core/Deal.Api/Hosting/OperatorBootstrapHostedService.cs
+++ b/src/core/Deal.Api/Hosting/OperatorBootstrapHostedService.cs
@@ -1,84 +1,84 @@
-using Deal.Modules.Tenants.Application.Services;
-
-namespace Deal.Api.Hosting;
-
-///
-/// Hosted-шаг bootstrap оператора при старте
-///
-public sealed class OperatorBootstrapHostedService(
- IServiceScopeFactory scopeFactory,
- IConfiguration configuration,
- IHostEnvironment environment,
- ILogger logger) : IHostedService
-{
- ///
- public async Task StartAsync(CancellationToken ct)
- {
- await using var scope = scopeFactory.CreateAsyncScope();
- var operatorBootstrapService = scope.ServiceProvider.GetRequiredService();
-
- var login = configuration[OperatorBootstrapService.LoginEnvKey];
- var password = configuration[OperatorBootstrapService.PasswordEnvKey];
- bool hasLogin = !string.IsNullOrWhiteSpace(login);
- bool hasPassword = !string.IsNullOrWhiteSpace(password);
- bool allowDevelopmentDefaults = environment.IsDevelopment();
-
- // Предупреждения о пропуске/частичной конфигурации логируются ДО вызова шага — сам шаг
- // в этих случаях ничего не создаёт (EnsureOperatorAsync возвращает null, хранилище не тронуто).
- if (!hasLogin || !hasPassword)
- {
- if (hasLogin != hasPassword)
- {
- LogPartialConfigurationWarning(hasLogin, allowDevelopmentDefaults);
- }
- else if (!allowDevelopmentDefaults)
- {
- logger.LogWarning(
- "DEAL_OPERATOR_LOGIN/DEAL_OPERATOR_PASSWORD не заданы (Production) — bootstrap оператора "
- + "пропущен. Оператор заводится позже: задайте env DEAL_OPERATOR_* и перезапустите хост.");
- }
- else
- {
- // Development без кред: штатный dev-дефолт (зеркало dev-seed admin/admin).
- logger.LogInformation(
- "DEAL_OPERATOR_LOGIN/DEAL_OPERATOR_PASSWORD не заданы (Development) — используется "
- + "dev-дефолт оператора {DefaultLogin}.",
- OperatorBootstrapService.DefaultOperatorLogin);
- }
- }
-
- var ensuredLogin = await operatorBootstrapService.EnsureOperatorAsync(
- login, password, allowDevelopmentDefaults, ct);
- if (ensuredLogin is not null)
- {
- logger.LogInformation("Bootstrap оператора: оператор {Login} присутствует.", ensuredLogin);
- }
- }
-
- ///
- public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
-
- // Логирует warning о неполной env-конфигурации (задана одна из двух переменных).
- // hasLogin: Задан ли логин (пароль при этом пуст).
- // allowDevelopmentDefaults: Разрешены ли dev-дефолты (Development).
- private void LogPartialConfigurationWarning(bool hasLogin, bool allowDevelopmentDefaults)
- {
- string missingEnv = hasLogin
- ? OperatorBootstrapService.PasswordEnvKey
- : OperatorBootstrapService.LoginEnvKey;
- if (allowDevelopmentDefaults)
- {
- logger.LogWarning(
- "Env-конфигурация оператора неполна: не задан {MissingEnv}. В Development используются "
- + "dev-дефолты оператора {DefaultLogin}.",
- missingEnv, OperatorBootstrapService.DefaultOperatorLogin);
- }
- else
- {
- logger.LogWarning(
- "Env-конфигурация оператора неполна: не задан {MissingEnv}. Bootstrap оператора в Production "
- + "пропущен — оператор заводится позже через env DEAL_OPERATOR_* и рестарт хоста.",
- missingEnv);
- }
- }
-}
+using Deal.Modules.Tenants.Application.Services;
+
+namespace Deal.Api.Hosting;
+
+///
+/// Hosted-шаг bootstrap оператора при старте
+///
+public sealed class OperatorBootstrapHostedService(
+ IServiceScopeFactory scopeFactory,
+ IConfiguration configuration,
+ IHostEnvironment environment,
+ ILogger logger) : IHostedService
+{
+ ///
+ public async Task StartAsync(CancellationToken ct)
+ {
+ await using var scope = scopeFactory.CreateAsyncScope();
+ var operatorBootstrapService = scope.ServiceProvider.GetRequiredService();
+
+ string? login = configuration[OperatorBootstrapService.LoginEnvKey];
+ string? password = configuration[OperatorBootstrapService.PasswordEnvKey];
+ bool hasLogin = !string.IsNullOrWhiteSpace(login);
+ bool hasPassword = !string.IsNullOrWhiteSpace(password);
+ bool allowDevelopmentDefaults = environment.IsDevelopment();
+
+ // Предупреждения о пропуске/частичной конфигурации логируются ДО вызова шага — сам шаг
+ // в этих случаях ничего не создаёт (EnsureOperatorAsync возвращает null, хранилище не тронуто).
+ if (!hasLogin || !hasPassword)
+ {
+ if (hasLogin != hasPassword)
+ {
+ LogPartialConfigurationWarning(hasLogin, allowDevelopmentDefaults);
+ }
+ else if (!allowDevelopmentDefaults)
+ {
+ logger.LogWarning(
+ "DEAL_OPERATOR_LOGIN/DEAL_OPERATOR_PASSWORD не заданы (Production) — bootstrap оператора "
+ + "пропущен. Оператор заводится позже: задайте env DEAL_OPERATOR_* и перезапустите хост.");
+ }
+ else
+ {
+ // Development без кред: штатный dev-дефолт (зеркало dev-seed admin/admin).
+ logger.LogInformation(
+ "DEAL_OPERATOR_LOGIN/DEAL_OPERATOR_PASSWORD не заданы (Development) — используется "
+ + "dev-дефолт оператора {DefaultLogin}.",
+ OperatorBootstrapService.DefaultOperatorLogin);
+ }
+ }
+
+ string? ensuredLogin = await operatorBootstrapService.EnsureOperatorAsync(
+ login, password, allowDevelopmentDefaults, ct);
+ if (ensuredLogin is not null)
+ {
+ logger.LogInformation("Bootstrap оператора: оператор {Login} присутствует.", ensuredLogin);
+ }
+ }
+
+ ///
+ public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
+
+ // Логирует warning о неполной env-конфигурации (задана одна из двух переменных).
+ // hasLogin: Задан ли логин (пароль при этом пуст).
+ // allowDevelopmentDefaults: Разрешены ли dev-дефолты (Development).
+ private void LogPartialConfigurationWarning(bool hasLogin, bool allowDevelopmentDefaults)
+ {
+ string missingEnv = hasLogin
+ ? OperatorBootstrapService.PasswordEnvKey
+ : OperatorBootstrapService.LoginEnvKey;
+ if (allowDevelopmentDefaults)
+ {
+ logger.LogWarning(
+ "Env-конфигурация оператора неполна: не задан {MissingEnv}. В Development используются "
+ + "dev-дефолты оператора {DefaultLogin}.",
+ missingEnv, OperatorBootstrapService.DefaultOperatorLogin);
+ }
+ else
+ {
+ logger.LogWarning(
+ "Env-конфигурация оператора неполна: не задан {MissingEnv}. Bootstrap оператора в Production "
+ + "пропущен — оператор заводится позже через env DEAL_OPERATOR_* и рестарт хоста.",
+ missingEnv);
+ }
+ }
+}
diff --git a/src/core/Deal.Api/Hosting/TenantBootstrapService.cs b/src/core/Deal.Api/Hosting/TenantBootstrapService.cs
index b171fe1..f4ad41d 100644
--- a/src/core/Deal.Api/Hosting/TenantBootstrapService.cs
+++ b/src/core/Deal.Api/Hosting/TenantBootstrapService.cs
@@ -1,78 +1,78 @@
-using Deal.Infrastructure.Tenancy;
-using Deal.Modules.Tenants.Application.Abstractions;
-using Deal.Modules.Tenants.Application.Models;
-using Deal.Modules.Tenants.Application.Services;
-
-namespace Deal.Api.Hosting;
-
-///
-/// Bootstrap при старте
-///
-public sealed class TenantBootstrapService(IServiceScopeFactory scopeFactory) : IHostedService
-{
- private const string DefaultTenantName = "Default";
-
- private static readonly Guid DefaultTenantId = Guid.Parse("00000000-0000-0000-0000-000000000001");
-
- private const string ActiveStatus = "active";
- private const string BootstrapLoginEnvKey = "DEAL_BOOTSTRAP_LOGIN";
- private const string BootstrapPasswordEnvKey = "DEAL_BOOTSTRAP_PASSWORD";
- private const string DefaultAdminLogin = "admin";
- private const string DefaultAdminPassword = "admin";
-
- private const string DefaultTenantBootstrapEnvKey = "DEAL_BOOTSTRAP_DEFAULT_TENANT";
-
- private const string DefaultTenantBootstrapEnabledValue = "1";
-
- ///
- public async Task StartAsync(CancellationToken ct)
- {
- await using var scope = scopeFactory.CreateAsyncScope();
- var configuration = scope.ServiceProvider.GetRequiredService();
- var tenantService = scope.ServiceProvider.GetRequiredService();
- var tenantRepository = scope.ServiceProvider.GetRequiredService();
- var authStore = scope.ServiceProvider.GetRequiredService();
- var passwordHasher = scope.ServiceProvider.GetRequiredService();
-
- var login = NormalizeLogin(configuration[BootstrapLoginEnvKey] ?? DefaultAdminLogin);
- var password = configuration[BootstrapPasswordEnvKey] ?? DefaultAdminPassword;
- var environment = scope.ServiceProvider.GetRequiredService();
-
- var seedDefaultTenant = environment.IsDevelopment()
- || configuration[DefaultTenantBootstrapEnvKey] == DefaultTenantBootstrapEnabledValue;
- if (seedDefaultTenant)
- {
- var tenants = await tenantRepository.ListAsync(ct);
- if (tenants.Count == 0)
- {
- // CreateTenantAsync сам провижинит схему дефолтного тенанта (TenantService → ITenantProvisioner).
- await tenantService.CreateTenantAsync(DefaultTenantName, DefaultTenantId, ct);
- }
-
- // Идемпотентность: пользователь с таким логином уже есть — ничего не делаем.
- var existingUser = await authStore.FindUserByLoginAsync(login, ct);
- if (existingUser is null)
- {
- await authStore.CreateUserAsync(
- new StoredUserDto(
- Id: Guid.NewGuid(),
- Login: login,
- TenantId: DefaultTenantId,
- Status: ActiveStatus,
- PasswordHash: passwordHasher.Hash(password)),
- ct);
- }
- }
-
- var tenantSchemaMigrationService = scope.ServiceProvider.GetRequiredService();
- await tenantSchemaMigrationService.MigrateAllAsync(ct);
- }
-
- ///
- public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
-
- // Нормализация логина: нижний регистр и обрезка пробелов (единая форма хранения).
- // login: Входной логин.
- // Возвращает: Нормализованный логин.
- private static string NormalizeLogin(string login) => login.Trim().ToLowerInvariant();
-}
+using Deal.Infrastructure.Tenancy;
+using Deal.Modules.Tenants.Application.Abstractions;
+using Deal.Modules.Tenants.Application.Models;
+using Deal.Modules.Tenants.Application.Services;
+
+namespace Deal.Api.Hosting;
+
+///
+/// Bootstrap при старте
+///
+public sealed class TenantBootstrapService(IServiceScopeFactory scopeFactory) : IHostedService
+{
+ private const string DefaultTenantName = "Default";
+
+ private static readonly Guid DefaultTenantId = Guid.Parse("00000000-0000-0000-0000-000000000001");
+
+ private const string ActiveStatus = "active";
+ private const string BootstrapLoginEnvKey = "DEAL_BOOTSTRAP_LOGIN";
+ private const string BootstrapPasswordEnvKey = "DEAL_BOOTSTRAP_PASSWORD";
+ private const string DefaultAdminLogin = "admin";
+ private const string DefaultAdminPassword = "admin";
+
+ private const string DefaultTenantBootstrapEnvKey = "DEAL_BOOTSTRAP_DEFAULT_TENANT";
+
+ private const string DefaultTenantBootstrapEnabledValue = "1";
+
+ ///
+ public async Task StartAsync(CancellationToken ct)
+ {
+ await using var scope = scopeFactory.CreateAsyncScope();
+ var configuration = scope.ServiceProvider.GetRequiredService();
+ var tenantService = scope.ServiceProvider.GetRequiredService();
+ var tenantRepository = scope.ServiceProvider.GetRequiredService();
+ var authStore = scope.ServiceProvider.GetRequiredService();
+ var passwordHasher = scope.ServiceProvider.GetRequiredService();
+
+ string login = NormalizeLogin(configuration[BootstrapLoginEnvKey] ?? DefaultAdminLogin);
+ string password = configuration[BootstrapPasswordEnvKey] ?? DefaultAdminPassword;
+ var environment = scope.ServiceProvider.GetRequiredService();
+
+ bool seedDefaultTenant = environment.IsDevelopment()
+ || configuration[DefaultTenantBootstrapEnvKey] == DefaultTenantBootstrapEnabledValue;
+ if (seedDefaultTenant)
+ {
+ var tenants = await tenantRepository.ListAsync(ct);
+ if (tenants.Count == 0)
+ {
+ // CreateTenantAsync сам провижинит схему дефолтного тенанта (TenantService → ITenantProvisioner).
+ await tenantService.CreateTenantAsync(DefaultTenantName, DefaultTenantId, ct);
+ }
+
+ // Идемпотентность: пользователь с таким логином уже есть — ничего не делаем.
+ var existingUser = await authStore.FindUserByLoginAsync(login, ct);
+ if (existingUser is null)
+ {
+ await authStore.CreateUserAsync(
+ new StoredUserDto(
+ Id: Guid.NewGuid(),
+ Login: login,
+ TenantId: DefaultTenantId,
+ Status: ActiveStatus,
+ PasswordHash: passwordHasher.Hash(password)),
+ ct);
+ }
+ }
+
+ var tenantSchemaMigrationService = scope.ServiceProvider.GetRequiredService();
+ await tenantSchemaMigrationService.MigrateAllAsync(ct);
+ }
+
+ ///
+ public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
+
+ // Нормализация логина: нижний регистр и обрезка пробелов (единая форма хранения).
+ // login: Входной логин.
+ // Возвращает: Нормализованный логин.
+ private static string NormalizeLogin(string login) => login.Trim().ToLowerInvariant();
+}
diff --git a/src/core/Deal.Api/Middleware/OperatorSessionMiddleware.cs b/src/core/Deal.Api/Middleware/OperatorSessionMiddleware.cs
index 9f8e6c7..fc0c35c 100644
--- a/src/core/Deal.Api/Middleware/OperatorSessionMiddleware.cs
+++ b/src/core/Deal.Api/Middleware/OperatorSessionMiddleware.cs
@@ -1,45 +1,45 @@
-using Deal.Api.Extensions;
-using Deal.Api.Models;
-using Deal.Modules.Tenants.Application.Services;
-using Microsoft.Extensions.Options;
-// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом.
-using OperatorCookieOptions = Deal.Api.Configuration.OperatorCookieOptions;
-
-namespace Deal.Api.Middleware;
-
-///
-/// Middleware операторской сессии
-///
-public sealed class OperatorSessionMiddleware
-{
- private readonly RequestDelegate _next;
- private readonly IOptionsMonitor _cookieOptions;
-
- public OperatorSessionMiddleware(RequestDelegate next, IOptionsMonitor cookieOptions)
- {
- _next = next;
- _cookieOptions = cookieOptions;
- }
-
- ///
- /// Обрабатывает запрос
- ///
- public async Task InvokeAsync(HttpContext context)
- {
- var cookieName = _cookieOptions.CurrentValue.Name;
- if (context.Request.Cookies.TryGetValue(cookieName, out var rawToken)
- && !string.IsNullOrWhiteSpace(rawToken))
- {
- // OperatorAuthService scoped: создаём scope на запрос через RequestServices.
- await using var scope = context.RequestServices.CreateAsyncScope();
- var operatorAuthService = scope.ServiceProvider.GetRequiredService();
- var identity = await operatorAuthService.ResolveSessionAsync(rawToken, context.RequestAborted);
- if (identity is not null)
- {
- context.SetCurrentOperator(new CurrentOperator(identity.Id, identity.Login, identity.Status));
- }
- }
-
- await _next(context);
- }
-}
+using Deal.Api.Extensions;
+using Deal.Api.Models;
+using Deal.Modules.Tenants.Application.Services;
+using Microsoft.Extensions.Options;
+// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом.
+using OperatorCookieOptions = Deal.Api.Configuration.OperatorCookieOptions;
+
+namespace Deal.Api.Middleware;
+
+///
+/// Middleware операторской сессии
+///
+public sealed class OperatorSessionMiddleware
+{
+ private readonly RequestDelegate _next;
+ private readonly IOptionsMonitor _cookieOptions;
+
+ public OperatorSessionMiddleware(RequestDelegate next, IOptionsMonitor cookieOptions)
+ {
+ _next = next;
+ _cookieOptions = cookieOptions;
+ }
+
+ ///
+ /// Обрабатывает запрос
+ ///
+ public async Task InvokeAsync(HttpContext context)
+ {
+ string cookieName = _cookieOptions.CurrentValue.Name;
+ if (context.Request.Cookies.TryGetValue(cookieName, out string? rawToken)
+ && !string.IsNullOrWhiteSpace(rawToken))
+ {
+ // OperatorAuthService scoped: создаём scope на запрос через RequestServices.
+ await using var scope = context.RequestServices.CreateAsyncScope();
+ var operatorAuthService = scope.ServiceProvider.GetRequiredService();
+ var identity = await operatorAuthService.ResolveSessionAsync(rawToken, context.RequestAborted);
+ if (identity is not null)
+ {
+ context.SetCurrentOperator(new CurrentOperator(identity.Id, identity.Login, identity.Status));
+ }
+ }
+
+ await _next(context);
+ }
+}
diff --git a/src/core/Deal.Api/Middleware/SessionMiddleware.cs b/src/core/Deal.Api/Middleware/SessionMiddleware.cs
index 12a16f0..d0de452 100644
--- a/src/core/Deal.Api/Middleware/SessionMiddleware.cs
+++ b/src/core/Deal.Api/Middleware/SessionMiddleware.cs
@@ -1,62 +1,62 @@
-using Deal.Api.Extensions;
-using Deal.Api.Models;
-using Deal.Modules.Tenants.Application.Services;
-using Deal.SharedKernel.Tenants.Abstractions;
-using Deal.SharedKernel.Tenants.Models;
-using Microsoft.Extensions.Options;
-// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом.
-using CookieOptions = Deal.Api.Configuration.CookieOptions;
-
-namespace Deal.Api.Middleware;
-
-///
-/// Middleware сессии
-///
-public sealed class SessionMiddleware
-{
- private readonly RequestDelegate _next;
- private readonly IOptionsMonitor _cookieOptions;
- private readonly ITenantContext _tenantContext;
-
- public SessionMiddleware(
- RequestDelegate next,
- IOptionsMonitor cookieOptions,
- ITenantContext tenantContext)
- {
- _next = next;
- _cookieOptions = cookieOptions;
- _tenantContext = tenantContext;
- }
-
- ///
- /// Обрабатывает запрос
- ///
- public async Task InvokeAsync(HttpContext context)
- {
- try
- {
- var cookieName = _cookieOptions.CurrentValue.Name;
- if (context.Request.Cookies.TryGetValue(cookieName, out var rawToken)
- && !string.IsNullOrWhiteSpace(rawToken))
- {
- // AuthService scoped: создаём scope на запрос через RequestServices.
- await using var scope = context.RequestServices.CreateAsyncScope();
- var authService = scope.ServiceProvider.GetRequiredService();
- var user = await authService.ResolveSessionAsync(rawToken, context.RequestAborted);
- if (user is not null)
- {
- context.SetCurrentUser(new CurrentUser(user.Id, user.Login, user.TenantId, user.Status));
- // Схема тенанта именуется tenant_<id>, где id — Guid в формате "N" (см. TenantService).
- _tenantContext.SetTenant(new TenantId(user.TenantId.ToString("N")));
- }
- }
-
- await _next(context);
- }
- finally
- {
- // Контекст AsyncLocal не должен переживать запрос.
- _tenantContext.Reset();
- }
- }
-}
+using Deal.Api.Extensions;
+using Deal.Api.Models;
+using Deal.Modules.Tenants.Application.Services;
+using Deal.SharedKernel.Tenants.Abstractions;
+using Deal.SharedKernel.Tenants.Models;
+using Microsoft.Extensions.Options;
+// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом.
+using CookieOptions = Deal.Api.Configuration.CookieOptions;
+
+namespace Deal.Api.Middleware;
+
+///
+/// Middleware сессии
+///
+public sealed class SessionMiddleware
+{
+ private readonly RequestDelegate _next;
+ private readonly IOptionsMonitor _cookieOptions;
+ private readonly ITenantContext _tenantContext;
+
+ public SessionMiddleware(
+ RequestDelegate next,
+ IOptionsMonitor cookieOptions,
+ ITenantContext tenantContext)
+ {
+ _next = next;
+ _cookieOptions = cookieOptions;
+ _tenantContext = tenantContext;
+ }
+
+ ///
+ /// Обрабатывает запрос
+ ///
+ public async Task InvokeAsync(HttpContext context)
+ {
+ try
+ {
+ string cookieName = _cookieOptions.CurrentValue.Name;
+ if (context.Request.Cookies.TryGetValue(cookieName, out string? rawToken)
+ && !string.IsNullOrWhiteSpace(rawToken))
+ {
+ // AuthService scoped: создаём scope на запрос через RequestServices.
+ await using var scope = context.RequestServices.CreateAsyncScope();
+ var authService = scope.ServiceProvider.GetRequiredService();
+ var user = await authService.ResolveSessionAsync(rawToken, context.RequestAborted);
+ if (user is not null)
+ {
+ context.SetCurrentUser(new CurrentUser(user.Id, user.Login, user.TenantId, user.Status));
+ // Схема тенанта именуется tenant_<id>, где id — Guid в формате "N" (см. TenantService).
+ _tenantContext.SetTenant(new TenantId(user.TenantId.ToString("N")));
+ }
+ }
+
+ await _next(context);
+ }
+ finally
+ {
+ // Контекст AsyncLocal не должен переживать запрос.
+ _tenantContext.Reset();
+ }
+ }
+}
diff --git a/src/core/Deal.Api/Program.cs b/src/core/Deal.Api/Program.cs
index 9cd9cc5..897ec6f 100644
--- a/src/core/Deal.Api/Program.cs
+++ b/src/core/Deal.Api/Program.cs
@@ -1,533 +1,533 @@
-using System.Net;
-using System.Net.Sockets;
-using System.Text.Encodings.Web;
-using Deal.Api.Configuration;
-using Deal.Api.Endpoints;
-using Deal.Api.Events;
-using Deal.Api.Hosting;
-using Deal.Api.Logging;
-using Deal.Api.Middleware;
-using Deal.Api.Observability;
-using Deal.Api.Services;
-using Deal.Api.Sources;
-using Deal.Api.Telegram;
-using Deal.Contracts.Integrations.Abstractions;
-using Deal.Infrastructure;
-using Deal.Infrastructure.Data;
-using Deal.Infrastructure.Integrations.Models;
-using Deal.Infrastructure.Integrations.Options;
-using Deal.Infrastructure.Integrations.Services;
-using Deal.Infrastructure.Integrations.Storage.Services;
-using Deal.Infrastructure.Persistence;
-using Deal.Infrastructure.Services;
-using Deal.Modules.Discovery.Application.Registrars;
-using Deal.Modules.Kanban.Application.Registrars;
-using Deal.Modules.Pipeline.Application.Registrars;
-using Deal.Modules.Settings.Application.Abstractions;
-using Deal.Modules.Settings.Application.Registrars;
-using Deal.Modules.Telegram.Application;
-using Deal.Modules.Tenants.Application.Models;
-using Deal.Modules.Tenants.Application.Registrars;
-using Deal.SharedKernel.Tenants.Abstractions;
-using Microsoft.AspNetCore.HttpOverrides;
-using Microsoft.AspNetCore.Server.Kestrel.Core;
-using Microsoft.AspNetCore.Server.Kestrel.Https;
-using Microsoft.EntityFrameworkCore;
-using Microsoft.Extensions.Diagnostics.HealthChecks;
-// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом.
-using CookieOptions = Deal.Api.Configuration.CookieOptions;
-
-const string cookiesSectionName = "Cookies";
-const string operatorCookiesSectionName = "OperatorCookies";
-const string corsPolicyName = "cors";
-const string servicesSectionName = "Services:Ml";
-const string aiServicesSectionName = "Services:Ai";
-const string telegramServicesSectionName = "Services:Telegram";
-// Имя истории tenant-миграций без схемы (схема — через search_path; миграции применяет
-// TenantProvisioningService на старте, runtime-контекст их не выполняет).
-const string tenantMigrationsHistoryTable = "__TenantMigrationsHistory";
-const int defaultIngressPort = 5082;
-const string ingressPortEnvKey = "GRPC_INGRESS_PORT";
-// Ключ конфигурации адресов основного HTTP-эндпоинта (--urls/ASPNETCORE_URLS/launchSettings).
-const string serverUrlsKey = "urls";
-// Фолбэк основного HTTP-адреса при отсутствии явных URL (дефолт ASP.NET Core http://localhost:5000).
-const string defaultHttpUrl = "http://localhost:5000";
-const string defaultAiBudgetEnvKey = "DEAL_DEFAULT_AI_BUDGET";
-const string rateLimitSectionName = "RateLimit";
-const string dataRetentionSectionName = "DataRetention";
-const string securitySectionName = "Security";
-const string forwardedHeadersSectionName = "ForwardedHeaders";
-
-const string coreProcessName = "core";
-
-var builder = WebApplication.CreateBuilder(args);
-
-DealLogging.Configure(builder, coreProcessName);
-
-int metricsPort = DealMetricsHosting.ResolveMetricsPort(DealMetricsHosting.DefaultMetricsPort);
-DealMetricsHosting.AddDealMetrics(builder, metricsPort);
-
-// Строка подключения Postgres — ТОЛЬКО из конфигурации (env/appsettings): dev-пароль в коде отсутствует
-// (Security review). Отсутствие строки = fail-fast на старте, а не тихий уход на несуществующую dev-БД.
-var connectionString = builder.Configuration.GetConnectionString("DealPostgres")
- ?? throw new InvalidOperationException("ConnectionStrings:DealPostgres не задан");
-builder.Services.AddDbContext(options => options.UseNpgsql(connectionString));
-builder.Services.AddSingleton();
-builder.Services.AddSingleton();
-
-MtlsOptions mtlsOptions = MtlsOptions.FromConfiguration(builder.Configuration);
-MtlsCertificates? mtlsCertificates = MtlsCertificates.Load(mtlsOptions);
-if (mtlsCertificates is not null)
-{
- builder.Services.AddSingleton(mtlsCertificates);
-}
-
-builder.WebHost.ConfigureKestrel(kestrel =>
-{
- BindMainHttpEndpoints(kestrel, builder.Configuration[serverUrlsKey]);
- int ingressPort = ParsePort(builder.Configuration[ingressPortEnvKey]) ?? defaultIngressPort;
- kestrel.ListenAnyIP(ingressPort, listen =>
- {
- listen.Protocols = HttpProtocols.Http2;
- if (mtlsCertificates is not null)
- {
- listen.UseHttps(https =>
- {
- https.ServerCertificate = mtlsCertificates.ServerCertificate;
- https.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
- https.ClientCertificateValidation = mtlsCertificates.ValidateClientCertificate;
- });
- }
- });
-});
-
-// TenantDbContext — scoped-контекст бессхемной модели тенанта (таблица settings и др.): строка
-// подключения на каждый scope строится по текущему ITenantContext (заполняет SessionMiddleware)
-// с Search Path на схему тенанта (ConnectionStringProvider.ForTenant). Опции живут в scope запроса
-// (optionsLifetime: Scoped) — иначе опции с первым тенантом закешировались бы в singleton.
-// Безопасность: вне tenant-запроса (нет сессии) контекст не имеет смысла — ошибка конфигурации.
-builder.Services.AddDbContext(
- (serviceProvider, options) =>
- {
- var tenantContext = serviceProvider.GetRequiredService();
- if (!tenantContext.HasTenant)
- {
- throw new InvalidOperationException(
- "TenantDbContext запрошен вне tenant-запроса: на запрос не разрешена сессия "
- + "(ITenantContext.HasTenant == false).");
- }
-
- var connectionStringProvider = serviceProvider.GetRequiredService();
- options.UseNpgsql(
- connectionStringProvider.ForTenant(tenantContext.TenantId),
- npgsql => npgsql.MigrationsHistoryTable(tenantMigrationsHistoryTable));
- },
- contextLifetime: ServiceLifetime.Scoped,
- optionsLifetime: ServiceLifetime.Scoped);
-
-builder.Services.AddTenantsModule();
-
-TokenLimitDefaults tenantLimitDefaults = new(
- ResolveDefaultAiBudget(builder.Configuration), TokenBudgetDefaults.DefaultPeriod);
-builder.Services.AddDealPersistence(tenantLimitDefaults);
-
-builder.Services.AddDealSecurity(builder.Environment.ContentRootPath);
-
-MlServiceOptions mlOptions = builder.Configuration.GetSection(servicesSectionName).Get() ?? new MlServiceOptions();
-builder.Services.AddSingleton(mlOptions);
-AiServiceOptions aiOptions = builder.Configuration.GetSection(aiServicesSectionName).Get() ?? new AiServiceOptions();
-builder.Services.AddSingleton(aiOptions);
-TelegramServiceOptions telegramOptions = builder.Configuration.GetSection(telegramServicesSectionName).Get() ?? new TelegramServiceOptions();
-builder.Services.AddSingleton(telegramOptions);
-builder.Services.AddDealIntegrations(mlOptions, aiOptions, telegramOptions, mtlsCertificates);
-
-builder.Services.AddSingleton(new ServiceHealthProbe(mtlsCertificates));
-
-builder.Services.AddDealFileStorage(builder.Configuration, builder.Environment.ContentRootPath);
-
-builder.Services.AddSettingsModule();
-
-builder.Services.AddKanbanModule();
-
-builder.Services.AddPipelineModule();
-
-builder.Services.AddTelegramModule();
-
-builder.Services.AddDiscoveryModule();
-
-builder.Services.AddScoped();
-builder.Services.AddScoped();
-
-builder.Services.AddSingleton();
-
-builder.Services.AddScoped();
-
-builder.Services.AddScoped();
-
-builder.Services.AddSingleton();
-
-builder.Services.AddSingleton();
-
-builder.Services.AddSingleton();
-
-RateLimitOptions rateLimitOptions = builder.Configuration
- .GetSection(rateLimitSectionName)
- .Get() ?? new RateLimitOptions();
-builder.Services.AddSingleton(rateLimitOptions);
-// Гвард попыток входа — scoped: его хранилище счётчиков (IRateLimitCounterStore) — scoped EF-адаптер
-// (public.rate_limit_counters). Активен только при Enabled (no-op иначе).
-builder.Services.AddScoped();
-builder.Services.AddScoped();
-if (rateLimitOptions.Enabled)
-{
- builder.Services.AddDealRateLimiter(rateLimitOptions);
-}
-
-builder.Services.AddGrpc(grpc =>
-{
- grpc.Interceptors.Add();
- grpc.Interceptors.Add();
- if (rateLimitOptions.Enabled)
- {
- grpc.Interceptors.Add();
- }
-});
-if (rateLimitOptions.Enabled)
-{
- builder.Services.AddSingleton(provider =>
- IngressRateLimitInterceptor.CreateLimiter(
- provider.GetRequiredService(),
- rateLimitOptions.GrpcIngressPerMinute));
-}
-
-builder.Services.AddScoped();
-builder.Services.AddScoped();
-builder.Services.AddScoped();
-
-builder.Services
- .AddGrpcHealthChecks()
- .AddCheck("ready", () => HealthCheckResult.Healthy("хост Deal.Api готов"));
-
-builder.Services.AddHttpClient(
- client => client.Timeout = TimeSpan.FromSeconds(AiConnectionChecker.RequestTimeoutSeconds));
-
-builder.Services.AddHttpClient(
- client => client.Timeout = TimeSpan.FromSeconds(CbrRateSource.RequestTimeoutSeconds));
-
-builder.Services.AddSingleton();
-
-builder.Services.AddHostedService();
-
-builder.Services.AddHostedService();
-
-builder.Services.AddHostedService();
-
-builder.Services.AddHostedService();
-
-builder.Services.AddHostedService();
-
-if (!mlOptions.UseLocal)
-{
- builder.Services.AddHostedService();
-}
-
-builder.Services.AddHostedService();
-
-builder.Services.AddSingleton();
-
-builder.Services.AddHostedService();
-
-DataRetentionOptions dataRetentionOptions = builder.Configuration
- .GetSection(dataRetentionSectionName)
- .Get() ?? new DataRetentionOptions();
-builder.Services.AddSingleton(dataRetentionOptions);
-builder.Services.AddHostedService();
-
-// Кука сессии: имя/срок/Secure из секции "Cookies" (appsettings.json + env Cookies__*).
-builder.Services.Configure(builder.Configuration.GetSection(cookiesSectionName));
-
-builder.Services.Configure(builder.Configuration.GetSection(operatorCookiesSectionName));
-
-builder.Services.ConfigureHttpJsonOptions(options =>
- options.SerializerOptions.Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping);
-
-SecurityOptions securityOptions = builder.Configuration
- .GetSection(securitySectionName)
- .Get() ?? new SecurityOptions();
-builder.Services.AddSingleton(securityOptions);
-
-ForwardedHeadersConfig forwardedHeadersConfig = builder.Configuration
- .GetSection(forwardedHeadersSectionName)
- .Get() ?? new ForwardedHeadersConfig();
-builder.Services.AddSingleton(forwardedHeadersConfig);
-
-builder.Services.AddCors(options =>
- options.AddPolicy(corsPolicyName, cors =>
- {
- cors.AllowAnyHeader()
- .AllowAnyMethod()
- .AllowCredentials();
- if (securityOptions.AllowedOrigins.Length == 0)
- {
- cors.SetIsOriginAllowed(_ => true);
- }
- else
- {
- cors.WithOrigins(securityOptions.AllowedOrigins);
- }
- }));
-
-var app = builder.Build();
-
-DealMetricsHosting.MapDealMetrics(app);
-
-// Fail-closed для Production (Security review): дефолты кода рассчитаны на dev/тесты (rate limit выключен,
-// CORS — «любой origin»). Прод-окружение обязано задать защиту ЯВНО — иначе старт отказывается, а не
-// молча работает без лимитов/с открытым CORS.
-if (app.Environment.IsProduction())
-{
- if (!rateLimitOptions.Enabled)
- {
- throw new InvalidOperationException(
- "Production требует RateLimit__Enabled=true (анти-брутфорс и лимиты выключены код-дефолтом).");
- }
-
- if (securityOptions.AllowedOrigins.Length == 0)
- {
- throw new InvalidOperationException(
- "Production требует непустой Security__AllowedOrigins (CORS fail-open при пустом списке).");
- }
-}
-
-app.Logger.LogInformation("Файловое хранилище: {FileStorage}", app.Services.GetRequiredService());
-
-app.Logger.LogInformation(
- "ML-интеграция: {Mode} ({Endpoint})",
- mlOptions.UseLocal ? "Local-заглушка (MlOutbox накапливается)" : "gRPC-клиент ml-service",
- mlOptions.Endpoint);
-
-app.Logger.LogInformation(
- "AI-интеграция: {Mode} ({Endpoint})",
- aiOptions.UseLocal ? "Local-адаптеры (разбор ядра/инструменты выключены)" : "gRPC-клиент ai-service",
- aiOptions.Endpoint);
-
-app.Logger.LogInformation(
- "Telegram-гейт: {Mode} ({Endpoint})",
- telegramOptions.UseLocal ? "Local-заглушка (idle/не подключён)" : "gRPC-клиент telegram-service",
- telegramOptions.Endpoint);
-
-app.Logger.LogInformation(
- "Транспорт внутреннего gRPC: {Transport}",
- mtlsOptions.Enabled ? "mTLS (DEAL_MTLS_ENABLED=1, сертификаты из DEAL_MTLS_*)" : "plaintext + service-token (dev)");
-
-if (forwardedHeadersConfig.Enabled)
-{
- app.UseForwardedHeaders(BuildForwardedHeadersOptions(forwardedHeadersConfig));
-}
-
-app.UseMiddleware();
-
-app.UseCors(corsPolicyName);
-app.UseMiddleware();
-app.UseMiddleware();
-if (rateLimitOptions.Enabled)
-{
- app.UseRateLimiter();
-}
-
-app.UseMiddleware();
-
-app.MapGet("/api/health", () => Results.Ok(new { ok = true, service = "deal" }));
-app.MapAuthEndpoints();
-app.MapOperatorAuthEndpoints();
-app.MapOperatorAuditEndpoints();
-app.MapOperatorAnalyticsEndpoints();
-app.MapOperatorInvitesEndpoints();
-app.MapOperatorTenantsEndpoints();
-app.MapOperatorLimitsEndpoints();
-app.MapOperatorHealthEndpoints();
-app.MapOperatorSettingsEndpoints();
-app.MapOperatorMaintenanceEndpoints();
-app.MapJoinEndpoint();
-app.MapSettingsEndpoints();
-app.MapAiCheckEndpoint();
-app.MapRatesEndpoints();
-app.MapMlEndpoints();
-app.MapFilterTesterEndpoints();
-app.MapContainersEndpoints();
-app.MapCardsEndpoints();
-app.MapCardDetailsEndpoints();
-app.MapStorageEndpoints();
-app.MapEventsEndpoint();
-app.MapAiSuggestEndpoints();
-app.MapPipelineEndpoints();
-app.MapTelegramEndpoints();
-app.MapTelegramQrImageEndpoint();
-app.MapDiscoveryEndpoints();
-app.MapGrpcService().DisableRateLimiting();
-app.MapGrpcService().DisableRateLimiting();
-app.MapGrpcHealthChecksService().DisableRateLimiting();
-
-app.Run();
-
-// ── Kestrel: основной HTTP/1.1-эндпоинт из URL-конфигурации (явные Listen заменяют URL-биндинг) ──
-
-// Биндит основной HTTP-эндпоинт(ы) из ключа "urls" (--urls/ASPNETCORE_URLS/launchSettings): без явных
-// адресов используется фолбэк defaultHttpUrl (как дефолт ASP.NET Core). Схема https — сертификат из
-// стандартной конфигурации Kestrel (UseHttps без аргументов), как при URL-биндинге.
-static void BindMainHttpEndpoints(KestrelServerOptions kestrel, string? urlsConfig)
-{
- List addresses = ParseHttpAddresses(urlsConfig);
- if (addresses.Count == 0)
- {
- addresses.Add(new Uri(defaultHttpUrl));
- }
-
- foreach (Uri address in addresses)
- {
- // Dev-запуски используют loopback («localhost»/127.0.0.1) — ListenLocalhost; «0.0.0.0»/«*»/«+»/«::»
- // (compose/host) — все интерфейсы. Иных хостов в конфигурации нет (URL-хосты не биндятся по имени).
- bool isHttps = string.Equals(address.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase);
- string host = address.Host.Trim('[', ']');
- if (host is "0.0.0.0" or "*" or "+" or "::")
- {
- kestrel.Listen(IPAddress.Any, address.Port, listen =>
- {
- if (isHttps)
- {
- listen.UseHttps();
- }
- });
- }
- else if (isHttps)
- {
- kestrel.ListenLocalhost(address.Port, listen => listen.UseHttps());
- }
- else
- {
- kestrel.ListenLocalhost(address.Port);
- }
- }
-}
-
-// Разбирает адреса конфигурации "urls" (http/https, разделитель «;»); невалидные/чужие схемы пропускаются.
-static List ParseHttpAddresses(string? urlsConfig)
-{
- var addresses = new List();
- if (string.IsNullOrWhiteSpace(urlsConfig))
- {
- return addresses;
- }
-
- foreach (string part in urlsConfig.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
- {
- if (Uri.TryCreate(part, UriKind.Absolute, out Uri? uri)
- && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
- {
- addresses.Add(uri);
- }
- }
-
- return addresses;
-}
-
-// Парсит порт из env-строки; пустое/нечисловое значение — null (фолбэк дефолта).
-static int? ParsePort(string? rawValue)
- => int.TryParse(rawValue, out int parsedPort) ? parsedPort : null;
-
-long ResolveDefaultAiBudget(IConfiguration configuration)
-{
- string? rawValue = configuration[defaultAiBudgetEnvKey];
- return long.TryParse(rawValue, out long parsedBudget) && parsedBudget > 0
- ? parsedBudget
- : TokenBudgetDefaults.DefaultBudgetTokens;
-}
-
-public partial class Program
-{
- ///
- /// Строит опции UseForwardedHeaders из ForwardedHeadersConfig
- ///
- /// Секция ForwardedHeaders конфигурации.
- /// Опции для app.UseForwardedHeaders.
- public static ForwardedHeadersOptions BuildForwardedHeadersOptions(ForwardedHeadersConfig config)
- {
- ArgumentNullException.ThrowIfNull(config);
- var options = new ForwardedHeadersOptions
- {
- // Адрес клиента и схема (Origin-проверка «своего» origin за Caddy требует https из
- // X-Forwarded-Proto). X-Forwarded-Host не нужен: Caddy транслирует Host без перезаписи.
- ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto,
- // Между клиентом и core ровно один прокси (Caddy): читаем ближайший к нам элемент цепочки.
- ForwardLimit = 1,
- };
- // Доверие — только явный конфиг + loopback-фолбэк ниже: очищаем дефолты конструктора, чтобы
- // перечисление в KnownProxies/KnownIPNetworks было единственным источником истины.
- options.KnownProxies.Clear();
- options.KnownIPNetworks.Clear();
- foreach (string proxy in config.KnownProxies)
- {
- if (!IPAddress.TryParse(proxy, out IPAddress? address))
- {
- throw new InvalidOperationException(
- $"Невалидный IP доверенного прокси в ForwardedHeaders:KnownProxies: \"{proxy}\".");
- }
-
- options.KnownProxies.Add(address);
- }
-
- foreach (string network in config.KnownNetworks)
- {
- if (!TryParseCidr(network, out System.Net.IPNetwork parsedNetwork))
- {
- throw new InvalidOperationException(
- $"Невалидная подсеть доверенного прокси в ForwardedHeaders:KnownNetworks: \"{network}\" "
- + "(ожидается CIDR, напр. 172.16.0.0/12).");
- }
-
- options.KnownIPNetworks.Add(parsedNetwork);
- }
-
- if (options.KnownProxies.Count == 0 && options.KnownIPNetworks.Count == 0)
- {
- // Пустые списки KnownProxies/KnownIPNetworks у ForwardedHeadersMiddleware означают «доверять
- // ЛЮБОМУ клиенту» (спуфинг XFF) — пустоту до middleware не допускаем. Дефолт без явного
- // конфига — loopback (dev-прокси на хосте: vite/локальный Caddy, см. appsettings); PROD
- // перечисляет Caddy адресами/подсетями в конфиге.
- options.KnownProxies.Add(IPAddress.Loopback);
- options.KnownProxies.Add(IPAddress.IPv6Loopback);
- }
-
- return options;
- }
-
- // Разбирает CIDR-запись подсети ("127.0.0.0/8", "2001:db8::/32"); без префикса — вся
- // подсеть одного адреса (IPv4 — /32, IPv6 — /128).
- // value: Строка конфига (ForwardedHeaders:KnownNetworks).
- // network: Разобранная подсеть при успехе.
- // Возвращает: true — запись валидна.
- private static bool TryParseCidr(string value, out System.Net.IPNetwork network)
- {
- network = default;
- int slashIndex = value.IndexOf('/');
- string addressPart = slashIndex >= 0 ? value[..slashIndex] : value;
- if (!IPAddress.TryParse(addressPart, out IPAddress? address))
- {
- return false;
- }
-
- int maxPrefixLength = address.AddressFamily == AddressFamily.InterNetwork ? 32 : 128;
- int prefixLength = maxPrefixLength;
- if (slashIndex >= 0)
- {
- string lengthPart = value[(slashIndex + 1)..];
- if (!int.TryParse(lengthPart, out prefixLength) || prefixLength < 0 || prefixLength > maxPrefixLength)
- {
- return false;
- }
- }
-
- network = new System.Net.IPNetwork(address, prefixLength);
- return true;
- }
-}
+using System.Net;
+using System.Net.Sockets;
+using System.Text.Encodings.Web;
+using Deal.Api.Configuration;
+using Deal.Api.Endpoints;
+using Deal.Api.Events;
+using Deal.Api.Hosting;
+using Deal.Api.Logging;
+using Deal.Api.Middleware;
+using Deal.Api.Observability;
+using Deal.Api.Services;
+using Deal.Api.Sources;
+using Deal.Api.Telegram;
+using Deal.Contracts.Integrations.Abstractions;
+using Deal.Infrastructure;
+using Deal.Infrastructure.Data;
+using Deal.Infrastructure.Integrations.Models;
+using Deal.Infrastructure.Integrations.Options;
+using Deal.Infrastructure.Integrations.Services;
+using Deal.Infrastructure.Integrations.Storage.Services;
+using Deal.Infrastructure.Persistence;
+using Deal.Infrastructure.Services;
+using Deal.Modules.Discovery.Application.Registrars;
+using Deal.Modules.Kanban.Application.Registrars;
+using Deal.Modules.Pipeline.Application.Registrars;
+using Deal.Modules.Settings.Application.Abstractions;
+using Deal.Modules.Settings.Application.Registrars;
+using Deal.Modules.Telegram.Application;
+using Deal.Modules.Tenants.Application.Models;
+using Deal.Modules.Tenants.Application.Registrars;
+using Deal.SharedKernel.Tenants.Abstractions;
+using Microsoft.AspNetCore.HttpOverrides;
+using Microsoft.AspNetCore.Server.Kestrel.Core;
+using Microsoft.AspNetCore.Server.Kestrel.Https;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом.
+using CookieOptions = Deal.Api.Configuration.CookieOptions;
+
+const string cookiesSectionName = "Cookies";
+const string operatorCookiesSectionName = "OperatorCookies";
+const string corsPolicyName = "cors";
+const string servicesSectionName = "Services:Ml";
+const string aiServicesSectionName = "Services:Ai";
+const string telegramServicesSectionName = "Services:Telegram";
+// Имя истории tenant-миграций без схемы (схема — через search_path; миграции применяет
+// TenantProvisioningService на старте, runtime-контекст их не выполняет).
+const string tenantMigrationsHistoryTable = "__TenantMigrationsHistory";
+const int defaultIngressPort = 5082;
+const string ingressPortEnvKey = "GRPC_INGRESS_PORT";
+// Ключ конфигурации адресов основного HTTP-эндпоинта (--urls/ASPNETCORE_URLS/launchSettings).
+const string serverUrlsKey = "urls";
+// Фолбэк основного HTTP-адреса при отсутствии явных URL (дефолт ASP.NET Core http://localhost:5000).
+const string defaultHttpUrl = "http://localhost:5000";
+const string defaultAiBudgetEnvKey = "DEAL_DEFAULT_AI_BUDGET";
+const string rateLimitSectionName = "RateLimit";
+const string dataRetentionSectionName = "DataRetention";
+const string securitySectionName = "Security";
+const string forwardedHeadersSectionName = "ForwardedHeaders";
+
+const string coreProcessName = "core";
+
+var builder = WebApplication.CreateBuilder(args);
+
+DealLogging.Configure(builder, coreProcessName);
+
+int metricsPort = DealMetricsHosting.ResolveMetricsPort(DealMetricsHosting.DefaultMetricsPort);
+DealMetricsHosting.AddDealMetrics(builder, metricsPort);
+
+// Строка подключения Postgres — ТОЛЬКО из конфигурации (env/appsettings): dev-пароль в коде отсутствует
+// (Security review). Отсутствие строки = fail-fast на старте, а не тихий уход на несуществующую dev-БД.
+string connectionString = builder.Configuration.GetConnectionString("DealPostgres")
+ ?? throw new InvalidOperationException("ConnectionStrings:DealPostgres не задан");
+builder.Services.AddDbContext(options => options.UseNpgsql(connectionString));
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+
+MtlsOptions mtlsOptions = MtlsOptions.FromConfiguration(builder.Configuration);
+MtlsCertificates? mtlsCertificates = MtlsCertificates.Load(mtlsOptions);
+if (mtlsCertificates is not null)
+{
+ builder.Services.AddSingleton(mtlsCertificates);
+}
+
+builder.WebHost.ConfigureKestrel(kestrel =>
+{
+ BindMainHttpEndpoints(kestrel, builder.Configuration[serverUrlsKey]);
+ int ingressPort = ParsePort(builder.Configuration[ingressPortEnvKey]) ?? defaultIngressPort;
+ kestrel.ListenAnyIP(ingressPort, listen =>
+ {
+ listen.Protocols = HttpProtocols.Http2;
+ if (mtlsCertificates is not null)
+ {
+ listen.UseHttps(https =>
+ {
+ https.ServerCertificate = mtlsCertificates.ServerCertificate;
+ https.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
+ https.ClientCertificateValidation = mtlsCertificates.ValidateClientCertificate;
+ });
+ }
+ });
+});
+
+// TenantDbContext — scoped-контекст бессхемной модели тенанта (таблица settings и др.): строка
+// подключения на каждый scope строится по текущему ITenantContext (заполняет SessionMiddleware)
+// с Search Path на схему тенанта (ConnectionStringProvider.ForTenant). Опции живут в scope запроса
+// (optionsLifetime: Scoped) — иначе опции с первым тенантом закешировались бы в singleton.
+// Безопасность: вне tenant-запроса (нет сессии) контекст не имеет смысла — ошибка конфигурации.
+builder.Services.AddDbContext(
+ (serviceProvider, options) =>
+ {
+ var tenantContext = serviceProvider.GetRequiredService();
+ if (!tenantContext.HasTenant)
+ {
+ throw new InvalidOperationException(
+ "TenantDbContext запрошен вне tenant-запроса: на запрос не разрешена сессия "
+ + "(ITenantContext.HasTenant == false).");
+ }
+
+ var connectionStringProvider = serviceProvider.GetRequiredService();
+ options.UseNpgsql(
+ connectionStringProvider.ForTenant(tenantContext.TenantId),
+ npgsql => npgsql.MigrationsHistoryTable(tenantMigrationsHistoryTable));
+ },
+ contextLifetime: ServiceLifetime.Scoped,
+ optionsLifetime: ServiceLifetime.Scoped);
+
+builder.Services.AddTenantsModule();
+
+TokenLimitDefaults tenantLimitDefaults = new(
+ ResolveDefaultAiBudget(builder.Configuration), TokenBudgetDefaults.DefaultPeriod);
+builder.Services.AddDealPersistence(tenantLimitDefaults);
+
+builder.Services.AddDealSecurity(builder.Environment.ContentRootPath);
+
+MlServiceOptions mlOptions = builder.Configuration.GetSection(servicesSectionName).Get() ?? new MlServiceOptions();
+builder.Services.AddSingleton(mlOptions);
+AiServiceOptions aiOptions = builder.Configuration.GetSection(aiServicesSectionName).Get() ?? new AiServiceOptions();
+builder.Services.AddSingleton(aiOptions);
+TelegramServiceOptions telegramOptions = builder.Configuration.GetSection(telegramServicesSectionName).Get() ?? new TelegramServiceOptions();
+builder.Services.AddSingleton(telegramOptions);
+builder.Services.AddDealIntegrations(mlOptions, aiOptions, telegramOptions, mtlsCertificates);
+
+builder.Services.AddSingleton(new ServiceHealthProbe(mtlsCertificates));
+
+builder.Services.AddDealFileStorage(builder.Configuration, builder.Environment.ContentRootPath);
+
+builder.Services.AddSettingsModule();
+
+builder.Services.AddKanbanModule();
+
+builder.Services.AddPipelineModule();
+
+builder.Services.AddTelegramModule();
+
+builder.Services.AddDiscoveryModule();
+
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+
+builder.Services.AddSingleton();
+
+builder.Services.AddScoped();
+
+builder.Services.AddScoped();
+
+builder.Services.AddSingleton();
+
+builder.Services.AddSingleton();
+
+builder.Services.AddSingleton();
+
+RateLimitOptions rateLimitOptions = builder.Configuration
+ .GetSection(rateLimitSectionName)
+ .Get() ?? new RateLimitOptions();
+builder.Services.AddSingleton(rateLimitOptions);
+// Гвард попыток входа — scoped: его хранилище счётчиков (IRateLimitCounterStore) — scoped EF-адаптер
+// (public.rate_limit_counters). Активен только при Enabled (no-op иначе).
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+if (rateLimitOptions.Enabled)
+{
+ builder.Services.AddDealRateLimiter(rateLimitOptions);
+}
+
+builder.Services.AddGrpc(grpc =>
+{
+ grpc.Interceptors.Add();
+ grpc.Interceptors.Add();
+ if (rateLimitOptions.Enabled)
+ {
+ grpc.Interceptors.Add();
+ }
+});
+if (rateLimitOptions.Enabled)
+{
+ builder.Services.AddSingleton(provider =>
+ IngressRateLimitInterceptor.CreateLimiter(
+ provider.GetRequiredService(),
+ rateLimitOptions.GrpcIngressPerMinute));
+}
+
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+
+builder.Services
+ .AddGrpcHealthChecks()
+ .AddCheck("ready", () => HealthCheckResult.Healthy("хост Deal.Api готов"));
+
+builder.Services.AddHttpClient(
+ client => client.Timeout = TimeSpan.FromSeconds(AiConnectionChecker.RequestTimeoutSeconds));
+
+builder.Services.AddHttpClient(
+ client => client.Timeout = TimeSpan.FromSeconds(CbrRateSource.RequestTimeoutSeconds));
+
+builder.Services.AddSingleton();
+
+builder.Services.AddHostedService();
+
+builder.Services.AddHostedService();
+
+builder.Services.AddHostedService();
+
+builder.Services.AddHostedService();
+
+builder.Services.AddHostedService();
+
+if (!mlOptions.UseLocal)
+{
+ builder.Services.AddHostedService();
+}
+
+builder.Services.AddHostedService();
+
+builder.Services.AddSingleton();
+
+builder.Services.AddHostedService();
+
+DataRetentionOptions dataRetentionOptions = builder.Configuration
+ .GetSection(dataRetentionSectionName)
+ .Get() ?? new DataRetentionOptions();
+builder.Services.AddSingleton(dataRetentionOptions);
+builder.Services.AddHostedService();
+
+// Кука сессии: имя/срок/Secure из секции "Cookies" (appsettings.json + env Cookies__*).
+builder.Services.Configure(builder.Configuration.GetSection(cookiesSectionName));
+
+builder.Services.Configure(builder.Configuration.GetSection(operatorCookiesSectionName));
+
+builder.Services.ConfigureHttpJsonOptions(options =>
+ options.SerializerOptions.Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping);
+
+SecurityOptions securityOptions = builder.Configuration
+ .GetSection(securitySectionName)
+ .Get() ?? new SecurityOptions();
+builder.Services.AddSingleton(securityOptions);
+
+ForwardedHeadersConfig forwardedHeadersConfig = builder.Configuration
+ .GetSection(forwardedHeadersSectionName)
+ .Get() ?? new ForwardedHeadersConfig();
+builder.Services.AddSingleton(forwardedHeadersConfig);
+
+builder.Services.AddCors(options =>
+ options.AddPolicy(corsPolicyName, cors =>
+ {
+ cors.AllowAnyHeader()
+ .AllowAnyMethod()
+ .AllowCredentials();
+ if (securityOptions.AllowedOrigins.Length == 0)
+ {
+ cors.SetIsOriginAllowed(_ => true);
+ }
+ else
+ {
+ cors.WithOrigins(securityOptions.AllowedOrigins);
+ }
+ }));
+
+var app = builder.Build();
+
+DealMetricsHosting.MapDealMetrics(app);
+
+// Fail-closed для Production (Security review): дефолты кода рассчитаны на dev/тесты (rate limit выключен,
+// CORS — «любой origin»). Прод-окружение обязано задать защиту ЯВНО — иначе старт отказывается, а не
+// молча работает без лимитов/с открытым CORS.
+if (app.Environment.IsProduction())
+{
+ if (!rateLimitOptions.Enabled)
+ {
+ throw new InvalidOperationException(
+ "Production требует RateLimit__Enabled=true (анти-брутфорс и лимиты выключены код-дефолтом).");
+ }
+
+ if (securityOptions.AllowedOrigins.Length == 0)
+ {
+ throw new InvalidOperationException(
+ "Production требует непустой Security__AllowedOrigins (CORS fail-open при пустом списке).");
+ }
+}
+
+app.Logger.LogInformation("Файловое хранилище: {FileStorage}", app.Services.GetRequiredService());
+
+app.Logger.LogInformation(
+ "ML-интеграция: {Mode} ({Endpoint})",
+ mlOptions.UseLocal ? "Local-заглушка (MlOutbox накапливается)" : "gRPC-клиент ml-service",
+ mlOptions.Endpoint);
+
+app.Logger.LogInformation(
+ "AI-интеграция: {Mode} ({Endpoint})",
+ aiOptions.UseLocal ? "Local-адаптеры (разбор ядра/инструменты выключены)" : "gRPC-клиент ai-service",
+ aiOptions.Endpoint);
+
+app.Logger.LogInformation(
+ "Telegram-гейт: {Mode} ({Endpoint})",
+ telegramOptions.UseLocal ? "Local-заглушка (idle/не подключён)" : "gRPC-клиент telegram-service",
+ telegramOptions.Endpoint);
+
+app.Logger.LogInformation(
+ "Транспорт внутреннего gRPC: {Transport}",
+ mtlsOptions.Enabled ? "mTLS (DEAL_MTLS_ENABLED=1, сертификаты из DEAL_MTLS_*)" : "plaintext + service-token (dev)");
+
+if (forwardedHeadersConfig.Enabled)
+{
+ app.UseForwardedHeaders(BuildForwardedHeadersOptions(forwardedHeadersConfig));
+}
+
+app.UseMiddleware();
+
+app.UseCors(corsPolicyName);
+app.UseMiddleware();
+app.UseMiddleware();
+if (rateLimitOptions.Enabled)
+{
+ app.UseRateLimiter();
+}
+
+app.UseMiddleware();
+
+app.MapGet("/api/health", () => Results.Ok(new { ok = true, service = "deal" }));
+app.MapAuthEndpoints();
+app.MapOperatorAuthEndpoints();
+app.MapOperatorAuditEndpoints();
+app.MapOperatorAnalyticsEndpoints();
+app.MapOperatorInvitesEndpoints();
+app.MapOperatorTenantsEndpoints();
+app.MapOperatorLimitsEndpoints();
+app.MapOperatorHealthEndpoints();
+app.MapOperatorSettingsEndpoints();
+app.MapOperatorMaintenanceEndpoints();
+app.MapJoinEndpoint();
+app.MapSettingsEndpoints();
+app.MapAiCheckEndpoint();
+app.MapRatesEndpoints();
+app.MapMlEndpoints();
+app.MapFilterTesterEndpoints();
+app.MapContainersEndpoints();
+app.MapCardsEndpoints();
+app.MapCardDetailsEndpoints();
+app.MapStorageEndpoints();
+app.MapEventsEndpoint();
+app.MapAiSuggestEndpoints();
+app.MapPipelineEndpoints();
+app.MapTelegramEndpoints();
+app.MapTelegramQrImageEndpoint();
+app.MapDiscoveryEndpoints();
+app.MapGrpcService().DisableRateLimiting();
+app.MapGrpcService().DisableRateLimiting();
+app.MapGrpcHealthChecksService().DisableRateLimiting();
+
+app.Run();
+
+// ── Kestrel: основной HTTP/1.1-эндпоинт из URL-конфигурации (явные Listen заменяют URL-биндинг) ──
+
+// Биндит основной HTTP-эндпоинт(ы) из ключа "urls" (--urls/ASPNETCORE_URLS/launchSettings): без явных
+// адресов используется фолбэк defaultHttpUrl (как дефолт ASP.NET Core). Схема https — сертификат из
+// стандартной конфигурации Kestrel (UseHttps без аргументов), как при URL-биндинге.
+static void BindMainHttpEndpoints(KestrelServerOptions kestrel, string? urlsConfig)
+{
+ List addresses = ParseHttpAddresses(urlsConfig);
+ if (addresses.Count == 0)
+ {
+ addresses.Add(new Uri(defaultHttpUrl));
+ }
+
+ foreach (Uri address in addresses)
+ {
+ // Dev-запуски используют loopback («localhost»/127.0.0.1) — ListenLocalhost; «0.0.0.0»/«*»/«+»/«::»
+ // (compose/host) — все интерфейсы. Иных хостов в конфигурации нет (URL-хосты не биндятся по имени).
+ bool isHttps = string.Equals(address.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase);
+ string host = address.Host.Trim('[', ']');
+ if (host is "0.0.0.0" or "*" or "+" or "::")
+ {
+ kestrel.Listen(IPAddress.Any, address.Port, listen =>
+ {
+ if (isHttps)
+ {
+ listen.UseHttps();
+ }
+ });
+ }
+ else if (isHttps)
+ {
+ kestrel.ListenLocalhost(address.Port, listen => listen.UseHttps());
+ }
+ else
+ {
+ kestrel.ListenLocalhost(address.Port);
+ }
+ }
+}
+
+// Разбирает адреса конфигурации "urls" (http/https, разделитель «;»); невалидные/чужие схемы пропускаются.
+static List ParseHttpAddresses(string? urlsConfig)
+{
+ var addresses = new List();
+ if (string.IsNullOrWhiteSpace(urlsConfig))
+ {
+ return addresses;
+ }
+
+ foreach (string part in urlsConfig.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+ {
+ if (Uri.TryCreate(part, UriKind.Absolute, out Uri? uri)
+ && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
+ {
+ addresses.Add(uri);
+ }
+ }
+
+ return addresses;
+}
+
+// Парсит порт из env-строки; пустое/нечисловое значение — null (фолбэк дефолта).
+static int? ParsePort(string? rawValue)
+ => int.TryParse(rawValue, out int parsedPort) ? parsedPort : null;
+
+long ResolveDefaultAiBudget(IConfiguration configuration)
+{
+ string? rawValue = configuration[defaultAiBudgetEnvKey];
+ return long.TryParse(rawValue, out long parsedBudget) && parsedBudget > 0
+ ? parsedBudget
+ : TokenBudgetDefaults.DefaultBudgetTokens;
+}
+
+public partial class Program
+{
+ ///
+ /// Строит опции UseForwardedHeaders из ForwardedHeadersConfig
+ ///
+ /// Секция ForwardedHeaders конфигурации.
+ /// Опции для app.UseForwardedHeaders.
+ public static ForwardedHeadersOptions BuildForwardedHeadersOptions(ForwardedHeadersConfig config)
+ {
+ ArgumentNullException.ThrowIfNull(config);
+ var options = new ForwardedHeadersOptions
+ {
+ // Адрес клиента и схема (Origin-проверка «своего» origin за Caddy требует https из
+ // X-Forwarded-Proto). X-Forwarded-Host не нужен: Caddy транслирует Host без перезаписи.
+ ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto,
+ // Между клиентом и core ровно один прокси (Caddy): читаем ближайший к нам элемент цепочки.
+ ForwardLimit = 1,
+ };
+ // Доверие — только явный конфиг + loopback-фолбэк ниже: очищаем дефолты конструктора, чтобы
+ // перечисление в KnownProxies/KnownIPNetworks было единственным источником истины.
+ options.KnownProxies.Clear();
+ options.KnownIPNetworks.Clear();
+ foreach (string proxy in config.KnownProxies)
+ {
+ if (!IPAddress.TryParse(proxy, out IPAddress? address))
+ {
+ throw new InvalidOperationException(
+ $"Невалидный IP доверенного прокси в ForwardedHeaders:KnownProxies: \"{proxy}\".");
+ }
+
+ options.KnownProxies.Add(address);
+ }
+
+ foreach (string network in config.KnownNetworks)
+ {
+ if (!TryParseCidr(network, out System.Net.IPNetwork parsedNetwork))
+ {
+ throw new InvalidOperationException(
+ $"Невалидная подсеть доверенного прокси в ForwardedHeaders:KnownNetworks: \"{network}\" "
+ + "(ожидается CIDR, напр. 172.16.0.0/12).");
+ }
+
+ options.KnownIPNetworks.Add(parsedNetwork);
+ }
+
+ if (options.KnownProxies.Count == 0 && options.KnownIPNetworks.Count == 0)
+ {
+ // Пустые списки KnownProxies/KnownIPNetworks у ForwardedHeadersMiddleware означают «доверять
+ // ЛЮБОМУ клиенту» (спуфинг XFF) — пустоту до middleware не допускаем. Дефолт без явного
+ // конфига — loopback (dev-прокси на хосте: vite/локальный Caddy, см. appsettings); PROD
+ // перечисляет Caddy адресами/подсетями в конфиге.
+ options.KnownProxies.Add(IPAddress.Loopback);
+ options.KnownProxies.Add(IPAddress.IPv6Loopback);
+ }
+
+ return options;
+ }
+
+ // Разбирает CIDR-запись подсети ("127.0.0.0/8", "2001:db8::/32"); без префикса — вся
+ // подсеть одного адреса (IPv4 — /32, IPv6 — /128).
+ // value: Строка конфига (ForwardedHeaders:KnownNetworks).
+ // network: Разобранная подсеть при успехе.
+ // Возвращает: true — запись валидна.
+ private static bool TryParseCidr(string value, out System.Net.IPNetwork network)
+ {
+ network = default;
+ int slashIndex = value.IndexOf('/');
+ string addressPart = slashIndex >= 0 ? value[..slashIndex] : value;
+ if (!IPAddress.TryParse(addressPart, out IPAddress? address))
+ {
+ return false;
+ }
+
+ int maxPrefixLength = address.AddressFamily == AddressFamily.InterNetwork ? 32 : 128;
+ int prefixLength = maxPrefixLength;
+ if (slashIndex >= 0)
+ {
+ string lengthPart = value[(slashIndex + 1)..];
+ if (!int.TryParse(lengthPart, out prefixLength) || prefixLength < 0 || prefixLength > maxPrefixLength)
+ {
+ return false;
+ }
+ }
+
+ network = new System.Net.IPNetwork(address, prefixLength);
+ return true;
+ }
+}
diff --git a/src/core/Deal.Infrastructure/Integrations/Extensions/RpcExceptionExtensions.cs b/src/core/Deal.Infrastructure/Integrations/Extensions/RpcExceptionExtensions.cs
index 640e6cf..085deb0 100644
--- a/src/core/Deal.Infrastructure/Integrations/Extensions/RpcExceptionExtensions.cs
+++ b/src/core/Deal.Infrastructure/Integrations/Extensions/RpcExceptionExtensions.cs
@@ -1,17 +1,15 @@
-using Grpc.Core;
-
-namespace Deal.Infrastructure.Integrations.Extensions;
-
-///
-/// Расширения классификации gRPC-исключений клиентов автономных сервисов.
-///
-internal static class RpcExceptionExtensions
-{
- ///
- /// Ошибки коммуникации, при которых сервис считается недоступным
- ///
- /// Исключение RPC.
- /// True — транспорта/контракта health нет (down); false — прикладной статус (не наша зона).
- public static bool IsCommunicationFailure(this RpcException exception) =>
- exception.StatusCode is StatusCode.Unavailable or StatusCode.DeadlineExceeded or StatusCode.Unimplemented;
-}
+using Grpc.Core;
+
+namespace Deal.Infrastructure.Integrations.Extensions;
+
+// Расширения классификации gRPC-исключений клиентов автономных сервисов.
+internal static class RpcExceptionExtensions
+{
+ ///
+ /// Ошибки коммуникации, при которых сервис считается недоступным
+ ///
+ /// Исключение RPC.
+ /// True — транспорта/контракта health нет (down); false — прикладной статус (не наша зона).
+ public static bool IsCommunicationFailure(this RpcException exception) =>
+ exception.StatusCode is StatusCode.Unavailable or StatusCode.DeadlineExceeded or StatusCode.Unimplemented;
+}
diff --git a/src/core/Deal.Infrastructure/Integrations/Extensions/UriExtensions.cs b/src/core/Deal.Infrastructure/Integrations/Extensions/UriExtensions.cs
index d2d76a9..5a38a50 100644
--- a/src/core/Deal.Infrastructure/Integrations/Extensions/UriExtensions.cs
+++ b/src/core/Deal.Infrastructure/Integrations/Extensions/UriExtensions.cs
@@ -1,53 +1,51 @@
-using System.Net;
-using System.Net.Sockets;
-
-namespace Deal.Infrastructure.Integrations.Extensions;
-
-///
-/// Расширения для SSRF-гейта интеграций
-///
-internal static class UriExtensions
-{
- ///
- /// Проверяет, указывает ли URL на приватный/loopback/link-local адрес
- ///
- /// Абсолютный http(s)-адрес.
- /// True — адрес приватный/локальный (HTTP к нему запрещён).
- public static bool IsPrivateEndpoint(this Uri uri)
- {
- string host = uri.Host;
- if (string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase))
- {
- return true;
- }
-
- if (!IPAddress.TryParse(host, out IPAddress? address))
- {
- return false; // DNS-имя — резолв вне этого слоя
- }
-
- if (address.IsIPv4MappedToIPv6)
- {
- address = address.MapToIPv4();
- }
-
- if (IPAddress.IsLoopback(address))
- {
- return true;
- }
-
- if (address.AddressFamily == AddressFamily.InterNetwork)
- {
- byte[] bytes = address.GetAddressBytes();
- return bytes[0] == 10
- || (bytes[0] == 172 && bytes[1] is >= 16 and <= 31)
- || (bytes[0] == 192 && bytes[1] == 168)
- || 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();
- return (v6[0] & 0xFE) == 0xFC || (v6[0] == 0xFE && (v6[1] & 0xC0) == 0x80);
- }
-}
+using System.Net;
+using System.Net.Sockets;
+
+namespace Deal.Infrastructure.Integrations.Extensions;
+
+// Расширения Uri для SSRF-гейта интеграций
+internal static class UriExtensions
+{
+ ///
+ /// Проверяет, указывает ли URL на приватный/loopback/link-local адрес
+ ///
+ /// Абсолютный http(s)-адрес.
+ /// True — адрес приватный/локальный (HTTP к нему запрещён).
+ public static bool IsPrivateEndpoint(this Uri uri)
+ {
+ string host = uri.Host;
+ if (string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ if (!IPAddress.TryParse(host, out IPAddress? address))
+ {
+ return false; // DNS-имя — резолв вне этого слоя
+ }
+
+ if (address.IsIPv4MappedToIPv6)
+ {
+ address = address.MapToIPv4();
+ }
+
+ if (IPAddress.IsLoopback(address))
+ {
+ return true;
+ }
+
+ if (address.AddressFamily == AddressFamily.InterNetwork)
+ {
+ byte[] bytes = address.GetAddressBytes();
+ return bytes[0] == 10
+ || (bytes[0] == 172 && bytes[1] is >= 16 and <= 31)
+ || (bytes[0] == 192 && bytes[1] == 168)
+ || 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();
+ return (v6[0] & 0xFE) == 0xFC || (v6[0] == 0xFE && (v6[1] & 0xC0) == 0x80);
+ }
+}
diff --git a/src/core/Deal.Infrastructure/Integrations/Storage/Extensions/MinioStorageOptionsExtensions.cs b/src/core/Deal.Infrastructure/Integrations/Storage/Extensions/MinioStorageOptionsExtensions.cs
index d394f73..29415b0 100644
--- a/src/core/Deal.Infrastructure/Integrations/Storage/Extensions/MinioStorageOptionsExtensions.cs
+++ b/src/core/Deal.Infrastructure/Integrations/Storage/Extensions/MinioStorageOptionsExtensions.cs
@@ -1,21 +1,19 @@
-using Deal.Infrastructure.Integrations.Storage.Options;
-
-namespace Deal.Infrastructure.Integrations.Storage.Extensions;
-
-///
-/// Расширения
-///
-internal static class MinioStorageOptionsExtensions
-{
- ///
- /// True — секция Minio заполнена настолько, что возможен Minio-адаптер.
- ///
- /// Настройки MinIO из секции Storage:Minio.
- /// True — заданы Endpoint, AccessKey и SecretKey.
- public static bool IsConfigured(this MinioStorageOptions minio)
- {
- return !string.IsNullOrWhiteSpace(minio.Endpoint)
- && !string.IsNullOrWhiteSpace(minio.AccessKey)
- && !string.IsNullOrWhiteSpace(minio.SecretKey);
- }
-}
+using Deal.Infrastructure.Integrations.Storage.Options;
+
+namespace Deal.Infrastructure.Integrations.Storage.Extensions;
+
+// Расширения MinioStorageOptions
+internal static class MinioStorageOptionsExtensions
+{
+ ///
+ /// True — секция Minio заполнена настолько, что возможен Minio-адаптер.
+ ///
+ /// Настройки MinIO из секции Storage:Minio.
+ /// True — заданы Endpoint, AccessKey и SecretKey.
+ public static bool IsConfigured(this MinioStorageOptions minio)
+ {
+ return !string.IsNullOrWhiteSpace(minio.Endpoint)
+ && !string.IsNullOrWhiteSpace(minio.AccessKey)
+ && !string.IsNullOrWhiteSpace(minio.SecretKey);
+ }
+}
diff --git a/src/core/Deal.Infrastructure/Integrations/Storage/Extensions/StringExtensions.cs b/src/core/Deal.Infrastructure/Integrations/Storage/Extensions/StringExtensions.cs
index 3d988a1..f35320a 100644
--- a/src/core/Deal.Infrastructure/Integrations/Storage/Extensions/StringExtensions.cs
+++ b/src/core/Deal.Infrastructure/Integrations/Storage/Extensions/StringExtensions.cs
@@ -1,17 +1,15 @@
-namespace Deal.Infrastructure.Integrations.Storage.Extensions;
-
-///
-/// Расширения для разбора конфигурационных значений.
-///
-internal static class StringExtensions
-{
- ///
- /// Разбирает строковое значение как булев флаг конфигурации
- ///
- /// Сырое значение настройки.
- /// True — значение распознано как включённое.
- public static bool IsTrue(this string raw)
- {
- return string.Equals(raw, "true", StringComparison.OrdinalIgnoreCase) || raw == "1";
- }
-}
+namespace Deal.Infrastructure.Integrations.Storage.Extensions;
+
+// Расширения string для разбора конфигурационных значений.
+internal static class StringExtensions
+{
+ ///
+ /// Разбирает строковое значение как булев флаг конфигурации
+ ///
+ /// Сырое значение настройки.
+ /// True — значение распознано как включённое.
+ public static bool IsTrue(this string raw)
+ {
+ return string.Equals(raw, "true", StringComparison.OrdinalIgnoreCase) || raw == "1";
+ }
+}
diff --git a/src/core/Deal.Infrastructure/Migrations/TenantSchemaMigrator.cs b/src/core/Deal.Infrastructure/Migrations/TenantSchemaMigrator.cs
index f557430..7f63505 100644
--- a/src/core/Deal.Infrastructure/Migrations/TenantSchemaMigrator.cs
+++ b/src/core/Deal.Infrastructure/Migrations/TenantSchemaMigrator.cs
@@ -10,7 +10,7 @@ public static class TenantSchemaMigrator
///
public static string CreateSchemaSql(string schemaName)
{
- var escaped = schemaName.Replace("\"", "\"\"");
+ string escaped = schemaName.Replace("\"", "\"\"");
return $"CREATE SCHEMA IF NOT EXISTS \"{escaped}\"";
}
diff --git a/src/core/Deal.Infrastructure/Persistence/DealDbDesignTimeFactory.cs b/src/core/Deal.Infrastructure/Persistence/DealDbDesignTimeFactory.cs
index 0f7895c..9a07fd2 100644
--- a/src/core/Deal.Infrastructure/Persistence/DealDbDesignTimeFactory.cs
+++ b/src/core/Deal.Infrastructure/Persistence/DealDbDesignTimeFactory.cs
@@ -1,20 +1,20 @@
-using Microsoft.EntityFrameworkCore;
-using Microsoft.EntityFrameworkCore.Design;
-
-namespace Deal.Infrastructure.Persistence;
-
-///
-/// Фабрика для dotnet-ef
-///
-public sealed class DealDbDesignTimeFactory : IDesignTimeDbContextFactory
-{
- public DealDbContext CreateDbContext(string[] args)
- {
- var connectionString = Environment.GetEnvironmentVariable("DEAL_PG_CONNECTION")
- ?? "Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password";
- var options = new DbContextOptionsBuilder()
- .UseNpgsql(connectionString)
- .Options;
- return new DealDbContext(options);
- }
-}
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Design;
+
+namespace Deal.Infrastructure.Persistence;
+
+///
+/// Фабрика для dotnet-ef
+///
+public sealed class DealDbDesignTimeFactory : IDesignTimeDbContextFactory
+{
+ public DealDbContext CreateDbContext(string[] args)
+ {
+ string connectionString = Environment.GetEnvironmentVariable("DEAL_PG_CONNECTION")
+ ?? "Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password";
+ var options = new DbContextOptionsBuilder()
+ .UseNpgsql(connectionString)
+ .Options;
+ return new DealDbContext(options);
+ }
+}
diff --git a/src/core/Deal.Infrastructure/Persistence/TenantDbDesignTimeFactory.cs b/src/core/Deal.Infrastructure/Persistence/TenantDbDesignTimeFactory.cs
index 3476857..3a7382c 100644
--- a/src/core/Deal.Infrastructure/Persistence/TenantDbDesignTimeFactory.cs
+++ b/src/core/Deal.Infrastructure/Persistence/TenantDbDesignTimeFactory.cs
@@ -1,20 +1,20 @@
-using Microsoft.EntityFrameworkCore;
-using Microsoft.EntityFrameworkCore.Design;
-
-namespace Deal.Infrastructure.Persistence;
-
-///
-/// Фабрика для dotnet-ef
-///
-public sealed class TenantDbDesignTimeFactory : IDesignTimeDbContextFactory
-{
- public TenantDbContext CreateDbContext(string[] args)
- {
- var connectionString = Environment.GetEnvironmentVariable("DEAL_PG_CONNECTION")
- ?? "Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password";
- var options = new DbContextOptionsBuilder()
- .UseNpgsql(connectionString, npgsql => npgsql.MigrationsHistoryTable("__TenantMigrationsHistory"))
- .Options;
- return new TenantDbContext(options);
- }
-}
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Design;
+
+namespace Deal.Infrastructure.Persistence;
+
+///
+/// Фабрика для dotnet-ef
+///
+public sealed class TenantDbDesignTimeFactory : IDesignTimeDbContextFactory
+{
+ public TenantDbContext CreateDbContext(string[] args)
+ {
+ string connectionString = Environment.GetEnvironmentVariable("DEAL_PG_CONNECTION")
+ ?? "Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password";
+ var options = new DbContextOptionsBuilder()
+ .UseNpgsql(connectionString, npgsql => npgsql.MigrationsHistoryTable("__TenantMigrationsHistory"))
+ .Options;
+ return new TenantDbContext(options);
+ }
+}
diff --git a/src/core/Deal.Infrastructure/Tenancy/TenantProvisioningService.cs b/src/core/Deal.Infrastructure/Tenancy/TenantProvisioningService.cs
index 3d6e1b3..8a677a9 100644
--- a/src/core/Deal.Infrastructure/Tenancy/TenantProvisioningService.cs
+++ b/src/core/Deal.Infrastructure/Tenancy/TenantProvisioningService.cs
@@ -1,75 +1,75 @@
-using System.Collections.Concurrent;
-using Deal.Infrastructure.Data;
-using Deal.Infrastructure.Migrations;
-using Deal.Infrastructure.Persistence;
-using Deal.Modules.Tenants.Application.Abstractions;
-using Deal.SharedKernel.Tenants.Models;
-using Microsoft.EntityFrameworkCore;
-using Npgsql;
-
-namespace Deal.Infrastructure.Tenancy;
-
-///
-/// Провижининг схемы тенанта
-///
-public sealed class TenantProvisioningService(ConnectionStringProvider connectionStringProvider) : ITenantProvisioner
-{
- private const string TenantMigrationsHistoryTable = "__TenantMigrationsHistory";
-
- // Семафор на схему тенанта: сериализует провижининг одного тенанта при параллельных вызовах.
- // Сами операции идемпотентны, но создание схемы обязано предшествовать применению миграций,
- // а два одновременных Migrate одной схемы — источник гонки. Статический: живёт дольше scoped-сервиса.
- private static readonly ConcurrentDictionary SchemaProvisionLocks = new();
-
- ///
- public async Task ProvisionAsync(TenantId tenantId, CancellationToken ct)
- {
- var schemaName = tenantId.SchemaName;
- var provisionLock = SchemaProvisionLocks.GetOrAdd(schemaName, static _ => new SemaphoreSlim(1, 1));
- await provisionLock.WaitAsync(ct);
- try
- {
- await CreateSchemaAsync(schemaName, ct);
- await ApplyTenantMigrationsAsync(tenantId, schemaName, ct);
- }
- finally
- {
- provisionLock.Release();
- }
- }
-
- // Создаёт схему тенанта в БД, если её ещё нет (CREATE SCHEMA IF NOT EXISTS).
- // schemaName: Имя схемы (DDL-идентификатор экранирует TenantSchemaMigrator).
- // ct: Токен отмены.
- private async Task CreateSchemaAsync(string schemaName, CancellationToken ct)
- {
- // Соединение закрывается обязательно (await using). DDL (CREATE SCHEMA) выполняется мигратор-строкой,
- // если задана ConnectionStrings:DealMigrator (прод, least privilege); иначе — прикладной (dev).
- await using var connection = new NpgsqlConnection(connectionStringProvider.ForSchemaDdl(null));
- await connection.OpenAsync(ct);
- await using var command = connection.CreateCommand();
- command.CommandText = TenantSchemaMigrator.CreateSchemaSql(schemaName);
- await command.ExecuteNonQueryAsync(ct);
- }
-
- // Применяет tenant-миграции: контекст на строке с search_path и историей миграций в схеме тенанта.
- // tenantId: Идентификатор тенанта.
- // schemaName: Имя схемы тенанта (для MigrationsHistoryTable).
- // ct: Токен отмены.
- private async Task ApplyTenantMigrationsAsync(
- TenantId tenantId,
- string schemaName,
- CancellationToken ct)
- {
- // Строка уже с Search Path=tenant_<id> (ForSchemaDdl) — таблицы бессхемной модели TenantDbContext
- // лягут в схему тенанта. DDL применяется мигратор-ролью при её наличии (см. ConnectionStringProvider).
- var connectionString = connectionStringProvider.ForSchemaDdl(tenantId);
- var options = new DbContextOptionsBuilder()
- .UseNpgsql(connectionString, npgsql => npgsql.MigrationsHistoryTable(TenantMigrationsHistoryTable, schemaName))
- .Options;
- await using var db = new TenantDbContext(options);
- await db.Database.MigrateAsync(ct);
-
- await DefaultContainerProvisioner.EnsureAsync(db, DateTimeOffset.UtcNow, ct);
- }
-}
+using System.Collections.Concurrent;
+using Deal.Infrastructure.Data;
+using Deal.Infrastructure.Migrations;
+using Deal.Infrastructure.Persistence;
+using Deal.Modules.Tenants.Application.Abstractions;
+using Deal.SharedKernel.Tenants.Models;
+using Microsoft.EntityFrameworkCore;
+using Npgsql;
+
+namespace Deal.Infrastructure.Tenancy;
+
+///
+/// Провижининг схемы тенанта
+///
+public sealed class TenantProvisioningService(ConnectionStringProvider connectionStringProvider) : ITenantProvisioner
+{
+ private const string TenantMigrationsHistoryTable = "__TenantMigrationsHistory";
+
+ // Семафор на схему тенанта: сериализует провижининг одного тенанта при параллельных вызовах.
+ // Сами операции идемпотентны, но создание схемы обязано предшествовать применению миграций,
+ // а два одновременных Migrate одной схемы — источник гонки. Статический: живёт дольше scoped-сервиса.
+ private static readonly ConcurrentDictionary SchemaProvisionLocks = new();
+
+ ///
+ public async Task ProvisionAsync(TenantId tenantId, CancellationToken ct)
+ {
+ string schemaName = tenantId.SchemaName;
+ var provisionLock = SchemaProvisionLocks.GetOrAdd(schemaName, static _ => new SemaphoreSlim(1, 1));
+ await provisionLock.WaitAsync(ct);
+ try
+ {
+ await CreateSchemaAsync(schemaName, ct);
+ await ApplyTenantMigrationsAsync(tenantId, schemaName, ct);
+ }
+ finally
+ {
+ provisionLock.Release();
+ }
+ }
+
+ // Создаёт схему тенанта в БД, если её ещё нет (CREATE SCHEMA IF NOT EXISTS).
+ // schemaName: Имя схемы (DDL-идентификатор экранирует TenantSchemaMigrator).
+ // ct: Токен отмены.
+ private async Task CreateSchemaAsync(string schemaName, CancellationToken ct)
+ {
+ // Соединение закрывается обязательно (await using). DDL (CREATE SCHEMA) выполняется мигратор-строкой,
+ // если задана ConnectionStrings:DealMigrator (прод, least privilege); иначе — прикладной (dev).
+ await using var connection = new NpgsqlConnection(connectionStringProvider.ForSchemaDdl(null));
+ await connection.OpenAsync(ct);
+ await using var command = connection.CreateCommand();
+ command.CommandText = TenantSchemaMigrator.CreateSchemaSql(schemaName);
+ await command.ExecuteNonQueryAsync(ct);
+ }
+
+ // Применяет tenant-миграции: контекст на строке с search_path и историей миграций в схеме тенанта.
+ // tenantId: Идентификатор тенанта.
+ // schemaName: Имя схемы тенанта (для MigrationsHistoryTable).
+ // ct: Токен отмены.
+ private async Task ApplyTenantMigrationsAsync(
+ TenantId tenantId,
+ string schemaName,
+ CancellationToken ct)
+ {
+ // Строка уже с Search Path=tenant_<id> (ForSchemaDdl) — таблицы бессхемной модели TenantDbContext
+ // лягут в схему тенанта. DDL применяется мигратор-ролью при её наличии (см. ConnectionStringProvider).
+ string connectionString = connectionStringProvider.ForSchemaDdl(tenantId);
+ var options = new DbContextOptionsBuilder()
+ .UseNpgsql(connectionString, npgsql => npgsql.MigrationsHistoryTable(TenantMigrationsHistoryTable, schemaName))
+ .Options;
+ await using var db = new TenantDbContext(options);
+ await db.Database.MigrateAsync(ct);
+
+ await DefaultContainerProvisioner.EnsureAsync(db, DateTimeOffset.UtcNow, ct);
+ }
+}
diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/IContainerRules.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/IContainerRules.cs
index 30a914d..b35244d 100644
--- a/src/core/Deal.Modules.Cards/Application/Abstractions/IContainerRules.cs
+++ b/src/core/Deal.Modules.Cards/Application/Abstractions/IContainerRules.cs
@@ -12,8 +12,14 @@ public interface IContainerRules
///
public string Mode { get; }
+ ///
+ /// Ключевые слова
+ ///
public IReadOnlyList Keywords { get; }
+ ///
+ /// Стек технологий
+ ///
public IReadOnlyList Stack { get; }
///
diff --git a/src/core/Deal.Modules.Kanban/Application/ColumnRules/BudgetRangeDtoExtensions.cs b/src/core/Deal.Modules.Kanban/Application/ColumnRules/BudgetRangeDtoExtensions.cs
index 28a279a..455d4c3 100644
--- a/src/core/Deal.Modules.Kanban/Application/ColumnRules/BudgetRangeDtoExtensions.cs
+++ b/src/core/Deal.Modules.Kanban/Application/ColumnRules/BudgetRangeDtoExtensions.cs
@@ -1,20 +1,18 @@
-using Deal.Modules.Kanban.Application.Models;
-
-namespace Deal.Modules.Kanban.Application.ColumnRules;
-
-///
-/// Расширения бюджетной группы правил колонки.
-///
-internal static class BudgetRangeDtoExtensions
-{
- ///
- /// Активна ли бюджетная группа
- ///
- /// Поле budget/prices правил (может быть null).
- /// True — группа бюджета участвует в матчинге.
- 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));
- }
-}
+using Deal.Modules.Kanban.Application.Models;
+
+namespace Deal.Modules.Kanban.Application.ColumnRules;
+
+// Расширения бюджетной группы правил колонки.
+internal static class BudgetRangeDtoExtensions
+{
+ ///
+ /// Активна ли бюджетная группа
+ ///
+ /// Поле budget/prices правил (может быть null).
+ /// True — группа бюджета участвует в матчинге.
+ 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));
+ }
+}
diff --git a/src/core/Deal.Modules.Kanban/Application/ColumnRules/TermListExtensions.cs b/src/core/Deal.Modules.Kanban/Application/ColumnRules/TermListExtensions.cs
index b53ec76..20f5c74 100644
--- a/src/core/Deal.Modules.Kanban/Application/ColumnRules/TermListExtensions.cs
+++ b/src/core/Deal.Modules.Kanban/Application/ColumnRules/TermListExtensions.cs
@@ -1,30 +1,28 @@
-namespace Deal.Modules.Kanban.Application.ColumnRules;
-
-///
-/// Расширения списков термов правил колонки.
-///
-internal static class TermListExtensions
-{
- ///
- /// Есть ли в списке непустой
- ///
- /// Список термов группы (может быть null).
- /// True — хотя бы один терм непустой.
- public static bool HasAnyTerm(this IReadOnlyList? terms)
- {
- if (terms is null)
- {
- return false;
- }
-
- foreach (string? raw in terms)
- {
- if (!string.IsNullOrWhiteSpace(raw))
- {
- return true;
- }
- }
-
- return false;
- }
-}
+namespace Deal.Modules.Kanban.Application.ColumnRules;
+
+// Расширения списков термов правил колонки.
+internal static class TermListExtensions
+{
+ ///
+ /// Есть ли в списке непустой
+ ///
+ /// Список термов группы (может быть null).
+ /// True — хотя бы один терм непустой.
+ public static bool HasAnyTerm(this IReadOnlyList? terms)
+ {
+ if (terms is null)
+ {
+ return false;
+ }
+
+ foreach (string? raw in terms)
+ {
+ if (!string.IsNullOrWhiteSpace(raw))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/src/core/Deal.Modules.Kanban/Application/Extensions/CharExtensions.cs b/src/core/Deal.Modules.Kanban/Application/Extensions/CharExtensions.cs
index c0807bb..8324e9d 100644
--- a/src/core/Deal.Modules.Kanban/Application/Extensions/CharExtensions.cs
+++ b/src/core/Deal.Modules.Kanban/Application/Extensions/CharExtensions.cs
@@ -1,15 +1,13 @@
-namespace Deal.Modules.Kanban.Application.Extensions;
-
-///
-/// Расширения символов для нормализации бюджетной валюты.
-///
-internal static class CharExtensions
-{
- ///
- /// Буква кода валюты
- ///
- /// Символ (строка уже в верхнем регистре).
- /// True — буква, участвующая в распознавании валюты.
- public static bool IsCurrencyLetter(this char c) =>
- c is >= 'A' and <= 'Z' or >= 'А' and <= 'Я';
-}
+namespace Deal.Modules.Kanban.Application.Extensions;
+
+// Расширения символов для нормализации бюджетной валюты.
+internal static class CharExtensions
+{
+ ///
+ /// Буква кода валюты
+ ///
+ /// Символ (строка уже в верхнем регистре).
+ /// True — буква, участвующая в распознавании валюты.
+ public static bool IsCurrencyLetter(this char c) =>
+ c is >= 'A' and <= 'Z' or >= 'А' and <= 'Я';
+}
diff --git a/src/core/Deal.Modules.Pipeline/Application/Parse/CodePointExtensions.cs b/src/core/Deal.Modules.Pipeline/Application/Parse/CodePointExtensions.cs
index fb6dfd5..9f1580b 100644
--- a/src/core/Deal.Modules.Pipeline/Application/Parse/CodePointExtensions.cs
+++ b/src/core/Deal.Modules.Pipeline/Application/Parse/CodePointExtensions.cs
@@ -1,18 +1,16 @@
-namespace Deal.Modules.Pipeline.Application.Parse;
-
-///
-/// Расширения кодовых точек для чистки текста сообщений.
-///
-internal static class CodePointExtensions
-{
- ///
- /// Входит ли кодовая точка в эмодзи-диапазоны.
- ///
- /// Кодовая точка (BMP или доп. плоскость).
- /// True — декоративный символ, подлежащий удалению.
- public static bool IsEmojiCodePoint(this int codePoint) =>
- codePoint is >= 0x1F000 and <= 0x1FAFF
- or >= 0x2600 and <= 0x27BF
- or >= 0x2B00 and <= 0x2BFF
- or 0xFE0F;
-}
+namespace Deal.Modules.Pipeline.Application.Parse;
+
+// Расширения кодовых точек для чистки текста сообщений.
+internal static class CodePointExtensions
+{
+ ///
+ /// Входит ли кодовая точка в эмодзи-диапазоны.
+ ///
+ /// Кодовая точка (BMP или доп. плоскость).
+ /// True — декоративный символ, подлежащий удалению.
+ public static bool IsEmojiCodePoint(this int codePoint) =>
+ codePoint is >= 0x1F000 and <= 0x1FAFF
+ or >= 0x2600 and <= 0x27BF
+ or >= 0x2B00 and <= 0x2BFF
+ or 0xFE0F;
+}
diff --git a/src/core/Deal.Modules.Pipeline/Application/Parse/StringExtensions.cs b/src/core/Deal.Modules.Pipeline/Application/Parse/StringExtensions.cs
index 9d0b237..4112fc9 100644
--- a/src/core/Deal.Modules.Pipeline/Application/Parse/StringExtensions.cs
+++ b/src/core/Deal.Modules.Pipeline/Application/Parse/StringExtensions.cs
@@ -1,32 +1,30 @@
-namespace Deal.Modules.Pipeline.Application.Parse;
-
-///
-/// Расширения строк для разбора текста сообщений.
-///
-internal static class StringExtensions
-{
- private static readonly string[] FooterHintsArray =
- {
- "откликнуться через", "runello", "больше вакансий", "teletype", "при отклике укажите",
- "больше заявок", "узнать подробнее", "написать в лс", "пишите в лс",
- };
-
- ///
- /// Содержит ли текст служебный футер-хинт.
- ///
- /// Текст (в любом регистре; null трактуется как пустая строка).
- /// True — текст похож на футер агрегатора/служебную строку.
- public static bool ContainsFooterHint(this string text)
- {
- string lower = text.ToLowerInvariant();
- foreach (string hint in FooterHintsArray)
- {
- if (lower.Contains(hint, StringComparison.Ordinal))
- {
- return true;
- }
- }
-
- return false;
- }
-}
+namespace Deal.Modules.Pipeline.Application.Parse;
+
+// Расширения строк для разбора текста сообщений.
+internal static class StringExtensions
+{
+ private static readonly string[] FooterHintsArray =
+ {
+ "откликнуться через", "runello", "больше вакансий", "teletype", "при отклике укажите",
+ "больше заявок", "узнать подробнее", "написать в лс", "пишите в лс",
+ };
+
+ ///
+ /// Содержит ли текст служебный футер-хинт.
+ ///
+ /// Текст (в любом регистре; null трактуется как пустая строка).
+ /// True — текст похож на футер агрегатора/служебную строку.
+ public static bool ContainsFooterHint(this string text)
+ {
+ string lower = text.ToLowerInvariant();
+ foreach (string hint in FooterHintsArray)
+ {
+ if (lower.Contains(hint, StringComparison.Ordinal))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/src/core/Deal.Modules.Settings/Application/Models/SettingsKeys.cs b/src/core/Deal.Modules.Settings/Application/Models/SettingsKeys.cs
index f695d0f..35ca284 100644
--- a/src/core/Deal.Modules.Settings/Application/Models/SettingsKeys.cs
+++ b/src/core/Deal.Modules.Settings/Application/Models/SettingsKeys.cs
@@ -141,7 +141,7 @@ public static class SettingsKeys
public const string ExcludeTypes = "excludeTypes";
- // ── Dict / special ──
+ // ── Словари / особые ──
public const string ColState = "colState";
diff --git a/src/core/Deal.Modules.Tenants/Application/Extensions/AuditRecordDtoExtensions.cs b/src/core/Deal.Modules.Tenants/Application/Extensions/AuditRecordDtoExtensions.cs
index 125359c..04e8434 100644
--- a/src/core/Deal.Modules.Tenants/Application/Extensions/AuditRecordDtoExtensions.cs
+++ b/src/core/Deal.Modules.Tenants/Application/Extensions/AuditRecordDtoExtensions.cs
@@ -1,35 +1,33 @@
-namespace Deal.Modules.Tenants.Application.Models;
-
-///
-/// Расширения записей аудита
-///
-internal static class AuditRecordDtoExtensions
-{
- // События аудита «неудачный вход» (тенант/оператор).
- private static readonly string[] FailedLoginEvents =
- {
- AuditEvents.TenantLoginFailed,
- AuditEvents.OperatorLoginFailed,
- };
-
- // События аудита «успешный вход» (тенант/оператор).
- private static readonly string[] SuccessfulLoginEvents =
- {
- AuditEvents.TenantLoginOk,
- AuditEvents.OperatorLoginOk,
- };
-
- ///
- /// Неудачный вход
- ///
- /// Запись аудита.
- /// True — событие из FailedLoginEvents.
- public static bool IsFailedLogin(this AuditRecordDto record) => FailedLoginEvents.Contains(record.EventType);
-
- ///
- /// Успешный вход
- ///
- /// Запись аудита.
- /// True — событие из SuccessfulLoginEvents.
- public static bool IsSuccessfulLogin(this AuditRecordDto record) => SuccessfulLoginEvents.Contains(record.EventType);
-}
+namespace Deal.Modules.Tenants.Application.Models;
+
+// Расширения записей аудита
+internal static class AuditRecordDtoExtensions
+{
+ // События аудита «неудачный вход» (тенант/оператор).
+ private static readonly string[] FailedLoginEvents =
+ {
+ AuditEvents.TenantLoginFailed,
+ AuditEvents.OperatorLoginFailed,
+ };
+
+ // События аудита «успешный вход» (тенант/оператор).
+ private static readonly string[] SuccessfulLoginEvents =
+ {
+ AuditEvents.TenantLoginOk,
+ AuditEvents.OperatorLoginOk,
+ };
+
+ ///
+ /// Неудачный вход
+ ///
+ /// Запись аудита.
+ /// True — событие из FailedLoginEvents.
+ public static bool IsFailedLogin(this AuditRecordDto record) => FailedLoginEvents.Contains(record.EventType);
+
+ ///
+ /// Успешный вход
+ ///
+ /// Запись аудита.
+ /// True — событие из SuccessfulLoginEvents.
+ public static bool IsSuccessfulLogin(this AuditRecordDto record) => SuccessfulLoginEvents.Contains(record.EventType);
+}
diff --git a/src/core/Deal.Modules.Tenants/Application/Services/AuthService.cs b/src/core/Deal.Modules.Tenants/Application/Services/AuthService.cs
index 413e6c7..1703be3 100644
--- a/src/core/Deal.Modules.Tenants/Application/Services/AuthService.cs
+++ b/src/core/Deal.Modules.Tenants/Application/Services/AuthService.cs
@@ -1,269 +1,269 @@
-using Deal.Modules.Tenants.Application.Abstractions;
-using Deal.Modules.Tenants.Application.Models;
-
-namespace Deal.Modules.Tenants.Application.Services;
-
-///
-/// Прикладной сервис аутентификации
-///
-public sealed class AuthService(
- IAuthStore authStore,
- IPasswordHasher passwordHasher,
- ITenantRepository tenantRepository)
-{
- ///
- /// Срок жизни сессии, дней.
- ///
- public const int SessionLifetimeDays = 30;
-
- ///
- /// Минимальная длина нового пароля.
- ///
- public const int MinNewPasswordLength = 8;
-
- // Статус «активен»: только активного пользователя разрешает ResolveSessionAsync (деактивированный
- // вручную с живой сессией — null, как у оператора OperatorAuthService).
- private const string UserActiveStatus = "active";
-
- ///
- /// Вход: при успехе создаёт сессию и возвращает её raw-токен.
- ///
- /// Логин (регистр и пробелы не важны — нормализуется).
- /// Пароль в открытом виде.
- /// При успехе — Login и Token (UserId/TenantId для аудита). Иначе Login/Token null: Error может отличать заблокированный вход приостановленного тенанта (, UserId/TenantId заполнены) от «неверные учётные данные» (Error null, UserId/TenantId пусты).
- public async Task LoginAsync(
- string login,
- string password,
- CancellationToken ct)
- {
- var normalizedLogin = NormalizeLogin(login);
- if (string.IsNullOrEmpty(normalizedLogin) || string.IsNullOrEmpty(password))
- {
- return new LoginResultDto(null, null);
- }
-
- var user = await authStore.FindUserByLoginAsync(normalizedLogin, ct);
- // Порядок проверок: сначала учётные данные, затем статус тенанта — неверный пароль не раскрывает
- // приостановку (тот же «Неверный логин или пароль», что и для активного тенанта).
- if (user is null || !passwordHasher.Verify(password, user.PasswordHash))
- {
- return new LoginResultDto(null, null);
- }
-
- var tenant = await tenantRepository.FindByIdAsync(user.TenantId, ct);
- if (tenant is not null && tenant.Status == TenantStatuses.Suspended)
- {
- return new LoginResultDto(
- Login: null,
- Token: null,
- UserId: user.Id,
- TenantId: user.TenantId,
- Error: LoginResultDto.ErrorTenantSuspended);
- }
-
- var token = await CreateSessionForUserAsync(user.Id, user.Login, impersonatedByOperatorId: null, ct);
- return new LoginResultDto(user.Login, token, user.Id, user.TenantId);
- }
-
- ///
- /// Выход: удаляет сессию по raw-токену
- ///
- /// Raw-токен из куки (может отсутствовать — no-op).
- /// Не null, если удалённая сессия была impersonation — сведения для аудита impersonation_stopped (актор — оператор по маркеру сессии). Обычный logout и no-op возвращают null.
- public async Task LogoutAsync(string? rawToken, CancellationToken ct)
- {
- if (string.IsNullOrWhiteSpace(rawToken))
- {
- return null;
- }
-
- var tokenHash = SessionTokens.HashToken(rawToken);
- var session = await authStore.FindSessionByTokenHashAsync(tokenHash, ct);
- await authStore.DeleteSessionAsync(tokenHash, ct);
-
- if (session is null || session.ImpersonatedByOperatorId is null)
- {
- return null;
- }
-
- // Пользователь не удаляется при живой сессии (FK sessions→users Cascade, SessionConfiguration):
- // TenantId берём из реестра пользователей для поля-фильтра аудита.
- var user = await authStore.FindUserByIdAsync(session.UserId, ct);
- return user is null
- ? null
- : new LogoutResultDto(
- OperatorId: session.ImpersonatedByOperatorId.Value,
- Login: session.Login,
- UserId: user.Id,
- TenantId: user.TenantId);
- }
-
- ///
- /// Смена пароля: инвалидирует все сессии, обновляет хэш и выдаёт свежую сессию.
- ///
- /// Логин пользователя.
- /// Текущий пароль.
- /// Новый пароль (минимум 8 символов).
- /// При успехе — Ok=true и NewToken (raw-токен свежей сессии). Иначе Ok=false и код ошибки: или .
- public async Task ChangePasswordAsync(
- string login,
- string oldPassword,
- string newPassword,
- CancellationToken ct)
- {
- var normalizedLogin = NormalizeLogin(login);
- var user = await authStore.FindUserByLoginAsync(normalizedLogin, ct);
- if (user is null || oldPassword is null || !passwordHasher.Verify(oldPassword, user.PasswordHash))
- {
- return new ChangePasswordResultDto(Ok: false, Error: ChangePasswordResultDto.ErrorOldPassword, NewToken: null);
- }
-
- if (string.IsNullOrEmpty(newPassword) || newPassword.Length < MinNewPasswordLength)
- {
- return new ChangePasswordResultDto(Ok: false, Error: ChangePasswordResultDto.ErrorTooShort, NewToken: null);
- }
-
- await authStore.DeleteSessionsByUserIdAsync(user.Id, ct);
- await authStore.UpdatePasswordHashAsync(user.Id, passwordHasher.Hash(newPassword), ct);
- var token = await CreateSessionForUserAsync(user.Id, user.Login, impersonatedByOperatorId: null, ct);
- return new ChangePasswordResultDto(Ok: true, Error: null, NewToken: token);
- }
-
- ///
- /// Impersonation: tenant-сессия целевого пользователя от имени оператора.
- ///
- /// Идентификатор тенанта.
- /// Логин пользователя (null/пустой — первый пользователь тенанта).
- /// Идентификатор оператора, начинающего impersonation (маркер сессии).
- /// Результат: Ok=true — SessionToken (raw-токен для куки deal_session) и срок жизни; иначе код ошибки.
- public async Task ImpersonateAsync(
- Guid tenantId,
- string? targetLogin,
- Guid operatorId,
- CancellationToken ct)
- {
- var tenant = await tenantRepository.FindByIdAsync(tenantId, ct);
- if (tenant is null)
- {
- return ImpersonationResultDto.Failed(ImpersonationResultDto.ErrorTenantNotFound);
- }
-
- var normalizedLogin = NormalizeLogin(targetLogin);
- if (string.IsNullOrEmpty(normalizedLogin))
- {
- // login не задан — первый пользователь тенанта (по CreatedAt, порядок ListUsersByTenantIdAsync).
- var tenantUsers = await authStore.ListUsersByTenantIdAsync(tenantId, ct);
- if (tenantUsers.Count == 0)
- {
- return ImpersonationResultDto.Failed(ImpersonationResultDto.ErrorTenantHasNoUsers);
- }
-
- UserIdentityDto firstUser = tenantUsers[0];
- var rawTokenForFirst = await CreateSessionForUserAsync(firstUser.Id, firstUser.Login, operatorId, ct);
- return Success(rawTokenForFirst, firstUser.Id, firstUser.Login, firstUser.TenantId);
- }
-
- var user = await authStore.FindUserByLoginAsync(normalizedLogin, ct);
- if (user is null || user.TenantId != tenantId)
- {
- // Пользователь не найден или принадлежит другому тенанту — не раскрываем существование логина.
- return ImpersonationResultDto.Failed(ImpersonationResultDto.ErrorUserNotFound);
- }
-
- var rawToken = await CreateSessionForUserAsync(user.Id, user.Login, operatorId, ct);
- return Success(rawToken, user.Id, user.Login, user.TenantId);
- }
-
- ///
- /// Разрешение сессии по raw-токену
- ///
- /// Raw-токен из куки.
- /// Идентичность пользователя или null.
- public async Task ResolveSessionAsync(string? rawToken, CancellationToken ct)
- {
- if (string.IsNullOrWhiteSpace(rawToken))
- {
- return null;
- }
-
- var tokenHash = SessionTokens.HashToken(rawToken);
- var session = await authStore.FindSessionByTokenHashAsync(tokenHash, ct);
- UserIdentityDto? user = null;
- if (session is not null && session.ExpiresAt > DateTimeOffset.UtcNow)
- {
- user = await authStore.FindUserByIdAsync(session.UserId, ct);
- // Деактивированный пользователь при живой сессии не получает доступ (Security review):
- // сессия разрешается только для активной учётки (как операторская в OperatorAuthService).
- if (user is not null && user.Status != UserActiveStatus)
- {
- user = null;
- }
-
- if (user is not null)
- {
- var tenant = await tenantRepository.FindByIdAsync(user.TenantId, ct);
- if (tenant is not null && tenant.Status == TenantStatuses.Suspended)
- {
- user = null;
- }
- }
- }
- else if (session is not null)
- {
- // Сессия протухла: глобальная очистка протухших сессий выполняется ТОЛЬКО при обнаружении
- // протухшей (редкий случай), а не на каждом разрешении сессии (hot-path, Security review).
- await authStore.DeleteExpiredSessionsAsync(ct);
- }
-
- return user;
- }
-
- // Создаёт сессию пользователя: raw-токен наружу, в хранилище — его SHA-256-хэш.
- // userId: Идентификатор пользователя.
- // login: Логин (денормализуется в сессию для чтения без join).
- // impersonatedByOperatorId: Маркер impersonation: оператор, создавший сессию; null — обычный вход.
- // ct: Токен отмены.
- // Возвращает: Raw-токен для выдачи клиенту.
- private async Task CreateSessionForUserAsync(
- Guid userId,
- string login,
- Guid? impersonatedByOperatorId,
- CancellationToken ct)
- {
- var rawToken = SessionTokens.NewToken();
- var session = new SessionDto(
- TokenHash: SessionTokens.HashToken(rawToken),
- UserId: userId,
- Login: login,
- ExpiresAt: DateTimeOffset.UtcNow.AddDays(SessionLifetimeDays),
- ImpersonatedByOperatorId: impersonatedByOperatorId);
-
- await authStore.CreateSessionAsync(session, ct);
- return rawToken;
- }
-
- // Успешный результат impersonation (токен + пользователь + тенант).
- // rawToken: Raw-токен созданной сессии.
- // userId: Идентификатор пользователя.
- // login: Логин пользователя.
- // tenantId: Тенант пользователя.
- // Возвращает: ImpersonationResultDto с Ok=true.
- private ImpersonationResultDto Success(
- string rawToken,
- Guid userId,
- string login,
- Guid tenantId) =>
- new(
- Ok: true,
- Error: null,
- SessionToken: rawToken,
- ExpiresAt: DateTimeOffset.UtcNow.AddDays(SessionLifetimeDays),
- Login: login,
- UserId: userId,
- TenantId: tenantId);
-
- // Нормализация логина: нижний регистр и обрезка пробелов (единая форма хранения/сравнения).
- // login: Входной логин.
- // Возвращает: Нормализованный логин (пустая строка, если вход был пустым).
- private static string NormalizeLogin(string? login) => (login ?? string.Empty).ToLowerInvariant().Trim();
-}
+using Deal.Modules.Tenants.Application.Abstractions;
+using Deal.Modules.Tenants.Application.Models;
+
+namespace Deal.Modules.Tenants.Application.Services;
+
+///
+/// Прикладной сервис аутентификации
+///
+public sealed class AuthService(
+ IAuthStore authStore,
+ IPasswordHasher passwordHasher,
+ ITenantRepository tenantRepository)
+{
+ ///
+ /// Срок жизни сессии, дней.
+ ///
+ public const int SessionLifetimeDays = 30;
+
+ ///
+ /// Минимальная длина нового пароля.
+ ///
+ public const int MinNewPasswordLength = 8;
+
+ // Статус «активен»: только активного пользователя разрешает ResolveSessionAsync (деактивированный
+ // вручную с живой сессией — null, как у оператора OperatorAuthService).
+ private const string UserActiveStatus = "active";
+
+ ///
+ /// Вход: при успехе создаёт сессию и возвращает её raw-токен.
+ ///
+ /// Логин (регистр и пробелы не важны — нормализуется).
+ /// Пароль в открытом виде.
+ /// При успехе — Login и Token (UserId/TenantId для аудита). Иначе Login/Token null: Error может отличать заблокированный вход приостановленного тенанта (, UserId/TenantId заполнены) от «неверные учётные данные» (Error null, UserId/TenantId пусты).
+ public async Task LoginAsync(
+ string login,
+ string password,
+ CancellationToken ct)
+ {
+ string normalizedLogin = NormalizeLogin(login);
+ if (string.IsNullOrEmpty(normalizedLogin) || string.IsNullOrEmpty(password))
+ {
+ return new LoginResultDto(null, null);
+ }
+
+ var user = await authStore.FindUserByLoginAsync(normalizedLogin, ct);
+ // Порядок проверок: сначала учётные данные, затем статус тенанта — неверный пароль не раскрывает
+ // приостановку (тот же «Неверный логин или пароль», что и для активного тенанта).
+ if (user is null || !passwordHasher.Verify(password, user.PasswordHash))
+ {
+ return new LoginResultDto(null, null);
+ }
+
+ var tenant = await tenantRepository.FindByIdAsync(user.TenantId, ct);
+ if (tenant is not null && tenant.Status == TenantStatuses.Suspended)
+ {
+ return new LoginResultDto(
+ Login: null,
+ Token: null,
+ UserId: user.Id,
+ TenantId: user.TenantId,
+ Error: LoginResultDto.ErrorTenantSuspended);
+ }
+
+ string token = await CreateSessionForUserAsync(user.Id, user.Login, impersonatedByOperatorId: null, ct);
+ return new LoginResultDto(user.Login, token, user.Id, user.TenantId);
+ }
+
+ ///
+ /// Выход: удаляет сессию по raw-токену
+ ///
+ /// Raw-токен из куки (может отсутствовать — no-op).
+ /// Не null, если удалённая сессия была impersonation — сведения для аудита impersonation_stopped (актор — оператор по маркеру сессии). Обычный logout и no-op возвращают null.
+ public async Task LogoutAsync(string? rawToken, CancellationToken ct)
+ {
+ if (string.IsNullOrWhiteSpace(rawToken))
+ {
+ return null;
+ }
+
+ string tokenHash = SessionTokens.HashToken(rawToken);
+ var session = await authStore.FindSessionByTokenHashAsync(tokenHash, ct);
+ await authStore.DeleteSessionAsync(tokenHash, ct);
+
+ if (session is null || session.ImpersonatedByOperatorId is null)
+ {
+ return null;
+ }
+
+ // Пользователь не удаляется при живой сессии (FK sessions→users Cascade, SessionConfiguration):
+ // TenantId берём из реестра пользователей для поля-фильтра аудита.
+ var user = await authStore.FindUserByIdAsync(session.UserId, ct);
+ return user is null
+ ? null
+ : new LogoutResultDto(
+ OperatorId: session.ImpersonatedByOperatorId.Value,
+ Login: session.Login,
+ UserId: user.Id,
+ TenantId: user.TenantId);
+ }
+
+ ///
+ /// Смена пароля: инвалидирует все сессии, обновляет хэш и выдаёт свежую сессию.
+ ///
+ /// Логин пользователя.
+ /// Текущий пароль.
+ /// Новый пароль (минимум 8 символов).
+ /// При успехе — Ok=true и NewToken (raw-токен свежей сессии). Иначе Ok=false и код ошибки: или .
+ public async Task ChangePasswordAsync(
+ string login,
+ string oldPassword,
+ string newPassword,
+ CancellationToken ct)
+ {
+ string normalizedLogin = NormalizeLogin(login);
+ var user = await authStore.FindUserByLoginAsync(normalizedLogin, ct);
+ if (user is null || oldPassword is null || !passwordHasher.Verify(oldPassword, user.PasswordHash))
+ {
+ return new ChangePasswordResultDto(Ok: false, Error: ChangePasswordResultDto.ErrorOldPassword, NewToken: null);
+ }
+
+ if (string.IsNullOrEmpty(newPassword) || newPassword.Length < MinNewPasswordLength)
+ {
+ return new ChangePasswordResultDto(Ok: false, Error: ChangePasswordResultDto.ErrorTooShort, NewToken: null);
+ }
+
+ await authStore.DeleteSessionsByUserIdAsync(user.Id, ct);
+ await authStore.UpdatePasswordHashAsync(user.Id, passwordHasher.Hash(newPassword), ct);
+ string token = await CreateSessionForUserAsync(user.Id, user.Login, impersonatedByOperatorId: null, ct);
+ return new ChangePasswordResultDto(Ok: true, Error: null, NewToken: token);
+ }
+
+ ///
+ /// Impersonation: tenant-сессия целевого пользователя от имени оператора.
+ ///
+ /// Идентификатор тенанта.
+ /// Логин пользователя (null/пустой — первый пользователь тенанта).
+ /// Идентификатор оператора, начинающего impersonation (маркер сессии).
+ /// Результат: Ok=true — SessionToken (raw-токен для куки deal_session) и срок жизни; иначе код ошибки.
+ public async Task ImpersonateAsync(
+ Guid tenantId,
+ string? targetLogin,
+ Guid operatorId,
+ CancellationToken ct)
+ {
+ var tenant = await tenantRepository.FindByIdAsync(tenantId, ct);
+ if (tenant is null)
+ {
+ return ImpersonationResultDto.Failed(ImpersonationResultDto.ErrorTenantNotFound);
+ }
+
+ string normalizedLogin = NormalizeLogin(targetLogin);
+ if (string.IsNullOrEmpty(normalizedLogin))
+ {
+ // login не задан — первый пользователь тенанта (по CreatedAt, порядок ListUsersByTenantIdAsync).
+ var tenantUsers = await authStore.ListUsersByTenantIdAsync(tenantId, ct);
+ if (tenantUsers.Count == 0)
+ {
+ return ImpersonationResultDto.Failed(ImpersonationResultDto.ErrorTenantHasNoUsers);
+ }
+
+ UserIdentityDto firstUser = tenantUsers[0];
+ string rawTokenForFirst = await CreateSessionForUserAsync(firstUser.Id, firstUser.Login, operatorId, ct);
+ return Success(rawTokenForFirst, firstUser.Id, firstUser.Login, firstUser.TenantId);
+ }
+
+ var user = await authStore.FindUserByLoginAsync(normalizedLogin, ct);
+ if (user is null || user.TenantId != tenantId)
+ {
+ // Пользователь не найден или принадлежит другому тенанту — не раскрываем существование логина.
+ return ImpersonationResultDto.Failed(ImpersonationResultDto.ErrorUserNotFound);
+ }
+
+ string rawToken = await CreateSessionForUserAsync(user.Id, user.Login, operatorId, ct);
+ return Success(rawToken, user.Id, user.Login, user.TenantId);
+ }
+
+ ///
+ /// Разрешение сессии по raw-токену
+ ///
+ /// Raw-токен из куки.
+ /// Идентичность пользователя или null.
+ public async Task ResolveSessionAsync(string? rawToken, CancellationToken ct)
+ {
+ if (string.IsNullOrWhiteSpace(rawToken))
+ {
+ return null;
+ }
+
+ string tokenHash = SessionTokens.HashToken(rawToken);
+ var session = await authStore.FindSessionByTokenHashAsync(tokenHash, ct);
+ UserIdentityDto? user = null;
+ if (session is not null && session.ExpiresAt > DateTimeOffset.UtcNow)
+ {
+ user = await authStore.FindUserByIdAsync(session.UserId, ct);
+ // Деактивированный пользователь при живой сессии не получает доступ (Security review):
+ // сессия разрешается только для активной учётки (как операторская в OperatorAuthService).
+ if (user is not null && user.Status != UserActiveStatus)
+ {
+ user = null;
+ }
+
+ if (user is not null)
+ {
+ var tenant = await tenantRepository.FindByIdAsync(user.TenantId, ct);
+ if (tenant is not null && tenant.Status == TenantStatuses.Suspended)
+ {
+ user = null;
+ }
+ }
+ }
+ else if (session is not null)
+ {
+ // Сессия протухла: глобальная очистка протухших сессий выполняется ТОЛЬКО при обнаружении
+ // протухшей (редкий случай), а не на каждом разрешении сессии (hot-path, Security review).
+ await authStore.DeleteExpiredSessionsAsync(ct);
+ }
+
+ return user;
+ }
+
+ // Создаёт сессию пользователя: raw-токен наружу, в хранилище — его SHA-256-хэш.
+ // userId: Идентификатор пользователя.
+ // login: Логин (денормализуется в сессию для чтения без join).
+ // impersonatedByOperatorId: Маркер impersonation: оператор, создавший сессию; null — обычный вход.
+ // ct: Токен отмены.
+ // Возвращает: Raw-токен для выдачи клиенту.
+ private async Task CreateSessionForUserAsync(
+ Guid userId,
+ string login,
+ Guid? impersonatedByOperatorId,
+ CancellationToken ct)
+ {
+ string rawToken = SessionTokens.NewToken();
+ var session = new SessionDto(
+ TokenHash: SessionTokens.HashToken(rawToken),
+ UserId: userId,
+ Login: login,
+ ExpiresAt: DateTimeOffset.UtcNow.AddDays(SessionLifetimeDays),
+ ImpersonatedByOperatorId: impersonatedByOperatorId);
+
+ await authStore.CreateSessionAsync(session, ct);
+ return rawToken;
+ }
+
+ // Успешный результат impersonation (токен + пользователь + тенант).
+ // rawToken: Raw-токен созданной сессии.
+ // userId: Идентификатор пользователя.
+ // login: Логин пользователя.
+ // tenantId: Тенант пользователя.
+ // Возвращает: ImpersonationResultDto с Ok=true.
+ private ImpersonationResultDto Success(
+ string rawToken,
+ Guid userId,
+ string login,
+ Guid tenantId) =>
+ new(
+ Ok: true,
+ Error: null,
+ SessionToken: rawToken,
+ ExpiresAt: DateTimeOffset.UtcNow.AddDays(SessionLifetimeDays),
+ Login: login,
+ UserId: userId,
+ TenantId: tenantId);
+
+ // Нормализация логина: нижний регистр и обрезка пробелов (единая форма хранения/сравнения).
+ // login: Входной логин.
+ // Возвращает: Нормализованный логин (пустая строка, если вход был пустым).
+ private static string NormalizeLogin(string? login) => (login ?? string.Empty).ToLowerInvariant().Trim();
+}
diff --git a/src/core/Deal.Modules.Tenants/Application/Services/OperatorAuthService.cs b/src/core/Deal.Modules.Tenants/Application/Services/OperatorAuthService.cs
index 61540a9..30b5020 100644
--- a/src/core/Deal.Modules.Tenants/Application/Services/OperatorAuthService.cs
+++ b/src/core/Deal.Modules.Tenants/Application/Services/OperatorAuthService.cs
@@ -1,114 +1,114 @@
-using Deal.Modules.Tenants.Application.Abstractions;
-using Deal.Modules.Tenants.Application.Models;
-
-namespace Deal.Modules.Tenants.Application.Services;
-
-///
-/// Прикладной сервис аутентификации оператора
-///
-public sealed class OperatorAuthService(IOperatorAuthStore operatorAuthStore, IPasswordHasher passwordHasher)
-{
- ///
- /// Срок жизни сессии оператора, часов.
- ///
- public const int SessionLifetimeHours = 12;
-
- // Статус «активен»: только активного оператора разрешает ResolveSessionAsync (удалённый/приостановленный — null).
- private const string ActiveStatus = "active";
-
- ///
- /// Вход оператора: при успехе создаёт сессию и возвращает её raw-токен.
- ///
- /// Логин (регистр и пробелы не важны — нормализуется).
- /// Пароль в открытом виде.
- /// При успехе — Login и Token; иначе оба null (текст «Неверный логин или пароль оператора» фиксирует endpoint).
- public async Task LoginAsync(
- string login,
- string password,
- CancellationToken ct)
- {
- var normalizedLogin = NormalizeLogin(login);
- if (string.IsNullOrEmpty(normalizedLogin) || string.IsNullOrEmpty(password))
- {
- return new OperatorLoginResultDto(null, null);
- }
-
- var operatorRecord = await operatorAuthStore.FindByLoginAsync(normalizedLogin, ct);
- if (operatorRecord is null || !passwordHasher.Verify(password, operatorRecord.PasswordHash))
- {
- return new OperatorLoginResultDto(null, null);
- }
-
- var token = await CreateSessionForOperatorAsync(operatorRecord, ct);
- return new OperatorLoginResultDto(operatorRecord.Login, token, operatorRecord.Id);
- }
-
- ///
- /// Выход оператора
- ///
- /// Raw-токен из куки (может отсутствовать — no-op).
- public async Task LogoutAsync(string? rawToken, CancellationToken ct)
- {
- if (string.IsNullOrWhiteSpace(rawToken))
- {
- return;
- }
-
- await operatorAuthStore.DeleteSessionAsync(SessionTokens.HashToken(rawToken), ct);
- }
-
- ///
- /// Разрешение операторской сессии по raw-токену
- ///
- /// Raw-токен из куки deal_operator_session.
- /// Идентичность активного оператора или null.
- public async Task ResolveSessionAsync(string? rawToken, CancellationToken ct)
- {
- if (string.IsNullOrWhiteSpace(rawToken))
- {
- return null;
- }
-
- var tokenHash = SessionTokens.HashToken(rawToken);
- var session = await operatorAuthStore.FindSessionByTokenHashAsync(tokenHash, ct);
- OperatorIdentityDto? operatorIdentity = null;
- if (session is not null && session.ExpiresAt > DateTimeOffset.UtcNow)
- {
- var operatorRecord = await operatorAuthStore.FindByLoginAsync(session.Login, ct);
- if (operatorRecord is not null && operatorRecord.Status == ActiveStatus)
- {
- operatorIdentity = new OperatorIdentityDto(operatorRecord.Id, operatorRecord.Login, operatorRecord.Status);
- }
- }
- else if (session is not null)
- {
- // Очистка протухших сессий — только при обнаружении протухшей (редкий случай), не на каждый запрос
- // (hot-path, Security review).
- await operatorAuthStore.DeleteExpiredSessionsAsync(ct);
- }
-
- return operatorIdentity;
- }
-
- // Создаёт сессию оператора: raw-токен наружу, в хранилище — его SHA-256-хэш (формат как у SessionTokens).
- // operatorRecord: Оператор (логин денормализуется в сессию).
- // ct: Токен отмены.
- // Возвращает: Raw-токен для выдачи клиенту.
- private async Task CreateSessionForOperatorAsync(StoredOperatorDto operatorRecord, CancellationToken ct)
- {
- var rawToken = SessionTokens.NewToken();
- var session = new OperatorSessionDto(
- TokenHash: SessionTokens.HashToken(rawToken),
- OperatorId: operatorRecord.Id,
- Login: operatorRecord.Login,
- ExpiresAt: DateTimeOffset.UtcNow.AddHours(SessionLifetimeHours));
-
- await operatorAuthStore.CreateSessionAsync(session, ct);
- return rawToken;
- }
-
- // Нормализация логина: нижний регистр и обрезка пробелов (единая форма хранения/сравнения).
- // login: Входной логин.
- // Возвращает: Нормализованный логин (пустая строка, если вход был пустым).
- private static string NormalizeLogin(string? login) => (login ?? string.Empty).ToLowerInvariant().Trim();
-}
+using Deal.Modules.Tenants.Application.Abstractions;
+using Deal.Modules.Tenants.Application.Models;
+
+namespace Deal.Modules.Tenants.Application.Services;
+
+///
+/// Прикладной сервис аутентификации оператора
+///
+public sealed class OperatorAuthService(IOperatorAuthStore operatorAuthStore, IPasswordHasher passwordHasher)
+{
+ ///
+ /// Срок жизни сессии оператора, часов.
+ ///
+ public const int SessionLifetimeHours = 12;
+
+ // Статус «активен»: только активного оператора разрешает ResolveSessionAsync (удалённый/приостановленный — null).
+ private const string ActiveStatus = "active";
+
+ ///
+ /// Вход оператора: при успехе создаёт сессию и возвращает её raw-токен.
+ ///
+ /// Логин (регистр и пробелы не важны — нормализуется).
+ /// Пароль в открытом виде.
+ /// При успехе — Login и Token; иначе оба null (текст «Неверный логин или пароль оператора» фиксирует endpoint).
+ public async Task LoginAsync(
+ string login,
+ string password,
+ CancellationToken ct)
+ {
+ string normalizedLogin = NormalizeLogin(login);
+ if (string.IsNullOrEmpty(normalizedLogin) || string.IsNullOrEmpty(password))
+ {
+ return new OperatorLoginResultDto(null, null);
+ }
+
+ var operatorRecord = await operatorAuthStore.FindByLoginAsync(normalizedLogin, ct);
+ if (operatorRecord is null || !passwordHasher.Verify(password, operatorRecord.PasswordHash))
+ {
+ return new OperatorLoginResultDto(null, null);
+ }
+
+ string token = await CreateSessionForOperatorAsync(operatorRecord, ct);
+ return new OperatorLoginResultDto(operatorRecord.Login, token, operatorRecord.Id);
+ }
+
+ ///
+ /// Выход оператора
+ ///
+ /// Raw-токен из куки (может отсутствовать — no-op).
+ public async Task LogoutAsync(string? rawToken, CancellationToken ct)
+ {
+ if (string.IsNullOrWhiteSpace(rawToken))
+ {
+ return;
+ }
+
+ await operatorAuthStore.DeleteSessionAsync(SessionTokens.HashToken(rawToken), ct);
+ }
+
+ ///
+ /// Разрешение операторской сессии по raw-токену
+ ///
+ /// Raw-токен из куки deal_operator_session.
+ /// Идентичность активного оператора или null.
+ public async Task ResolveSessionAsync(string? rawToken, CancellationToken ct)
+ {
+ if (string.IsNullOrWhiteSpace(rawToken))
+ {
+ return null;
+ }
+
+ string tokenHash = SessionTokens.HashToken(rawToken);
+ var session = await operatorAuthStore.FindSessionByTokenHashAsync(tokenHash, ct);
+ OperatorIdentityDto? operatorIdentity = null;
+ if (session is not null && session.ExpiresAt > DateTimeOffset.UtcNow)
+ {
+ var operatorRecord = await operatorAuthStore.FindByLoginAsync(session.Login, ct);
+ if (operatorRecord is not null && operatorRecord.Status == ActiveStatus)
+ {
+ operatorIdentity = new OperatorIdentityDto(operatorRecord.Id, operatorRecord.Login, operatorRecord.Status);
+ }
+ }
+ else if (session is not null)
+ {
+ // Очистка протухших сессий — только при обнаружении протухшей (редкий случай), не на каждый запрос
+ // (горячий путь запросов).
+ await operatorAuthStore.DeleteExpiredSessionsAsync(ct);
+ }
+
+ return operatorIdentity;
+ }
+
+ // Создаёт сессию оператора: raw-токен наружу, в хранилище — его SHA-256-хэш (формат как у SessionTokens).
+ // operatorRecord: Оператор (логин денормализуется в сессию).
+ // ct: Токен отмены.
+ // Возвращает: Raw-токен для выдачи клиенту.
+ private async Task CreateSessionForOperatorAsync(StoredOperatorDto operatorRecord, CancellationToken ct)
+ {
+ string rawToken = SessionTokens.NewToken();
+ var session = new OperatorSessionDto(
+ TokenHash: SessionTokens.HashToken(rawToken),
+ OperatorId: operatorRecord.Id,
+ Login: operatorRecord.Login,
+ ExpiresAt: DateTimeOffset.UtcNow.AddHours(SessionLifetimeHours));
+
+ await operatorAuthStore.CreateSessionAsync(session, ct);
+ return rawToken;
+ }
+
+ // Нормализация логина: нижний регистр и обрезка пробелов (единая форма хранения/сравнения).
+ // login: Входной логин.
+ // Возвращает: Нормализованный логин (пустая строка, если вход был пустым).
+ private static string NormalizeLogin(string? login) => (login ?? string.Empty).ToLowerInvariant().Trim();
+}
diff --git a/src/core/Deal.Modules.Tenants/Application/Services/OperatorBootstrapService.cs b/src/core/Deal.Modules.Tenants/Application/Services/OperatorBootstrapService.cs
index 5a7f2db..c88b300 100644
--- a/src/core/Deal.Modules.Tenants/Application/Services/OperatorBootstrapService.cs
+++ b/src/core/Deal.Modules.Tenants/Application/Services/OperatorBootstrapService.cs
@@ -1,80 +1,80 @@
-using Deal.Modules.Tenants.Application.Abstractions;
-using Deal.Modules.Tenants.Application.Models;
-
-namespace Deal.Modules.Tenants.Application.Services;
-
-///
-/// Bootstrap оператора при старте
-///
-public sealed class OperatorBootstrapService(IOperatorAuthStore operatorAuthStore, IPasswordHasher passwordHasher)
-{
- ///
- /// Переменная окружения
- ///
- public const string LoginEnvKey = "DEAL_OPERATOR_LOGIN";
-
- ///
- /// Переменная окружения
- ///
- public const string PasswordEnvKey = "DEAL_OPERATOR_PASSWORD";
-
- ///
- /// Дефолтный логин в Development при отсутствии env-кред.
- ///
- public const string DefaultOperatorLogin = "operator";
-
- ///
- /// Дефолтный пароль в Development при отсутствии env-кред.
- ///
- public const string DefaultOperatorPassword = "operator";
-
- private const string ActiveStatus = "active";
-
- ///
- /// Гарантирует наличие оператора
- ///
- /// Логин из env () или null/пусто, если не задан.
- /// Пароль из env () или null/пусто, если не задан.
- /// true в Development: при отсутствии кред берутся дефолты operator/operator; false (Production) при отсутствии кред — шаг пропускается, хост логирует warning.
- /// Логин оператора, присутствующего после шага (созданного или уже существовавшего); null — шаг пропущен.
- public async Task EnsureOperatorAsync(
- string? login,
- string? password,
- bool allowDevelopmentDefaults,
- CancellationToken ct)
- {
- var normalizedLogin = NormalizeLogin(login);
- var resolvedPassword = password;
- if (string.IsNullOrEmpty(normalizedLogin) || string.IsNullOrEmpty(resolvedPassword))
- {
- if (!allowDevelopmentDefaults)
- {
- return null;
- }
-
- normalizedLogin = DefaultOperatorLogin;
- resolvedPassword = DefaultOperatorPassword;
- }
-
- // Идемпотентность: оператор с таким логином уже есть — пароль не перезаписываем.
- var existing = await operatorAuthStore.FindByLoginAsync(normalizedLogin, ct);
- if (existing is not null)
- {
- return existing.Login;
- }
-
- await operatorAuthStore.CreateAsync(
- new StoredOperatorDto(
- Id: Guid.NewGuid(),
- Login: normalizedLogin,
- Status: ActiveStatus,
- PasswordHash: passwordHasher.Hash(resolvedPassword)),
- ct);
- return normalizedLogin;
- }
-
- // Нормализация логина: нижний регистр и обрезка пробелов (единая форма хранения).
- // login: Входной логин (может быть null — как «не задан»).
- // Возвращает: Нормализованный логин (пустая строка, если вход был пустым).
- private static string NormalizeLogin(string? login) => (login ?? string.Empty).ToLowerInvariant().Trim();
-}
+using Deal.Modules.Tenants.Application.Abstractions;
+using Deal.Modules.Tenants.Application.Models;
+
+namespace Deal.Modules.Tenants.Application.Services;
+
+///
+/// Bootstrap оператора при старте
+///
+public sealed class OperatorBootstrapService(IOperatorAuthStore operatorAuthStore, IPasswordHasher passwordHasher)
+{
+ ///
+ /// Переменная окружения
+ ///
+ public const string LoginEnvKey = "DEAL_OPERATOR_LOGIN";
+
+ ///
+ /// Переменная окружения
+ ///
+ public const string PasswordEnvKey = "DEAL_OPERATOR_PASSWORD";
+
+ ///
+ /// Дефолтный логин в Development при отсутствии env-кред.
+ ///
+ public const string DefaultOperatorLogin = "operator";
+
+ ///
+ /// Дефолтный пароль в Development при отсутствии env-кред.
+ ///
+ public const string DefaultOperatorPassword = "operator";
+
+ private const string ActiveStatus = "active";
+
+ ///
+ /// Гарантирует наличие оператора
+ ///
+ /// Логин из env () или null/пусто, если не задан.
+ /// Пароль из env () или null/пусто, если не задан.
+ /// true в Development: при отсутствии кред берутся дефолты operator/operator; false (Production) при отсутствии кред — шаг пропускается, хост логирует warning.
+ /// Логин оператора, присутствующего после шага (созданного или уже существовавшего); null — шаг пропущен.
+ public async Task EnsureOperatorAsync(
+ string? login,
+ string? password,
+ bool allowDevelopmentDefaults,
+ CancellationToken ct)
+ {
+ string normalizedLogin = NormalizeLogin(login);
+ string? resolvedPassword = password;
+ if (string.IsNullOrEmpty(normalizedLogin) || string.IsNullOrEmpty(resolvedPassword))
+ {
+ if (!allowDevelopmentDefaults)
+ {
+ return null;
+ }
+
+ normalizedLogin = DefaultOperatorLogin;
+ resolvedPassword = DefaultOperatorPassword;
+ }
+
+ // Идемпотентность: оператор с таким логином уже есть — пароль не перезаписываем.
+ var existing = await operatorAuthStore.FindByLoginAsync(normalizedLogin, ct);
+ if (existing is not null)
+ {
+ return existing.Login;
+ }
+
+ await operatorAuthStore.CreateAsync(
+ new StoredOperatorDto(
+ Id: Guid.NewGuid(),
+ Login: normalizedLogin,
+ Status: ActiveStatus,
+ PasswordHash: passwordHasher.Hash(resolvedPassword)),
+ ct);
+ return normalizedLogin;
+ }
+
+ // Нормализация логина: нижний регистр и обрезка пробелов (единая форма хранения).
+ // login: Входной логин (может быть null — как «не задан»).
+ // Возвращает: Нормализованный логин (пустая строка, если вход был пустым).
+ private static string NormalizeLogin(string? login) => (login ?? string.Empty).ToLowerInvariant().Trim();
+}
diff --git a/src/core/Deal.Modules.Tenants/Application/Services/SessionTokens.cs b/src/core/Deal.Modules.Tenants/Application/Services/SessionTokens.cs
index f956690..96b62db 100644
--- a/src/core/Deal.Modules.Tenants/Application/Services/SessionTokens.cs
+++ b/src/core/Deal.Modules.Tenants/Application/Services/SessionTokens.cs
@@ -1,32 +1,32 @@
-using System.Security.Cryptography;
-using System.Text;
-using Deal.SharedKernel;
-using Deal.SharedKernel.Utilities;
-
-namespace Deal.Modules.Tenants.Application.Services;
-
-///
-/// Токены сессий: генерация raw-токена и его SHA-256-хэша для хранения.
-///
-public static class SessionTokens
-{
- // Случайные байты raw-токена (32 → 43 символа Base64Url).
- private const int RawTokenByteLength = 32;
-
- ///
- /// Новый raw-токен
- ///
- /// Строка токена длиной 43 символа.
- public static string NewToken() => UrlSafeToken.New(RawTokenByteLength);
-
- ///
- /// SHA-256-хэш raw-токена в нижнем регистре
- ///
- /// Raw-токен из (или от клиента).
- /// 64 hex-символа.
- public static string HashToken(string rawToken)
- {
- var hash = SHA256.HashData(Encoding.UTF8.GetBytes(rawToken));
- return Convert.ToHexString(hash).ToLowerInvariant();
- }
-}
+using System.Security.Cryptography;
+using System.Text;
+using Deal.SharedKernel;
+using Deal.SharedKernel.Utilities;
+
+namespace Deal.Modules.Tenants.Application.Services;
+
+///
+/// Токены сессий: генерация raw-токена и его SHA-256-хэша для хранения.
+///
+public static class SessionTokens
+{
+ // Случайные байты raw-токена (32 → 43 символа Base64Url).
+ private const int RawTokenByteLength = 32;
+
+ ///
+ /// Новый raw-токен
+ ///
+ /// Строка токена длиной 43 символа.
+ public static string NewToken() => UrlSafeToken.New(RawTokenByteLength);
+
+ ///
+ /// SHA-256-хэш raw-токена в нижнем регистре
+ ///
+ /// Raw-токен из (или от клиента).
+ /// 64 hex-символа.
+ public static string HashToken(string rawToken)
+ {
+ byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(rawToken));
+ return Convert.ToHexString(hash).ToLowerInvariant();
+ }
+}
diff --git a/src/core/Deal.Modules.Tenants/Application/Services/TenantService.cs b/src/core/Deal.Modules.Tenants/Application/Services/TenantService.cs
index 2a2794c..73a626f 100644
--- a/src/core/Deal.Modules.Tenants/Application/Services/TenantService.cs
+++ b/src/core/Deal.Modules.Tenants/Application/Services/TenantService.cs
@@ -1,50 +1,50 @@
-using Deal.Modules.Tenants.Application.Abstractions;
-using Deal.Modules.Tenants.Application.Models;
-using Deal.SharedKernel.Tenants.Models;
-
-namespace Deal.Modules.Tenants.Application.Services;
-
-///
-/// Прикладной сервис реестра тенантов
-///
-public sealed class TenantService(ITenantRepository tenantRepository, ITenantProvisioner tenantProvisioner)
-{
- ///
- /// Создаёт тенанта
- ///
- /// Имя тенанта.
- /// Идентификатор созданного тенанта (он же имя схемы tenant_<id>).
- public Task CreateTenantAsync(string name, CancellationToken ct) =>
- CreateTenantAsync(name, Guid.NewGuid(), ct);
-
- ///
- /// Создаёт тенанта с явным id и провижинит его схему.
- ///
- /// Имя тенанта.
- /// Идентификатор тенанта (определяет имя схемы).
- /// Идентификатор созданного тенанта (он же имя схемы tenant_<id>).
- public async Task CreateTenantAsync(
- string name,
- Guid id,
- CancellationToken ct)
- {
- await tenantRepository.CreateAsync(
- new TenantRecordDto(
- Id: id,
- Name: name,
- Status: TenantStatuses.Active,
- CreatedAt: DateTimeOffset.UtcNow),
- ct);
-
- var tenantIdValue = id.ToString("N");
- await tenantProvisioner.ProvisionAsync(new TenantId(tenantIdValue), ct);
- return new TenantId(tenantIdValue);
- }
-
- ///
- /// Возвращает список всех тенантов.
- ///
- /// Список тенантов.
- public Task> ListTenantsAsync(CancellationToken ct) =>
- tenantRepository.ListAsync(ct);
-}
+using Deal.Modules.Tenants.Application.Abstractions;
+using Deal.Modules.Tenants.Application.Models;
+using Deal.SharedKernel.Tenants.Models;
+
+namespace Deal.Modules.Tenants.Application.Services;
+
+///
+/// Прикладной сервис реестра тенантов
+///
+public sealed class TenantService(ITenantRepository tenantRepository, ITenantProvisioner tenantProvisioner)
+{
+ ///
+ /// Создаёт тенанта
+ ///
+ /// Имя тенанта.
+ /// Идентификатор созданного тенанта (он же имя схемы tenant_<id>).
+ public Task CreateTenantAsync(string name, CancellationToken ct) =>
+ CreateTenantAsync(name, Guid.NewGuid(), ct);
+
+ ///
+ /// Создаёт тенанта с явным id и провижинит его схему.
+ ///
+ /// Имя тенанта.
+ /// Идентификатор тенанта (определяет имя схемы).
+ /// Идентификатор созданного тенанта (он же имя схемы tenant_<id>).
+ public async Task CreateTenantAsync(
+ string name,
+ Guid id,
+ CancellationToken ct)
+ {
+ await tenantRepository.CreateAsync(
+ new TenantRecordDto(
+ Id: id,
+ Name: name,
+ Status: TenantStatuses.Active,
+ CreatedAt: DateTimeOffset.UtcNow),
+ ct);
+
+ string tenantIdValue = id.ToString("N");
+ await tenantProvisioner.ProvisionAsync(new TenantId(tenantIdValue), ct);
+ return new TenantId(tenantIdValue);
+ }
+
+ ///
+ /// Возвращает список всех тенантов.
+ ///
+ /// Список тенантов.
+ public Task> ListTenantsAsync(CancellationToken ct) =>
+ tenantRepository.ListAsync(ct);
+}
diff --git a/src/core/Deal.SharedKernel/Tenants/Abstractions/ITenantContext.cs b/src/core/Deal.SharedKernel/Tenants/Abstractions/ITenantContext.cs
index e4e8174..42aed77 100644
--- a/src/core/Deal.SharedKernel/Tenants/Abstractions/ITenantContext.cs
+++ b/src/core/Deal.SharedKernel/Tenants/Abstractions/ITenantContext.cs
@@ -7,8 +7,14 @@ namespace Deal.SharedKernel.Tenants.Abstractions;
///
public interface ITenantContext
{
+ ///
+ /// Идентификатор текущего тенанта или null для системного контекста
+ ///
public TenantId? TenantId { get; }
+ ///
+ /// Признак наличия текущего тенанта
+ ///
public bool HasTenant { get; }
///
diff --git a/src/core/Deal.SharedKernel/Utilities/UrlSafeToken.cs b/src/core/Deal.SharedKernel/Utilities/UrlSafeToken.cs
index 767f7a3..819fe1b 100644
--- a/src/core/Deal.SharedKernel/Utilities/UrlSafeToken.cs
+++ b/src/core/Deal.SharedKernel/Utilities/UrlSafeToken.cs
@@ -18,7 +18,7 @@ public static class UrlSafeToken
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(byteCount);
- var bytes = new byte[byteCount];
+ byte[] bytes = new byte[byteCount];
RandomNumberGenerator.Fill(bytes);
return Convert.ToBase64String(bytes)
.TrimEnd('=')
diff --git a/src/core/tests/Deal.Tests.Unit/Infrastructure/AuthStoreTests.cs b/src/core/tests/Deal.Tests.Unit/Infrastructure/AuthStoreTests.cs
index b3722a8..a3d8249 100644
--- a/src/core/tests/Deal.Tests.Unit/Infrastructure/AuthStoreTests.cs
+++ b/src/core/tests/Deal.Tests.Unit/Infrastructure/AuthStoreTests.cs
@@ -1,80 +1,80 @@
-using Deal.Infrastructure.Persistence;
-using Deal.Infrastructure.Persistence.Repositories;
-using Deal.Modules.Tenants.Application.Models;
-using Microsoft.EntityFrameworkCore;
-
-namespace Deal.Tests.Unit.Infrastructure;
-
-///
-/// Юнит-тесты EF-адаптера на InMemory-провайдере
-///
-public sealed class AuthStoreTests
-{
- [Fact]
- public async Task ListUsersByTenantIdAsync_ReturnsOnlyTenantUsers()
- {
- var store = CreateStore();
- var tenantId = Guid.NewGuid();
- var otherTenantId = Guid.NewGuid();
- await CreateUserAsync(store, tenantId, "first@example.com");
- await CreateUserAsync(store, tenantId, "second@example.com");
- await CreateUserAsync(store, otherTenantId, "other@example.com");
-
- IReadOnlyList users = await store.ListUsersByTenantIdAsync(tenantId, CancellationToken.None);
-
- Assert.Equal(2, users.Count);
- // Порядок EF: CreatedAt, затем Login — «первый пользователь» для impersonation без login детерминирован.
- Assert.Equal("first@example.com", users[0].Login);
- Assert.Equal("second@example.com", users[1].Login);
- Assert.All(users, u => Assert.Equal(tenantId, u.TenantId));
- Assert.DoesNotContain(users, u => u.Login == "other@example.com");
- }
-
- [Fact]
- public async Task CreateSession_ThenFindByTokenHash_ReturnsImpersonationMarker()
- {
- var store = CreateStore();
- var userId = Guid.NewGuid();
- await CreateUserAsync(store, Guid.NewGuid(), "user@example.com");
- var operatorId = Guid.NewGuid();
- var tokenHash = "token-hash-impersonation";
-
- await store.CreateSessionAsync(
- new SessionDto(tokenHash, userId, "user@example.com", DateTimeOffset.UtcNow.AddDays(30), operatorId),
- CancellationToken.None);
-
- SessionDto? found = await store.FindSessionByTokenHashAsync(tokenHash, CancellationToken.None);
- Assert.NotNull(found);
- Assert.Equal(operatorId, found!.ImpersonatedByOperatorId);
- Assert.Equal(userId, found.UserId);
-
- // Обычная сессия (без маркера) читается как ImpersonatedByOperatorId=null.
- var plainHash = "token-hash-plain";
- await store.CreateSessionAsync(
- new SessionDto(plainHash, userId, "user@example.com", DateTimeOffset.UtcNow.AddDays(30)),
- CancellationToken.None);
- SessionDto? plain = await store.FindSessionByTokenHashAsync(plainHash, CancellationToken.None);
- Assert.NotNull(plain);
- Assert.Null(plain!.ImpersonatedByOperatorId);
- }
-
- // Создаёт пользователя тенанта (хэш пароля — заглушка, для чтения не нужен).
- private static async Task CreateUserAsync(
- AuthStore store,
- Guid tenantId,
- string login)
- {
- await store.CreateUserAsync(
- new StoredUserDto(Guid.NewGuid(), login, tenantId, "active", "fake-argon2-encoded-hash"),
- CancellationToken.None);
- }
-
- // Создаёт адаптер на уникальной InMemory-БД (контексты тестов не пересекаются).
- private static AuthStore CreateStore()
- {
- var options = new DbContextOptionsBuilder()
- .UseInMemoryDatabase(Guid.NewGuid().ToString("N"))
- .Options;
- return new AuthStore(new DealDbContext(options));
- }
-}
+using Deal.Infrastructure.Persistence;
+using Deal.Infrastructure.Persistence.Repositories;
+using Deal.Modules.Tenants.Application.Models;
+using Microsoft.EntityFrameworkCore;
+
+namespace Deal.Tests.Unit.Infrastructure;
+
+///
+/// Юнит-тесты EF-адаптера на InMemory-провайдере
+///
+public sealed class AuthStoreTests
+{
+ [Fact]
+ public async Task ListUsersByTenantIdAsync_ReturnsOnlyTenantUsers()
+ {
+ var store = CreateStore();
+ var tenantId = Guid.NewGuid();
+ var otherTenantId = Guid.NewGuid();
+ await CreateUserAsync(store, tenantId, "first@example.com");
+ await CreateUserAsync(store, tenantId, "second@example.com");
+ await CreateUserAsync(store, otherTenantId, "other@example.com");
+
+ IReadOnlyList users = await store.ListUsersByTenantIdAsync(tenantId, CancellationToken.None);
+
+ Assert.Equal(2, users.Count);
+ // Порядок EF: CreatedAt, затем Login — «первый пользователь» для impersonation без login детерминирован.
+ Assert.Equal("first@example.com", users[0].Login);
+ Assert.Equal("second@example.com", users[1].Login);
+ Assert.All(users, u => Assert.Equal(tenantId, u.TenantId));
+ Assert.DoesNotContain(users, u => u.Login == "other@example.com");
+ }
+
+ [Fact]
+ public async Task CreateSession_ThenFindByTokenHash_ReturnsImpersonationMarker()
+ {
+ var store = CreateStore();
+ var userId = Guid.NewGuid();
+ await CreateUserAsync(store, Guid.NewGuid(), "user@example.com");
+ var operatorId = Guid.NewGuid();
+ string tokenHash = "token-hash-impersonation";
+
+ await store.CreateSessionAsync(
+ new SessionDto(tokenHash, userId, "user@example.com", DateTimeOffset.UtcNow.AddDays(30), operatorId),
+ CancellationToken.None);
+
+ SessionDto? found = await store.FindSessionByTokenHashAsync(tokenHash, CancellationToken.None);
+ Assert.NotNull(found);
+ Assert.Equal(operatorId, found!.ImpersonatedByOperatorId);
+ Assert.Equal(userId, found.UserId);
+
+ // Обычная сессия (без маркера) читается как ImpersonatedByOperatorId=null.
+ string plainHash = "token-hash-plain";
+ await store.CreateSessionAsync(
+ new SessionDto(plainHash, userId, "user@example.com", DateTimeOffset.UtcNow.AddDays(30)),
+ CancellationToken.None);
+ SessionDto? plain = await store.FindSessionByTokenHashAsync(plainHash, CancellationToken.None);
+ Assert.NotNull(plain);
+ Assert.Null(plain!.ImpersonatedByOperatorId);
+ }
+
+ // Создаёт пользователя тенанта (хэш пароля — заглушка, для чтения не нужен).
+ private static async Task CreateUserAsync(
+ AuthStore store,
+ Guid tenantId,
+ string login)
+ {
+ await store.CreateUserAsync(
+ new StoredUserDto(Guid.NewGuid(), login, tenantId, "active", "fake-argon2-encoded-hash"),
+ CancellationToken.None);
+ }
+
+ // Создаёт адаптер на уникальной InMemory-БД (контексты тестов не пересекаются).
+ private static AuthStore CreateStore()
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(Guid.NewGuid().ToString("N"))
+ .Options;
+ return new AuthStore(new DealDbContext(options));
+ }
+}
diff --git a/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantSchemaMigrationServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantSchemaMigrationServiceTests.cs
index 7fea5a1..4881ecf 100644
--- a/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantSchemaMigrationServiceTests.cs
+++ b/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantSchemaMigrationServiceTests.cs
@@ -1,194 +1,194 @@
-using Deal.Infrastructure.Tenancy;
-using Deal.Modules.Tenants.Application.Abstractions;
-using Deal.Modules.Tenants.Application.Models;
-using Deal.Tests.Unit.Modules.Tenants;
-using Microsoft.Extensions.Logging.Abstractions;
-
-namespace Deal.Tests.Unit.Infrastructure;
-
-///
-/// Тесты пакетной миграции схем тенантов
-///
-public sealed class TenantSchemaMigrationServiceTests
-{
- // Тенант сценария A.
- private static readonly Guid TenantA = Guid.Parse("11111111-1111-1111-1111-111111111111");
-
- // Тенант сценария B.
- private static readonly Guid TenantB = Guid.Parse("22222222-2222-2222-2222-222222222222");
-
- [Fact]
- public async Task MigrateAllAsync_EmptyRegistry_ReturnsEmptySummary()
- {
- var service = NewService(new FakeTenantRepository(), new FakeTenantProvisioner());
-
- TenantMigrationSummary summary = await service.MigrateAllAsync(CancellationToken.None);
-
- Assert.True(summary.Ok);
- Assert.Equal(0, summary.Total);
- Assert.Equal(0, summary.Migrated);
- Assert.Empty(summary.FailedSchemas);
- }
-
- [Fact]
- public async Task MigrateAllAsync_ProvisionsEveryTenantSchema()
- {
- var provisioner = new FakeTenantProvisioner();
- var repository = new FakeTenantRepository(
- TenantRecord(TenantA, "A"),
- TenantRecord(TenantB, "B"));
- var service = NewService(repository, provisioner);
-
- TenantMigrationSummary summary = await service.MigrateAllAsync(CancellationToken.None);
-
- Assert.True(summary.Ok);
- Assert.Equal(2, summary.Total);
- Assert.Equal(2, summary.Migrated);
- Assert.Equal(0, summary.Failed);
- Assert.Equal(
- new[] { $"tenant_{TenantA:N}", $"tenant_{TenantB:N}" }.OrderBy(name => name, StringComparer.Ordinal),
- provisioner.ProvisionedSchemaNames.OrderBy(name => name, StringComparer.Ordinal));
- }
-
- [Fact]
- public async Task MigrateAllAsync_OneSchemaFails_ContinuesAndReportsFailed()
- {
- var failingSchema = $"tenant_{TenantA:N}";
- var provisioner = new FailingTenantProvisioner(failingSchema);
- var repository = new FakeTenantRepository(
- TenantRecord(TenantA, "A"),
- TenantRecord(TenantB, "B"));
- var service = NewService(repository, provisioner);
-
- TenantMigrationSummary summary = await service.MigrateAllAsync(CancellationToken.None);
-
- Assert.False(summary.Ok);
- Assert.Equal(2, summary.Total);
- Assert.Equal(1, summary.Migrated);
- Assert.Equal(1, summary.Failed);
- Assert.Equal(new[] { failingSchema }, summary.FailedSchemas);
- // Уцелевшая схема мигрирована, сбойная — не попала в список успешных.
- Assert.Equal(new[] { $"tenant_{TenantB:N}" }, provisioner.ProvisionedSchemaNames);
- }
-
- [Fact]
- public async Task MigrateAllAsync_MultiplePages_ReadsEveryTenant()
- {
- var provisioner = new FakeTenantProvisioner();
- var repository = new FakeTenantRepository(Records(5));
- var service = NewService(repository, provisioner);
-
- TenantMigrationSummary summary = await service.MigrateAllAsync(4, 2, CancellationToken.None);
-
- Assert.True(summary.Ok);
- Assert.Equal(5, summary.Total);
- Assert.Equal(5, summary.Migrated);
- Assert.Equal(0, summary.Failed);
- Assert.Equal(5, provisioner.ProvisionedSchemaNames.Count);
- Assert.Equal(5, provisioner.ProvisionedSchemaNames.Distinct().Count());
- // Страницы запрошены последовательно с устойчивым offset: 0, 2, 4 (страница на 4 неполная — обход остановлен).
- Assert.Equal(
- new (int Offset, int Limit)[] { (0, 2), (2, 2), (4, 2) },
- repository.PageRequests);
- }
-
- [Fact]
- public async Task MigrateAllAsync_PageSizeOne_ReadsEveryTenant()
- {
- var provisioner = new FakeTenantProvisioner();
- var repository = new FakeTenantRepository(Records(4));
- var service = NewService(repository, provisioner);
-
- TenantMigrationSummary summary = await service.MigrateAllAsync(2, 1, CancellationToken.None);
-
- Assert.True(summary.Ok);
- Assert.Equal(4, summary.Total);
- Assert.Equal(4, summary.Migrated);
- Assert.Equal(4, provisioner.ProvisionedSchemaNames.Count);
- // По одной записи на страницу: 0,1,2,3, затем пустая страница завершает обход.
- Assert.Equal(5, repository.PageRequests.Count);
- Assert.Equal(0, repository.PageRequests[0].Offset);
- Assert.Equal(3, repository.PageRequests[3].Offset);
- }
-
- [Fact]
- public async Task MigrateAllAsync_SchemaFailsWithinPage_ContinuesOthers()
- {
- TenantRecordDto[] records = Records(3);
- var failingSchema = $"tenant_{records[0].Id:N}";
- var provisioner = new FailingTenantProvisioner(failingSchema);
- var repository = new FakeTenantRepository(records);
- var service = NewService(repository, provisioner);
-
- TenantMigrationSummary summary = await service.MigrateAllAsync(1, 2, CancellationToken.None);
-
- Assert.False(summary.Ok);
- Assert.Equal(3, summary.Total);
- Assert.Equal(2, summary.Migrated);
- Assert.Equal(1, summary.Failed);
- Assert.Equal(new[] { failingSchema }, summary.FailedSchemas);
- // Уцелевшие схемы обеих страниц провижинены, сбойная — нет.
- Assert.Equal(2, provisioner.ProvisionedSchemaNames.Count);
- }
-
- [Fact]
- public async Task MigrateAllAsync_NonPositiveParameters_ClampToMinimum()
- {
- var provisioner = new FakeTenantProvisioner();
- var repository = new FakeTenantRepository(Records(3));
- var service = NewService(repository, provisioner);
-
- TenantMigrationSummary summary = await service.MigrateAllAsync(0, 0, CancellationToken.None);
-
- Assert.True(summary.Ok);
- Assert.Equal(3, summary.Total);
- Assert.Equal(3, summary.Migrated);
- // Кламп pageSize → 1: по одной записи на страницу (плюс пустая на завершение).
- Assert.All(repository.PageRequests, request => Assert.Equal(1, request.Limit));
- }
-
- [Fact]
- public async Task MigrateAllAsync_WithExplicitParallelism_ProvisionsEveryTenant()
- {
- var provisioner = new FakeTenantProvisioner();
- var repository = new FakeTenantRepository(
- TenantRecord(TenantA, "A"),
- TenantRecord(TenantB, "B"));
- var service = NewService(repository, provisioner);
-
- TenantMigrationSummary summary = await service.MigrateAllAsync(2, CancellationToken.None);
-
- Assert.True(summary.Ok);
- Assert.Equal(2, summary.Total);
- Assert.Equal(2, summary.Migrated);
- }
-
- // Создаёт сервис на фейках (логирование не проверяется).
- // repository: Реестр тенантов.
- // provisioner: Провижинер схем.
- // Возвращает: Тестируемый сервис.
- private static TenantSchemaMigrationService NewService(ITenantRepository repository, ITenantProvisioner provisioner)
- => new(repository, provisioner, NullLogger.Instance);
-
- // Строка реестра тенантов для сценария.
- // id: Id тенанта.
- // name: Имя тенанта.
- // Возвращает: Запись реестра.
- private static TenantRecordDto TenantRecord(Guid id, string name)
- => new(id, name, TenantStatuses.Active, DateTimeOffset.UtcNow);
-
- // Последовательность тенантов с детерминированными Id (для проверки шардирования).
- // count: Сколько записей создать.
- // Возвращает: Записи реестра в порядке обхода.
- private static TenantRecordDto[] Records(int count)
- {
- var records = new TenantRecordDto[count];
- for (int index = 0; index < count; index++)
- {
- var id = Guid.Parse($"33333333-3333-3333-3333-{index + 1:D12}");
- records[index] = TenantRecord(id, $"T{index + 1}");
- }
-
- return records;
- }
-}
+using Deal.Infrastructure.Tenancy;
+using Deal.Modules.Tenants.Application.Abstractions;
+using Deal.Modules.Tenants.Application.Models;
+using Deal.Tests.Unit.Modules.Tenants;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Deal.Tests.Unit.Infrastructure;
+
+///
+/// Тесты пакетной миграции схем тенантов
+///
+public sealed class TenantSchemaMigrationServiceTests
+{
+ // Тенант сценария A.
+ private static readonly Guid TenantA = Guid.Parse("11111111-1111-1111-1111-111111111111");
+
+ // Тенант сценария B.
+ private static readonly Guid TenantB = Guid.Parse("22222222-2222-2222-2222-222222222222");
+
+ [Fact]
+ public async Task MigrateAllAsync_EmptyRegistry_ReturnsEmptySummary()
+ {
+ var service = NewService(new FakeTenantRepository(), new FakeTenantProvisioner());
+
+ TenantMigrationSummary summary = await service.MigrateAllAsync(CancellationToken.None);
+
+ Assert.True(summary.Ok);
+ Assert.Equal(0, summary.Total);
+ Assert.Equal(0, summary.Migrated);
+ Assert.Empty(summary.FailedSchemas);
+ }
+
+ [Fact]
+ public async Task MigrateAllAsync_ProvisionsEveryTenantSchema()
+ {
+ var provisioner = new FakeTenantProvisioner();
+ var repository = new FakeTenantRepository(
+ TenantRecord(TenantA, "A"),
+ TenantRecord(TenantB, "B"));
+ var service = NewService(repository, provisioner);
+
+ TenantMigrationSummary summary = await service.MigrateAllAsync(CancellationToken.None);
+
+ Assert.True(summary.Ok);
+ Assert.Equal(2, summary.Total);
+ Assert.Equal(2, summary.Migrated);
+ Assert.Equal(0, summary.Failed);
+ Assert.Equal(
+ new[] { $"tenant_{TenantA:N}", $"tenant_{TenantB:N}" }.OrderBy(name => name, StringComparer.Ordinal),
+ provisioner.ProvisionedSchemaNames.OrderBy(name => name, StringComparer.Ordinal));
+ }
+
+ [Fact]
+ public async Task MigrateAllAsync_OneSchemaFails_ContinuesAndReportsFailed()
+ {
+ string failingSchema = $"tenant_{TenantA:N}";
+ var provisioner = new FailingTenantProvisioner(failingSchema);
+ var repository = new FakeTenantRepository(
+ TenantRecord(TenantA, "A"),
+ TenantRecord(TenantB, "B"));
+ var service = NewService(repository, provisioner);
+
+ TenantMigrationSummary summary = await service.MigrateAllAsync(CancellationToken.None);
+
+ Assert.False(summary.Ok);
+ Assert.Equal(2, summary.Total);
+ Assert.Equal(1, summary.Migrated);
+ Assert.Equal(1, summary.Failed);
+ Assert.Equal(new[] { failingSchema }, summary.FailedSchemas);
+ // Уцелевшая схема мигрирована, сбойная — не попала в список успешных.
+ Assert.Equal(new[] { $"tenant_{TenantB:N}" }, provisioner.ProvisionedSchemaNames);
+ }
+
+ [Fact]
+ public async Task MigrateAllAsync_MultiplePages_ReadsEveryTenant()
+ {
+ var provisioner = new FakeTenantProvisioner();
+ var repository = new FakeTenantRepository(Records(5));
+ var service = NewService(repository, provisioner);
+
+ TenantMigrationSummary summary = await service.MigrateAllAsync(4, 2, CancellationToken.None);
+
+ Assert.True(summary.Ok);
+ Assert.Equal(5, summary.Total);
+ Assert.Equal(5, summary.Migrated);
+ Assert.Equal(0, summary.Failed);
+ Assert.Equal(5, provisioner.ProvisionedSchemaNames.Count);
+ Assert.Equal(5, provisioner.ProvisionedSchemaNames.Distinct().Count());
+ // Страницы запрошены последовательно с устойчивым offset: 0, 2, 4 (страница на 4 неполная — обход остановлен).
+ Assert.Equal(
+ new (int Offset, int Limit)[] { (0, 2), (2, 2), (4, 2) },
+ repository.PageRequests);
+ }
+
+ [Fact]
+ public async Task MigrateAllAsync_PageSizeOne_ReadsEveryTenant()
+ {
+ var provisioner = new FakeTenantProvisioner();
+ var repository = new FakeTenantRepository(Records(4));
+ var service = NewService(repository, provisioner);
+
+ TenantMigrationSummary summary = await service.MigrateAllAsync(2, 1, CancellationToken.None);
+
+ Assert.True(summary.Ok);
+ Assert.Equal(4, summary.Total);
+ Assert.Equal(4, summary.Migrated);
+ Assert.Equal(4, provisioner.ProvisionedSchemaNames.Count);
+ // По одной записи на страницу: 0,1,2,3, затем пустая страница завершает обход.
+ Assert.Equal(5, repository.PageRequests.Count);
+ Assert.Equal(0, repository.PageRequests[0].Offset);
+ Assert.Equal(3, repository.PageRequests[3].Offset);
+ }
+
+ [Fact]
+ public async Task MigrateAllAsync_SchemaFailsWithinPage_ContinuesOthers()
+ {
+ TenantRecordDto[] records = Records(3);
+ string failingSchema = $"tenant_{records[0].Id:N}";
+ var provisioner = new FailingTenantProvisioner(failingSchema);
+ var repository = new FakeTenantRepository(records);
+ var service = NewService(repository, provisioner);
+
+ TenantMigrationSummary summary = await service.MigrateAllAsync(1, 2, CancellationToken.None);
+
+ Assert.False(summary.Ok);
+ Assert.Equal(3, summary.Total);
+ Assert.Equal(2, summary.Migrated);
+ Assert.Equal(1, summary.Failed);
+ Assert.Equal(new[] { failingSchema }, summary.FailedSchemas);
+ // Уцелевшие схемы обеих страниц провижинены, сбойная — нет.
+ Assert.Equal(2, provisioner.ProvisionedSchemaNames.Count);
+ }
+
+ [Fact]
+ public async Task MigrateAllAsync_NonPositiveParameters_ClampToMinimum()
+ {
+ var provisioner = new FakeTenantProvisioner();
+ var repository = new FakeTenantRepository(Records(3));
+ var service = NewService(repository, provisioner);
+
+ TenantMigrationSummary summary = await service.MigrateAllAsync(0, 0, CancellationToken.None);
+
+ Assert.True(summary.Ok);
+ Assert.Equal(3, summary.Total);
+ Assert.Equal(3, summary.Migrated);
+ // Кламп pageSize → 1: по одной записи на страницу (плюс пустая на завершение).
+ Assert.All(repository.PageRequests, request => Assert.Equal(1, request.Limit));
+ }
+
+ [Fact]
+ public async Task MigrateAllAsync_WithExplicitParallelism_ProvisionsEveryTenant()
+ {
+ var provisioner = new FakeTenantProvisioner();
+ var repository = new FakeTenantRepository(
+ TenantRecord(TenantA, "A"),
+ TenantRecord(TenantB, "B"));
+ var service = NewService(repository, provisioner);
+
+ TenantMigrationSummary summary = await service.MigrateAllAsync(2, CancellationToken.None);
+
+ Assert.True(summary.Ok);
+ Assert.Equal(2, summary.Total);
+ Assert.Equal(2, summary.Migrated);
+ }
+
+ // Создаёт сервис на фейках (логирование не проверяется).
+ // repository: Реестр тенантов.
+ // provisioner: Провижинер схем.
+ // Возвращает: Тестируемый сервис.
+ private static TenantSchemaMigrationService NewService(ITenantRepository repository, ITenantProvisioner provisioner)
+ => new(repository, provisioner, NullLogger.Instance);
+
+ // Строка реестра тенантов для сценария.
+ // id: Id тенанта.
+ // name: Имя тенанта.
+ // Возвращает: Запись реестра.
+ private static TenantRecordDto TenantRecord(Guid id, string name)
+ => new(id, name, TenantStatuses.Active, DateTimeOffset.UtcNow);
+
+ // Последовательность тенантов с детерминированными Id (для проверки шардирования).
+ // count: Сколько записей создать.
+ // Возвращает: Записи реестра в порядке обхода.
+ private static TenantRecordDto[] Records(int count)
+ {
+ var records = new TenantRecordDto[count];
+ for (int index = 0; index < count; index++)
+ {
+ var id = Guid.Parse($"33333333-3333-3333-3333-{index + 1:D12}");
+ records[index] = TenantRecord(id, $"T{index + 1}");
+ }
+
+ return records;
+ }
+}
diff --git a/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantSchemaMigratorTests.cs b/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantSchemaMigratorTests.cs
index b1b5fdc..343eeb2 100644
--- a/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantSchemaMigratorTests.cs
+++ b/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantSchemaMigratorTests.cs
@@ -7,7 +7,7 @@ public sealed class TenantSchemaMigratorTests
[Fact]
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.DoesNotContain("; DROP", sql);
}
@@ -15,7 +15,7 @@ public sealed class TenantSchemaMigratorTests
[Fact]
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.DoesNotContain("\"tenant_a\"b\"", sql);
}
diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Settings/PromptDefaultsTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Settings/PromptDefaultsTests.cs
index 9e53bd5..7f96f43 100644
--- a/src/core/tests/Deal.Tests.Unit/Modules/Settings/PromptDefaultsTests.cs
+++ b/src/core/tests/Deal.Tests.Unit/Modules/Settings/PromptDefaultsTests.cs
@@ -91,7 +91,7 @@ public sealed class PromptDefaultsTests
[Fact]
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);
diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuthServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuthServiceTests.cs
index f1d3b1f..a0298b7 100644
--- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuthServiceTests.cs
+++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuthServiceTests.cs
@@ -125,7 +125,7 @@ public sealed class AuthServiceTests
[Fact]
public async Task ResolveSessionAsync_WithExpiredSession_ReturnsNull()
{
- var rawToken = "expired-session-raw-token";
+ string rawToken = "expired-session-raw-token";
_store.AddSession(new SessionDto(
SessionTokens.HashToken(rawToken), UserId, UserLogin, DateTimeOffset.UtcNow.AddDays(-1)));
@@ -140,7 +140,7 @@ public sealed class AuthServiceTests
[Fact]
public async Task ResolveSessionAsync_WithValidSession_ReturnsUser()
{
- var rawToken = "valid-session-raw-token";
+ string rawToken = "valid-session-raw-token";
_store.AddSession(new SessionDto(
SessionTokens.HashToken(rawToken), UserId, UserLogin, DateTimeOffset.UtcNow.AddDays(30)));
@@ -157,7 +157,7 @@ public sealed class AuthServiceTests
[Fact]
public async Task ResolveSessionAsync_WhenTenantSuspended_ReturnsNullImmediately()
{
- var rawToken = "suspended-tenant-session";
+ string rawToken = "suspended-tenant-session";
_store.AddSession(new SessionDto(
SessionTokens.HashToken(rawToken), UserId, UserLogin, DateTimeOffset.UtcNow.AddDays(30)));
await _tenantStore.UpdateStatusAsync(UserTenantId, TenantStatuses.Suspended, CancellationToken.None);
@@ -181,8 +181,8 @@ public sealed class AuthServiceTests
[Fact]
public async Task LogoutAsync_WithToken_DeletesSession()
{
- var rawToken = "logout-session-raw-token";
- var tokenHash = SessionTokens.HashToken(rawToken);
+ string rawToken = "logout-session-raw-token";
+ string tokenHash = SessionTokens.HashToken(rawToken);
_store.AddSession(new SessionDto(tokenHash, UserId, UserLogin, DateTimeOffset.UtcNow.AddDays(30)));
await _service.LogoutAsync(rawToken, CancellationToken.None);
@@ -330,8 +330,8 @@ public sealed class AuthServiceTests
public async Task LogoutAsync_ForImpersonationSession_ReturnsStopInfoAndDeletesSession()
{
var operatorId = Guid.NewGuid();
- var rawToken = "impersonation-session-raw-token";
- var tokenHash = SessionTokens.HashToken(rawToken);
+ string rawToken = "impersonation-session-raw-token";
+ string tokenHash = SessionTokens.HashToken(rawToken);
_store.AddSession(new SessionDto(tokenHash, UserId, UserLogin, DateTimeOffset.UtcNow.AddDays(30), operatorId));
var logout = await _service.LogoutAsync(rawToken, CancellationToken.None);
@@ -347,8 +347,8 @@ public sealed class AuthServiceTests
[Fact]
public async Task LogoutAsync_ForNormalSession_ReturnsNull()
{
- var rawToken = "normal-session-raw-token";
- var tokenHash = SessionTokens.HashToken(rawToken);
+ string rawToken = "normal-session-raw-token";
+ string tokenHash = SessionTokens.HashToken(rawToken);
_store.AddSession(new SessionDto(tokenHash, UserId, UserLogin, DateTimeOffset.UtcNow.AddDays(30)));
var logout = await _service.LogoutAsync(rawToken, CancellationToken.None);
diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeAuthStore.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeAuthStore.cs
index ab8348a..518f7b6 100644
--- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeAuthStore.cs
+++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeAuthStore.cs
@@ -102,7 +102,7 @@ public sealed class FakeAuthStore : IAuthStore
string passwordHash,
CancellationToken ct)
{
- var index = _users.FindIndex(u => u.Id == userId);
+ int index = _users.FindIndex(u => u.Id == userId);
if (index >= 0)
{
var user = _users[index];
diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/OperatorAuthServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/OperatorAuthServiceTests.cs
index 8eff488..4d575e3 100644
--- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/OperatorAuthServiceTests.cs
+++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/OperatorAuthServiceTests.cs
@@ -71,7 +71,7 @@ public sealed class OperatorAuthServiceTests
[Fact]
public async Task ResolveSessionAsync_WithValidSession_ReturnsOperatorIdentity()
{
- var rawToken = "valid-operator-session-raw-token";
+ string rawToken = "valid-operator-session-raw-token";
_store.AddSession(new OperatorSessionDto(
SessionTokens.HashToken(rawToken), OperatorId, OperatorLogin, DateTimeOffset.UtcNow.AddHours(12)));
@@ -89,7 +89,7 @@ public sealed class OperatorAuthServiceTests
[Fact]
public async Task ResolveSessionAsync_WithExpiredSession_ReturnsNull()
{
- var rawToken = "expired-operator-session-raw-token";
+ string rawToken = "expired-operator-session-raw-token";
_store.AddSession(new OperatorSessionDto(
SessionTokens.HashToken(rawToken), OperatorId, OperatorLogin, DateTimeOffset.UtcNow.AddHours(-1)));
@@ -104,7 +104,7 @@ public sealed class OperatorAuthServiceTests
[Fact]
public async Task ResolveSessionAsync_WhenOperatorWasDeleted_ReturnsNull()
{
- var rawToken = "orphan-operator-session-raw-token";
+ string rawToken = "orphan-operator-session-raw-token";
_store.AddSession(new OperatorSessionDto(
SessionTokens.HashToken(rawToken), Guid.NewGuid(), "deleted-operator", DateTimeOffset.UtcNow.AddHours(12)));
@@ -119,7 +119,7 @@ public sealed class OperatorAuthServiceTests
const string suspendedLogin = "suspended-operator";
var passwordHasher = new FakePasswordHasher();
_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(
SessionTokens.HashToken(rawToken), Guid.NewGuid(), suspendedLogin, DateTimeOffset.UtcNow.AddHours(12)));
@@ -141,8 +141,8 @@ public sealed class OperatorAuthServiceTests
[Fact]
public async Task LogoutAsync_WithToken_DeletesSession()
{
- var rawToken = "logout-operator-session-raw-token";
- var tokenHash = SessionTokens.HashToken(rawToken);
+ string rawToken = "logout-operator-session-raw-token";
+ string tokenHash = SessionTokens.HashToken(rawToken);
_store.AddSession(new OperatorSessionDto(tokenHash, OperatorId, OperatorLogin, DateTimeOffset.UtcNow.AddHours(12)));
await _service.LogoutAsync(rawToken, CancellationToken.None);
diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/OperatorBootstrapServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/OperatorBootstrapServiceTests.cs
index a7dee55..09c6d28 100644
--- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/OperatorBootstrapServiceTests.cs
+++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/OperatorBootstrapServiceTests.cs
@@ -22,7 +22,7 @@ public sealed class OperatorBootstrapServiceTests
[Fact]
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);
@@ -36,7 +36,7 @@ public sealed class OperatorBootstrapServiceTests
[Fact]
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.Single(_store.Operators);
@@ -45,8 +45,8 @@ public sealed class OperatorBootstrapServiceTests
[Fact]
public async Task RepeatedBootstrap_IsIdempotent_KeepsSingleOperator()
{
- var first = await _service.EnsureOperatorAsync(null, null, allowDevelopmentDefaults: true, CancellationToken.None);
- var second = await _service.EnsureOperatorAsync(null, null, allowDevelopmentDefaults: true, CancellationToken.None);
+ string? first = 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, second);
@@ -58,7 +58,7 @@ public sealed class OperatorBootstrapServiceTests
[Fact]
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.Empty(_store.Operators);
@@ -68,7 +68,7 @@ public sealed class OperatorBootstrapServiceTests
[Fact]
public async Task Production_WithEnvCredentials_CreatesOperatorWithNormalizedLogin()
{
- var login = await _service.EnsureOperatorAsync(
+ string? login = await _service.EnsureOperatorAsync(
" Root-Admin ", "root-password", allowDevelopmentDefaults: false, CancellationToken.None);
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")));
- 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.Single(_store.Operators);
diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/PasswordHasherTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/PasswordHasherTests.cs
index 54de23c..4d79279 100644
--- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/PasswordHasherTests.cs
+++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/PasswordHasherTests.cs
@@ -13,7 +13,7 @@ public sealed class PasswordHasherTests
[Fact]
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.StartsWith("$argon2id$", hash);
@@ -23,7 +23,7 @@ public sealed class PasswordHasherTests
[Fact]
public void Verify_WithCorrectPassword_ReturnsTrue()
{
- var hash = _hasher.Hash("secret42");
+ string hash = _hasher.Hash("secret42");
Assert.True(_hasher.Verify("secret42", hash));
}
@@ -31,7 +31,7 @@ public sealed class PasswordHasherTests
[Fact]
public void Verify_WithWrongPassword_ReturnsFalse()
{
- var hash = _hasher.Hash("secret42");
+ string hash = _hasher.Hash("secret42");
Assert.False(_hasher.Verify("wrong-password", hash));
}
@@ -39,8 +39,8 @@ public sealed class PasswordHasherTests
[Fact]
public void Hash_SamePasswordTwice_DifferentHashesBecauseOfRandomSalt()
{
- var first = _hasher.Hash("same-password");
- var second = _hasher.Hash("same-password");
+ string first = _hasher.Hash("same-password");
+ string second = _hasher.Hash("same-password");
Assert.NotEqual(first, second);
Assert.True(_hasher.Verify("same-password", first));
diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/SessionTokensTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/SessionTokensTests.cs
index 07a53de..4ee2c50 100644
--- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/SessionTokensTests.cs
+++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/SessionTokensTests.cs
@@ -10,8 +10,8 @@ public sealed class SessionTokensTests
[Fact]
public void NewToken_ReturnsUniqueLongEnoughTokens()
{
- var first = SessionTokens.NewToken();
- var second = SessionTokens.NewToken();
+ string first = SessionTokens.NewToken();
+ string second = SessionTokens.NewToken();
Assert.NotEqual(first, second);
Assert.True(first.Length > 32);
@@ -21,7 +21,7 @@ public sealed class SessionTokensTests
[Fact]
public void NewToken_UsesBase64UrlAlphabetWithoutPadding()
{
- var token = SessionTokens.NewToken();
+ string token = SessionTokens.NewToken();
// 32 байта в Base64Url без padding: 43 символа, без '=' и без небезопасных '+'/ '/'.
Assert.Equal(43, token.Length);
@@ -33,8 +33,8 @@ public sealed class SessionTokensTests
[Fact]
public void HashToken_IsDeterministicHexSha256()
{
- var first = SessionTokens.HashToken("raw-token-value");
- var second = SessionTokens.HashToken("raw-token-value");
+ string first = SessionTokens.HashToken("raw-token-value");
+ string second = SessionTokens.HashToken("raw-token-value");
Assert.Equal(first, second);
Assert.Equal(64, first.Length);
@@ -43,8 +43,8 @@ public sealed class SessionTokensTests
[Fact]
public void HashToken_DifferentTokensProduceDifferentHashes()
{
- var first = SessionTokens.HashToken("raw-token-1");
- var second = SessionTokens.HashToken("raw-token-2");
+ string first = SessionTokens.HashToken("raw-token-1");
+ string second = SessionTokens.HashToken("raw-token-2");
Assert.NotEqual(first, second);
}
diff --git a/src/core/tests/Deal.Tests.Unit/Support/ConversionRecomputerTests.cs b/src/core/tests/Deal.Tests.Unit/Support/ConversionRecomputerTests.cs
index eec9445..49f660e 100644
--- a/src/core/tests/Deal.Tests.Unit/Support/ConversionRecomputerTests.cs
+++ b/src/core/tests/Deal.Tests.Unit/Support/ConversionRecomputerTests.cs
@@ -41,7 +41,7 @@ public sealed class ConversionRecomputerTests
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);
AssertConverted(store, "l_board", convFrom: 92.59, convTo: 92.59, "EUR");
}
diff --git a/src/grpc-hosting/Deal.Grpc.Hosting/Interceptors/RpcCallLoggingInterceptor.cs b/src/grpc-hosting/Deal.Grpc.Hosting/Interceptors/RpcCallLoggingInterceptor.cs
index 7e2c70e..a7902a5 100644
--- a/src/grpc-hosting/Deal.Grpc.Hosting/Interceptors/RpcCallLoggingInterceptor.cs
+++ b/src/grpc-hosting/Deal.Grpc.Hosting/Interceptors/RpcCallLoggingInterceptor.cs
@@ -1,138 +1,138 @@
-using System.Diagnostics;
-using Grpc.Core;
-using Grpc.Core.Interceptors;
-using Microsoft.Extensions.Logging;
-using Deal.Grpc.Hosting.Models;
-using Deal.Grpc.Hosting.Options;
-using Deal.Grpc.Hosting.Services;
-
-namespace Deal.Grpc.Hosting.Interceptors;
-
-///
-/// Access-лог RPC Deal-сервисов
-///
-public sealed class RpcCallLoggingInterceptor : Interceptor
-{
- // Префикс методов стандартного gRPC-health — не логируется (инфраструктурный liveness).
- private const string HealthMethodPrefix = "/grpc.health.v1.Health/";
-
- // Деталь RpcException для сбоя реализации (фиксированный текст; детали ошибки не наружу).
- private const string UnknownFailureDetail = "Внутренняя ошибка сервиса";
-
- private readonly ILogger _logger;
-
- ///
- /// Создаёт интерцептор access-лога gRPC-вызовов.
- ///
- /// Логгер.
- public RpcCallLoggingInterceptor(ILogger logger)
- {
- ArgumentNullException.ThrowIfNull(logger);
- _logger = logger;
- }
-
- ///
- /// Логирует unary-RPC
- ///
- public override Task UnaryServerHandler(
- TRequest request,
- ServerCallContext context,
- UnaryServerMethod continuation)
- => LogAsync(context, () => continuation(request, context));
-
- ///
- /// Логирует client-streaming-RPC
- ///
- public override Task ClientStreamingServerHandler(
- IAsyncStreamReader requestStream,
- ServerCallContext context,
- ClientStreamingServerMethod continuation)
- => LogAsync(context, () => continuation(requestStream, context));
-
- ///
- /// Логирует server-streaming-RPC
- ///
- public override Task ServerStreamingServerHandler(
- TRequest request,
- IServerStreamWriter responseStream,
- ServerCallContext context,
- ServerStreamingServerMethod continuation)
- => LogAsync(context, () => continuation(request, responseStream, context));
-
- ///
- /// Логирует дуплексный RPC
- ///
- public override Task DuplexStreamingServerHandler(
- IAsyncStreamReader requestStream,
- IServerStreamWriter responseStream,
- ServerCallContext context,
- DuplexStreamingServerMethod continuation)
- => LogAsync(context, () => continuation(requestStream, responseStream, context));
-
- // Исполняет вызов под access-логом: health пропускается; успех — OK, отмена клиента — Cancelled,
- // RpcException — код статуса исключения, прочие сбои реализации — Unknown + RpcException.
- // TResult: Тип результата вызова.
- // context: Контекст вызова (метод — context.Method).
- // invoke: Вызов нижестоящего обработчика.
- private async Task LogAsync(ServerCallContext context, Func> invoke)
- {
- if (context.Method.StartsWith(HealthMethodPrefix, StringComparison.Ordinal))
- {
- return await invoke().ConfigureAwait(false);
- }
-
- long startedAt = Stopwatch.GetTimestamp();
- try
- {
- TResult response = await invoke().ConfigureAwait(false);
- LogCall(context, startedAt, null);
- return response;
- }
- catch (OperationCanceledException)
- {
- // Клиент отменил вызов (дисконнект/дедлайн) — статус Cancelled.
- LogCall(context, startedAt, StatusCode.Cancelled);
- throw;
- }
- catch (RpcException rpcException)
- {
- LogCall(context, startedAt, rpcException.Status.StatusCode);
- throw;
- }
- catch (Exception exception)
- {
- // «Прочие» сбои реализации gRPC показал бы клиенту как UNKNOWN мимо access-лога: логируем
- // строку со статусом Unknown, пишем детали сбоя и переводим в RpcException (текст фиксирован).
- _logger.LogError(exception, "gRPC {RpcMethod}: необработанный сбой реализации", context.Method);
- LogCall(context, startedAt, StatusCode.Unknown);
- throw new RpcException(new Status(StatusCode.Unknown, UnknownFailureDetail));
- }
- }
-
- // Обёртка для handler-ов, возвращающих Task (server-streaming/дуплексный).
- // context: Контекст вызова (метод — context.Method).
- // invoke: Вызов нижестоящего обработчика.
- private Task LogAsync(ServerCallContext context, Func invoke)
- => LogAsync(context, async () =>
- {
- await invoke().ConfigureAwait(false);
- return true;
- });
-
- // Пишет одну строку access-лога: полное имя RPC-метода, статус, длительность.
- // context: Контекст вызова (метод).
- // startedAt: Метка времени старта вызова (Stopwatch.GetTimestamp).
- // statusCode: Итоговый gRPC-статус; null — успех (OK).
- private void LogCall(
- ServerCallContext context,
- long startedAt,
- StatusCode? statusCode)
- {
- long elapsedMs = (long)Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds;
- _logger.LogInformation(
- "gRPC {RpcMethod}: {GrpcStatus} за {DurationMs} мс",
- context.Method,
- statusCode?.ToString() ?? StatusCode.OK.ToString(),
- elapsedMs);
- }
-}
+using System.Diagnostics;
+using Deal.Grpc.Hosting.Models;
+using Deal.Grpc.Hosting.Options;
+using Deal.Grpc.Hosting.Services;
+using Grpc.Core;
+using Grpc.Core.Interceptors;
+using Microsoft.Extensions.Logging;
+
+namespace Deal.Grpc.Hosting.Interceptors;
+
+///
+/// Access-лог RPC Deal-сервисов
+///
+public sealed class RpcCallLoggingInterceptor : Interceptor
+{
+ // Префикс методов стандартного gRPC-health — не логируется (инфраструктурный liveness).
+ private const string HealthMethodPrefix = "/grpc.health.v1.Health/";
+
+ // Деталь RpcException для сбоя реализации (фиксированный текст; детали ошибки не наружу).
+ private const string UnknownFailureDetail = "Внутренняя ошибка сервиса";
+
+ private readonly ILogger _logger;
+
+ ///
+ /// Создаёт интерцептор access-лога gRPC-вызовов.
+ ///
+ /// Логгер.
+ public RpcCallLoggingInterceptor(ILogger logger)
+ {
+ ArgumentNullException.ThrowIfNull(logger);
+ _logger = logger;
+ }
+
+ ///
+ /// Логирует unary-RPC
+ ///
+ public override Task UnaryServerHandler(
+ TRequest request,
+ ServerCallContext context,
+ UnaryServerMethod continuation)
+ => LogAsync(context, () => continuation(request, context));
+
+ ///
+ /// Логирует client-streaming-RPC
+ ///
+ public override Task ClientStreamingServerHandler(
+ IAsyncStreamReader requestStream,
+ ServerCallContext context,
+ ClientStreamingServerMethod continuation)
+ => LogAsync(context, () => continuation(requestStream, context));
+
+ ///
+ /// Логирует server-streaming-RPC
+ ///
+ public override Task ServerStreamingServerHandler(
+ TRequest request,
+ IServerStreamWriter responseStream,
+ ServerCallContext context,
+ ServerStreamingServerMethod continuation)
+ => LogAsync(context, () => continuation(request, responseStream, context));
+
+ ///
+ /// Логирует дуплексный RPC
+ ///
+ public override Task DuplexStreamingServerHandler(
+ IAsyncStreamReader requestStream,
+ IServerStreamWriter responseStream,
+ ServerCallContext context,
+ DuplexStreamingServerMethod continuation)
+ => LogAsync(context, () => continuation(requestStream, responseStream, context));
+
+ // Исполняет вызов под access-логом: health пропускается; успех — OK, отмена клиента — Cancelled,
+ // RpcException — код статуса исключения, прочие сбои реализации — Unknown + RpcException.
+ // TResult: Тип результата вызова.
+ // context: Контекст вызова (метод — context.Method).
+ // invoke: Вызов нижестоящего обработчика.
+ private async Task LogAsync(ServerCallContext context, Func> invoke)
+ {
+ if (context.Method.StartsWith(HealthMethodPrefix, StringComparison.Ordinal))
+ {
+ return await invoke().ConfigureAwait(false);
+ }
+
+ long startedAt = Stopwatch.GetTimestamp();
+ try
+ {
+ TResult response = await invoke().ConfigureAwait(false);
+ LogCall(context, startedAt, null);
+ return response;
+ }
+ catch (OperationCanceledException)
+ {
+ // Клиент отменил вызов (дисконнект/дедлайн) — статус Cancelled.
+ LogCall(context, startedAt, StatusCode.Cancelled);
+ throw;
+ }
+ catch (RpcException rpcException)
+ {
+ LogCall(context, startedAt, rpcException.Status.StatusCode);
+ throw;
+ }
+ catch (Exception exception)
+ {
+ // «Прочие» сбои реализации gRPC показал бы клиенту как UNKNOWN мимо access-лога: логируем
+ // строку со статусом Unknown, пишем детали сбоя и переводим в RpcException (текст фиксирован).
+ _logger.LogError(exception, "gRPC {RpcMethod}: необработанный сбой реализации", context.Method);
+ LogCall(context, startedAt, StatusCode.Unknown);
+ throw new RpcException(new Status(StatusCode.Unknown, UnknownFailureDetail));
+ }
+ }
+
+ // Обёртка для handler-ов, возвращающих Task (server-streaming/дуплексный).
+ // context: Контекст вызова (метод — context.Method).
+ // invoke: Вызов нижестоящего обработчика.
+ private Task LogAsync(ServerCallContext context, Func invoke)
+ => LogAsync(context, async () =>
+ {
+ await invoke().ConfigureAwait(false);
+ return true;
+ });
+
+ // Пишет одну строку access-лога: полное имя RPC-метода, статус, длительность.
+ // context: Контекст вызова (метод).
+ // startedAt: Метка времени старта вызова (Stopwatch.GetTimestamp).
+ // statusCode: Итоговый gRPC-статус; null — успех (OK).
+ private void LogCall(
+ ServerCallContext context,
+ long startedAt,
+ StatusCode? statusCode)
+ {
+ long elapsedMs = (long)Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds;
+ _logger.LogInformation(
+ "gRPC {RpcMethod}: {GrpcStatus} за {DurationMs} мс",
+ context.Method,
+ statusCode?.ToString() ?? StatusCode.OK.ToString(),
+ elapsedMs);
+ }
+}
diff --git a/src/grpc-hosting/Deal.Grpc.Hosting/Interceptors/ServiceTokenInterceptor.cs b/src/grpc-hosting/Deal.Grpc.Hosting/Interceptors/ServiceTokenInterceptor.cs
index 1bdb4e3..0c02934 100644
--- a/src/grpc-hosting/Deal.Grpc.Hosting/Interceptors/ServiceTokenInterceptor.cs
+++ b/src/grpc-hosting/Deal.Grpc.Hosting/Interceptors/ServiceTokenInterceptor.cs
@@ -1,130 +1,130 @@
-using System.Security.Cryptography;
-using System.Text;
-using Grpc.Core;
-using Grpc.Core.Interceptors;
-using Microsoft.Extensions.Configuration;
-using Deal.Grpc.Hosting.Models;
-using Deal.Grpc.Hosting.Options;
-using Deal.Grpc.Hosting.Services;
-
-namespace Deal.Grpc.Hosting.Interceptors;
-
-///
-/// Серверный интерцептор service-token.
-///
-public sealed class ServiceTokenInterceptor : Interceptor
-{
- public const string ServiceTokenMetadataKey = "service-token";
-
- // Префикс методов стандартного gRPC-health, освобождённых от проверки токена.
- private const string HealthMethodPrefix = "/grpc.health.v1.Health/";
-
- private const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN";
-
- private const string RejectionDetail = "service-token отсутствует или неверен";
-
- private readonly byte[] _expectedTokenBytes;
-
- ///
- /// Создаёт интерцептор.
- ///
- /// Конфигурация хоста (env-провайдер WebApplicationBuilder).
- public ServiceTokenInterceptor(IConfiguration configuration)
- {
- ArgumentNullException.ThrowIfNull(configuration);
- _expectedTokenBytes = Encoding.UTF8.GetBytes(configuration[ServiceTokenEnvKey] ?? string.Empty);
- }
-
- ///
- /// Проверяет токен для unary-RPC и передаёт вызов дальше.
- ///
- public override async Task UnaryServerHandler(
- TRequest request,
- ServerCallContext context,
- UnaryServerMethod continuation)
- {
- EnsureAuthorized(context);
- return await continuation(request, context).ConfigureAwait(false);
- }
-
- ///
- /// Проверяет токен для client-streaming-RPC и передаёт вызов дальше.
- ///
- public override async Task ClientStreamingServerHandler(
- IAsyncStreamReader requestStream,
- ServerCallContext context,
- ClientStreamingServerMethod continuation)
- {
- EnsureAuthorized(context);
- return await continuation(requestStream, context).ConfigureAwait(false);
- }
-
- ///
- /// Проверяет токен для server-streaming-RPC и передаёт вызов дальше.
- ///
- public override async Task ServerStreamingServerHandler(
- TRequest request,
- IServerStreamWriter responseStream,
- ServerCallContext context,
- ServerStreamingServerMethod continuation)
- {
- EnsureAuthorized(context);
- await continuation(request, responseStream, context).ConfigureAwait(false);
- }
-
- ///
- /// Проверяет токен для дуплексного RPC и передаёт вызов дальше.
- ///
- public override async Task DuplexStreamingServerHandler(
- IAsyncStreamReader requestStream,
- IServerStreamWriter responseStream,
- ServerCallContext context,
- DuplexStreamingServerMethod continuation)
- {
- EnsureAuthorized(context);
- await continuation(requestStream, responseStream, context).ConfigureAwait(false);
- }
-
- // Проверка токена для любого вида RPC: сначала пропускаются методы gRPC-health (безопасны), затем
- // сверяется metadata «service-token» с ожидаемым значением; несовпадение — UNAUTHENTICATED.
- // context: Контекст вызова (метод и metadata из заголовков).
- private void EnsureAuthorized(ServerCallContext context)
- {
- if (context.Method.StartsWith(HealthMethodPrefix, StringComparison.Ordinal))
- {
- return;
- }
-
- // Fail-closed: env-токен не задан — Deal-RPC отклоняется, даже если запрос нёс «пустой» токен
- // (иначе «» == «» прошло бы сравнение ниже). Health уже пропущен выше — остаётся живым.
- if (_expectedTokenBytes.Length == 0)
- {
- throw Rejection();
- }
-
- string? actualToken = context.RequestHeaders.GetValue(ServiceTokenMetadataKey);
- if (!TokenMatches(actualToken, _expectedTokenBytes))
- {
- throw Rejection();
- }
- }
-
- // Сравнивает токен с ожидаемым constant-time (FixedTimeEquals по UTF-8-байтам): раннего выхода по
- // содержимому нет — время сравнения не зависит от совпадения префикса (замечание code-review).
- // actualToken: Токен из metadata (null — заголовка нет).
- // expectedTokenBytes: Ожидаемый токен в UTF-8-байтах.
- private static bool TokenMatches(string? actualToken, byte[] expectedTokenBytes)
- {
- if (actualToken is null)
- {
- return false;
- }
-
- byte[] actualTokenBytes = Encoding.UTF8.GetBytes(actualToken);
- return CryptographicOperations.FixedTimeEquals(actualTokenBytes, expectedTokenBytes);
- }
-
- // Создаёт отказ UNAUTHENTICATED с общим текстом детали.
- private static RpcException Rejection()
- => new(new Status(StatusCode.Unauthenticated, RejectionDetail));
-}
+using System.Security.Cryptography;
+using System.Text;
+using Deal.Grpc.Hosting.Models;
+using Deal.Grpc.Hosting.Options;
+using Deal.Grpc.Hosting.Services;
+using Grpc.Core;
+using Grpc.Core.Interceptors;
+using Microsoft.Extensions.Configuration;
+
+namespace Deal.Grpc.Hosting.Interceptors;
+
+///
+/// Серверный интерцептор service-token.
+///
+public sealed class ServiceTokenInterceptor : Interceptor
+{
+ public const string ServiceTokenMetadataKey = "service-token";
+
+ // Префикс методов стандартного gRPC-health, освобождённых от проверки токена.
+ private const string HealthMethodPrefix = "/grpc.health.v1.Health/";
+
+ private const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN";
+
+ private const string RejectionDetail = "service-token отсутствует или неверен";
+
+ private readonly byte[] _expectedTokenBytes;
+
+ ///
+ /// Создаёт интерцептор.
+ ///
+ /// Конфигурация хоста (env-провайдер WebApplicationBuilder).
+ public ServiceTokenInterceptor(IConfiguration configuration)
+ {
+ ArgumentNullException.ThrowIfNull(configuration);
+ _expectedTokenBytes = Encoding.UTF8.GetBytes(configuration[ServiceTokenEnvKey] ?? string.Empty);
+ }
+
+ ///
+ /// Проверяет токен для unary-RPC и передаёт вызов дальше.
+ ///
+ public override async Task UnaryServerHandler(
+ TRequest request,
+ ServerCallContext context,
+ UnaryServerMethod continuation)
+ {
+ EnsureAuthorized(context);
+ return await continuation(request, context).ConfigureAwait(false);
+ }
+
+ ///
+ /// Проверяет токен для client-streaming-RPC и передаёт вызов дальше.
+ ///
+ public override async Task ClientStreamingServerHandler(
+ IAsyncStreamReader requestStream,
+ ServerCallContext context,
+ ClientStreamingServerMethod continuation)
+ {
+ EnsureAuthorized(context);
+ return await continuation(requestStream, context).ConfigureAwait(false);
+ }
+
+ ///
+ /// Проверяет токен для server-streaming-RPC и передаёт вызов дальше.
+ ///
+ public override async Task ServerStreamingServerHandler(
+ TRequest request,
+ IServerStreamWriter responseStream,
+ ServerCallContext context,
+ ServerStreamingServerMethod continuation)
+ {
+ EnsureAuthorized(context);
+ await continuation(request, responseStream, context).ConfigureAwait(false);
+ }
+
+ ///
+ /// Проверяет токен для дуплексного RPC и передаёт вызов дальше.
+ ///
+ public override async Task DuplexStreamingServerHandler(
+ IAsyncStreamReader requestStream,
+ IServerStreamWriter responseStream,
+ ServerCallContext context,
+ DuplexStreamingServerMethod continuation)
+ {
+ EnsureAuthorized(context);
+ await continuation(requestStream, responseStream, context).ConfigureAwait(false);
+ }
+
+ // Проверка токена для любого вида RPC: сначала пропускаются методы gRPC-health (безопасны), затем
+ // сверяется metadata «service-token» с ожидаемым значением; несовпадение — UNAUTHENTICATED.
+ // context: Контекст вызова (метод и metadata из заголовков).
+ private void EnsureAuthorized(ServerCallContext context)
+ {
+ if (context.Method.StartsWith(HealthMethodPrefix, StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ // Fail-closed: env-токен не задан — Deal-RPC отклоняется, даже если запрос нёс «пустой» токен
+ // (иначе «» == «» прошло бы сравнение ниже). Health уже пропущен выше — остаётся живым.
+ if (_expectedTokenBytes.Length == 0)
+ {
+ throw Rejection();
+ }
+
+ string? actualToken = context.RequestHeaders.GetValue(ServiceTokenMetadataKey);
+ if (!TokenMatches(actualToken, _expectedTokenBytes))
+ {
+ throw Rejection();
+ }
+ }
+
+ // Сравнивает токен с ожидаемым constant-time (FixedTimeEquals по UTF-8-байтам): раннего выхода по
+ // содержимому нет — время сравнения не зависит от совпадения префикса (замечание code-review).
+ // actualToken: Токен из metadata (null — заголовка нет).
+ // expectedTokenBytes: Ожидаемый токен в UTF-8-байтах.
+ private static bool TokenMatches(string? actualToken, byte[] expectedTokenBytes)
+ {
+ if (actualToken is null)
+ {
+ return false;
+ }
+
+ byte[] actualTokenBytes = Encoding.UTF8.GetBytes(actualToken);
+ return CryptographicOperations.FixedTimeEquals(actualTokenBytes, expectedTokenBytes);
+ }
+
+ // Создаёт отказ UNAUTHENTICATED с общим текстом детали.
+ private static RpcException Rejection()
+ => new(new Status(StatusCode.Unauthenticated, RejectionDetail));
+}
diff --git a/src/grpc-hosting/Deal.Grpc.Hosting/Options/MtlsOptions.cs b/src/grpc-hosting/Deal.Grpc.Hosting/Options/MtlsOptions.cs
index 7ed5d98..5ea9ae1 100644
--- a/src/grpc-hosting/Deal.Grpc.Hosting/Options/MtlsOptions.cs
+++ b/src/grpc-hosting/Deal.Grpc.Hosting/Options/MtlsOptions.cs
@@ -1,104 +1,104 @@
-using Microsoft.Extensions.Configuration;
-using Deal.Grpc.Hosting.Interceptors;
-using Deal.Grpc.Hosting.Models;
-using Deal.Grpc.Hosting.Services;
-
-namespace Deal.Grpc.Hosting.Options;
-
-///
-/// Конфигурация mTLS-транспорта внутреннего gRPC.
-///
-public sealed class MtlsOptions
-{
- ///
- /// Env-ключ флага: 1/true включает mTLS
- ///
- public const string EnabledEnvKey = "DEAL_MTLS_ENABLED";
-
- ///
- /// Env-ключ пути к PFX серверного сертификата процесса
- ///
- public const string ServerCertPfxEnvKey = "DEAL_MTLS_SERVER_CERT_PFX";
-
- ///
- /// Env-ключ пароля серверного PFX.
- ///
- public const string ServerCertPasswordEnvKey = "DEAL_MTLS_SERVER_CERT_PASSWORD";
-
- ///
- /// Env-ключ пути к PFX клиентского сертификата
- ///
- public const string ClientCertPfxEnvKey = "DEAL_MTLS_CLIENT_CERT_PFX";
-
- ///
- /// Env-ключ пароля клиентского PFX.
- ///
- public const string ClientCertPasswordEnvKey = "DEAL_MTLS_CLIENT_CERT_PASSWORD";
-
- ///
- /// Env-ключ пути к PEM dev-CA
- ///
- public const string CaPemEnvKey = "DEAL_MTLS_CA_PEM";
-
- ///
- /// True — транспорт внутренних gRPC-эндпоинтов и исходящих каналов под mTLS.
- ///
- public bool Enabled { get; init; }
-
- ///
- /// Путь к PFX серверного сертификата процесса
- ///
- public string ServerCertPfx { get; init; } = string.Empty;
-
- ///
- /// Пароль серверного PFX
- ///
- public string ServerCertPassword { get; init; } = string.Empty;
-
- ///
- /// Путь к PFX клиентского сертификата
- ///
- public string ClientCertPfx { get; init; } = string.Empty;
-
- ///
- /// Пароль клиентского PFX
- ///
- public string ClientCertPassword { get; init; } = string.Empty;
-
- ///
- /// Путь к PEM-файлу dev-CA
- ///
- public string CaPem { get; init; } = string.Empty;
-
- ///
- /// Читает опции из конфигурации хоста.
- ///
- /// Конфигурация хоста (env-провайдер WebApplicationBuilder).
- /// Опции mTLS (флаг выключен — остальные поля пустые).
- public static MtlsOptions FromConfiguration(IConfiguration configuration)
- {
- ArgumentNullException.ThrowIfNull(configuration);
- return new MtlsOptions
- {
- Enabled = IsEnabled(configuration[EnabledEnvKey]),
- ServerCertPfx = Trimmed(configuration[ServerCertPfxEnvKey]),
- ServerCertPassword = configuration[ServerCertPasswordEnvKey] ?? string.Empty,
- ClientCertPfx = Trimmed(configuration[ClientCertPfxEnvKey]),
- ClientCertPassword = configuration[ClientCertPasswordEnvKey] ?? string.Empty,
- CaPem = Trimmed(configuration[CaPemEnvKey]),
- };
- }
-
- ///
- /// Разбирает значение флага DEAL_MTLS_ENABLED
- ///
- /// Сырое значение env (null/пусто — выключено).
- public static bool IsEnabled(string? rawValue)
- => string.Equals(rawValue, "1", StringComparison.Ordinal)
- || string.Equals(rawValue, "true", StringComparison.OrdinalIgnoreCase);
-
- // Обрезает путь конфигурации (env-значения с пробелами/кавычками не передаются в файловые API).
- // rawValue: Сырое значение env.
- private static string Trimmed(string? rawValue)
- => rawValue is null ? string.Empty : rawValue.Trim();
-}
+using Deal.Grpc.Hosting.Interceptors;
+using Deal.Grpc.Hosting.Models;
+using Deal.Grpc.Hosting.Services;
+using Microsoft.Extensions.Configuration;
+
+namespace Deal.Grpc.Hosting.Options;
+
+///
+/// Конфигурация mTLS-транспорта внутреннего gRPC.
+///
+public sealed class MtlsOptions
+{
+ ///
+ /// Env-ключ флага: 1/true включает mTLS
+ ///
+ public const string EnabledEnvKey = "DEAL_MTLS_ENABLED";
+
+ ///
+ /// Env-ключ пути к PFX серверного сертификата процесса
+ ///
+ public const string ServerCertPfxEnvKey = "DEAL_MTLS_SERVER_CERT_PFX";
+
+ ///
+ /// Env-ключ пароля серверного PFX.
+ ///
+ public const string ServerCertPasswordEnvKey = "DEAL_MTLS_SERVER_CERT_PASSWORD";
+
+ ///
+ /// Env-ключ пути к PFX клиентского сертификата
+ ///
+ public const string ClientCertPfxEnvKey = "DEAL_MTLS_CLIENT_CERT_PFX";
+
+ ///
+ /// Env-ключ пароля клиентского PFX.
+ ///
+ public const string ClientCertPasswordEnvKey = "DEAL_MTLS_CLIENT_CERT_PASSWORD";
+
+ ///
+ /// Env-ключ пути к PEM dev-CA
+ ///
+ public const string CaPemEnvKey = "DEAL_MTLS_CA_PEM";
+
+ ///
+ /// True — транспорт внутренних gRPC-эндпоинтов и исходящих каналов под mTLS.
+ ///
+ public bool Enabled { get; init; }
+
+ ///
+ /// Путь к PFX серверного сертификата процесса
+ ///
+ public string ServerCertPfx { get; init; } = string.Empty;
+
+ ///
+ /// Пароль серверного PFX
+ ///
+ public string ServerCertPassword { get; init; } = string.Empty;
+
+ ///
+ /// Путь к PFX клиентского сертификата
+ ///
+ public string ClientCertPfx { get; init; } = string.Empty;
+
+ ///
+ /// Пароль клиентского PFX
+ ///
+ public string ClientCertPassword { get; init; } = string.Empty;
+
+ ///
+ /// Путь к PEM-файлу dev-CA
+ ///
+ public string CaPem { get; init; } = string.Empty;
+
+ ///
+ /// Читает опции из конфигурации хоста.
+ ///
+ /// Конфигурация хоста (env-провайдер WebApplicationBuilder).
+ /// Опции mTLS (флаг выключен — остальные поля пустые).
+ public static MtlsOptions FromConfiguration(IConfiguration configuration)
+ {
+ ArgumentNullException.ThrowIfNull(configuration);
+ return new MtlsOptions
+ {
+ Enabled = IsEnabled(configuration[EnabledEnvKey]),
+ ServerCertPfx = Trimmed(configuration[ServerCertPfxEnvKey]),
+ ServerCertPassword = configuration[ServerCertPasswordEnvKey] ?? string.Empty,
+ ClientCertPfx = Trimmed(configuration[ClientCertPfxEnvKey]),
+ ClientCertPassword = configuration[ClientCertPasswordEnvKey] ?? string.Empty,
+ CaPem = Trimmed(configuration[CaPemEnvKey]),
+ };
+ }
+
+ ///
+ /// Разбирает значение флага DEAL_MTLS_ENABLED
+ ///
+ /// Сырое значение env (null/пусто — выключено).
+ public static bool IsEnabled(string? rawValue)
+ => string.Equals(rawValue, "1", StringComparison.Ordinal)
+ || string.Equals(rawValue, "true", StringComparison.OrdinalIgnoreCase);
+
+ // Обрезает путь конфигурации (env-значения с пробелами/кавычками не передаются в файловые API).
+ // rawValue: Сырое значение env.
+ private static string Trimmed(string? rawValue)
+ => rawValue is null ? string.Empty : rawValue.Trim();
+}
diff --git a/src/grpc-hosting/Deal.Grpc.Hosting/Services/DealLogging.cs b/src/grpc-hosting/Deal.Grpc.Hosting/Services/DealLogging.cs
index 62a652f..52eb428 100644
--- a/src/grpc-hosting/Deal.Grpc.Hosting/Services/DealLogging.cs
+++ b/src/grpc-hosting/Deal.Grpc.Hosting/Services/DealLogging.cs
@@ -1,109 +1,109 @@
-using Microsoft.AspNetCore.Builder;
-using Microsoft.Extensions.Configuration;
-using Microsoft.Extensions.Hosting;
-using Serilog;
-using Serilog.Events;
-using Serilog.Formatting.Compact;
-using Deal.Grpc.Hosting.Interceptors;
-using Deal.Grpc.Hosting.Models;
-using Deal.Grpc.Hosting.Options;
-
-namespace Deal.Grpc.Hosting.Services;
-
-///
-/// Serilog-конфигурация процесса Deal-сервиса.
-///
-public static class DealLogging
-{
- // Env-ключ минимального уровня Serilog (Debug/Information/Warning/Error; дефолт Information).
- private const string MinimumLevelEnvKey = "DEAL_LOG_LEVEL";
-
- // Env-ключ каталога rolling-файлов (дефолт data/logs под ContentRoot).
- private const string LogsDirectoryEnvKey = "DEAL_LOGS_DIR";
-
- // Каталог логов по умолчанию (относительно ContentRoot).
- private const string DefaultLogsSubdirectory = "data/logs";
-
- // Шаблон имени rolling-файла (Serilog добавляет дату): deal-telegram-20260908.json.
- private const string LogFileNameTemplate = "deal-{0}-.json";
-
- // Сколько rolling-файлов хранится (суток).
- private const int RetainedFileCount = 30;
-
- // Текстовая разметка консоли в Development (цвета — дефолтной темой Serilog).
- private const string DevelopmentConsoleTemplate =
- "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}";
-
- // Категория Grpc.AspNetCore: не ниже Information (внутренние Debug-события вызовов не дублируют access-лог).
- private const string GrpcCategory = "Grpc";
-
- // Дефолтный уровень при пустом/невалидном env DEAL_LOG_LEVEL.
- private const LogEventLevel DefaultMinimumLevel = LogEventLevel.Information;
-
- ///
- /// Подключает Serilog к хосту
- ///
- /// Билдер WebApplication процесса (до Build).
- /// Имя процесса для имени файла-лога (telegram/ai/ml/…).
- public static void Configure(WebApplicationBuilder builder, string processName)
- {
- ArgumentNullException.ThrowIfNull(builder);
- ArgumentException.ThrowIfNullOrWhiteSpace(processName);
- builder.Host.UseSerilog((context, loggerConfiguration) =>
- Apply(loggerConfiguration, context.HostingEnvironment, context.Configuration, processName));
- }
-
- // Собирает LoggerConfiguration процесса: уровень/фильтры, rolling-файл, консоль.
- // loggerConfiguration: Конфигурация Serilog (до CreateLogger).
- // environment: Окружение хоста (Development — текстовая консоль).
- // configuration: Конфигурация хоста (env DEAL_LOG_*).
- // processName: Имя процесса (суффикс имени rolling-файла).
- private static void Apply(
- LoggerConfiguration loggerConfiguration,
- IHostEnvironment environment,
- IConfiguration configuration,
- string processName)
- {
- loggerConfiguration
- .MinimumLevel.Is(ParseMinimumLevel(configuration[MinimumLevelEnvKey]))
- .MinimumLevel.Override(GrpcCategory, LogEventLevel.Information)
- .Enrich.FromLogContext();
-
- string logsDirectory = ResolveLogsDirectory(environment.ContentRootPath, configuration[LogsDirectoryEnvKey]);
- Directory.CreateDirectory(logsDirectory);
- string logFilePath = Path.Combine(
- logsDirectory,
- string.Format(LogFileNameTemplate, processName));
- loggerConfiguration.WriteTo.File(
- new CompactJsonFormatter(),
- logFilePath,
- rollingInterval: RollingInterval.Day,
- retainedFileCountLimit: RetainedFileCount);
-
- if (environment.IsDevelopment())
- {
- loggerConfiguration.WriteTo.Console(outputTemplate: DevelopmentConsoleTemplate);
- }
- else
- {
- loggerConfiguration.WriteTo.Console(new CompactJsonFormatter());
- }
- }
-
- // Каталог rolling-файлов: env DEAL_LOGS_DIR либо data/logs под ContentRoot процесса.
- // contentRootPath: ContentRoot хоста (/app в контейнере).
- // configuredDirectory: Значение env DEAL_LOGS_DIR (null/пусто — дефолт).
- // Возвращает: Абсолютный путь каталога логов.
- private static string ResolveLogsDirectory(string contentRootPath, string? configuredDirectory)
- => string.IsNullOrWhiteSpace(configuredDirectory)
- ? Path.Combine(contentRootPath, DefaultLogsSubdirectory)
- : configuredDirectory.Trim();
-
- // Разбирает env DEAL_LOG_LEVEL; пустое/невалидное значение — DefaultMinimumLevel.
- // rawValue: Сырое значение env.
- // Возвращает: Уровень Serilog.
- private static LogEventLevel ParseMinimumLevel(string? rawValue)
- => Enum.TryParse(rawValue, ignoreCase: true, out LogEventLevel parsedLevel)
- ? parsedLevel
- : DefaultMinimumLevel;
-}
+using Deal.Grpc.Hosting.Interceptors;
+using Deal.Grpc.Hosting.Models;
+using Deal.Grpc.Hosting.Options;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Hosting;
+using Serilog;
+using Serilog.Events;
+using Serilog.Formatting.Compact;
+
+namespace Deal.Grpc.Hosting.Services;
+
+///
+/// Serilog-конфигурация процесса Deal-сервиса.
+///
+public static class DealLogging
+{
+ // Env-ключ минимального уровня Serilog (Debug/Information/Warning/Error; дефолт Information).
+ private const string MinimumLevelEnvKey = "DEAL_LOG_LEVEL";
+
+ // Env-ключ каталога rolling-файлов (дефолт data/logs под ContentRoot).
+ private const string LogsDirectoryEnvKey = "DEAL_LOGS_DIR";
+
+ // Каталог логов по умолчанию (относительно ContentRoot).
+ private const string DefaultLogsSubdirectory = "data/logs";
+
+ // Шаблон имени rolling-файла (Serilog добавляет дату): deal-telegram-20260908.json.
+ private const string LogFileNameTemplate = "deal-{0}-.json";
+
+ // Сколько rolling-файлов хранится (суток).
+ private const int RetainedFileCount = 30;
+
+ // Текстовая разметка консоли в Development (цвета — дефолтной темой Serilog).
+ private const string DevelopmentConsoleTemplate =
+ "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}";
+
+ // Категория Grpc.AspNetCore: не ниже Information (внутренние Debug-события вызовов не дублируют access-лог).
+ private const string GrpcCategory = "Grpc";
+
+ // Дефолтный уровень при пустом/невалидном env DEAL_LOG_LEVEL.
+ private const LogEventLevel DefaultMinimumLevel = LogEventLevel.Information;
+
+ ///
+ /// Подключает Serilog к хосту
+ ///
+ /// Билдер WebApplication процесса (до Build).
+ /// Имя процесса для имени файла-лога (telegram/ai/ml/…).
+ public static void Configure(WebApplicationBuilder builder, string processName)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrWhiteSpace(processName);
+ builder.Host.UseSerilog((context, loggerConfiguration) =>
+ Apply(loggerConfiguration, context.HostingEnvironment, context.Configuration, processName));
+ }
+
+ // Собирает LoggerConfiguration процесса: уровень/фильтры, rolling-файл, консоль.
+ // loggerConfiguration: Конфигурация Serilog (до CreateLogger).
+ // environment: Окружение хоста (Development — текстовая консоль).
+ // configuration: Конфигурация хоста (env DEAL_LOG_*).
+ // processName: Имя процесса (суффикс имени rolling-файла).
+ private static void Apply(
+ LoggerConfiguration loggerConfiguration,
+ IHostEnvironment environment,
+ IConfiguration configuration,
+ string processName)
+ {
+ loggerConfiguration
+ .MinimumLevel.Is(ParseMinimumLevel(configuration[MinimumLevelEnvKey]))
+ .MinimumLevel.Override(GrpcCategory, LogEventLevel.Information)
+ .Enrich.FromLogContext();
+
+ string logsDirectory = ResolveLogsDirectory(environment.ContentRootPath, configuration[LogsDirectoryEnvKey]);
+ Directory.CreateDirectory(logsDirectory);
+ string logFilePath = Path.Combine(
+ logsDirectory,
+ string.Format(LogFileNameTemplate, processName));
+ loggerConfiguration.WriteTo.File(
+ new CompactJsonFormatter(),
+ logFilePath,
+ rollingInterval: RollingInterval.Day,
+ retainedFileCountLimit: RetainedFileCount);
+
+ if (environment.IsDevelopment())
+ {
+ loggerConfiguration.WriteTo.Console(outputTemplate: DevelopmentConsoleTemplate);
+ }
+ else
+ {
+ loggerConfiguration.WriteTo.Console(new CompactJsonFormatter());
+ }
+ }
+
+ // Каталог rolling-файлов: env DEAL_LOGS_DIR либо data/logs под ContentRoot процесса.
+ // contentRootPath: ContentRoot хоста (/app в контейнере).
+ // configuredDirectory: Значение env DEAL_LOGS_DIR (null/пусто — дефолт).
+ // Возвращает: Абсолютный путь каталога логов.
+ private static string ResolveLogsDirectory(string contentRootPath, string? configuredDirectory)
+ => string.IsNullOrWhiteSpace(configuredDirectory)
+ ? Path.Combine(contentRootPath, DefaultLogsSubdirectory)
+ : configuredDirectory.Trim();
+
+ // Разбирает env DEAL_LOG_LEVEL; пустое/невалидное значение — DefaultMinimumLevel.
+ // rawValue: Сырое значение env.
+ // Возвращает: Уровень Serilog.
+ private static LogEventLevel ParseMinimumLevel(string? rawValue)
+ => Enum.TryParse(rawValue, ignoreCase: true, out LogEventLevel parsedLevel)
+ ? parsedLevel
+ : DefaultMinimumLevel;
+}
diff --git a/src/grpc-hosting/Deal.Grpc.Hosting/Services/DealMetricsHosting.cs b/src/grpc-hosting/Deal.Grpc.Hosting/Services/DealMetricsHosting.cs
index 58c5fb7..9253ae3 100644
--- a/src/grpc-hosting/Deal.Grpc.Hosting/Services/DealMetricsHosting.cs
+++ b/src/grpc-hosting/Deal.Grpc.Hosting/Services/DealMetricsHosting.cs
@@ -1,78 +1,78 @@
-using Microsoft.AspNetCore.Builder;
-using Microsoft.AspNetCore.Hosting;
-using Microsoft.AspNetCore.Server.Kestrel.Core;
-using Microsoft.Extensions.DependencyInjection;
-using OpenTelemetry.Metrics;
-using Deal.Grpc.Hosting.Interceptors;
-using Deal.Grpc.Hosting.Models;
-using Deal.Grpc.Hosting.Options;
-
-namespace Deal.Grpc.Hosting.Services;
-
-///
-/// Общая настройка метрик Deal-сервисов
-///
-public static class DealMetricsHosting
-{
- ///
- /// Имя meter'а прикладных метрик Deal
- ///
- public const string MeterName = "Deal";
-
- ///
- /// Порт эндпоинта /metrics по умолчанию
- ///
- public const int DefaultMetricsPort = 9464;
-
- // Env-ключ порта метрик (переопределяет DefaultMetricsPort).
- private const string MetricsPortEnvKey = "METRICS_PORT";
-
- ///
- /// Порт эндпоинта метрик
- ///
- /// Дефолтный порт (обычно ).
- /// Порт HTTP/1.1-эндпоинта метрик.
- public static int ResolveMetricsPort(int defaultPort)
- => int.TryParse(Environment.GetEnvironmentVariable(MetricsPortEnvKey), out int port) && port > 0
- ? port
- : defaultPort;
-
- ///
- /// Регистрирует OTel-метрики и Kestrel-эндпоинт метрик
- ///
- /// Билдер хоста сервиса.
- /// Порт HTTP/1.1-эндпоинта метрик.
- public static void AddDealMetrics(WebApplicationBuilder builder, int metricsPort)
- {
- ArgumentNullException.ThrowIfNull(builder);
-
- // Отдельный HTTP/1.1-эндпоинт для scrape: gRPC-порт остаётся строго HTTP/2 (см. remarks класса).
- builder.WebHost.ConfigureKestrel(kestrel =>
- {
- kestrel.ListenAnyIP(metricsPort, listen => listen.Protocols = HttpProtocols.Http1);
- });
-
- // Инструментация входящих запросов (http.server.*: RPS/латентность/ошибки по route) и исходящих
- // HTTP-клиентов (http.client.*) + экспортёр Prometheus. AddMeter — прикладные метрики.
- // Исходящие gRPC-вызовы (пакет Instrumentation.GrpcNetClient) дают трейс-инструментацию, а не
- // метрики — в MeterProviderBuilder не добавляются (для клиентских метрик gRPC-хопа хватает
- // серверной стороны соответствующего сервиса).
- builder.Services
- .AddOpenTelemetry()
- .WithMetrics(metrics => metrics
- .AddMeter(MeterName)
- .AddAspNetCoreInstrumentation()
- .AddHttpClientInstrumentation()
- .AddPrometheusExporter());
- }
-
- ///
- /// Мапит эндпоинт /metrics
- ///
- /// Собранное приложение сервиса.
- public static void MapDealMetrics(WebApplication app)
- {
- ArgumentNullException.ThrowIfNull(app);
- app.MapPrometheusScrapingEndpoint();
- }
-}
+using Deal.Grpc.Hosting.Interceptors;
+using Deal.Grpc.Hosting.Models;
+using Deal.Grpc.Hosting.Options;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Server.Kestrel.Core;
+using Microsoft.Extensions.DependencyInjection;
+using OpenTelemetry.Metrics;
+
+namespace Deal.Grpc.Hosting.Services;
+
+///
+/// Общая настройка метрик Deal-сервисов
+///
+public static class DealMetricsHosting
+{
+ ///
+ /// Имя meter'а прикладных метрик Deal
+ ///
+ public const string MeterName = "Deal";
+
+ ///
+ /// Порт эндпоинта /metrics по умолчанию
+ ///
+ public const int DefaultMetricsPort = 9464;
+
+ // Env-ключ порта метрик (переопределяет DefaultMetricsPort).
+ private const string MetricsPortEnvKey = "METRICS_PORT";
+
+ ///
+ /// Порт эндпоинта метрик
+ ///
+ /// Дефолтный порт (обычно ).
+ /// Порт HTTP/1.1-эндпоинта метрик.
+ public static int ResolveMetricsPort(int defaultPort)
+ => int.TryParse(Environment.GetEnvironmentVariable(MetricsPortEnvKey), out int port) && port > 0
+ ? port
+ : defaultPort;
+
+ ///
+ /// Регистрирует OTel-метрики и Kestrel-эндпоинт метрик
+ ///
+ /// Билдер хоста сервиса.
+ /// Порт HTTP/1.1-эндпоинта метрик.
+ public static void AddDealMetrics(WebApplicationBuilder builder, int metricsPort)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ // Отдельный HTTP/1.1-эндпоинт для scrape: gRPC-порт остаётся строго HTTP/2 (см. remarks класса).
+ builder.WebHost.ConfigureKestrel(kestrel =>
+ {
+ kestrel.ListenAnyIP(metricsPort, listen => listen.Protocols = HttpProtocols.Http1);
+ });
+
+ // Инструментация входящих запросов (http.server.*: RPS/латентность/ошибки по route) и исходящих
+ // HTTP-клиентов (http.client.*) + экспортёр Prometheus. AddMeter — прикладные метрики.
+ // Исходящие gRPC-вызовы (пакет Instrumentation.GrpcNetClient) дают трейс-инструментацию, а не
+ // метрики — в MeterProviderBuilder не добавляются (для клиентских метрик gRPC-хопа хватает
+ // серверной стороны соответствующего сервиса).
+ builder.Services
+ .AddOpenTelemetry()
+ .WithMetrics(metrics => metrics
+ .AddMeter(MeterName)
+ .AddAspNetCoreInstrumentation()
+ .AddHttpClientInstrumentation()
+ .AddPrometheusExporter());
+ }
+
+ ///
+ /// Мапит эндпоинт /metrics
+ ///
+ /// Собранное приложение сервиса.
+ public static void MapDealMetrics(WebApplication app)
+ {
+ ArgumentNullException.ThrowIfNull(app);
+ app.MapPrometheusScrapingEndpoint();
+ }
+}
diff --git a/src/grpc-hosting/Deal.Grpc.Hosting/Services/GrpcServer.cs b/src/grpc-hosting/Deal.Grpc.Hosting/Services/GrpcServer.cs
index 7c40694..469b371 100644
--- a/src/grpc-hosting/Deal.Grpc.Hosting/Services/GrpcServer.cs
+++ b/src/grpc-hosting/Deal.Grpc.Hosting/Services/GrpcServer.cs
@@ -1,106 +1,106 @@
-using System.Net;
-using Microsoft.AspNetCore.Builder;
-using Microsoft.AspNetCore.Hosting;
-using Microsoft.AspNetCore.Server.Kestrel.Core;
-using Microsoft.AspNetCore.Server.Kestrel.Https;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Diagnostics.HealthChecks;
-using Deal.Grpc.Hosting.Interceptors;
-using Deal.Grpc.Hosting.Models;
-using Deal.Grpc.Hosting.Options;
-
-namespace Deal.Grpc.Hosting.Services;
-
-///
-/// Общие серверные блоки gRPC-хостов Deal-сервисов
-///
-public static class GrpcServer
-{
- // Имя gRPC-health-проверки готовности (без неё health-сервис отвечает UNKNOWN, а не SERVING).
- private const string ReadyHealthCheckName = "ready";
-
- ///
- /// Потолок входящего gRPC-сообщения
- ///
- public const int DefaultMaxReceiveMessageSize = 4 * 1024 * 1024;
-
- ///
- /// Загружает сертификаты mTLS из env и регистрирует набор в DI
- ///
- /// Билдер хоста (конфигурация env + DI).
- /// Набор сертификатов либо null (mTLS выключен).
- public static MtlsCertificates? LoadMtlsCertificates(WebApplicationBuilder builder)
- {
- ArgumentNullException.ThrowIfNull(builder);
-
- MtlsCertificates? mtlsCertificates = MtlsCertificates.Load(
- MtlsOptions.FromConfiguration(builder.Configuration));
- if (mtlsCertificates is not null)
- {
- builder.Services.AddSingleton(mtlsCertificates);
- }
-
- return mtlsCertificates;
- }
-
- ///
- /// Настраивает единственный Kestrel-эндпоинт HTTP/2 на 0.0.0.0:grpcPort
- ///
- /// Билдер хоста (WebHost для ConfigureKestrel).
- /// TCP-порт Kestrel.
- /// Набор сертификатов mTLS (null — plaintext).
- public static void ConfigureKestrelHttp2Endpoint(
- WebApplicationBuilder builder,
- int grpcPort,
- MtlsCertificates? mtlsCertificates)
- {
- ArgumentNullException.ThrowIfNull(builder);
- builder.WebHost.ConfigureKestrel(kestrel =>
- {
- kestrel.Listen(IPAddress.Any, grpcPort, listen =>
- {
- listen.Protocols = HttpProtocols.Http2;
- if (mtlsCertificates is not null)
- {
- listen.UseHttps(https =>
- {
- https.ServerCertificate = mtlsCertificates.ServerCertificate;
- https.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
- https.ClientCertificateValidation = mtlsCertificates.ValidateClientCertificate;
- });
- }
- });
- });
- }
-
- ///
- /// Регистрирует AddGrpc с общей серверной обвязкой
- ///
- /// DI сервисов хоста.
- public static IServiceCollection AddDealGrpcServer(this IServiceCollection services)
- {
- ArgumentNullException.ThrowIfNull(services);
- services.AddGrpc(grpc =>
- {
- grpc.MaxReceiveMessageSize = DefaultMaxReceiveMessageSize;
- grpc.Interceptors.Add();
- grpc.Interceptors.Add();
- });
- return services;
- }
-
- ///
- /// Регистрирует стандартный gRPC-health
- ///
- /// DI сервисов хоста.
- /// Текст готовности проверки (имя хоста в логах healthcheck).
- public static IServiceCollection AddReadyHealthCheck(this IServiceCollection services, string readyDetail)
- {
- ArgumentNullException.ThrowIfNull(services);
- ArgumentException.ThrowIfNullOrWhiteSpace(readyDetail);
- services
- .AddGrpcHealthChecks()
- .AddCheck(ReadyHealthCheckName, () => HealthCheckResult.Healthy(readyDetail));
- return services;
- }
-}
+using System.Net;
+using Deal.Grpc.Hosting.Interceptors;
+using Deal.Grpc.Hosting.Models;
+using Deal.Grpc.Hosting.Options;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Server.Kestrel.Core;
+using Microsoft.AspNetCore.Server.Kestrel.Https;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+
+namespace Deal.Grpc.Hosting.Services;
+
+///
+/// Общие серверные блоки gRPC-хостов Deal-сервисов
+///
+public static class GrpcServer
+{
+ // Имя gRPC-health-проверки готовности (без неё health-сервис отвечает UNKNOWN, а не SERVING).
+ private const string ReadyHealthCheckName = "ready";
+
+ ///
+ /// Потолок входящего gRPC-сообщения
+ ///
+ public const int DefaultMaxReceiveMessageSize = 4 * 1024 * 1024;
+
+ ///
+ /// Загружает сертификаты mTLS из env и регистрирует набор в DI
+ ///
+ /// Билдер хоста (конфигурация env + DI).
+ /// Набор сертификатов либо null (mTLS выключен).
+ public static MtlsCertificates? LoadMtlsCertificates(WebApplicationBuilder builder)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ MtlsCertificates? mtlsCertificates = MtlsCertificates.Load(
+ MtlsOptions.FromConfiguration(builder.Configuration));
+ if (mtlsCertificates is not null)
+ {
+ builder.Services.AddSingleton(mtlsCertificates);
+ }
+
+ return mtlsCertificates;
+ }
+
+ ///
+ /// Настраивает единственный Kestrel-эндпоинт HTTP/2 на 0.0.0.0:grpcPort
+ ///
+ /// Билдер хоста (WebHost для ConfigureKestrel).
+ /// TCP-порт Kestrel.
+ /// Набор сертификатов mTLS (null — plaintext).
+ public static void ConfigureKestrelHttp2Endpoint(
+ WebApplicationBuilder builder,
+ int grpcPort,
+ MtlsCertificates? mtlsCertificates)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ builder.WebHost.ConfigureKestrel(kestrel =>
+ {
+ kestrel.Listen(IPAddress.Any, grpcPort, listen =>
+ {
+ listen.Protocols = HttpProtocols.Http2;
+ if (mtlsCertificates is not null)
+ {
+ listen.UseHttps(https =>
+ {
+ https.ServerCertificate = mtlsCertificates.ServerCertificate;
+ https.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
+ https.ClientCertificateValidation = mtlsCertificates.ValidateClientCertificate;
+ });
+ }
+ });
+ });
+ }
+
+ ///
+ /// Регистрирует AddGrpc с общей серверной обвязкой
+ ///
+ /// DI сервисов хоста.
+ public static IServiceCollection AddDealGrpcServer(this IServiceCollection services)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+ services.AddGrpc(grpc =>
+ {
+ grpc.MaxReceiveMessageSize = DefaultMaxReceiveMessageSize;
+ grpc.Interceptors.Add();
+ grpc.Interceptors.Add();
+ });
+ return services;
+ }
+
+ ///
+ /// Регистрирует стандартный gRPC-health
+ ///
+ /// DI сервисов хоста.
+ /// Текст готовности проверки (имя хоста в логах healthcheck).
+ public static IServiceCollection AddReadyHealthCheck(this IServiceCollection services, string readyDetail)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+ ArgumentException.ThrowIfNullOrWhiteSpace(readyDetail);
+ services
+ .AddGrpcHealthChecks()
+ .AddCheck(ReadyHealthCheckName, () => HealthCheckResult.Healthy(readyDetail));
+ return services;
+ }
+}
diff --git a/src/ml-service/Deal.Ml/Extensions/LabelExtensions.cs b/src/ml-service/Deal.Ml/Extensions/LabelExtensions.cs
index a29bea6..8b37d33 100644
--- a/src/ml-service/Deal.Ml/Extensions/LabelExtensions.cs
+++ b/src/ml-service/Deal.Ml/Extensions/LabelExtensions.cs
@@ -1,19 +1,17 @@
-using Deal.Ml.Model;
-
-namespace Deal.Ml.Extensions;
-
-///
-/// Расширения