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

Гейт csharp_style_var_for_built_in_types=false:warning (ломает сборку), остаток
выправлен dotnet format IDE0008 по 5 sln (только встроенные типы). Понижено 12
новых XML-доков private/internal, добавлены <summary> членам IContainerRules и
ITenantContext, 3 англоязычных комментария переведены на русский.
This commit is contained in:
Rustam Khalimov
2026-09-11 19:00:35 +03:00
parent dde86fd63c
commit 39c9bdc1b7
51 changed files with 3204 additions and 3215 deletions
+74 -73
View File
@@ -1,73 +1,74 @@
root = true
[*]
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
+193 -193
View File
@@ -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;
/// <summary>
/// HTTP-эндпоинты аутентификации
/// </summary>
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";
/// <summary>
/// Регистрирует группу /api/auth
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
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<IResult> LoginAsync(
LoginRequest body,
AuthService authService,
AuditService auditService,
IOptions<CookieOptions> 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<IResult> LogoutAsync(
AuthService authService,
AuditService auditService,
IOptions<CookieOptions> 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<IResult> ChangePasswordAsync(
ChangePasswordRequest body,
AuthService authService,
IOptions<CookieOptions> 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;
/// <summary>
/// HTTP-эндпоинты аутентификации
/// </summary>
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";
/// <summary>
/// Регистрирует группу /api/auth
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
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<IResult> LoginAsync(
LoginRequest body,
AuthService authService,
AuditService auditService,
IOptions<CookieOptions> 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<IResult> LogoutAsync(
AuthService authService,
AuditService auditService,
IOptions<CookieOptions> 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<IResult> ChangePasswordAsync(
ChangePasswordRequest body,
AuthService authService,
IOptions<CookieOptions> 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();
}
@@ -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;
/// <summary>
/// HTTP-эндпоинты аутентификации оператора
/// </summary>
public static class OperatorAuthEndpoints
{
private const string InvalidCredentialsDetail = "Неверный логин или пароль оператора";
private const string OperatorAuthGroupPrefix = "/api/operator/auth";
private const string OperatorAuthOpenApiTag = "operator-auth";
/// <summary>
/// Регистрирует группу /api/operator/auth
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
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<IResult> LoginAsync(
LoginRequest body,
OperatorAuthService operatorAuthService,
AuditService auditService,
IOptions<OperatorCookieOptions> 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<IResult> LogoutAsync(
OperatorAuthService operatorAuthService,
IOptions<OperatorCookieOptions> 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;
/// <summary>
/// HTTP-эндпоинты аутентификации оператора
/// </summary>
public static class OperatorAuthEndpoints
{
private const string InvalidCredentialsDetail = "Неверный логин или пароль оператора";
private const string OperatorAuthGroupPrefix = "/api/operator/auth";
private const string OperatorAuthOpenApiTag = "operator-auth";
/// <summary>
/// Регистрирует группу /api/operator/auth
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
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<IResult> LoginAsync(
LoginRequest body,
OperatorAuthService operatorAuthService,
AuditService auditService,
IOptions<OperatorCookieOptions> 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<IResult> LogoutAsync(
OperatorAuthService operatorAuthService,
IOptions<OperatorCookieOptions> 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();
}
@@ -1,84 +1,84 @@
using Deal.Modules.Tenants.Application.Services;
namespace Deal.Api.Hosting;
/// <summary>
/// Hosted-шаг bootstrap оператора при старте
/// </summary>
public sealed class OperatorBootstrapHostedService(
IServiceScopeFactory scopeFactory,
IConfiguration configuration,
IHostEnvironment environment,
ILogger<OperatorBootstrapHostedService> logger) : IHostedService
{
/// <inheritdoc />
public async Task StartAsync(CancellationToken ct)
{
await using var scope = scopeFactory.CreateAsyncScope();
var operatorBootstrapService = scope.ServiceProvider.GetRequiredService<OperatorBootstrapService>();
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);
}
}
/// <inheritdoc />
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;
/// <summary>
/// Hosted-шаг bootstrap оператора при старте
/// </summary>
public sealed class OperatorBootstrapHostedService(
IServiceScopeFactory scopeFactory,
IConfiguration configuration,
IHostEnvironment environment,
ILogger<OperatorBootstrapHostedService> logger) : IHostedService
{
/// <inheritdoc />
public async Task StartAsync(CancellationToken ct)
{
await using var scope = scopeFactory.CreateAsyncScope();
var operatorBootstrapService = scope.ServiceProvider.GetRequiredService<OperatorBootstrapService>();
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);
}
}
/// <inheritdoc />
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);
}
}
}
@@ -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;
/// <summary>
/// Bootstrap при старте
/// </summary>
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";
/// <inheritdoc />
public async Task StartAsync(CancellationToken ct)
{
await using var scope = scopeFactory.CreateAsyncScope();
var configuration = scope.ServiceProvider.GetRequiredService<IConfiguration>();
var tenantService = scope.ServiceProvider.GetRequiredService<TenantService>();
var tenantRepository = scope.ServiceProvider.GetRequiredService<ITenantRepository>();
var authStore = scope.ServiceProvider.GetRequiredService<IAuthStore>();
var passwordHasher = scope.ServiceProvider.GetRequiredService<IPasswordHasher>();
var login = NormalizeLogin(configuration[BootstrapLoginEnvKey] ?? DefaultAdminLogin);
var password = configuration[BootstrapPasswordEnvKey] ?? DefaultAdminPassword;
var environment = scope.ServiceProvider.GetRequiredService<IHostEnvironment>();
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<TenantSchemaMigrationService>();
await tenantSchemaMigrationService.MigrateAllAsync(ct);
}
/// <inheritdoc />
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;
/// <summary>
/// Bootstrap при старте
/// </summary>
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";
/// <inheritdoc />
public async Task StartAsync(CancellationToken ct)
{
await using var scope = scopeFactory.CreateAsyncScope();
var configuration = scope.ServiceProvider.GetRequiredService<IConfiguration>();
var tenantService = scope.ServiceProvider.GetRequiredService<TenantService>();
var tenantRepository = scope.ServiceProvider.GetRequiredService<ITenantRepository>();
var authStore = scope.ServiceProvider.GetRequiredService<IAuthStore>();
var passwordHasher = scope.ServiceProvider.GetRequiredService<IPasswordHasher>();
string login = NormalizeLogin(configuration[BootstrapLoginEnvKey] ?? DefaultAdminLogin);
string password = configuration[BootstrapPasswordEnvKey] ?? DefaultAdminPassword;
var environment = scope.ServiceProvider.GetRequiredService<IHostEnvironment>();
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<TenantSchemaMigrationService>();
await tenantSchemaMigrationService.MigrateAllAsync(ct);
}
/// <inheritdoc />
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
// Нормализация логина: нижний регистр и обрезка пробелов (единая форма хранения).
// login: Входной логин.
// Возвращает: Нормализованный логин.
private static string NormalizeLogin(string login) => login.Trim().ToLowerInvariant();
}
@@ -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;
/// <summary>
/// Middleware операторской сессии
/// </summary>
public sealed class OperatorSessionMiddleware
{
private readonly RequestDelegate _next;
private readonly IOptionsMonitor<OperatorCookieOptions> _cookieOptions;
public OperatorSessionMiddleware(RequestDelegate next, IOptionsMonitor<OperatorCookieOptions> cookieOptions)
{
_next = next;
_cookieOptions = cookieOptions;
}
/// <summary>
/// Обрабатывает запрос
/// </summary>
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<OperatorAuthService>();
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;
/// <summary>
/// Middleware операторской сессии
/// </summary>
public sealed class OperatorSessionMiddleware
{
private readonly RequestDelegate _next;
private readonly IOptionsMonitor<OperatorCookieOptions> _cookieOptions;
public OperatorSessionMiddleware(RequestDelegate next, IOptionsMonitor<OperatorCookieOptions> cookieOptions)
{
_next = next;
_cookieOptions = cookieOptions;
}
/// <summary>
/// Обрабатывает запрос
/// </summary>
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<OperatorAuthService>();
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);
}
}
@@ -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;
/// <summary>
/// Middleware сессии
/// </summary>
public sealed class SessionMiddleware
{
private readonly RequestDelegate _next;
private readonly IOptionsMonitor<CookieOptions> _cookieOptions;
private readonly ITenantContext _tenantContext;
public SessionMiddleware(
RequestDelegate next,
IOptionsMonitor<CookieOptions> cookieOptions,
ITenantContext tenantContext)
{
_next = next;
_cookieOptions = cookieOptions;
_tenantContext = tenantContext;
}
/// <summary>
/// Обрабатывает запрос
/// </summary>
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<AuthService>();
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_&lt;id&gt;, где 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;
/// <summary>
/// Middleware сессии
/// </summary>
public sealed class SessionMiddleware
{
private readonly RequestDelegate _next;
private readonly IOptionsMonitor<CookieOptions> _cookieOptions;
private readonly ITenantContext _tenantContext;
public SessionMiddleware(
RequestDelegate next,
IOptionsMonitor<CookieOptions> cookieOptions,
ITenantContext tenantContext)
{
_next = next;
_cookieOptions = cookieOptions;
_tenantContext = tenantContext;
}
/// <summary>
/// Обрабатывает запрос
/// </summary>
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<AuthService>();
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_&lt;id&gt;, где id — Guid в формате "N" (см. TenantService).
_tenantContext.SetTenant(new TenantId(user.TenantId.ToString("N")));
}
}
await _next(context);
}
finally
{
// Контекст AsyncLocal не должен переживать запрос.
_tenantContext.Reset();
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,17 +1,15 @@
using Grpc.Core;
namespace Deal.Infrastructure.Integrations.Extensions;
/// <summary>
/// Расширения классификации gRPC-исключений клиентов автономных сервисов.
/// </summary>
internal static class RpcExceptionExtensions
{
/// <summary>
/// Ошибки коммуникации, при которых сервис считается недоступным
/// </summary>
/// <param name="exception">Исключение RPC.</param>
/// <returns>True — транспорта/контракта health нет (down); false — прикладной статус (не наша зона).</returns>
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
{
/// <summary>
/// Ошибки коммуникации, при которых сервис считается недоступным
/// </summary>
/// <param name="exception">Исключение RPC.</param>
/// <returns>True — транспорта/контракта health нет (down); false — прикладной статус (не наша зона).</returns>
public static bool IsCommunicationFailure(this RpcException exception) =>
exception.StatusCode is StatusCode.Unavailable or StatusCode.DeadlineExceeded or StatusCode.Unimplemented;
}
@@ -1,53 +1,51 @@
using System.Net;
using System.Net.Sockets;
namespace Deal.Infrastructure.Integrations.Extensions;
/// <summary>
/// Расширения <see cref="Uri"/> для SSRF-гейта интеграций
/// </summary>
internal static class UriExtensions
{
/// <summary>
/// Проверяет, указывает ли URL на приватный/loopback/link-local адрес
/// </summary>
/// <param name="uri">Абсолютный http(s)-адрес.</param>
/// <returns>True — адрес приватный/локальный (HTTP к нему запрещён).</returns>
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
{
/// <summary>
/// Проверяет, указывает ли URL на приватный/loopback/link-local адрес
/// </summary>
/// <param name="uri">Абсолютный http(s)-адрес.</param>
/// <returns>True — адрес приватный/локальный (HTTP к нему запрещён).</returns>
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);
}
}
@@ -1,21 +1,19 @@
using Deal.Infrastructure.Integrations.Storage.Options;
namespace Deal.Infrastructure.Integrations.Storage.Extensions;
/// <summary>
/// Расширения <see cref="MinioStorageOptions"/>
/// </summary>
internal static class MinioStorageOptionsExtensions
{
/// <summary>
/// True — секция Minio заполнена настолько, что возможен Minio-адаптер.
/// </summary>
/// <param name="minio">Настройки MinIO из секции Storage:Minio.</param>
/// <returns>True — заданы Endpoint, AccessKey и SecretKey.</returns>
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
{
/// <summary>
/// True — секция Minio заполнена настолько, что возможен Minio-адаптер.
/// </summary>
/// <param name="minio">Настройки MinIO из секции Storage:Minio.</param>
/// <returns>True — заданы Endpoint, AccessKey и SecretKey.</returns>
public static bool IsConfigured(this MinioStorageOptions minio)
{
return !string.IsNullOrWhiteSpace(minio.Endpoint)
&& !string.IsNullOrWhiteSpace(minio.AccessKey)
&& !string.IsNullOrWhiteSpace(minio.SecretKey);
}
}
@@ -1,17 +1,15 @@
namespace Deal.Infrastructure.Integrations.Storage.Extensions;
/// <summary>
/// Расширения <see cref="string"/> для разбора конфигурационных значений.
/// </summary>
internal static class StringExtensions
{
/// <summary>
/// Разбирает строковое значение как булев флаг конфигурации
/// </summary>
/// <param name="raw">Сырое значение настройки.</param>
/// <returns>True — значение распознано как включённое.</returns>
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
{
/// <summary>
/// Разбирает строковое значение как булев флаг конфигурации
/// </summary>
/// <param name="raw">Сырое значение настройки.</param>
/// <returns>True — значение распознано как включённое.</returns>
public static bool IsTrue(this string raw)
{
return string.Equals(raw, "true", StringComparison.OrdinalIgnoreCase) || raw == "1";
}
}
@@ -10,7 +10,7 @@ public static class TenantSchemaMigrator
/// </summary>
public static string CreateSchemaSql(string schemaName)
{
var escaped = schemaName.Replace("\"", "\"\"");
string escaped = schemaName.Replace("\"", "\"\"");
return $"CREATE SCHEMA IF NOT EXISTS \"{escaped}\"";
}
@@ -1,20 +1,20 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace Deal.Infrastructure.Persistence;
/// <summary>
/// Фабрика для dotnet-ef
/// </summary>
public sealed class DealDbDesignTimeFactory : IDesignTimeDbContextFactory<DealDbContext>
{
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<DealDbContext>()
.UseNpgsql(connectionString)
.Options;
return new DealDbContext(options);
}
}
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace Deal.Infrastructure.Persistence;
/// <summary>
/// Фабрика для dotnet-ef
/// </summary>
public sealed class DealDbDesignTimeFactory : IDesignTimeDbContextFactory<DealDbContext>
{
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<DealDbContext>()
.UseNpgsql(connectionString)
.Options;
return new DealDbContext(options);
}
}
@@ -1,20 +1,20 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace Deal.Infrastructure.Persistence;
/// <summary>
/// Фабрика для dotnet-ef
/// </summary>
public sealed class TenantDbDesignTimeFactory : IDesignTimeDbContextFactory<TenantDbContext>
{
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<TenantDbContext>()
.UseNpgsql(connectionString, npgsql => npgsql.MigrationsHistoryTable("__TenantMigrationsHistory"))
.Options;
return new TenantDbContext(options);
}
}
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace Deal.Infrastructure.Persistence;
/// <summary>
/// Фабрика для dotnet-ef
/// </summary>
public sealed class TenantDbDesignTimeFactory : IDesignTimeDbContextFactory<TenantDbContext>
{
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<TenantDbContext>()
.UseNpgsql(connectionString, npgsql => npgsql.MigrationsHistoryTable("__TenantMigrationsHistory"))
.Options;
return new TenantDbContext(options);
}
}
@@ -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;
/// <summary>
/// Провижининг схемы тенанта
/// </summary>
public sealed class TenantProvisioningService(ConnectionStringProvider connectionStringProvider) : ITenantProvisioner
{
private const string TenantMigrationsHistoryTable = "__TenantMigrationsHistory";
// Семафор на схему тенанта: сериализует провижининг одного тенанта при параллельных вызовах.
// Сами операции идемпотентны, но создание схемы обязано предшествовать применению миграций,
// а два одновременных Migrate одной схемы — источник гонки. Статический: живёт дольше scoped-сервиса.
private static readonly ConcurrentDictionary<string, SemaphoreSlim> SchemaProvisionLocks = new();
/// <inheritdoc />
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_&lt;id&gt; (ForSchemaDdl) — таблицы бессхемной модели TenantDbContext
// лягут в схему тенанта. DDL применяется мигратор-ролью при её наличии (см. ConnectionStringProvider).
var connectionString = connectionStringProvider.ForSchemaDdl(tenantId);
var options = new DbContextOptionsBuilder<TenantDbContext>()
.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;
/// <summary>
/// Провижининг схемы тенанта
/// </summary>
public sealed class TenantProvisioningService(ConnectionStringProvider connectionStringProvider) : ITenantProvisioner
{
private const string TenantMigrationsHistoryTable = "__TenantMigrationsHistory";
// Семафор на схему тенанта: сериализует провижининг одного тенанта при параллельных вызовах.
// Сами операции идемпотентны, но создание схемы обязано предшествовать применению миграций,
// а два одновременных Migrate одной схемы — источник гонки. Статический: живёт дольше scoped-сервиса.
private static readonly ConcurrentDictionary<string, SemaphoreSlim> SchemaProvisionLocks = new();
/// <inheritdoc />
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_&lt;id&gt; (ForSchemaDdl) — таблицы бессхемной модели TenantDbContext
// лягут в схему тенанта. DDL применяется мигратор-ролью при её наличии (см. ConnectionStringProvider).
string connectionString = connectionStringProvider.ForSchemaDdl(tenantId);
var options = new DbContextOptionsBuilder<TenantDbContext>()
.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);
}
}
@@ -12,8 +12,14 @@ public interface IContainerRules
/// </summary>
public string Mode { get; }
/// <summary>
/// Ключевые слова
/// </summary>
public IReadOnlyList<string> Keywords { get; }
/// <summary>
/// Стек технологий
/// </summary>
public IReadOnlyList<string> Stack { get; }
/// <summary>
@@ -1,20 +1,18 @@
using Deal.Modules.Kanban.Application.Models;
namespace Deal.Modules.Kanban.Application.ColumnRules;
/// <summary>
/// Расширения бюджетной группы правил колонки.
/// </summary>
internal static class BudgetRangeDtoExtensions
{
/// <summary>
/// Активна ли бюджетная группа
/// </summary>
/// <param name="budget">Поле budget/prices правил (может быть null).</param>
/// <returns>True — группа бюджета участвует в матчинге.</returns>
public static bool HasBudget(this BudgetRangeDto? budget)
{
return budget is not null
&& (budget.From is not null || budget.To is not null || !string.IsNullOrWhiteSpace(budget.Cur));
}
}
using Deal.Modules.Kanban.Application.Models;
namespace Deal.Modules.Kanban.Application.ColumnRules;
// Расширения бюджетной группы правил колонки.
internal static class BudgetRangeDtoExtensions
{
/// <summary>
/// Активна ли бюджетная группа
/// </summary>
/// <param name="budget">Поле budget/prices правил (может быть null).</param>
/// <returns>True — группа бюджета участвует в матчинге.</returns>
public static bool HasBudget(this BudgetRangeDto? budget)
{
return budget is not null
&& (budget.From is not null || budget.To is not null || !string.IsNullOrWhiteSpace(budget.Cur));
}
}
@@ -1,30 +1,28 @@
namespace Deal.Modules.Kanban.Application.ColumnRules;
/// <summary>
/// Расширения списков термов правил колонки.
/// </summary>
internal static class TermListExtensions
{
/// <summary>
/// Есть ли в списке непустой
/// </summary>
/// <param name="terms">Список термов группы (может быть null).</param>
/// <returns>True — хотя бы один терм непустой.</returns>
public static bool HasAnyTerm(this IReadOnlyList<string>? 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
{
/// <summary>
/// Есть ли в списке непустой
/// </summary>
/// <param name="terms">Список термов группы (может быть null).</param>
/// <returns>True — хотя бы один терм непустой.</returns>
public static bool HasAnyTerm(this IReadOnlyList<string>? terms)
{
if (terms is null)
{
return false;
}
foreach (string? raw in terms)
{
if (!string.IsNullOrWhiteSpace(raw))
{
return true;
}
}
return false;
}
}
@@ -1,15 +1,13 @@
namespace Deal.Modules.Kanban.Application.Extensions;
/// <summary>
/// Расширения символов для нормализации бюджетной валюты.
/// </summary>
internal static class CharExtensions
{
/// <summary>
/// Буква кода валюты
/// </summary>
/// <param name="c">Символ (строка уже в верхнем регистре).</param>
/// <returns>True — буква, участвующая в распознавании валюты.</returns>
public static bool IsCurrencyLetter(this char c) =>
c is >= 'A' and <= 'Z' or >= 'А' and <= 'Я';
}
namespace Deal.Modules.Kanban.Application.Extensions;
// Расширения символов для нормализации бюджетной валюты.
internal static class CharExtensions
{
/// <summary>
/// Буква кода валюты
/// </summary>
/// <param name="c">Символ (строка уже в верхнем регистре).</param>
/// <returns>True — буква, участвующая в распознавании валюты.</returns>
public static bool IsCurrencyLetter(this char c) =>
c is >= 'A' and <= 'Z' or >= 'А' and <= 'Я';
}
@@ -1,18 +1,16 @@
namespace Deal.Modules.Pipeline.Application.Parse;
/// <summary>
/// Расширения кодовых точек для чистки текста сообщений.
/// </summary>
internal static class CodePointExtensions
{
/// <summary>
/// Входит ли кодовая точка в эмодзи-диапазоны.
/// </summary>
/// <param name="codePoint">Кодовая точка (BMP или доп. плоскость).</param>
/// <returns>True — декоративный символ, подлежащий удалению.</returns>
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
{
/// <summary>
/// Входит ли кодовая точка в эмодзи-диапазоны.
/// </summary>
/// <param name="codePoint">Кодовая точка (BMP или доп. плоскость).</param>
/// <returns>True — декоративный символ, подлежащий удалению.</returns>
public static bool IsEmojiCodePoint(this int codePoint) =>
codePoint is >= 0x1F000 and <= 0x1FAFF
or >= 0x2600 and <= 0x27BF
or >= 0x2B00 and <= 0x2BFF
or 0xFE0F;
}
@@ -1,32 +1,30 @@
namespace Deal.Modules.Pipeline.Application.Parse;
/// <summary>
/// Расширения строк для разбора текста сообщений.
/// </summary>
internal static class StringExtensions
{
private static readonly string[] FooterHintsArray =
{
"откликнуться через", "runello", "больше вакансий", "teletype", "при отклике укажите",
"больше заявок", "узнать подробнее", "написать в лс", "пишите в лс",
};
/// <summary>
/// Содержит ли текст служебный футер-хинт.
/// </summary>
/// <param name="text">Текст (в любом регистре; null трактуется как пустая строка).</param>
/// <returns>True — текст похож на футер агрегатора/служебную строку.</returns>
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", "при отклике укажите",
"больше заявок", "узнать подробнее", "написать в лс", "пишите в лс",
};
/// <summary>
/// Содержит ли текст служебный футер-хинт.
/// </summary>
/// <param name="text">Текст (в любом регистре; null трактуется как пустая строка).</param>
/// <returns>True — текст похож на футер агрегатора/служебную строку.</returns>
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;
}
}
@@ -141,7 +141,7 @@ public static class SettingsKeys
public const string ExcludeTypes = "excludeTypes";
// ── Dict / special ──
// ── Словари / особые ──
public const string ColState = "colState";
@@ -1,35 +1,33 @@
namespace Deal.Modules.Tenants.Application.Models;
/// <summary>
/// Расширения записей аудита
/// </summary>
internal static class AuditRecordDtoExtensions
{
// События аудита «неудачный вход» (тенант/оператор).
private static readonly string[] FailedLoginEvents =
{
AuditEvents.TenantLoginFailed,
AuditEvents.OperatorLoginFailed,
};
// События аудита «успешный вход» (тенант/оператор).
private static readonly string[] SuccessfulLoginEvents =
{
AuditEvents.TenantLoginOk,
AuditEvents.OperatorLoginOk,
};
/// <summary>
/// Неудачный вход
/// </summary>
/// <param name="record">Запись аудита.</param>
/// <returns>True — событие из FailedLoginEvents.</returns>
public static bool IsFailedLogin(this AuditRecordDto record) => FailedLoginEvents.Contains(record.EventType);
/// <summary>
/// Успешный вход
/// </summary>
/// <param name="record">Запись аудита.</param>
/// <returns>True — событие из SuccessfulLoginEvents.</returns>
public static bool IsSuccessfulLogin(this AuditRecordDto record) => SuccessfulLoginEvents.Contains(record.EventType);
}
namespace Deal.Modules.Tenants.Application.Models;
// Расширения записей аудита
internal static class AuditRecordDtoExtensions
{
// События аудита «неудачный вход» (тенант/оператор).
private static readonly string[] FailedLoginEvents =
{
AuditEvents.TenantLoginFailed,
AuditEvents.OperatorLoginFailed,
};
// События аудита «успешный вход» (тенант/оператор).
private static readonly string[] SuccessfulLoginEvents =
{
AuditEvents.TenantLoginOk,
AuditEvents.OperatorLoginOk,
};
/// <summary>
/// Неудачный вход
/// </summary>
/// <param name="record">Запись аудита.</param>
/// <returns>True — событие из FailedLoginEvents.</returns>
public static bool IsFailedLogin(this AuditRecordDto record) => FailedLoginEvents.Contains(record.EventType);
/// <summary>
/// Успешный вход
/// </summary>
/// <param name="record">Запись аудита.</param>
/// <returns>True — событие из SuccessfulLoginEvents.</returns>
public static bool IsSuccessfulLogin(this AuditRecordDto record) => SuccessfulLoginEvents.Contains(record.EventType);
}
@@ -1,269 +1,269 @@
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Modules.Tenants.Application.Services;
/// <summary>
/// Прикладной сервис аутентификации
/// </summary>
public sealed class AuthService(
IAuthStore authStore,
IPasswordHasher passwordHasher,
ITenantRepository tenantRepository)
{
/// <summary>
/// Срок жизни сессии, дней.
/// </summary>
public const int SessionLifetimeDays = 30;
/// <summary>
/// Минимальная длина нового пароля.
/// </summary>
public const int MinNewPasswordLength = 8;
// Статус «активен»: только активного пользователя разрешает ResolveSessionAsync (деактивированный
// вручную с живой сессией — null, как у оператора OperatorAuthService).
private const string UserActiveStatus = "active";
/// <summary>
/// Вход: при успехе создаёт сессию и возвращает её raw-токен.
/// </summary>
/// <param name="login">Логин (регистр и пробелы не важны — нормализуется).</param>
/// <param name="password">Пароль в открытом виде.</param>
/// <returns>При успехе — Login и Token (UserId/TenantId для аудита). Иначе Login/Token null: Error может отличать заблокированный вход приостановленного тенанта (<see cref="LoginResultDto.ErrorTenantSuspended"/>, UserId/TenantId заполнены) от «неверные учётные данные» (Error null, UserId/TenantId пусты).</returns>
public async Task<LoginResultDto> LoginAsync(
string login,
string password,
CancellationToken ct)
{
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);
}
/// <summary>
/// Выход: удаляет сессию по raw-токену
/// </summary>
/// <param name="rawToken">Raw-токен из куки (может отсутствовать — no-op).</param>
/// <returns>Не null, если удалённая сессия была impersonation — сведения для аудита impersonation_stopped (актор — оператор по маркеру сессии). Обычный logout и no-op возвращают null.</returns>
public async Task<LogoutResultDto?> LogoutAsync(string? rawToken, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(rawToken))
{
return null;
}
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);
}
/// <summary>
/// Смена пароля: инвалидирует все сессии, обновляет хэш и выдаёт свежую сессию.
/// </summary>
/// <param name="login">Логин пользователя.</param>
/// <param name="oldPassword">Текущий пароль.</param>
/// <param name="NewPassword">Новый пароль (минимум 8 символов).</param>
/// <returns>При успехе — Ok=true и NewToken (raw-токен свежей сессии). Иначе Ok=false и код ошибки: <see cref="ChangePasswordResultDto.ErrorOldPassword"/> или <see cref="ChangePasswordResultDto.ErrorTooShort"/>.</returns>
public async Task<ChangePasswordResultDto> ChangePasswordAsync(
string login,
string oldPassword,
string newPassword,
CancellationToken ct)
{
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);
}
/// <summary>
/// Impersonation: tenant-сессия целевого пользователя от имени оператора.
/// </summary>
/// <param name="tenantId">Идентификатор тенанта.</param>
/// <param name="targetLogin">Логин пользователя (null/пустой — первый пользователь тенанта).</param>
/// <param name="operatorId">Идентификатор оператора, начинающего impersonation (маркер сессии).</param>
/// <returns>Результат: Ok=true — SessionToken (raw-токен для куки deal_session) и срок жизни; иначе код ошибки.</returns>
public async Task<ImpersonationResultDto> ImpersonateAsync(
Guid tenantId,
string? targetLogin,
Guid operatorId,
CancellationToken ct)
{
var tenant = await tenantRepository.FindByIdAsync(tenantId, ct);
if (tenant is null)
{
return ImpersonationResultDto.Failed(ImpersonationResultDto.ErrorTenantNotFound);
}
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);
}
/// <summary>
/// Разрешение сессии по raw-токену
/// </summary>
/// <param name="rawToken">Raw-токен из куки.</param>
/// <returns>Идентичность пользователя или null.</returns>
public async Task<UserIdentityDto?> ResolveSessionAsync(string? rawToken, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(rawToken))
{
return null;
}
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<string> 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;
/// <summary>
/// Прикладной сервис аутентификации
/// </summary>
public sealed class AuthService(
IAuthStore authStore,
IPasswordHasher passwordHasher,
ITenantRepository tenantRepository)
{
/// <summary>
/// Срок жизни сессии, дней.
/// </summary>
public const int SessionLifetimeDays = 30;
/// <summary>
/// Минимальная длина нового пароля.
/// </summary>
public const int MinNewPasswordLength = 8;
// Статус «активен»: только активного пользователя разрешает ResolveSessionAsync (деактивированный
// вручную с живой сессией — null, как у оператора OperatorAuthService).
private const string UserActiveStatus = "active";
/// <summary>
/// Вход: при успехе создаёт сессию и возвращает её raw-токен.
/// </summary>
/// <param name="login">Логин (регистр и пробелы не важны — нормализуется).</param>
/// <param name="password">Пароль в открытом виде.</param>
/// <returns>При успехе — Login и Token (UserId/TenantId для аудита). Иначе Login/Token null: Error может отличать заблокированный вход приостановленного тенанта (<see cref="LoginResultDto.ErrorTenantSuspended"/>, UserId/TenantId заполнены) от «неверные учётные данные» (Error null, UserId/TenantId пусты).</returns>
public async Task<LoginResultDto> LoginAsync(
string login,
string password,
CancellationToken ct)
{
string normalizedLogin = NormalizeLogin(login);
if (string.IsNullOrEmpty(normalizedLogin) || string.IsNullOrEmpty(password))
{
return new LoginResultDto(null, null);
}
var user = await authStore.FindUserByLoginAsync(normalizedLogin, ct);
// Порядок проверок: сначала учётные данные, затем статус тенанта — неверный пароль не раскрывает
// приостановку (тот же «Неверный логин или пароль», что и для активного тенанта).
if (user is null || !passwordHasher.Verify(password, user.PasswordHash))
{
return new LoginResultDto(null, null);
}
var tenant = await tenantRepository.FindByIdAsync(user.TenantId, ct);
if (tenant is not null && tenant.Status == TenantStatuses.Suspended)
{
return new LoginResultDto(
Login: null,
Token: null,
UserId: user.Id,
TenantId: user.TenantId,
Error: LoginResultDto.ErrorTenantSuspended);
}
string token = await CreateSessionForUserAsync(user.Id, user.Login, impersonatedByOperatorId: null, ct);
return new LoginResultDto(user.Login, token, user.Id, user.TenantId);
}
/// <summary>
/// Выход: удаляет сессию по raw-токену
/// </summary>
/// <param name="rawToken">Raw-токен из куки (может отсутствовать — no-op).</param>
/// <returns>Не null, если удалённая сессия была impersonation — сведения для аудита impersonation_stopped (актор — оператор по маркеру сессии). Обычный logout и no-op возвращают null.</returns>
public async Task<LogoutResultDto?> LogoutAsync(string? rawToken, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(rawToken))
{
return null;
}
string tokenHash = SessionTokens.HashToken(rawToken);
var session = await authStore.FindSessionByTokenHashAsync(tokenHash, ct);
await authStore.DeleteSessionAsync(tokenHash, ct);
if (session is null || session.ImpersonatedByOperatorId is null)
{
return null;
}
// Пользователь не удаляется при живой сессии (FK sessions→users Cascade, SessionConfiguration):
// TenantId берём из реестра пользователей для поля-фильтра аудита.
var user = await authStore.FindUserByIdAsync(session.UserId, ct);
return user is null
? null
: new LogoutResultDto(
OperatorId: session.ImpersonatedByOperatorId.Value,
Login: session.Login,
UserId: user.Id,
TenantId: user.TenantId);
}
/// <summary>
/// Смена пароля: инвалидирует все сессии, обновляет хэш и выдаёт свежую сессию.
/// </summary>
/// <param name="login">Логин пользователя.</param>
/// <param name="oldPassword">Текущий пароль.</param>
/// <param name="NewPassword">Новый пароль (минимум 8 символов).</param>
/// <returns>При успехе — Ok=true и NewToken (raw-токен свежей сессии). Иначе Ok=false и код ошибки: <see cref="ChangePasswordResultDto.ErrorOldPassword"/> или <see cref="ChangePasswordResultDto.ErrorTooShort"/>.</returns>
public async Task<ChangePasswordResultDto> ChangePasswordAsync(
string login,
string oldPassword,
string newPassword,
CancellationToken ct)
{
string normalizedLogin = NormalizeLogin(login);
var user = await authStore.FindUserByLoginAsync(normalizedLogin, ct);
if (user is null || oldPassword is null || !passwordHasher.Verify(oldPassword, user.PasswordHash))
{
return new ChangePasswordResultDto(Ok: false, Error: ChangePasswordResultDto.ErrorOldPassword, NewToken: null);
}
if (string.IsNullOrEmpty(newPassword) || newPassword.Length < MinNewPasswordLength)
{
return new ChangePasswordResultDto(Ok: false, Error: ChangePasswordResultDto.ErrorTooShort, NewToken: null);
}
await authStore.DeleteSessionsByUserIdAsync(user.Id, ct);
await authStore.UpdatePasswordHashAsync(user.Id, passwordHasher.Hash(newPassword), ct);
string token = await CreateSessionForUserAsync(user.Id, user.Login, impersonatedByOperatorId: null, ct);
return new ChangePasswordResultDto(Ok: true, Error: null, NewToken: token);
}
/// <summary>
/// Impersonation: tenant-сессия целевого пользователя от имени оператора.
/// </summary>
/// <param name="tenantId">Идентификатор тенанта.</param>
/// <param name="targetLogin">Логин пользователя (null/пустой — первый пользователь тенанта).</param>
/// <param name="operatorId">Идентификатор оператора, начинающего impersonation (маркер сессии).</param>
/// <returns>Результат: Ok=true — SessionToken (raw-токен для куки deal_session) и срок жизни; иначе код ошибки.</returns>
public async Task<ImpersonationResultDto> ImpersonateAsync(
Guid tenantId,
string? targetLogin,
Guid operatorId,
CancellationToken ct)
{
var tenant = await tenantRepository.FindByIdAsync(tenantId, ct);
if (tenant is null)
{
return ImpersonationResultDto.Failed(ImpersonationResultDto.ErrorTenantNotFound);
}
string normalizedLogin = NormalizeLogin(targetLogin);
if (string.IsNullOrEmpty(normalizedLogin))
{
// login не задан — первый пользователь тенанта (по CreatedAt, порядок ListUsersByTenantIdAsync).
var tenantUsers = await authStore.ListUsersByTenantIdAsync(tenantId, ct);
if (tenantUsers.Count == 0)
{
return ImpersonationResultDto.Failed(ImpersonationResultDto.ErrorTenantHasNoUsers);
}
UserIdentityDto firstUser = tenantUsers[0];
string rawTokenForFirst = await CreateSessionForUserAsync(firstUser.Id, firstUser.Login, operatorId, ct);
return Success(rawTokenForFirst, firstUser.Id, firstUser.Login, firstUser.TenantId);
}
var user = await authStore.FindUserByLoginAsync(normalizedLogin, ct);
if (user is null || user.TenantId != tenantId)
{
// Пользователь не найден или принадлежит другому тенанту — не раскрываем существование логина.
return ImpersonationResultDto.Failed(ImpersonationResultDto.ErrorUserNotFound);
}
string rawToken = await CreateSessionForUserAsync(user.Id, user.Login, operatorId, ct);
return Success(rawToken, user.Id, user.Login, user.TenantId);
}
/// <summary>
/// Разрешение сессии по raw-токену
/// </summary>
/// <param name="rawToken">Raw-токен из куки.</param>
/// <returns>Идентичность пользователя или null.</returns>
public async Task<UserIdentityDto?> ResolveSessionAsync(string? rawToken, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(rawToken))
{
return null;
}
string tokenHash = SessionTokens.HashToken(rawToken);
var session = await authStore.FindSessionByTokenHashAsync(tokenHash, ct);
UserIdentityDto? user = null;
if (session is not null && session.ExpiresAt > DateTimeOffset.UtcNow)
{
user = await authStore.FindUserByIdAsync(session.UserId, ct);
// Деактивированный пользователь при живой сессии не получает доступ (Security review):
// сессия разрешается только для активной учётки (как операторская в OperatorAuthService).
if (user is not null && user.Status != UserActiveStatus)
{
user = null;
}
if (user is not null)
{
var tenant = await tenantRepository.FindByIdAsync(user.TenantId, ct);
if (tenant is not null && tenant.Status == TenantStatuses.Suspended)
{
user = null;
}
}
}
else if (session is not null)
{
// Сессия протухла: глобальная очистка протухших сессий выполняется ТОЛЬКО при обнаружении
// протухшей (редкий случай), а не на каждом разрешении сессии (hot-path, Security review).
await authStore.DeleteExpiredSessionsAsync(ct);
}
return user;
}
// Создаёт сессию пользователя: raw-токен наружу, в хранилище — его SHA-256-хэш.
// userId: Идентификатор пользователя.
// login: Логин (денормализуется в сессию для чтения без join).
// impersonatedByOperatorId: Маркер impersonation: оператор, создавший сессию; null — обычный вход.
// ct: Токен отмены.
// Возвращает: Raw-токен для выдачи клиенту.
private async Task<string> CreateSessionForUserAsync(
Guid userId,
string login,
Guid? impersonatedByOperatorId,
CancellationToken ct)
{
string rawToken = SessionTokens.NewToken();
var session = new SessionDto(
TokenHash: SessionTokens.HashToken(rawToken),
UserId: userId,
Login: login,
ExpiresAt: DateTimeOffset.UtcNow.AddDays(SessionLifetimeDays),
ImpersonatedByOperatorId: impersonatedByOperatorId);
await authStore.CreateSessionAsync(session, ct);
return rawToken;
}
// Успешный результат impersonation (токен + пользователь + тенант).
// rawToken: Raw-токен созданной сессии.
// userId: Идентификатор пользователя.
// login: Логин пользователя.
// tenantId: Тенант пользователя.
// Возвращает: ImpersonationResultDto с Ok=true.
private ImpersonationResultDto Success(
string rawToken,
Guid userId,
string login,
Guid tenantId) =>
new(
Ok: true,
Error: null,
SessionToken: rawToken,
ExpiresAt: DateTimeOffset.UtcNow.AddDays(SessionLifetimeDays),
Login: login,
UserId: userId,
TenantId: tenantId);
// Нормализация логина: нижний регистр и обрезка пробелов (единая форма хранения/сравнения).
// login: Входной логин.
// Возвращает: Нормализованный логин (пустая строка, если вход был пустым).
private static string NormalizeLogin(string? login) => (login ?? string.Empty).ToLowerInvariant().Trim();
}
@@ -1,114 +1,114 @@
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Modules.Tenants.Application.Services;
/// <summary>
/// Прикладной сервис аутентификации оператора
/// </summary>
public sealed class OperatorAuthService(IOperatorAuthStore operatorAuthStore, IPasswordHasher passwordHasher)
{
/// <summary>
/// Срок жизни сессии оператора, часов.
/// </summary>
public const int SessionLifetimeHours = 12;
// Статус «активен»: только активного оператора разрешает ResolveSessionAsync (удалённый/приостановленный — null).
private const string ActiveStatus = "active";
/// <summary>
/// Вход оператора: при успехе создаёт сессию и возвращает её raw-токен.
/// </summary>
/// <param name="login">Логин (регистр и пробелы не важны — нормализуется).</param>
/// <param name="password">Пароль в открытом виде.</param>
/// <returns>При успехе — Login и Token; иначе оба null (текст «Неверный логин или пароль оператора» фиксирует endpoint).</returns>
public async Task<OperatorLoginResultDto> LoginAsync(
string login,
string password,
CancellationToken ct)
{
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);
}
/// <summary>
/// Выход оператора
/// </summary>
/// <param name="rawToken">Raw-токен из куки (может отсутствовать — no-op).</param>
public async Task LogoutAsync(string? rawToken, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(rawToken))
{
return;
}
await operatorAuthStore.DeleteSessionAsync(SessionTokens.HashToken(rawToken), ct);
}
/// <summary>
/// Разрешение операторской сессии по raw-токену
/// </summary>
/// <param name="rawToken">Raw-токен из куки deal_operator_session.</param>
/// <returns>Идентичность активного оператора или null.</returns>
public async Task<OperatorIdentityDto?> ResolveSessionAsync(string? rawToken, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(rawToken))
{
return null;
}
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<string> 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;
/// <summary>
/// Прикладной сервис аутентификации оператора
/// </summary>
public sealed class OperatorAuthService(IOperatorAuthStore operatorAuthStore, IPasswordHasher passwordHasher)
{
/// <summary>
/// Срок жизни сессии оператора, часов.
/// </summary>
public const int SessionLifetimeHours = 12;
// Статус «активен»: только активного оператора разрешает ResolveSessionAsync (удалённый/приостановленный — null).
private const string ActiveStatus = "active";
/// <summary>
/// Вход оператора: при успехе создаёт сессию и возвращает её raw-токен.
/// </summary>
/// <param name="login">Логин (регистр и пробелы не важны — нормализуется).</param>
/// <param name="password">Пароль в открытом виде.</param>
/// <returns>При успехе — Login и Token; иначе оба null (текст «Неверный логин или пароль оператора» фиксирует endpoint).</returns>
public async Task<OperatorLoginResultDto> LoginAsync(
string login,
string password,
CancellationToken ct)
{
string normalizedLogin = NormalizeLogin(login);
if (string.IsNullOrEmpty(normalizedLogin) || string.IsNullOrEmpty(password))
{
return new OperatorLoginResultDto(null, null);
}
var operatorRecord = await operatorAuthStore.FindByLoginAsync(normalizedLogin, ct);
if (operatorRecord is null || !passwordHasher.Verify(password, operatorRecord.PasswordHash))
{
return new OperatorLoginResultDto(null, null);
}
string token = await CreateSessionForOperatorAsync(operatorRecord, ct);
return new OperatorLoginResultDto(operatorRecord.Login, token, operatorRecord.Id);
}
/// <summary>
/// Выход оператора
/// </summary>
/// <param name="rawToken">Raw-токен из куки (может отсутствовать — no-op).</param>
public async Task LogoutAsync(string? rawToken, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(rawToken))
{
return;
}
await operatorAuthStore.DeleteSessionAsync(SessionTokens.HashToken(rawToken), ct);
}
/// <summary>
/// Разрешение операторской сессии по raw-токену
/// </summary>
/// <param name="rawToken">Raw-токен из куки deal_operator_session.</param>
/// <returns>Идентичность активного оператора или null.</returns>
public async Task<OperatorIdentityDto?> ResolveSessionAsync(string? rawToken, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(rawToken))
{
return null;
}
string tokenHash = SessionTokens.HashToken(rawToken);
var session = await operatorAuthStore.FindSessionByTokenHashAsync(tokenHash, ct);
OperatorIdentityDto? operatorIdentity = null;
if (session is not null && session.ExpiresAt > DateTimeOffset.UtcNow)
{
var operatorRecord = await operatorAuthStore.FindByLoginAsync(session.Login, ct);
if (operatorRecord is not null && operatorRecord.Status == ActiveStatus)
{
operatorIdentity = new OperatorIdentityDto(operatorRecord.Id, operatorRecord.Login, operatorRecord.Status);
}
}
else if (session is not null)
{
// Очистка протухших сессий — только при обнаружении протухшей (редкий случай), не на каждый запрос
// (горячий путь запросов).
await operatorAuthStore.DeleteExpiredSessionsAsync(ct);
}
return operatorIdentity;
}
// Создаёт сессию оператора: raw-токен наружу, в хранилище — его SHA-256-хэш (формат как у SessionTokens).
// operatorRecord: Оператор (логин денормализуется в сессию).
// ct: Токен отмены.
// Возвращает: Raw-токен для выдачи клиенту.
private async Task<string> CreateSessionForOperatorAsync(StoredOperatorDto operatorRecord, CancellationToken ct)
{
string rawToken = SessionTokens.NewToken();
var session = new OperatorSessionDto(
TokenHash: SessionTokens.HashToken(rawToken),
OperatorId: operatorRecord.Id,
Login: operatorRecord.Login,
ExpiresAt: DateTimeOffset.UtcNow.AddHours(SessionLifetimeHours));
await operatorAuthStore.CreateSessionAsync(session, ct);
return rawToken;
}
// Нормализация логина: нижний регистр и обрезка пробелов (единая форма хранения/сравнения).
// login: Входной логин.
// Возвращает: Нормализованный логин (пустая строка, если вход был пустым).
private static string NormalizeLogin(string? login) => (login ?? string.Empty).ToLowerInvariant().Trim();
}
@@ -1,80 +1,80 @@
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Modules.Tenants.Application.Services;
/// <summary>
/// Bootstrap оператора при старте
/// </summary>
public sealed class OperatorBootstrapService(IOperatorAuthStore operatorAuthStore, IPasswordHasher passwordHasher)
{
/// <summary>
/// Переменная окружения
/// </summary>
public const string LoginEnvKey = "DEAL_OPERATOR_LOGIN";
/// <summary>
/// Переменная окружения
/// </summary>
public const string PasswordEnvKey = "DEAL_OPERATOR_PASSWORD";
/// <summary>
/// Дефолтный логин в Development при отсутствии env-кред.
/// </summary>
public const string DefaultOperatorLogin = "operator";
/// <summary>
/// Дефолтный пароль в Development при отсутствии env-кред.
/// </summary>
public const string DefaultOperatorPassword = "operator";
private const string ActiveStatus = "active";
/// <summary>
/// Гарантирует наличие оператора
/// </summary>
/// <param name="login">Логин из env (<see cref="LoginEnvKey"/>) или null/пусто, если не задан.</param>
/// <param name="password">Пароль из env (<see cref="PasswordEnvKey"/>) или null/пусто, если не задан.</param>
/// <param name="allowDevelopmentDefaults">true в Development: при отсутствии кред берутся дефолты operator/operator; false (Production) при отсутствии кред — шаг пропускается, хост логирует warning.</param>
/// <returns>Логин оператора, присутствующего после шага (созданного или уже существовавшего); null — шаг пропущен.</returns>
public async Task<string?> EnsureOperatorAsync(
string? login,
string? password,
bool allowDevelopmentDefaults,
CancellationToken ct)
{
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;
/// <summary>
/// Bootstrap оператора при старте
/// </summary>
public sealed class OperatorBootstrapService(IOperatorAuthStore operatorAuthStore, IPasswordHasher passwordHasher)
{
/// <summary>
/// Переменная окружения
/// </summary>
public const string LoginEnvKey = "DEAL_OPERATOR_LOGIN";
/// <summary>
/// Переменная окружения
/// </summary>
public const string PasswordEnvKey = "DEAL_OPERATOR_PASSWORD";
/// <summary>
/// Дефолтный логин в Development при отсутствии env-кред.
/// </summary>
public const string DefaultOperatorLogin = "operator";
/// <summary>
/// Дефолтный пароль в Development при отсутствии env-кред.
/// </summary>
public const string DefaultOperatorPassword = "operator";
private const string ActiveStatus = "active";
/// <summary>
/// Гарантирует наличие оператора
/// </summary>
/// <param name="login">Логин из env (<see cref="LoginEnvKey"/>) или null/пусто, если не задан.</param>
/// <param name="password">Пароль из env (<see cref="PasswordEnvKey"/>) или null/пусто, если не задан.</param>
/// <param name="allowDevelopmentDefaults">true в Development: при отсутствии кред берутся дефолты operator/operator; false (Production) при отсутствии кред — шаг пропускается, хост логирует warning.</param>
/// <returns>Логин оператора, присутствующего после шага (созданного или уже существовавшего); null — шаг пропущен.</returns>
public async Task<string?> EnsureOperatorAsync(
string? login,
string? password,
bool allowDevelopmentDefaults,
CancellationToken ct)
{
string normalizedLogin = NormalizeLogin(login);
string? resolvedPassword = password;
if (string.IsNullOrEmpty(normalizedLogin) || string.IsNullOrEmpty(resolvedPassword))
{
if (!allowDevelopmentDefaults)
{
return null;
}
normalizedLogin = DefaultOperatorLogin;
resolvedPassword = DefaultOperatorPassword;
}
// Идемпотентность: оператор с таким логином уже есть — пароль не перезаписываем.
var existing = await operatorAuthStore.FindByLoginAsync(normalizedLogin, ct);
if (existing is not null)
{
return existing.Login;
}
await operatorAuthStore.CreateAsync(
new StoredOperatorDto(
Id: Guid.NewGuid(),
Login: normalizedLogin,
Status: ActiveStatus,
PasswordHash: passwordHasher.Hash(resolvedPassword)),
ct);
return normalizedLogin;
}
// Нормализация логина: нижний регистр и обрезка пробелов (единая форма хранения).
// login: Входной логин (может быть null — как «не задан»).
// Возвращает: Нормализованный логин (пустая строка, если вход был пустым).
private static string NormalizeLogin(string? login) => (login ?? string.Empty).ToLowerInvariant().Trim();
}
@@ -1,32 +1,32 @@
using System.Security.Cryptography;
using System.Text;
using Deal.SharedKernel;
using Deal.SharedKernel.Utilities;
namespace Deal.Modules.Tenants.Application.Services;
/// <summary>
/// Токены сессий: генерация raw-токена и его SHA-256-хэша для хранения.
/// </summary>
public static class SessionTokens
{
// Случайные байты raw-токена (32 → 43 символа Base64Url).
private const int RawTokenByteLength = 32;
/// <summary>
/// Новый raw-токен
/// </summary>
/// <returns>Строка токена длиной 43 символа.</returns>
public static string NewToken() => UrlSafeToken.New(RawTokenByteLength);
/// <summary>
/// SHA-256-хэш raw-токена в нижнем регистре
/// </summary>
/// <param name="rawToken">Raw-токен из <see cref="NewToken"/> (или от клиента).</param>
/// <returns>64 hex-символа.</returns>
public static string HashToken(string rawToken)
{
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;
/// <summary>
/// Токены сессий: генерация raw-токена и его SHA-256-хэша для хранения.
/// </summary>
public static class SessionTokens
{
// Случайные байты raw-токена (32 → 43 символа Base64Url).
private const int RawTokenByteLength = 32;
/// <summary>
/// Новый raw-токен
/// </summary>
/// <returns>Строка токена длиной 43 символа.</returns>
public static string NewToken() => UrlSafeToken.New(RawTokenByteLength);
/// <summary>
/// SHA-256-хэш raw-токена в нижнем регистре
/// </summary>
/// <param name="rawToken">Raw-токен из <see cref="NewToken"/> (или от клиента).</param>
/// <returns>64 hex-символа.</returns>
public static string HashToken(string rawToken)
{
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(rawToken));
return Convert.ToHexString(hash).ToLowerInvariant();
}
}
@@ -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;
/// <summary>
/// Прикладной сервис реестра тенантов
/// </summary>
public sealed class TenantService(ITenantRepository tenantRepository, ITenantProvisioner tenantProvisioner)
{
/// <summary>
/// Создаёт тенанта
/// </summary>
/// <param name="name">Имя тенанта.</param>
/// <returns>Идентификатор созданного тенанта (он же имя схемы tenant_&lt;id&gt).</returns>
public Task<TenantId> CreateTenantAsync(string name, CancellationToken ct) =>
CreateTenantAsync(name, Guid.NewGuid(), ct);
/// <summary>
/// Создаёт тенанта с явным id и провижинит его схему.
/// </summary>
/// <param name="name">Имя тенанта.</param>
/// <param name="id">Идентификатор тенанта (определяет имя схемы).</param>
/// <returns>Идентификатор созданного тенанта (он же имя схемы tenant_&lt;id&gt).</returns>
public async Task<TenantId> CreateTenantAsync(
string name,
Guid id,
CancellationToken ct)
{
await tenantRepository.CreateAsync(
new TenantRecordDto(
Id: id,
Name: name,
Status: TenantStatuses.Active,
CreatedAt: DateTimeOffset.UtcNow),
ct);
var tenantIdValue = id.ToString("N");
await tenantProvisioner.ProvisionAsync(new TenantId(tenantIdValue), ct);
return new TenantId(tenantIdValue);
}
/// <summary>
/// Возвращает список всех тенантов.
/// </summary>
/// <returns>Список тенантов.</returns>
public Task<IReadOnlyList<TenantRecordDto>> ListTenantsAsync(CancellationToken ct) =>
tenantRepository.ListAsync(ct);
}
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
using Deal.SharedKernel.Tenants.Models;
namespace Deal.Modules.Tenants.Application.Services;
/// <summary>
/// Прикладной сервис реестра тенантов
/// </summary>
public sealed class TenantService(ITenantRepository tenantRepository, ITenantProvisioner tenantProvisioner)
{
/// <summary>
/// Создаёт тенанта
/// </summary>
/// <param name="name">Имя тенанта.</param>
/// <returns>Идентификатор созданного тенанта (он же имя схемы tenant_&lt;id&gt).</returns>
public Task<TenantId> CreateTenantAsync(string name, CancellationToken ct) =>
CreateTenantAsync(name, Guid.NewGuid(), ct);
/// <summary>
/// Создаёт тенанта с явным id и провижинит его схему.
/// </summary>
/// <param name="name">Имя тенанта.</param>
/// <param name="id">Идентификатор тенанта (определяет имя схемы).</param>
/// <returns>Идентификатор созданного тенанта (он же имя схемы tenant_&lt;id&gt).</returns>
public async Task<TenantId> CreateTenantAsync(
string name,
Guid id,
CancellationToken ct)
{
await tenantRepository.CreateAsync(
new TenantRecordDto(
Id: id,
Name: name,
Status: TenantStatuses.Active,
CreatedAt: DateTimeOffset.UtcNow),
ct);
string tenantIdValue = id.ToString("N");
await tenantProvisioner.ProvisionAsync(new TenantId(tenantIdValue), ct);
return new TenantId(tenantIdValue);
}
/// <summary>
/// Возвращает список всех тенантов.
/// </summary>
/// <returns>Список тенантов.</returns>
public Task<IReadOnlyList<TenantRecordDto>> ListTenantsAsync(CancellationToken ct) =>
tenantRepository.ListAsync(ct);
}
@@ -7,8 +7,14 @@ namespace Deal.SharedKernel.Tenants.Abstractions;
/// </summary>
public interface ITenantContext
{
/// <summary>
/// Идентификатор текущего тенанта или null для системного контекста
/// </summary>
public TenantId? TenantId { get; }
/// <summary>
/// Признак наличия текущего тенанта
/// </summary>
public bool HasTenant { get; }
/// <summary>
@@ -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('=')
@@ -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;
/// <summary>
/// Юнит-тесты EF-адаптера <see cref="AuthStore"/> на InMemory-провайдере
/// </summary>
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<UserIdentityDto> 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<DealDbContext>()
.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;
/// <summary>
/// Юнит-тесты EF-адаптера <see cref="AuthStore"/> на InMemory-провайдере
/// </summary>
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<UserIdentityDto> 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<DealDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString("N"))
.Options;
return new AuthStore(new DealDbContext(options));
}
}
@@ -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;
/// <summary>
/// Тесты пакетной миграции схем тенантов
/// </summary>
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<TenantSchemaMigrationService>.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;
/// <summary>
/// Тесты пакетной миграции схем тенантов
/// </summary>
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<TenantSchemaMigrationService>.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;
}
}
@@ -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);
}
@@ -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);
@@ -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);
@@ -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];
@@ -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);
@@ -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);
@@ -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));
@@ -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);
}
@@ -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");
}
@@ -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;
/// <summary>
/// Access-лог RPC Deal-сервисов
/// </summary>
public sealed class RpcCallLoggingInterceptor : Interceptor
{
// Префикс методов стандартного gRPC-health — не логируется (инфраструктурный liveness).
private const string HealthMethodPrefix = "/grpc.health.v1.Health/";
// Деталь RpcException для сбоя реализации (фиксированный текст; детали ошибки не наружу).
private const string UnknownFailureDetail = "Внутренняя ошибка сервиса";
private readonly ILogger<RpcCallLoggingInterceptor> _logger;
/// <summary>
/// Создаёт интерцептор access-лога gRPC-вызовов.
/// </summary>
/// <param name="logger">Логгер.</param>
public RpcCallLoggingInterceptor(ILogger<RpcCallLoggingInterceptor> logger)
{
ArgumentNullException.ThrowIfNull(logger);
_logger = logger;
}
/// <summary>
/// Логирует unary-RPC
/// </summary>
public override Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request,
ServerCallContext context,
UnaryServerMethod<TRequest, TResponse> continuation)
=> LogAsync(context, () => continuation(request, context));
/// <summary>
/// Логирует client-streaming-RPC
/// </summary>
public override Task<TResponse> ClientStreamingServerHandler<TRequest, TResponse>(
IAsyncStreamReader<TRequest> requestStream,
ServerCallContext context,
ClientStreamingServerMethod<TRequest, TResponse> continuation)
=> LogAsync(context, () => continuation(requestStream, context));
/// <summary>
/// Логирует server-streaming-RPC
/// </summary>
public override Task ServerStreamingServerHandler<TRequest, TResponse>(
TRequest request,
IServerStreamWriter<TResponse> responseStream,
ServerCallContext context,
ServerStreamingServerMethod<TRequest, TResponse> continuation)
=> LogAsync(context, () => continuation(request, responseStream, context));
/// <summary>
/// Логирует дуплексный RPC
/// </summary>
public override Task DuplexStreamingServerHandler<TRequest, TResponse>(
IAsyncStreamReader<TRequest> requestStream,
IServerStreamWriter<TResponse> responseStream,
ServerCallContext context,
DuplexStreamingServerMethod<TRequest, TResponse> continuation)
=> LogAsync(context, () => continuation(requestStream, responseStream, context));
// Исполняет вызов под access-логом: health пропускается; успех — OK, отмена клиента — Cancelled,
// RpcException — код статуса исключения, прочие сбои реализации — Unknown + RpcException.
// TResult: Тип результата вызова.
// context: Контекст вызова (метод — context.Method).
// invoke: Вызов нижестоящего обработчика.
private async Task<TResult> LogAsync<TResult>(ServerCallContext context, Func<Task<TResult>> 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<Task> 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;
/// <summary>
/// Access-лог RPC Deal-сервисов
/// </summary>
public sealed class RpcCallLoggingInterceptor : Interceptor
{
// Префикс методов стандартного gRPC-health — не логируется (инфраструктурный liveness).
private const string HealthMethodPrefix = "/grpc.health.v1.Health/";
// Деталь RpcException для сбоя реализации (фиксированный текст; детали ошибки не наружу).
private const string UnknownFailureDetail = "Внутренняя ошибка сервиса";
private readonly ILogger<RpcCallLoggingInterceptor> _logger;
/// <summary>
/// Создаёт интерцептор access-лога gRPC-вызовов.
/// </summary>
/// <param name="logger">Логгер.</param>
public RpcCallLoggingInterceptor(ILogger<RpcCallLoggingInterceptor> logger)
{
ArgumentNullException.ThrowIfNull(logger);
_logger = logger;
}
/// <summary>
/// Логирует unary-RPC
/// </summary>
public override Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request,
ServerCallContext context,
UnaryServerMethod<TRequest, TResponse> continuation)
=> LogAsync(context, () => continuation(request, context));
/// <summary>
/// Логирует client-streaming-RPC
/// </summary>
public override Task<TResponse> ClientStreamingServerHandler<TRequest, TResponse>(
IAsyncStreamReader<TRequest> requestStream,
ServerCallContext context,
ClientStreamingServerMethod<TRequest, TResponse> continuation)
=> LogAsync(context, () => continuation(requestStream, context));
/// <summary>
/// Логирует server-streaming-RPC
/// </summary>
public override Task ServerStreamingServerHandler<TRequest, TResponse>(
TRequest request,
IServerStreamWriter<TResponse> responseStream,
ServerCallContext context,
ServerStreamingServerMethod<TRequest, TResponse> continuation)
=> LogAsync(context, () => continuation(request, responseStream, context));
/// <summary>
/// Логирует дуплексный RPC
/// </summary>
public override Task DuplexStreamingServerHandler<TRequest, TResponse>(
IAsyncStreamReader<TRequest> requestStream,
IServerStreamWriter<TResponse> responseStream,
ServerCallContext context,
DuplexStreamingServerMethod<TRequest, TResponse> continuation)
=> LogAsync(context, () => continuation(requestStream, responseStream, context));
// Исполняет вызов под access-логом: health пропускается; успех — OK, отмена клиента — Cancelled,
// RpcException — код статуса исключения, прочие сбои реализации — Unknown + RpcException.
// TResult: Тип результата вызова.
// context: Контекст вызова (метод — context.Method).
// invoke: Вызов нижестоящего обработчика.
private async Task<TResult> LogAsync<TResult>(ServerCallContext context, Func<Task<TResult>> 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<Task> 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);
}
}
@@ -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;
/// <summary>
/// Серверный интерцептор service-token.
/// </summary>
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;
/// <summary>
/// Создаёт интерцептор.
/// </summary>
/// <param name="configuration">Конфигурация хоста (env-провайдер WebApplicationBuilder).</param>
public ServiceTokenInterceptor(IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(configuration);
_expectedTokenBytes = Encoding.UTF8.GetBytes(configuration[ServiceTokenEnvKey] ?? string.Empty);
}
/// <summary>
/// Проверяет токен для unary-RPC и передаёт вызов дальше.
/// </summary>
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request,
ServerCallContext context,
UnaryServerMethod<TRequest, TResponse> continuation)
{
EnsureAuthorized(context);
return await continuation(request, context).ConfigureAwait(false);
}
/// <summary>
/// Проверяет токен для client-streaming-RPC и передаёт вызов дальше.
/// </summary>
public override async Task<TResponse> ClientStreamingServerHandler<TRequest, TResponse>(
IAsyncStreamReader<TRequest> requestStream,
ServerCallContext context,
ClientStreamingServerMethod<TRequest, TResponse> continuation)
{
EnsureAuthorized(context);
return await continuation(requestStream, context).ConfigureAwait(false);
}
/// <summary>
/// Проверяет токен для server-streaming-RPC и передаёт вызов дальше.
/// </summary>
public override async Task ServerStreamingServerHandler<TRequest, TResponse>(
TRequest request,
IServerStreamWriter<TResponse> responseStream,
ServerCallContext context,
ServerStreamingServerMethod<TRequest, TResponse> continuation)
{
EnsureAuthorized(context);
await continuation(request, responseStream, context).ConfigureAwait(false);
}
/// <summary>
/// Проверяет токен для дуплексного RPC и передаёт вызов дальше.
/// </summary>
public override async Task DuplexStreamingServerHandler<TRequest, TResponse>(
IAsyncStreamReader<TRequest> requestStream,
IServerStreamWriter<TResponse> responseStream,
ServerCallContext context,
DuplexStreamingServerMethod<TRequest, TResponse> 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;
/// <summary>
/// Серверный интерцептор service-token.
/// </summary>
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;
/// <summary>
/// Создаёт интерцептор.
/// </summary>
/// <param name="configuration">Конфигурация хоста (env-провайдер WebApplicationBuilder).</param>
public ServiceTokenInterceptor(IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(configuration);
_expectedTokenBytes = Encoding.UTF8.GetBytes(configuration[ServiceTokenEnvKey] ?? string.Empty);
}
/// <summary>
/// Проверяет токен для unary-RPC и передаёт вызов дальше.
/// </summary>
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request,
ServerCallContext context,
UnaryServerMethod<TRequest, TResponse> continuation)
{
EnsureAuthorized(context);
return await continuation(request, context).ConfigureAwait(false);
}
/// <summary>
/// Проверяет токен для client-streaming-RPC и передаёт вызов дальше.
/// </summary>
public override async Task<TResponse> ClientStreamingServerHandler<TRequest, TResponse>(
IAsyncStreamReader<TRequest> requestStream,
ServerCallContext context,
ClientStreamingServerMethod<TRequest, TResponse> continuation)
{
EnsureAuthorized(context);
return await continuation(requestStream, context).ConfigureAwait(false);
}
/// <summary>
/// Проверяет токен для server-streaming-RPC и передаёт вызов дальше.
/// </summary>
public override async Task ServerStreamingServerHandler<TRequest, TResponse>(
TRequest request,
IServerStreamWriter<TResponse> responseStream,
ServerCallContext context,
ServerStreamingServerMethod<TRequest, TResponse> continuation)
{
EnsureAuthorized(context);
await continuation(request, responseStream, context).ConfigureAwait(false);
}
/// <summary>
/// Проверяет токен для дуплексного RPC и передаёт вызов дальше.
/// </summary>
public override async Task DuplexStreamingServerHandler<TRequest, TResponse>(
IAsyncStreamReader<TRequest> requestStream,
IServerStreamWriter<TResponse> responseStream,
ServerCallContext context,
DuplexStreamingServerMethod<TRequest, TResponse> 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));
}
@@ -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;
/// <summary>
/// Конфигурация mTLS-транспорта внутреннего gRPC.
/// </summary>
public sealed class MtlsOptions
{
/// <summary>
/// Env-ключ флага: 1/true включает mTLS
/// </summary>
public const string EnabledEnvKey = "DEAL_MTLS_ENABLED";
/// <summary>
/// Env-ключ пути к PFX серверного сертификата процесса
/// </summary>
public const string ServerCertPfxEnvKey = "DEAL_MTLS_SERVER_CERT_PFX";
/// <summary>
/// Env-ключ пароля серверного PFX.
/// </summary>
public const string ServerCertPasswordEnvKey = "DEAL_MTLS_SERVER_CERT_PASSWORD";
/// <summary>
/// Env-ключ пути к PFX клиентского сертификата
/// </summary>
public const string ClientCertPfxEnvKey = "DEAL_MTLS_CLIENT_CERT_PFX";
/// <summary>
/// Env-ключ пароля клиентского PFX.
/// </summary>
public const string ClientCertPasswordEnvKey = "DEAL_MTLS_CLIENT_CERT_PASSWORD";
/// <summary>
/// Env-ключ пути к PEM dev-CA
/// </summary>
public const string CaPemEnvKey = "DEAL_MTLS_CA_PEM";
/// <summary>
/// True — транспорт внутренних gRPC-эндпоинтов и исходящих каналов под mTLS.
/// </summary>
public bool Enabled { get; init; }
/// <summary>
/// Путь к PFX серверного сертификата процесса
/// </summary>
public string ServerCertPfx { get; init; } = string.Empty;
/// <summary>
/// Пароль серверного PFX
/// </summary>
public string ServerCertPassword { get; init; } = string.Empty;
/// <summary>
/// Путь к PFX клиентского сертификата
/// </summary>
public string ClientCertPfx { get; init; } = string.Empty;
/// <summary>
/// Пароль клиентского PFX
/// </summary>
public string ClientCertPassword { get; init; } = string.Empty;
/// <summary>
/// Путь к PEM-файлу dev-CA
/// </summary>
public string CaPem { get; init; } = string.Empty;
/// <summary>
/// Читает опции из конфигурации хоста.
/// </summary>
/// <param name="configuration">Конфигурация хоста (env-провайдер WebApplicationBuilder).</param>
/// <returns>Опции mTLS (флаг выключен — остальные поля пустые).</returns>
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]),
};
}
/// <summary>
/// Разбирает значение флага DEAL_MTLS_ENABLED
/// </summary>
/// <param name="rawValue">Сырое значение env (null/пусто — выключено).</param>
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;
/// <summary>
/// Конфигурация mTLS-транспорта внутреннего gRPC.
/// </summary>
public sealed class MtlsOptions
{
/// <summary>
/// Env-ключ флага: 1/true включает mTLS
/// </summary>
public const string EnabledEnvKey = "DEAL_MTLS_ENABLED";
/// <summary>
/// Env-ключ пути к PFX серверного сертификата процесса
/// </summary>
public const string ServerCertPfxEnvKey = "DEAL_MTLS_SERVER_CERT_PFX";
/// <summary>
/// Env-ключ пароля серверного PFX.
/// </summary>
public const string ServerCertPasswordEnvKey = "DEAL_MTLS_SERVER_CERT_PASSWORD";
/// <summary>
/// Env-ключ пути к PFX клиентского сертификата
/// </summary>
public const string ClientCertPfxEnvKey = "DEAL_MTLS_CLIENT_CERT_PFX";
/// <summary>
/// Env-ключ пароля клиентского PFX.
/// </summary>
public const string ClientCertPasswordEnvKey = "DEAL_MTLS_CLIENT_CERT_PASSWORD";
/// <summary>
/// Env-ключ пути к PEM dev-CA
/// </summary>
public const string CaPemEnvKey = "DEAL_MTLS_CA_PEM";
/// <summary>
/// True — транспорт внутренних gRPC-эндпоинтов и исходящих каналов под mTLS.
/// </summary>
public bool Enabled { get; init; }
/// <summary>
/// Путь к PFX серверного сертификата процесса
/// </summary>
public string ServerCertPfx { get; init; } = string.Empty;
/// <summary>
/// Пароль серверного PFX
/// </summary>
public string ServerCertPassword { get; init; } = string.Empty;
/// <summary>
/// Путь к PFX клиентского сертификата
/// </summary>
public string ClientCertPfx { get; init; } = string.Empty;
/// <summary>
/// Пароль клиентского PFX
/// </summary>
public string ClientCertPassword { get; init; } = string.Empty;
/// <summary>
/// Путь к PEM-файлу dev-CA
/// </summary>
public string CaPem { get; init; } = string.Empty;
/// <summary>
/// Читает опции из конфигурации хоста.
/// </summary>
/// <param name="configuration">Конфигурация хоста (env-провайдер WebApplicationBuilder).</param>
/// <returns>Опции mTLS (флаг выключен — остальные поля пустые).</returns>
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]),
};
}
/// <summary>
/// Разбирает значение флага DEAL_MTLS_ENABLED
/// </summary>
/// <param name="rawValue">Сырое значение env (null/пусто — выключено).</param>
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();
}
@@ -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;
/// <summary>
/// Serilog-конфигурация процесса Deal-сервиса.
/// </summary>
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;
/// <summary>
/// Подключает Serilog к хосту
/// </summary>
/// <param name="builder">Билдер WebApplication процесса (до Build).</param>
/// <param name="processName">Имя процесса для имени файла-лога (telegram/ai/ml/…).</param>
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;
/// <summary>
/// Serilog-конфигурация процесса Deal-сервиса.
/// </summary>
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;
/// <summary>
/// Подключает Serilog к хосту
/// </summary>
/// <param name="builder">Билдер WebApplication процесса (до Build).</param>
/// <param name="processName">Имя процесса для имени файла-лога (telegram/ai/ml/…).</param>
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;
}
@@ -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;
/// <summary>
/// Общая настройка метрик Deal-сервисов
/// </summary>
public static class DealMetricsHosting
{
/// <summary>
/// Имя meter'а прикладных метрик Deal
/// </summary>
public const string MeterName = "Deal";
/// <summary>
/// Порт эндпоинта <c>/metrics</c> по умолчанию
/// </summary>
public const int DefaultMetricsPort = 9464;
// Env-ключ порта метрик (переопределяет DefaultMetricsPort).
private const string MetricsPortEnvKey = "METRICS_PORT";
/// <summary>
/// Порт эндпоинта метрик
/// </summary>
/// <param name="defaultPort">Дефолтный порт (обычно <see cref="DefaultMetricsPort"/>).</param>
/// <returns>Порт HTTP/1.1-эндпоинта метрик.</returns>
public static int ResolveMetricsPort(int defaultPort)
=> int.TryParse(Environment.GetEnvironmentVariable(MetricsPortEnvKey), out int port) && port > 0
? port
: defaultPort;
/// <summary>
/// Регистрирует OTel-метрики и Kestrel-эндпоинт метрик
/// </summary>
/// <param name="builder">Билдер хоста сервиса.</param>
/// <param name="metricsPort">Порт HTTP/1.1-эндпоинта метрик.</param>
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());
}
/// <summary>
/// Мапит эндпоинт <c>/metrics</c>
/// </summary>
/// <param name="app">Собранное приложение сервиса.</param>
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;
/// <summary>
/// Общая настройка метрик Deal-сервисов
/// </summary>
public static class DealMetricsHosting
{
/// <summary>
/// Имя meter'а прикладных метрик Deal
/// </summary>
public const string MeterName = "Deal";
/// <summary>
/// Порт эндпоинта <c>/metrics</c> по умолчанию
/// </summary>
public const int DefaultMetricsPort = 9464;
// Env-ключ порта метрик (переопределяет DefaultMetricsPort).
private const string MetricsPortEnvKey = "METRICS_PORT";
/// <summary>
/// Порт эндпоинта метрик
/// </summary>
/// <param name="defaultPort">Дефолтный порт (обычно <see cref="DefaultMetricsPort"/>).</param>
/// <returns>Порт HTTP/1.1-эндпоинта метрик.</returns>
public static int ResolveMetricsPort(int defaultPort)
=> int.TryParse(Environment.GetEnvironmentVariable(MetricsPortEnvKey), out int port) && port > 0
? port
: defaultPort;
/// <summary>
/// Регистрирует OTel-метрики и Kestrel-эндпоинт метрик
/// </summary>
/// <param name="builder">Билдер хоста сервиса.</param>
/// <param name="metricsPort">Порт HTTP/1.1-эндпоинта метрик.</param>
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());
}
/// <summary>
/// Мапит эндпоинт <c>/metrics</c>
/// </summary>
/// <param name="app">Собранное приложение сервиса.</param>
public static void MapDealMetrics(WebApplication app)
{
ArgumentNullException.ThrowIfNull(app);
app.MapPrometheusScrapingEndpoint();
}
}
@@ -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;
/// <summary>
/// Общие серверные блоки gRPC-хостов Deal-сервисов
/// </summary>
public static class GrpcServer
{
// Имя gRPC-health-проверки готовности (без неё health-сервис отвечает UNKNOWN, а не SERVING).
private const string ReadyHealthCheckName = "ready";
/// <summary>
/// Потолок входящего gRPC-сообщения
/// </summary>
public const int DefaultMaxReceiveMessageSize = 4 * 1024 * 1024;
/// <summary>
/// Загружает сертификаты mTLS из env и регистрирует набор в DI
/// </summary>
/// <param name="builder">Билдер хоста (конфигурация env + DI).</param>
/// <returns>Набор сертификатов либо null (mTLS выключен).</returns>
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;
}
/// <summary>
/// Настраивает единственный Kestrel-эндпоинт HTTP/2 на 0.0.0.0:grpcPort
/// </summary>
/// <param name="builder">Билдер хоста (WebHost для ConfigureKestrel).</param>
/// <param name="grpcPort">TCP-порт Kestrel.</param>
/// <param name="mtlsCertificates">Набор сертификатов mTLS (null — plaintext).</param>
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;
});
}
});
});
}
/// <summary>
/// Регистрирует AddGrpc с общей серверной обвязкой
/// </summary>
/// <param name="services">DI сервисов хоста.</param>
public static IServiceCollection AddDealGrpcServer(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.AddGrpc(grpc =>
{
grpc.MaxReceiveMessageSize = DefaultMaxReceiveMessageSize;
grpc.Interceptors.Add<RpcCallLoggingInterceptor>();
grpc.Interceptors.Add<ServiceTokenInterceptor>();
});
return services;
}
/// <summary>
/// Регистрирует стандартный gRPC-health
/// </summary>
/// <param name="services">DI сервисов хоста.</param>
/// <param name="readyDetail">Текст готовности проверки (имя хоста в логах healthcheck).</param>
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;
/// <summary>
/// Общие серверные блоки gRPC-хостов Deal-сервисов
/// </summary>
public static class GrpcServer
{
// Имя gRPC-health-проверки готовности (без неё health-сервис отвечает UNKNOWN, а не SERVING).
private const string ReadyHealthCheckName = "ready";
/// <summary>
/// Потолок входящего gRPC-сообщения
/// </summary>
public const int DefaultMaxReceiveMessageSize = 4 * 1024 * 1024;
/// <summary>
/// Загружает сертификаты mTLS из env и регистрирует набор в DI
/// </summary>
/// <param name="builder">Билдер хоста (конфигурация env + DI).</param>
/// <returns>Набор сертификатов либо null (mTLS выключен).</returns>
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;
}
/// <summary>
/// Настраивает единственный Kestrel-эндпоинт HTTP/2 на 0.0.0.0:grpcPort
/// </summary>
/// <param name="builder">Билдер хоста (WebHost для ConfigureKestrel).</param>
/// <param name="grpcPort">TCP-порт Kestrel.</param>
/// <param name="mtlsCertificates">Набор сертификатов mTLS (null — plaintext).</param>
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;
});
}
});
});
}
/// <summary>
/// Регистрирует AddGrpc с общей серверной обвязкой
/// </summary>
/// <param name="services">DI сервисов хоста.</param>
public static IServiceCollection AddDealGrpcServer(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.AddGrpc(grpc =>
{
grpc.MaxReceiveMessageSize = DefaultMaxReceiveMessageSize;
grpc.Interceptors.Add<RpcCallLoggingInterceptor>();
grpc.Interceptors.Add<ServiceTokenInterceptor>();
});
return services;
}
/// <summary>
/// Регистрирует стандартный gRPC-health
/// </summary>
/// <param name="services">DI сервисов хоста.</param>
/// <param name="readyDetail">Текст готовности проверки (имя хоста в логах healthcheck).</param>
public static IServiceCollection AddReadyHealthCheck(this IServiceCollection services, string readyDetail)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentException.ThrowIfNullOrWhiteSpace(readyDetail);
services
.AddGrpcHealthChecks()
.AddCheck(ReadyHealthCheckName, () => HealthCheckResult.Healthy(readyDetail));
return services;
}
}
@@ -1,19 +1,17 @@
using Deal.Ml.Model;
namespace Deal.Ml.Extensions;
/// <summary>
/// Расширения <see cref="string"/> для меток классов ML-модели.
/// </summary>
internal static class LabelExtensions
{
/// <summary>
/// Метка внутреннего типа заявки
/// </summary>
/// <param name="label">Метка класса.</param>
/// <returns>True — метка является внутренним типом заявки (префикс t).</returns>
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
{
/// <summary>
/// Метка внутреннего типа заявки
/// </summary>
/// <param name="label">Метка класса.</param>
/// <returns>True — метка является внутренним типом заявки (префикс t).</returns>
public static bool IsTypeLabel(this string label)
{
return label.StartsWith(ModelConstants.TypeLabelPrefix, StringComparison.Ordinal);
}
}
@@ -1,53 +1,53 @@
using Deal.Telegram.Dialogs;
namespace Deal.Telegram.Tests.Telegram;
/// <summary>
/// Unit-тесты цвета диалога.
/// </summary>
public sealed class DialogHueTests
{
/// <summary>
/// Цвет по паре
/// </summary>
[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));
}
/// <summary>
/// Цвет детерминирован
/// </summary>
[Fact]
public void Compute_IsDeterministic()
{
string first = DialogHue.Compute("-1001234567890", "IT Канал");
Assert.Equal(first, DialogHue.Compute("-1001234567890", "IT Канал"));
}
/// <summary>
/// Разные источники распределяются по палитре
/// </summary>
[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;
/// <summary>
/// Unit-тесты цвета диалога.
/// </summary>
public sealed class DialogHueTests
{
/// <summary>
/// Цвет по паре
/// </summary>
[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));
}
/// <summary>
/// Цвет детерминирован
/// </summary>
[Fact]
public void Compute_IsDeterministic()
{
string first = DialogHue.Compute("-1001234567890", "IT Канал");
Assert.Equal(first, DialogHue.Compute("-1001234567890", "IT Канал"));
}
/// <summary>
/// Разные источники распределяются по палитре
/// </summary>
[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());
}
}
@@ -1,19 +1,17 @@
using Grpc.Core;
namespace Deal.Telegram.Extensions;
/// <summary>
/// Расширения <see cref="Exception"/> для классификации сбоев telegram-service.
/// </summary>
internal static class ExceptionExtensions
{
/// <summary>
/// Истинно транспортные/сетевые причины — только они дают UNAVAILABLE
/// </summary>
/// <param name="exception">Исключение для классификации.</param>
/// <returns>True — исключение транспортного/сетевого характера.</returns>
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
{
/// <summary>
/// Истинно транспортные/сетевые причины — только они дают UNAVAILABLE
/// </summary>
/// <param name="exception">Исключение для классификации.</param>
/// <returns>True — исключение транспортного/сетевого характера.</returns>
public static bool IsTransportFailure(this Exception exception)
{
return exception is HttpRequestException or IOException or TimeoutException or RpcException;
}
}