diff --git a/backlog.md b/backlog.md index 475dad3..ddfe9eb 100644 --- a/backlog.md +++ b/backlog.md @@ -43,7 +43,7 @@ | ID | Пункт | Источник | Приоритет | Статус | |---|---|---|---|---| | BL-ALERT-BUDGET | **Сделано (2026-09-11):** метрика `deal.ai.budget.used.ratio{tenant}` (доля израсходованного ИИ-бюджета периода, 0..1) в `DealMetrics` + сбор в `RuntimeDepthsCollector`/`DealMetricsCollector`; на её основе оператор настраивает алерт в Prometheus/Grafana | этап 12, A | P2 | DONE | -| BL-LOG-ACTOR | Идентичность актора в access-логах (сейчас login/tenant только в `audit_log`) | этап 12, T6 | P3 | TECHDEBT | +| BL-LOG-ACTOR | **Сделано (2026-09-11):** access-лог HTTP core (`HttpAccessLogMiddleware`) включает `actor` (login пользователя тенанта либо оператора) и `tenant` (id тенанта) — их берут из `HttpContext.Items` (Session/OperatorSession middleware) | этап 12, T6 | P3 | DONE | | BL-GRACEFUL | Дополнительные проверки устойчивости/ретраев (по результатам нагрузочного прогона) | этап 12, C | P2 | BACKLOG | | BL-SUSPICIOUS | Расширение детектора подозрительной активности (правила/пороги по логам безопасности) | ТЗ §10.5, этап 12 | P3 | BACKLOG | diff --git a/docs/superpowers/STATUS.md b/docs/superpowers/STATUS.md index 7675697..6b06d83 100644 --- a/docs/superpowers/STATUS.md +++ b/docs/superpowers/STATUS.md @@ -12,9 +12,10 @@ > `SourceIngressGrpcService`), `PushMessage` из telegram.proto удалён. Сухой прогон текста по конвейеру > (стоп-правила → ML → ИИ) без записи: `POST /api/admin/check-message` + UI настроек. Remote-просмотр > исходника: `TelegramService.ReadSource` + `TelegramSourceContentProvider` + UI «Обновить из источника». -> Метрика алертинга `deal.ai.budget.used.ratio{tenant}`. Hardening контейнеров (non-root/read-only/limits), -> шардированная пакетная миграция схем. Ядро: build 5 sln 0/0, `Deal.Tests.Unit` **1315/1315 PASS**, -> telegram **130/130**, фронт `build` + `lint:i18n` зелёные. +> Метрика алертинга `deal.ai.budget.used.ratio{tenant}`; actor/tenant в access-логе. Hardening контейнеров +> (non-root/read-only/limits), шардированная пакетная миграция схем, единый CI (`scripts/ci.sh`). +> Ядро: build 5 sln 0/0, `Deal.Tests.Unit` **1321/1321 PASS**, telegram **130/130**, ai **52/52**, +> ml **38/38**, storage **9/9**, фронт `build` + `lint:i18n` зелёные. > Детали — `docs/superpowers/specs/2026-09-11-source-contract-design.md`. > Осталось (в backlog): `GET /api/cards/{id}/source` + `ISourceContentProvider`, выгрузка вложений > telegram-адаптером в Storage, `TelegramSourceContentProvider`, перенос оставшейся Telegram-специфики diff --git a/docs/technical/Техническая-документация-Дейл.md b/docs/technical/Техническая-документация-Дейл.md index e9c9b98..ece5fab 100644 --- a/docs/technical/Техническая-документация-Дейл.md +++ b/docs/technical/Техническая-документация-Дейл.md @@ -227,7 +227,8 @@ settings(Key varchar(200) PK, ValueJson text, UpdatedAt timestamptz) -- (30 дней; env `DEAL_LOG_LEVEL`/`DEAL_LOGS_DIR`). Секреты/пароли/ключи не логируются; gRPC-health не логируется. - **Access-логи**: HTTP — `HttpAccessLogMiddleware` (первый в конвейере после ForwardedHeaders — - длительность и статус всего пути); gRPC-ингресс — `RpcCallLoggingInterceptor` (health освобождён). + длительность и статус всего пути; с BL-LOG-ACTOR — `actor` и `tenant` из сессии); gRPC-ингресс — + `RpcCallLoggingInterceptor` (health освобождён). - **PROD-стек логов**: docker-логи контейнеров → `promtail` → `loki` (retention 7 суток) → `grafana` (`127.0.0.1:3001` — только оператору по SSH-туннелю). Поднимается профилем `observability` файла `deploy/compose.prod.yml` (живой подъём — ⚠ Manual): @@ -251,9 +252,9 @@ settings(Key varchar(200) PK, ValueJson text, UpdatedAt timestamptz) -- - `Deal-Errors` — HTTP 5xx, необработанные исключения (`@x`), Error/Fatal, ошибки gRPC и общая лента; - `Deal-Rps` — нагрузка HTTP+gRPC (RPS), top-путей/методов и p50/p95 длительности запроса; - `Deal-Logs` — обзор логов с фильтрами по сервису и уровню, активность по тенантам (AI/ML/Telegram). - **Кто входил/что делал — в аудите, не в логах**: access-лог пишет только метод/путь/код (без - логина/тенанта/IP), поэтому входы в Loki различаются лишь по контуру. Полная лента с актором — - `public.audit_log` (append-only) через `GET /api/operator/audit` / экран «Аудит» оператор-консоли. + **Актор в логах:** с BL-LOG-ACTOR access-лог включает `actor` (login пользователя/оператора) и + `tenant`; полная лента действий с деталями — `public.audit_log` (append-only) через + `GET /api/operator/audit` / экран «Аудит» оператор-консоли. - Алерты Prometheus (этап 12) — правила `deploy/observability/prometheus-rules.yml` (см. подраздел «Метрики»); исчерпание ИИ-бюджета по-прежнему доставляется SSE-тостом тенанту — отдельной бюджетной метрики в Prometheus нет (метки метрик низкокардинальные, без tenantId/бюджета). @@ -1310,8 +1311,8 @@ docker compose -f deploy/compose.dev.yml start core telegram-service ml-service → **оператор:** `http://localhost:5173/#/operator`, вход `operator`/`operator` (dev-дефолт; в Production — env `DEAL_OPERATOR_*`); **активация:** `http://localhost:5173/#/join?code=<код>`; основное приложение — `http://localhost:5173/#/`. Prod-сценарий — §13.8. -- **Ограничения:** реальные Telegram/LLM-креды — ⚠ Manual (по решению владельца); identity в логах - ограничена (access-лог без логина) — полный аудит с актором в `public.audit_log`. +- **Ограничения:** реальные Telegram/LLM-креды — ⚠ Manual (по решению владельца); access-лог с + BL-LOG-ACTOR содержит `actor`/`tenant` (IP в строке нет) — полный аудит действий в `public.audit_log`. ## 14. Локализация интерфейса (i18n, этап 11) diff --git a/src/core/Deal.Api/Middleware/HttpAccessLogMiddleware.cs b/src/core/Deal.Api/Middleware/HttpAccessLogMiddleware.cs index 4bfede3..68df7f5 100644 --- a/src/core/Deal.Api/Middleware/HttpAccessLogMiddleware.cs +++ b/src/core/Deal.Api/Middleware/HttpAccessLogMiddleware.cs @@ -1,4 +1,6 @@ using System.Diagnostics; +using Deal.Api.Extensions; +using Deal.Api.Models; namespace Deal.Api.Middleware; @@ -53,7 +55,7 @@ public sealed class HttpAccessLogMiddleware } // Пишет одну строку access-лога запроса (успех либо исключение). - // context: Контекст запроса (метод/путь/статус ответа). + // context: Контекст запроса (метод/путь/статус ответа, актор из HttpContext.Items). // startedAt: Метка времени старта запроса (Stopwatch.GetTimestamp). // exception: Необработанное исключение (null — запрос завершился штатно). private void LogCall( @@ -64,23 +66,46 @@ public sealed class HttpAccessLogMiddleware long elapsedMs = (long)Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds; string method = context.Request.Method; string path = context.Request.Path.Value ?? "/"; + (string actor, string tenantId) = ResolveActor(context); if (exception is null) { _logger.LogInformation( - "HTTP {Method} {Path} -> {StatusCode} за {DurationMs} мс", + "HTTP {Method} {Path} -> {StatusCode} за {DurationMs} мс; actor={Actor} tenant={TenantId}", method, path, context.Response.StatusCode, - elapsedMs); + elapsedMs, + actor, + tenantId); } else { _logger.LogError( exception, - "HTTP {Method} {Path} -> исключение за {DurationMs} мс", + "HTTP {Method} {Path} -> исключение за {DurationMs} мс; actor={Actor} tenant={TenantId}", method, path, - elapsedMs); + elapsedMs, + actor, + tenantId); } } + + // Актор запроса: пользователь тенанта либо оператор; нет сессии — "-". + // context: Контекст запроса (Items заполняют Session/OperatorSession middleware ниже по конвейеру). + // Возвращает: Пару (актор, id тенанта) для структурированного access-лога. + private static (string Actor, string TenantId) ResolveActor(HttpContext context) + { + if (context.GetCurrentUser() is { } user) + { + return (user.Login, user.TenantId.ToString("N")); + } + + if (context.GetCurrentOperator() is { } operatorIdentity) + { + return (operatorIdentity.Login, "-"); + } + + return ("-", "-"); + } } diff --git a/src/core/tests/Deal.Tests.Unit/Api/HttpAccessLogMiddlewareTests.cs b/src/core/tests/Deal.Tests.Unit/Api/HttpAccessLogMiddlewareTests.cs new file mode 100644 index 0000000..b4b8558 --- /dev/null +++ b/src/core/tests/Deal.Tests.Unit/Api/HttpAccessLogMiddlewareTests.cs @@ -0,0 +1,161 @@ +using Deal.Api.Extensions; +using Deal.Api.Middleware; +using Deal.Api.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace Deal.Tests.Unit.Api; + +/// +/// Юнит-тесты +/// +public sealed class HttpAccessLogMiddlewareTests +{ + private static readonly Guid TenantId = Guid.Parse("11112222-3333-4444-5555-666677778888"); + + [Fact] + public async Task InvokeAsync_UserInItems_LogsActorLoginAndTenantId() + { + RecordingLogger logger = new(); + var context = new DefaultHttpContext(); + context.SetCurrentUser(new CurrentUser(Guid.NewGuid(), "user.login", TenantId, "active")); + HttpAccessLogMiddleware middleware = new(_ => Task.CompletedTask, logger); + + await middleware.InvokeAsync(context); + + LogEntry entry = Assert.Single(logger.Entries); + Assert.Equal(LogLevel.Information, entry.Level); + Assert.Contains($"actor=user.login tenant={TenantId:N}", entry.Message); + } + + [Fact] + public async Task InvokeAsync_OperatorInItems_LogsActorLoginAndDashTenant() + { + RecordingLogger logger = new(); + var context = new DefaultHttpContext(); + context.SetCurrentOperator(new CurrentOperator(Guid.NewGuid(), "operator.login", "active")); + HttpAccessLogMiddleware middleware = new(_ => Task.CompletedTask, logger); + + await middleware.InvokeAsync(context); + + LogEntry entry = Assert.Single(logger.Entries); + Assert.Equal(LogLevel.Information, entry.Level); + Assert.Contains("actor=operator.login tenant=-", entry.Message); + } + + [Fact] + public async Task InvokeAsync_NoSession_LogsDashActorAndTenant() + { + RecordingLogger logger = new(); + var context = new DefaultHttpContext(); + HttpAccessLogMiddleware middleware = new(_ => Task.CompletedTask, logger); + + await middleware.InvokeAsync(context); + + LogEntry entry = Assert.Single(logger.Entries); + Assert.Equal(LogLevel.Information, entry.Level); + Assert.Contains("actor=- tenant=-", entry.Message); + } + + [Fact] + public async Task InvokeAsync_ResponseStatusCode_IsLogged() + { + RecordingLogger logger = new(); + var context = new DefaultHttpContext(); + HttpAccessLogMiddleware middleware = new( + ctx => + { + ctx.Response.StatusCode = StatusCodes.Status204NoContent; + return Task.CompletedTask; + }, + logger); + + await middleware.InvokeAsync(context); + + LogEntry entry = Assert.Single(logger.Entries); + Assert.Contains("-> 204", entry.Message); + } + + [Fact] + public async Task InvokeAsync_NextThrows_LogsErrorWithActorAndRethrows() + { + RecordingLogger logger = new(); + var context = new DefaultHttpContext(); + context.SetCurrentUser(new CurrentUser(Guid.NewGuid(), "user.login", TenantId, "active")); + InvalidOperationException failure = new("ошибка конвейера"); + HttpAccessLogMiddleware middleware = new(_ => throw failure, logger); + + InvalidOperationException thrown = await Assert.ThrowsAsync( + () => middleware.InvokeAsync(context)); + + Assert.Same(failure, thrown); + LogEntry entry = Assert.Single(logger.Entries); + Assert.Equal(LogLevel.Error, entry.Level); + Assert.Same(failure, entry.Exception); + Assert.Contains($"actor=user.login tenant={TenantId:N}", entry.Message); + } + + [Fact] + public async Task InvokeAsync_GrpcContentType_SkipsLogging() + { + RecordingLogger logger = new(); + var context = new DefaultHttpContext(); + context.Request.ContentType = "application/grpc"; + bool nextCalled = false; + HttpAccessLogMiddleware middleware = new( + _ => + { + nextCalled = true; + return Task.CompletedTask; + }, + logger); + + await middleware.InvokeAsync(context); + + Assert.True(nextCalled); + Assert.Empty(logger.Entries); + } + + // In-memory логгер: копит записи access-лога с уровнем, сообщением, исключением и полями. + private sealed class RecordingLogger : ILogger + { + /// + /// Записи лога в порядке поступления. + /// + public List Entries { get; } = []; + + /// + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + /// + public bool IsEnabled(LogLevel logLevel) => true; + + /// + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + Dictionary fields = new(StringComparer.Ordinal); + if (state is IEnumerable> pairs) + { + foreach ((string key, object? value) in pairs) + { + fields[key] = value; + } + } + + Entries.Add(new LogEntry(logLevel, formatter(state, exception), exception, fields)); + } + } + + // Запись access-лога, снятая in-memory логгером. + private sealed record LogEntry( + LogLevel Level, + string Message, + Exception? Exception, + IReadOnlyDictionary Fields); +}