food-market/docs/food-market-server-postgres-role.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

6.4 KiB
Raw Permalink Blame History

Замена postgres superuser в food-market-server

Sprint 13, задача 1. Дата: 2026-06-07.

Контекст

food-market-server — legacy backend (back.food-market.kz, port 8084 на prod-vm 192.168.1.190, systemd food-market-server.service). Хранилище — food-market-server-postgres (Docker, port 5436).

До этой задачи в appsettings.Production.json была строка с superuser'ом:

Host=localhost;Port=5436;Database=food_market_server;Username=postgres;Password=1q2w3e4r

Это плохо по двум причинам:

  • Слабый пароль (1q2w3e4r), известен любому, кто прочитает конфиг.
  • postgres — суперюзер: CREATE DATABASE, CREATE ROLE, REPLICATION, BYPASS RLS, может уничтожить всё что угодно в кластере (включая другие БД, если они там появятся).

Решение

Создан dedicated app-role food_market_server_app:

  • LOGIN + сильный пароль (48 hex chars).
  • NOSUPERUSER, NOCREATEDB, NOCREATEROLE, NOREPLICATION, NOBYPASSRLS.
  • Гранты: SELECT/INSERT/UPDATE/DELETE на все существующие таблицы + USAGE/SELECT/UPDATE на sequences + USAGE/CREATE на schema public (CREATE нужен для EF миграций, которые app запускает на старте через db.Database.Migrate()).
  • DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public — все будущие таблицы, что создаст superuser (например, если миграцию применить вручную через postgres), автоматически получат CRUD для app-роли.

Что сделано (атомарно)

1. Бэкап конфига:
   /opt/food-market-server/appsettings.Production.json
     → appsettings.Production.json.bak.20260607-fms-rolemigration

2. Создание роли в БД:
   CREATE ROLE food_market_server_app LOGIN PASSWORD '...'
       NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;
   GRANT CONNECT ON DATABASE food_market_server TO food_market_server_app;
   GRANT USAGE, CREATE ON SCHEMA public TO food_market_server_app;
   GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO food_market_server_app;
   GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA public TO food_market_server_app;
   ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public
       GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO food_market_server_app;
   ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public
       GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO food_market_server_app;

3. Обновление appsettings.Production.json:
   Username: postgres                  → food_market_server_app
   Password: 1q2w3e4r                  → <48-hex>

4. systemctl restart food-market-server  → active.
5. curl http://localhost:8084/        → 200 (SPA fallback).
6. curl https://back.food-market.kz/  → 200.
7. Логи без EF errors после старта.

Проверка работоспособности

ssh nns@192.168.1.190 'sudo systemctl status food-market-server | head -5'
curl -fsS https://back.food-market.kz/ -o /dev/null -w "HTTP %{http_code}\n"
ssh nns@192.168.1.190 'sudo journalctl -u food-market-server --since "10 minutes ago" --no-pager | grep -iE "error|fail" | head'

Rollback

Если что-то сломается (миграция fails, EF Errors в логах), вернуться к старой конфигурации одной командой:

ssh nns@192.168.1.190 'sudo cp /opt/food-market-server/appsettings.Production.json.bak.20260607-fms-rolemigration /opt/food-market-server/appsettings.Production.json && sudo systemctl restart food-market-server'
curl -fsS https://back.food-market.kz/ -o /dev/null -w "HTTP %{http_code}\n"

Это вернёт Username=postgres;Password=1q2w3e4r — старый superuser. Новая роль food_market_server_app остаётся в БД (это идемпотентный CREATE IF NOT EXISTS), её можно дропнуть отдельно:

-- Только после успешного rollback'а и подтверждения что приложение
-- работает на postgres:
DROP OWNED BY food_market_server_app;  -- (если бы что-то создавала)
DROP ROLE food_market_server_app;

Что НЕ покрыто (TODO)

  • Ротация пароля postgres. Сам postgres superuser остался с тем же 1q2w3e4r. Пока он не используется в работе app'а (мы только что переключились на app-роль) — но всё равно нужно сменить на сильный, иначе кто-то с доступом к dev-машине или к /opt видит старый бэкап и пробует. Делать через ALTER ROLE postgres PASSWORD '...' под superuser-сессией.
  • PGHBA. Сейчас доверяется любой коннект с loopback (стандартный pg_hba.conf postgres-контейнера). Допустимо для single-host setup, но при сетевом расширении нужно ужесточать.
  • Audit log внутри PG (pgaudit) — не настроен. Логирует только app.
  • Per-table RLS — не используется, потому что приложение само фильтрует по OrganizationId. Под добавление RLS не подписывался.

Дальше

Аналогичную замену нужно провести в:

  • food-market-stage-postgres (port 5435) — там пользователь food_market уже без superuser-прав (см. deploy/docker-compose.yml, он создаётся через POSTGRES_USER env, что даёт superuser в рамках только этой БД — лучше чем кросс-БД superuser, но всё равно стоит ужесточить аналогично).
  • food-market-postgres (prod admin.food-market.kz, port 5434) — то же.
  • Локальный dev PG на host'е (brew postgresql@14) — там безразлично (dev-only, локальный сокет, пустой пароль работает по trust).