food-market/docs/sprint13-progress.md
nns 8e54e2e0d6 feat(s13): security headers + rate-limits + sensitive-ops audit + session revoke + Grafana
Sprint 13 — security + observability deep. 7 пунктов чек-листа ✓.

Подробности — docs/sprint13-progress.md и docs/food-market-server-postgres-role.md.

Главное:
- food-market-server (back.food-market.kz, legacy backend) теперь
  работает на dedicated PG-роли food_market_server_app (NOSUPERUSER /
  NOCREATEDB / NOCREATEROLE / NOREPLICATION / NOBYPASSRLS) с CRUD-only
  грантами. Раньше использовался postgres-superuser с паролем 1q2w3e4r.
  Бэкап конфига сохранён, rollback одной командой.
- SecurityHeadersMiddleware навешивает CSP / X-Frame-Options DENY /
  X-Content-Type-Options nosniff / Referrer-Policy strict-origin /
  Permissions-Policy. HSTS 365d + includeSubDomains + preload.
  Те же заголовки в deploy/nginx.conf для SPA HTML.
- Rate-limit:
  • Signup-IP — 3/час + 10/день (на stage'е переопределено через
    .env RATE_SIGNUP_HOUR=30 чтобы не ломать e2e).
  • Forgot-password — per-email 3/час + per-IP 10/час.
- SensitiveOpsAudit сервис, wired в:
  • TwoFactor enroll/disable
  • Employees.Update при смене RoleId (action=AssignRole,
    payload с prev/next role + полный RolePermissions)
  • MeAccount.ChangePassword (новый endpoint)
  • MeSessions.RevokeAll (новый endpoint)
- POST /api/me/sessions/revoke-all — через
  IOpenIddictAuthorizationManager.FindBySubjectAsync + TryRevokeAsync.
  Integration-тест: refresh после revoke → 400/401.
- Hangfire dashboard — nginx-route добавлен (раньше /hangfire ловился
  SPA-fallback'ом). Фильтр SuperAdmin'ом уже был. Тест: anon/tenant →
  401/403/404.
- Grafana dashboard JSON (deploy/grafana/dashboards/food-market.json,
  9 панелей) + инструкции импорта в docs/observability.md.

Проверено на stage'е: все 6 security-заголовков видны на /;
/hangfire → 401 (закрыт); 4-я форгот → 429; stage-smoke (5 этапов) ✓.

Тесты: 68 unit + 9 integration (включая 3 новых: SessionRevokeTests,
HangfireAccessTests).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 12:30:10 +05:00

8.5 KiB
Raw Permalink Blame History

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.

Чек-лист

  • 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.
  • 2. CSP + security headers middlewareSecurityHeadersMiddleware навешивает 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).
  • 3. Rate-limit на signup + password-resetAuthRateLimiterExtensions расширен 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.
  • 4. Audit-log на sensitive opsSensitiveOpsAudit сервис пишет в 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) сохранён.
  • 5. Session management endpointPOST /api/me/sessions/revoke-all итерирует IOpenIddictAuthorizationManager.FindBySubjectAsyncTryRevokeAsync для каждой authorization + tokens. Возвращает {revokedAuthorizations, revokedTokens}. Integration-тест SessionRevokeTests проверяет что refresh-токен после revoke отшивается 400/401.
  • 6. Hangfire dashboard authSuperAdminHangfireFilter уже был; добавлен nginx-route /hangfire чтобы дашборд не ловился SPA-fallback'ом. Integration-тест HangfireAccessTests проверяет что anonymous и tenant-Admin получают 401/403/404. На stage: curl https://test.admin.food-market.kz/hangfire → 401.
  • 7. Grafana dashboards JSONdeploy/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 п.45 (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-копию».