# 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-копию».
