From 39c9bdc1b75667ddbfca8225c895c37cdde59d1d Mon Sep 17 00:00:00 2001 From: Rustam Khalimov Date: Fri, 11 Sep 2026 19:00:35 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D1=87=D0=B8=D1=81=D1=82=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BA=D0=BE=D0=B4-=D1=81=D1=82=D0=B0=D0=B9?= =?UTF-8?q?=D0=BB:=20var=20=D0=B2=D1=81=D1=82=D1=80=D0=BE=D0=B5=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D1=85=20=D1=82=D0=B8=D0=BF=D0=BE=D0=B2,=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=D0=B2=D0=B0=D1=82=D0=BD=D1=8B=D0=B5=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=BA=D0=B8,=20=D0=B4=D0=BE=D0=BA=D0=B8=20=D0=B8=D0=BD=D1=82?= =?UTF-8?q?=D0=B5=D1=80=D1=84=D0=B5=D0=B9=D1=81=D0=B0=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Гейт csharp_style_var_for_built_in_types=false:warning (ломает сборку), остаток выправлен dotnet format IDE0008 по 5 sln (только встроенные типы). Понижено 12 новых XML-доков private/internal, добавлены членам IContainerRules и ITenantContext, 3 англоязычных комментария переведены на русский. --- .editorconfig | 147 +-- src/core/Deal.Api/Endpoints/AuthEndpoints.cs | 386 +++--- .../Endpoints/OperatorAuthEndpoints.cs | 314 ++--- .../Hosting/OperatorBootstrapHostedService.cs | 168 +-- .../Hosting/TenantBootstrapService.cs | 156 +-- .../Middleware/OperatorSessionMiddleware.cs | 90 +- .../Deal.Api/Middleware/SessionMiddleware.cs | 124 +- src/core/Deal.Api/Program.cs | 1066 ++++++++--------- .../Extensions/RpcExceptionExtensions.cs | 32 +- .../Integrations/Extensions/UriExtensions.cs | 104 +- .../MinioStorageOptionsExtensions.cs | 40 +- .../Storage/Extensions/StringExtensions.cs | 32 +- .../Migrations/TenantSchemaMigrator.cs | 2 +- .../Persistence/DealDbDesignTimeFactory.cs | 40 +- .../Persistence/TenantDbDesignTimeFactory.cs | 40 +- .../Tenancy/TenantProvisioningService.cs | 150 +-- .../Abstractions/IContainerRules.cs | 6 + .../ColumnRules/BudgetRangeDtoExtensions.cs | 38 +- .../ColumnRules/TermListExtensions.cs | 58 +- .../Application/Extensions/CharExtensions.cs | 28 +- .../Application/Parse/CodePointExtensions.cs | 34 +- .../Application/Parse/StringExtensions.cs | 62 +- .../Application/Models/SettingsKeys.cs | 2 +- .../Extensions/AuditRecordDtoExtensions.cs | 68 +- .../Application/Services/AuthService.cs | 538 ++++----- .../Services/OperatorAuthService.cs | 228 ++-- .../Services/OperatorBootstrapService.cs | 160 +-- .../Application/Services/SessionTokens.cs | 64 +- .../Application/Services/TenantService.cs | 100 +- .../Tenants/Abstractions/ITenantContext.cs | 6 + .../Utilities/UrlSafeToken.cs | 2 +- .../Infrastructure/AuthStoreTests.cs | 160 +-- .../TenantSchemaMigrationServiceTests.cs | 388 +++--- .../TenantSchemaMigratorTests.cs | 4 +- .../Modules/Settings/PromptDefaultsTests.cs | 2 +- .../Modules/Tenants/AuthServiceTests.cs | 18 +- .../Modules/Tenants/FakeAuthStore.cs | 2 +- .../Tenants/OperatorAuthServiceTests.cs | 12 +- .../Tenants/OperatorBootstrapServiceTests.cs | 14 +- .../Modules/Tenants/PasswordHasherTests.cs | 10 +- .../Modules/Tenants/SessionTokensTests.cs | 14 +- .../Support/ConversionRecomputerTests.cs | 2 +- .../Interceptors/RpcCallLoggingInterceptor.cs | 276 ++--- .../Interceptors/ServiceTokenInterceptor.cs | 260 ++-- .../Deal.Grpc.Hosting/Options/MtlsOptions.cs | 208 ++-- .../Deal.Grpc.Hosting/Services/DealLogging.cs | 218 ++-- .../Services/DealMetricsHosting.cs | 156 +-- .../Deal.Grpc.Hosting/Services/GrpcServer.cs | 212 ++-- .../Deal.Ml/Extensions/LabelExtensions.cs | 36 +- .../Telegram/DialogHueTests.cs | 106 +- .../Extensions/ExceptionExtensions.cs | 36 +- 51 files changed, 3204 insertions(+), 3215 deletions(-) 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; - -/// -/// Расширения для меток классов ML-модели. -/// -internal static class LabelExtensions -{ - /// - /// Метка внутреннего типа заявки - /// - /// Метка класса. - /// True — метка является внутренним типом заявки (префикс t). - public static bool IsTypeLabel(this string label) - { - return label.StartsWith(ModelConstants.TypeLabelPrefix, StringComparison.Ordinal); - } -} +using Deal.Ml.Model; + +namespace Deal.Ml.Extensions; + +// Расширения string для меток классов ML-модели. +internal static class LabelExtensions +{ + /// + /// Метка внутреннего типа заявки + /// + /// Метка класса. + /// True — метка является внутренним типом заявки (префикс t). + public static bool IsTypeLabel(this string label) + { + return label.StartsWith(ModelConstants.TypeLabelPrefix, StringComparison.Ordinal); + } +} diff --git a/src/telegram-service/Deal.Telegram.Tests/Telegram/DialogHueTests.cs b/src/telegram-service/Deal.Telegram.Tests/Telegram/DialogHueTests.cs index 6cbbd2a..54da0e2 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Telegram/DialogHueTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Telegram/DialogHueTests.cs @@ -1,53 +1,53 @@ -using Deal.Telegram.Dialogs; - -namespace Deal.Telegram.Tests.Telegram; - -/// -/// Unit-тесты цвета диалога. -/// -public sealed class DialogHueTests -{ - /// - /// Цвет по паре - /// - [Theory] - [InlineData("-1001234567890", "IT Канал", "#a78bfa")] - [InlineData("-123456", "Уютный чат", "#34d399")] - [InlineData("+79990001122", "Иван Петров", "#fb7185")] - [InlineData("-100999", "", "#34d399")] - public void Compute_MatchesPythonPrototype( - string dialogId, - string name, - string expectedHue) - { - Assert.Equal(expectedHue, DialogHue.Compute(dialogId, name)); - } - - /// - /// Цвет детерминирован - /// - [Fact] - public void Compute_IsDeterministic() - { - string first = DialogHue.Compute("-1001234567890", "IT Канал"); - - Assert.Equal(first, DialogHue.Compute("-1001234567890", "IT Канал")); - } - - /// - /// Разные источники распределяются по палитре - /// - [Fact] - public void Compute_DifferentSources_DistributeAcrossPalette() - { - var hues = new[] - { - DialogHue.Compute("-100111", "Канал А"), - DialogHue.Compute("-100222", "Канал Б"), - DialogHue.Compute("+79990001122", "Иван Петров"), - DialogHue.Compute("-5", "Работа"), - }; - - Assert.Equal(3, hues.Distinct().Count()); - } -} +using Deal.Telegram.Dialogs; + +namespace Deal.Telegram.Tests.Telegram; + +/// +/// Unit-тесты цвета диалога. +/// +public sealed class DialogHueTests +{ + /// + /// Цвет по паре + /// + [Theory] + [InlineData("-1001234567890", "IT Канал", "#a78bfa")] + [InlineData("-123456", "Уютный чат", "#34d399")] + [InlineData("+79990001122", "Иван Петров", "#fb7185")] + [InlineData("-100999", "", "#34d399")] + public void Compute_MatchesPythonPrototype( + string dialogId, + string name, + string expectedHue) + { + Assert.Equal(expectedHue, DialogHue.Compute(dialogId, name)); + } + + /// + /// Цвет детерминирован + /// + [Fact] + public void Compute_IsDeterministic() + { + string first = DialogHue.Compute("-1001234567890", "IT Канал"); + + Assert.Equal(first, DialogHue.Compute("-1001234567890", "IT Канал")); + } + + /// + /// Разные источники распределяются по палитре + /// + [Fact] + public void Compute_DifferentSources_DistributeAcrossPalette() + { + string[] hues = new[] + { + DialogHue.Compute("-100111", "Канал А"), + DialogHue.Compute("-100222", "Канал Б"), + DialogHue.Compute("+79990001122", "Иван Петров"), + DialogHue.Compute("-5", "Работа"), + }; + + Assert.Equal(3, hues.Distinct().Count()); + } +} diff --git a/src/telegram-service/Deal.Telegram/Extensions/ExceptionExtensions.cs b/src/telegram-service/Deal.Telegram/Extensions/ExceptionExtensions.cs index 58bfc3a..53ce7bb 100644 --- a/src/telegram-service/Deal.Telegram/Extensions/ExceptionExtensions.cs +++ b/src/telegram-service/Deal.Telegram/Extensions/ExceptionExtensions.cs @@ -1,19 +1,17 @@ -using Grpc.Core; - -namespace Deal.Telegram.Extensions; - -/// -/// Расширения для классификации сбоев telegram-service. -/// -internal static class ExceptionExtensions -{ - /// - /// Истинно транспортные/сетевые причины — только они дают UNAVAILABLE - /// - /// Исключение для классификации. - /// True — исключение транспортного/сетевого характера. - public static bool IsTransportFailure(this Exception exception) - { - return exception is HttpRequestException or IOException or TimeoutException or RpcException; - } -} +using Grpc.Core; + +namespace Deal.Telegram.Extensions; + +// Расширения Exception для классификации сбоев telegram-service. +internal static class ExceptionExtensions +{ + /// + /// Истинно транспортные/сетевые причины — только они дают UNAVAILABLE + /// + /// Исключение для классификации. + /// True — исключение транспортного/сетевого характера. + public static bool IsTransportFailure(this Exception exception) + { + return exception is HttpRequestException or IOException or TimeoutException or RpcException; + } +}