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