# Sprint 13 — безопасность + observability deep Цель: закрыть «гигиенические» дыры безопасности, навесить аудит на чувствительные операции, и довести observability до импортабельного Grafana-дашборда. Старт: 2026-06-07 (после Sprint 12). Исполнитель: Claude Opus 4.7. ## Принципы - Поведение должно деградировать gracefully — добавляемые ограничения не должны сломать e2e/integration тесты, которые делают много signup'ов/токенов в коротких сериях. - Все security-изменения с rollback-планом. БД-вмешательство в food-market-server — с бэкапом конфига до и проверкой через /health/ready после. - НЕ трогать: `global.json`, prod admin.food-market.kz, POS WPF. ## Чек-лист - [x] **1. Замена postgres superuser в food-market-server** — создана dedicated роль `food_market_server_app` (NOSUPERUSER, NOCREATEDB, NOCREATEROLE, NOREPLICATION, NOBYPASSRLS) с CRUD-only грантами + USAGE/CREATE на schema public (для EF миграций). Бэкап конфига до правки сохранён в `appsettings.Production.json.bak.20260607-fms-rolemigration`. Service restart прошёл чисто, https://back.food-market.kz/ → 200. Rollback-инструкция в `docs/food-market-server-postgres-role.md`. - [x] **2. CSP + security headers middleware** — `SecurityHeadersMiddleware` навешивает CSP (default-src 'self', script/style 'unsafe-inline', connect 'self' wss: ws:, img data: blob:), X-Frame-Options DENY, X-Content-Type-Options nosniff, Referrer-Policy strict-origin-when-cross-origin, Permissions-Policy (camera/mic/geo/payment/usb off), X-Permitted-Cross-Domain-Policies none. HSTS 365d + includeSubDomains + preload (только не-Development). Те же заголовки добавлены в `deploy/nginx.conf` для SPA HTML. Проверено на stage'е: `curl -sI https://test.admin.food-market.kz/` возвращает все 6 заголовков; `/api/me` дублирует (api + nginx). - [x] **3. Rate-limit на signup + password-reset** — `AuthRateLimiterExtensions` расширен signup-специфичными бакетами 3/hour и 10/day per-IP (`SignupPerIpPerHour`, `SignupPerIpPerDay`). `AuthForgotPasswordController` — per-email 3/hour + per-IP 10/hour (через `ConcurrentDictionary` партишены). На stage'е переопределено через `.env` (RATE_SIGNUP_HOUR=30, RATE_SIGNUP_DAY=200) чтобы не ломать e2e. Проверено вживую: 4-я попытка forgot-password на тот же email → 429. - [x] **4. Audit-log на sensitive ops** — `SensitiveOpsAudit` сервис пишет в `org_audit_log` + Serilog. Wired: • `TwoFactorController` — action="TwoFactorEnroll" / "TwoFactorDisable". • `EmployeesController.Update` — action="AssignRole" при смене RoleId, payload содержит prev/next role-name + полный `RolePermissions`. • `MeAccountController.ChangePassword` — action="ChangePassword". • `MeSessionsController.RevokeAll` — action="RevokeAllSessions" + счётчики погашенных authorizations/tokens. Существующий аудит change-owner (SuperAdminAuditLog) сохранён. - [x] **5. Session management endpoint** — `POST /api/me/sessions/revoke-all` итерирует `IOpenIddictAuthorizationManager.FindBySubjectAsync` → `TryRevokeAsync` для каждой authorization + tokens. Возвращает `{revokedAuthorizations, revokedTokens}`. Integration-тест `SessionRevokeTests` проверяет что refresh-токен после revoke отшивается 400/401. - [x] **6. Hangfire dashboard auth** — `SuperAdminHangfireFilter` уже был; добавлен nginx-route `/hangfire` чтобы дашборд не ловился SPA-fallback'ом. Integration-тест `HangfireAccessTests` проверяет что anonymous и tenant-Admin получают 401/403/404. На stage: `curl https://test.admin.food-market.kz/hangfire` → 401. - [x] **7. Grafana dashboards JSON** — `deploy/grafana/dashboards/food-market.json` с 9 панелями: HTTP RPS по статусам, HTTP p50/p95/p99 latency, бизнес-метрики per-type RPS (документы посчитаны), бизнес-ошибки per-type/reason, EF query duration heatmap, %5xx и %4xx stat'ы, process memory, GC collections per generation. Инструкции по импорту (UI / curl / provisioning) добавлены в `docs/observability.md`. ## Журнал ### 2026-06-07 старт Sprint 12 закрыт (6/6 ✓). Поехали по security-чек-листу. ### 2026-06-07 п.1 (food-market-server PG role) Subject — production-сервис на prod-vm. Бэкап → CREATE ROLE с ограниченными правами → ALTER DEFAULT PRIVILEGES для будущих миграций → обновлён `appsettings.Production.json` через python json-edit → `systemctl restart food-market-server` → /health 200. Rollback готов одной командой (восстановить bak, restart). ### 2026-06-07 п.2 (security headers) Middleware применяет 6 заголовков на каждый ответ (кроме /metrics, /health, /swagger). Nginx-fronting добавляет те же на SPA HTML (добавил `add_header ... always` в `deploy/nginx.conf`). Проверено curl-ом на stage'е. ### 2026-06-07 п.3 (rate-limits) Signup получил два дополнительных партишена в централизованном лимитере; forgot-password — отдельный in-memory лимитер с per-email и per-IP бакетами. Стейдж переопределяет через `.env` (`RATE_SIGNUP_HOUR=30`), prod останется на дефолтах 3/час. ### 2026-06-07 п.4–5 (audit + revoke-all) `SensitiveOpsAudit` — централизованный сервис; зашёл в TwoFactor, Employees.Update (смена роли), новые MeAccount.ChangePassword, MeSessions.RevokeAll. Revoke-all использует `IOpenIddictAuthorizationManager` / `IOpenIddictTokenManager`. Integration-тест SessionRevokeTests подтверждает: refresh после revoke → 400/401. ### 2026-06-07 п.6 (Hangfire) Фильтр `SuperAdminHangfireFilter` уже существовал — добавлен nginx location для `/hangfire`. В тестах Hangfire-сервер выключен → /hangfire отдаёт 404 (это тоже валидное «нет доступа»); тест HangfireAccessTests принимает 401/403/404. ### 2026-06-07 п.7 (Grafana) JSON с 9 панелями, готовый к импорту через UI / Grafana API / provisioning. Все expr'ы — PromQL поверх метрик в `AppMetrics.cs` + стандартного prometheus-net. ### Итог Все 7 пунктов ✓. Build чистый. Локальные тесты: 68 unit + 9 integration (включая 3 новых) ✓. Stage smoke (`tests/stage-smoke.sh`) → все 5 этапов зелёные. Security-заголовки видны на `https://test.admin.food-market.kz/`. Hangfire dashboard защищён. food-market-server (back.food-market.kz) работает на dedicated PG-роли. **Stage-deploy инциденты (для RUNBOOK)**: при синхронизации compose файла с main репо на stage оказалось, что они разошлись — main содержит `container_name:` атрибуты (для prod) и порты 8080/8081, а стейдж исторически работал без них на портах 8085/8086 с `docker compose -p food-market-stage`. После починки .env'а (POSTGRES_PASSWORD=stage_pass, REGISTRY=192.168.1.193:5001, API/WEB_TAG=stage) + удаления секции `public:` (нет образа `:stage`) + remap портов (8085/8086) — стэйдж поднялся. Инцидент задокументирован, добавить в RUNBOOK как «не push'ить main-compose на стейдж-вм напрямую, поддерживать отдельную stage-копию».