Инициализировать репозиторий «Дейл»

Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы
ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ,
инструкция пользователя, техдокументация, код-стайл), бэклог,
скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
Rustam Khalimov
2026-09-11 02:50:17 +03:00
commit 9e07568ddd
1402 changed files with 177470 additions and 0 deletions
@@ -0,0 +1,95 @@
using System.Diagnostics;
namespace Deal.Api.Middleware;
/// <summary>
/// Access-лог HTTP-запросов core (Ruling 7, план Task 14): каждый запрос — одна структурированная
/// строка «метод путь → статус за N мс» (Serilog JSON). Логируются метод и путь БЕЗ query-строки,
/// заголовков и тела — секреты/токены в query не попадают в логи (Ruling 13); клиентский IP не
/// логируется (аудит-IP живёт в AuditLog, операторский контур).
/// </summary>
/// <remarks>
/// Регистрируется самым первым в HTTP-конвейере (после UseForwardedHeaders): видит результат всех
/// слоёв ниже (CORS/session/rate-limiter/OriginGuard/эндпоинты) и полную длительность запроса.
/// Запросы gRPC-ингресса (Content-Type application/grpc) пропускаются — их содержательный access-лог
/// пишет интерцептор RpcCallLoggingInterceptor (HTTP-статус gRPC-вызовов всегда 200, полезен только
/// gRPC-статус). SSE-подписка /api/events логируется по завершении потока (длительность = время жизни
/// соединения). Отмена запроса (клиент закрыл SSE/дисконнект) отдельной строкой не пишется.
/// </remarks>
public sealed class HttpAccessLogMiddleware
{
// Content-Type gRPC-запросов (HTTP/2) — их логирует RpcCallLoggingInterceptor.
private const string GrpcContentType = "application/grpc";
private readonly RequestDelegate _next;
private readonly ILogger<HttpAccessLogMiddleware> _logger;
/// <summary>
/// Создаёт middleware access-лога HTTP-запросов.
/// </summary>
/// <param name="next">Следующий обработчик конвейера.</param>
/// <param name="logger">Логгер (Serilog, Ruling 7).</param>
public HttpAccessLogMiddleware(RequestDelegate next, ILogger<HttpAccessLogMiddleware> logger)
{
ArgumentNullException.ThrowIfNull(next);
ArgumentNullException.ThrowIfNull(logger);
_next = next;
_logger = logger;
}
/// <summary>
/// Обрабатывает запрос: пропускает gRPC-ингресс, остальные логирует по завершении.
/// </summary>
/// <param name="context">Контекст запроса.</param>
public async Task InvokeAsync(HttpContext context)
{
if (context.Request.ContentType?.StartsWith(GrpcContentType, StringComparison.OrdinalIgnoreCase) == true)
{
await _next(context);
return;
}
long startedAt = Stopwatch.GetTimestamp();
try
{
await _next(context);
LogCall(context, startedAt, null);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
// Необработанное исключение: фиксируем строку 500 (статус ещё не выставлен) и пробрасываем —
// обработку ошибки продолжает хостинг (DeveloperExceptionPage в dev/закрытие соединения в prod).
LogCall(context, startedAt, exception);
throw;
}
}
// Пишет одну строку access-лога запроса (успех либо исключение).
// context: Контекст запроса (метод/путь/статус ответа).
// startedAt: Метка времени старта запроса (Stopwatch.GetTimestamp).
// exception: Необработанное исключение (null — запрос завершился штатно).
private void LogCall(HttpContext context, long startedAt, Exception? exception)
{
long elapsedMs = (long)Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds;
string method = context.Request.Method;
string path = context.Request.Path.Value ?? "/";
if (exception is null)
{
_logger.LogInformation(
"HTTP {Method} {Path} -> {StatusCode} за {DurationMs} мс",
method,
path,
context.Response.StatusCode,
elapsedMs);
}
else
{
_logger.LogError(
exception,
"HTTP {Method} {Path} -> исключение за {DurationMs} мс",
method,
path,
elapsedMs);
}
}
}
@@ -0,0 +1,57 @@
using Deal.Api.Configuration;
using Deal.Api.Http;
using Deal.Modules.Tenants.Application;
using Microsoft.Extensions.Options;
// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом.
using OperatorCookieOptions = Deal.Api.Configuration.OperatorCookieOptions;
namespace Deal.Api.Middleware;
/// <summary>
/// Middleware операторской сессии: читает httpOnly-куку deal_operator_session, разрешает сессию через
/// OperatorAuthService и наполняет <c>HttpContext.Items["CurrentOperator"]</c> (Ruling 1 этапа 7).
/// </summary>
/// <remarks>
/// Зеркало <see cref="SessionMiddleware"/> для операторов: отдельная кука и отдельный ключ Items —
/// операторская сессия не может подменить тенантную и наоборот (разные имена куки, разные middleware).
/// Tenant-контекст (ITenantContext/CurrentUser) middleware не трогает — оператор не принадлежит тенанту.
/// Middleware НЕ отвечает 401 сама (pass-through): ручки /api/operator/*, требующие оператора, проверяют
/// <c>GetCurrentOperator()</c> и выставляют 401. OperatorAuthService — scoped, поэтому на запрос
/// создаётся собственный scope через RequestServices (как в SessionMiddleware).
/// </remarks>
public sealed class OperatorSessionMiddleware
{
private readonly RequestDelegate _next;
private readonly IOptionsMonitor<OperatorCookieOptions> _cookieOptions;
public OperatorSessionMiddleware(
RequestDelegate next,
IOptionsMonitor<OperatorCookieOptions> cookieOptions)
{
_next = next;
_cookieOptions = cookieOptions;
}
/// <summary>
/// Обрабатывает запрос: разрешает операторскую сессию по куке и наполняет контекст.
/// </summary>
/// <param name="context">Контекст запроса.</param>
public async Task InvokeAsync(HttpContext context)
{
var cookieName = _cookieOptions.CurrentValue.Name;
if (context.Request.Cookies.TryGetValue(cookieName, out var rawToken)
&& !string.IsNullOrWhiteSpace(rawToken))
{
// OperatorAuthService scoped: создаём scope на запрос через RequestServices.
await using var scope = context.RequestServices.CreateAsyncScope();
var operatorAuthService = scope.ServiceProvider.GetRequiredService<OperatorAuthService>();
var identity = await operatorAuthService.ResolveSessionAsync(rawToken, context.RequestAborted);
if (identity is not null)
{
context.SetCurrentOperator(new CurrentOperator(identity.Id, identity.Login, identity.Status));
}
}
await _next(context);
}
}
@@ -0,0 +1,97 @@
using Deal.Api.Configuration;
using Microsoft.Net.Http.Headers;
namespace Deal.Api.Middleware;
/// <summary>
/// Origin-проверка мутаций /api (план Task 12, Ruling 10(2)): запросы не-GET/HEAD/OPTIONS к /api,
/// у которых есть заголовок Origin, обязаны иметь Origin, равный «своему» origin запроса
/// (схема + Host; за Caddy схема — https из X-Forwarded-Proto, см. UseForwardedHeaders) либо входящий
/// в явный allowlist <see cref="SecurityOptions.AllowedOrigins"/>; несовпадение — HTTP 403
/// <c>{"detail":"…"}</c>.
/// </summary>
/// <remarks>
/// Дополнительный слой CSRF поверх SameSite=Lax кук (первый рубеж, документируется в техдок §10):
/// браузер всегда шлёт Origin на мутирующих запросах, а подделать его из чужого сайта нельзя, поэтому
/// «чужой» Origin — надёжный признак cross-site запроса. Запросы без Origin (curl, сервер-сервер,
/// gRPC) не проверяются и пропускаются — Origin обязателен только у браузерных вызовов. GET/HEAD —
/// не мутации, OPTIONS — CORS-preflight: не проверяются. Пустой allowlist (dev-режим, Ruling 10(2)) —
/// правило «Origin == свой origin запроса»; непустой список из конфига расширяет его (фронт за
/// прокси, меняющим Host, и/или явные домены PROD, Ruling 9).
/// <para>
/// Регистрируется последним из security-слоёв (Ruling 5: Session → Operator → RateLimiter → OriginGuard):
/// сессии уже разрешены, rate-limiter ответил 429 раньше, чем проверяется Origin.
/// </para>
/// </remarks>
public sealed class OriginGuardMiddleware
{
/// <summary>
/// Текст 403 Origin-проверки (Ruling 10(2)): единая формулировка для всех отказов.
/// </summary>
public const string OriginRejectedDetail = "Запрос отклонён: недопустимый Origin";
// Префикс пути, под которым живут все HTTP-эндпоинты приложения (Ruling 11).
private const string ApiPathPrefix = "/api";
private readonly RequestDelegate _next;
private readonly HashSet<string> _allowedOrigins;
public OriginGuardMiddleware(RequestDelegate next, SecurityOptions securityOptions)
{
ArgumentNullException.ThrowIfNull(next);
ArgumentNullException.ThrowIfNull(securityOptions);
_next = next;
// Регистронезависимо: схема/хост origin'ов регистронечувствительны, браузеры шлют нижний регистр.
_allowedOrigins = new HashSet<string>(securityOptions.AllowedOrigins, StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// Обрабатывает запрос: отклоняет мутации /api с чужим Origin (403 {detail}).
/// </summary>
/// <param name="context">Контекст запроса.</param>
public async Task InvokeAsync(HttpContext context)
{
HttpRequest request = context.Request;
if (request.Path.StartsWithSegments(ApiPathPrefix)
&& !HttpMethods.IsGet(request.Method)
&& !HttpMethods.IsHead(request.Method)
&& !HttpMethods.IsOptions(request.Method)
&& request.Headers.TryGetValue(HeaderNames.Origin, out var originValues))
{
string origin = originValues.ToString();
if (!string.IsNullOrWhiteSpace(origin) && !IsTrustedOrigin(origin, request))
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsJsonAsync(new { detail = OriginRejectedDetail }, context.RequestAborted);
return;
}
}
await _next(context);
}
// Проверяет Origin: allowlist конфига либо «свой» origin запроса (схема + Host).
// origin: Значение заголовка Origin запроса.
// request: Запрос (схема/Host — с учётом прокси-заголовков, если UseForwardedHeaders включён).
// Возвращает: true — Origin доверенный, запрос можно пропускать.
private bool IsTrustedOrigin(string origin, HttpRequest request)
{
if (_allowedOrigins.Contains(origin))
{
return true;
}
// «Свой» origin: схема + Host-заголовок запроса (без завершающего слэша — как шлёт браузер).
HostString host = request.Host;
if (host.HasValue)
{
string selfOrigin = request.Scheme + "://" + host.Value;
if (string.Equals(origin, selfOrigin, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
}
@@ -0,0 +1,146 @@
using System.Threading.RateLimiting;
using Deal.Api.Configuration;
using Deal.Api.Http;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.DependencyInjection;
namespace Deal.Api.Middleware;
/// <summary>
/// Регистрация встроенного rate limiter ASP.NET Core (план Task 11, Ruling 5; этап 12, пакет B — хранилище
/// на Postgres) и его политики: "auth" — фиксированное окно на IP клиента (ручки входа /api/auth/login и
/// /api/operator/auth/login), "api" — на тенанта из <see cref="CurrentUser"/> либо IP анонима
/// (Session/OperatorSessionMiddleware отрабатывают раньше — порядок Session → Operator → RateLimiter).
/// API-партиция выставляется и глобальным лимитером: все /api-эндпоинты без собственной политики
/// ограничены 600/мин на тенанта/IP. Отказ любого лимитера — HTTP 429 с телом <c>{"detail": "…"}</c>.
/// </summary>
/// <remarks>
/// Регистрируется только при <see cref="RateLimitOptions.Enabled"/> (в dev/тестах middleware и политики
/// не создаются — Ruling 5). gRPC-ингресс (:5082) HTTP-лимитером освобождён (DisableRateLimiting на
/// MapGrpcService): лимит по tenant-id там считает IngressRateLimitInterceptor — иначе входящий поток
/// telegram-service резался бы общим окном на IP.
/// <para>
/// Счётчики окон живут в Postgres (<see cref="IRateLimitCounterStore"/> через scoped
/// <see cref="StoreBackedFixedWindowRateLimiter"/>) — лимиты общие для всех инстансов core. Пороги/окна
/// не менялись: фиксированные 1 минута, PermitLimit из <see cref="RateLimitOptions"/>.
/// </para>
/// </remarks>
public static class RateLimitPolicies
{
/// <summary>
/// Имя политики входа: фиксированное окно по IP (RateLimit:AuthPerMinute).
/// </summary>
public const string AuthPolicy = "auth";
/// <summary>
/// Имя API-политики: фиксированное окно по CurrentUser.TenantId либо IP анонима (RateLimit:ApiPerMinute).
/// </summary>
public const string ApiPolicy = "api";
/// <summary>
/// Текст 429 rate limiter (Ruling 5): все отказы лимитов запросов — единый detail.
/// </summary>
public const string RejectedDetail = "Слишком много запросов. Повторите позже";
// Префикс ключа хранилища для HTTP-политик: изолирует счётчики от gRPC-ингресса и login-guard.
private const string HttpKeyPrefix = "http:";
// Пространство ключа глобального лимитера: отдельные счётчики от именованной "api"-политики
// (как и раньше — независимые FixedWindowRateLimiter'ы, лимиты не схлопываются в один бакет).
private const string GlobalScope = "global";
// Префикс партиции API-политики для анонима (IP не разрешён в сессию).
private const string IpKeyPrefix = "ip:";
// Префикс партиции API-политики для аутентифицированного пользователя (по тенанту).
private const string TenantKeyPrefix = "tenant:";
// Ключ партиции, когда IP клиента недоступен (служебные/тестовые вызовы).
private const string UnknownClientKey = "unknown";
// Длина окна политик — 1 минута (имена настроек — «PerMinute»).
private static readonly TimeSpan Window = TimeSpan.FromMinutes(1);
/// <summary>
/// Регистрирует AddRateLimiter с политиками "auth"/"api" и глобальным лимитером API-партиции.
/// </summary>
/// <param name="services">Коллекция сервисов хоста.</param>
/// <param name="options">Настройки rate limiting (секция RateLimit).</param>
/// <returns>Коллекция сервисов для цепочки вызовов.</returns>
public static IServiceCollection AddDealRateLimiter(this IServiceCollection services, RateLimitOptions options)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(options);
int apiPerMinute = options.ApiPerMinute;
services.AddRateLimiter(limiter =>
{
limiter.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
limiter.OnRejected = OnRejectedAsync;
limiter.AddPolicy(AuthPolicy, context => AuthPartition(context, options.AuthPerMinute));
limiter.AddPolicy(ApiPolicy, context => ApiPartition(context, ApiPolicy, apiPerMinute));
// Глобальный лимитер — та же API-партиция (своё пространство ключей): эндпоинты без
// RequireRateLimiting ограничены по тенанту/IP (весь /api, кроме ручек входа и gRPC).
limiter.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(
context => ApiPartition(context, GlobalScope, apiPerMinute));
});
return services;
}
// Партиция политики "auth": фиксированное окно в минуту на IP клиента.
// context: Контекст запроса (адрес клиента).
// permitsPerMinute: Разрешено запросов в минуту на партицию (RateLimit:AuthPerMinute).
// Возвращает: Партиция лимитера с ключом IP.
private static RateLimitPartition<string> AuthPartition(HttpContext context, int permitsPerMinute)
=> StorePartition(context, AuthPolicy, ClientKey(context), permitsPerMinute);
// Партиция политики "api": ключ — тенант сессии (CurrentUser) либо IP анонима.
// context: Контекст запроса (CurrentUser кладёт SessionMiddleware до UseRateLimiter).
// scope: Пространство ключей (имя политики или "global" для глобального лимитера).
// permitsPerMinute: Разрешено запросов в минуту на партицию (RateLimit:ApiPerMinute).
// Возвращает: Партиция лимитера с ключом tenant:{id} либо IP.
private static RateLimitPartition<string> ApiPartition(HttpContext context, string scope, int permitsPerMinute)
{
string partitionKey = context.GetCurrentUser() is { } user
? TenantKeyPrefix + user.TenantId.ToString("N")
: ClientKey(context);
return StorePartition(context, scope, partitionKey, permitsPerMinute);
}
// Строит партицию на хранилище Postgres: ключ = http:{scope}:{partitionKey}, лимитер — лениво.
// context: Контекст запроса (источник IServiceScopeFactory для лениво создаваемого лимитера).
// scope: Пространство ключей (политика/global).
// partitionKey: Ключ партиции (IP/тенант).
// permitsPerMinute: Разрешено запросов в минуту.
// Возвращает: Партиция store-backed лимитера (ленивое создание и кеш — менеджер партиций).
private static RateLimitPartition<string> StorePartition(
HttpContext context, string scope, string partitionKey, int permitsPerMinute)
{
string storeKey = HttpKeyPrefix + scope + ":" + partitionKey;
return RateLimitPartition.Get(storeKey, key => new StoreBackedFixedWindowRateLimiter(
context.RequestServices.GetRequiredService<IServiceScopeFactory>(),
key,
permitsPerMinute,
Window,
() => DateTimeOffset.UtcNow));
}
// Ключ клиента: строка IP (или unknown, если адрес недоступен).
// context: Контекст запроса.
// Возвращает: Ключ партиции по IP.
private static string ClientKey(HttpContext context)
{
string? ip = context.Connection.RemoteIpAddress?.ToString();
return string.IsNullOrEmpty(ip) ? UnknownClientKey : IpKeyPrefix + ip;
}
// Пишет ответ 429 формата прототипа: {"detail": "…"} (Ruling 10/5).
// context: Контекст отклонённого запроса.
// cancellationToken: Токен отмены ответа.
private static async ValueTask OnRejectedAsync(OnRejectedContext context, CancellationToken cancellationToken)
{
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
await context.HttpContext.Response.WriteAsJsonAsync(new { detail = RejectedDetail }, cancellationToken);
}
}
@@ -0,0 +1,73 @@
using Deal.Api.Configuration;
using Deal.Api.Http;
using Deal.Modules.Tenants.Application;
using Deal.SharedKernel.Tenants;
using Microsoft.Extensions.Options;
// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом.
using CookieOptions = Deal.Api.Configuration.CookieOptions;
namespace Deal.Api.Middleware;
/// <summary>
/// Middleware сессии: читает httpOnly-куку, разрешает сессию через AuthService и наполняет
/// <c>HttpContext.Items["CurrentUser"]</c> + tenant-контекст запроса (ITenantContext).
/// </summary>
/// <remarks>
/// Middleware НЕ отвечает 401 сама (pass-through): эндпоинты, требующие авторизации, проверяют
/// пользователя и выставляют 401. Нет куки или сессия невалидна — запрос идёт дальше без
/// пользователя. Tenant-контекст сбрасывается в finally после обработки запроса.
/// <para>
/// AuthService — scoped, поэтому на запрос создаётся собственный scope через RequestServices.
/// Экземпляр middleware — singleton (стандартный паттерн UseMiddleware), опции читаются через
/// IOptionsMonitor, чтобы подхватывать изменения конфигурации.
/// </para>
/// </remarks>
public sealed class SessionMiddleware
{
private readonly RequestDelegate _next;
private readonly IOptionsMonitor<CookieOptions> _cookieOptions;
private readonly ITenantContext _tenantContext;
public SessionMiddleware(
RequestDelegate next,
IOptionsMonitor<CookieOptions> cookieOptions,
ITenantContext tenantContext)
{
_next = next;
_cookieOptions = cookieOptions;
_tenantContext = tenantContext;
}
/// <summary>
/// Обрабатывает запрос: разрешает сессию по куке и наполняет контекст.
/// </summary>
/// <param name="context">Контекст запроса.</param>
public async Task InvokeAsync(HttpContext context)
{
try
{
var cookieName = _cookieOptions.CurrentValue.Name;
if (context.Request.Cookies.TryGetValue(cookieName, out var rawToken)
&& !string.IsNullOrWhiteSpace(rawToken))
{
// AuthService scoped: создаём scope на запрос через RequestServices.
await using var scope = context.RequestServices.CreateAsyncScope();
var authService = scope.ServiceProvider.GetRequiredService<AuthService>();
var user = await authService.ResolveSessionAsync(rawToken, context.RequestAborted);
if (user is not null)
{
context.SetCurrentUser(new CurrentUser(user.Id, user.Login, user.TenantId, user.Status));
// Схема тенанта именуется tenant_&lt;id&gt;, где id — Guid в формате "N" (см. TenantService).
_tenantContext.SetTenant(new TenantId(user.TenantId.ToString("N")));
}
}
await _next(context);
}
finally
{
// Контекст AsyncLocal не должен переживать запрос.
_tenantContext.Reset();
}
}
}