Dev Specs

BetterPlace Staff App — Specification · 11 Security & Operations

Статус: предложение (draft v1, 2026-07-27), согласуется с владельцем. Целевая модель безопасности и эксплуатации backend-фазы. База — принятый ADR-002 (собственный auth, портируемый деплой) + сверка с актуальными практиками mid-2026 (OWASP Cheat Sheets, NIST 800-63B). Рассчитано на команду 1–2 разработчиков и последующую передачу системы клиенту, у которого нет выделенного ops-инженера: везде, где есть выбор «мощнее vs проще в передаче», выбрано второе.

Модель масштабирования на новые компании — instance-per-company (решение владельца 2026-07-27): каждой компании — свой стек/БД/поддомен/бекапы; см. док 10 §8, включая SaaS-readiness правила.

1. Принципы

  1. Свой auth вместо IdP-контейнера (Keycloak/Zitadel отклонены — второй identity-периметр и RAM ради 150 пользователей). ~400 строк кода, каждая строка ревьюится.
  2. Минимум движущихся частей: без Redis, без брокера, без Vault — очередь в Postgres, лимиты в Postgres, секреты в sops+age. Каждый дополнительный сервис — это то, что клиенту придётся уметь чинить.
  3. Данные защищаются слоями: RBAC в приложении → GRANT/REVOKE в базе (append-only леджеры, read-only каталоги) → шифрованные бекапы → точечное шифрование особо чувствительного (медсправки).
  4. Всё аудируется: append-only audit_log + структурные логи; админ-действия — всегда след.
  5. Портируемость: ни одного managed-сервиса конкретного облака; переезд = docker compose + бекап.

2. Аутентификация

2.1 Хранение паролей

  • argon2id c параметрами m=19456 KiB, t=2, p=1 — канонический минимум OWASP Password Storage CS (актуален на mid-2026). Библиотека — argon2-cffi.
  • На каждом успешном логине — check_needs_rehash() + прозрачный re-hash: путь апгрейда параметров без миграции.
  • Политика паролей — по NIST 800-63B: минимум 12 символов, максимум ≥64, без composition rules и без периодической ротации; проверка по локальному списку топ-100k утёкших паролей.

2.2 Логин-hardening

  • Счётчик неудачных попыток per-account (в Postgres, не per-IP — атакующий ротирует IP): порог 5, экспоненциальный lockout (locked_until = now() + min(2^fails сек, 20 мин)), авто-разблокировка.
  • Поверх — per-IP rate limit на edge (Caddy) для auth-путей.
  • Единый ответ invalid credentials + dummy-verify (заранее посчитанный хеш константного пароля проверяется в ветке «нет такого пользователя») — защита от user enumeration по таймингу.

2.3 Сессии

  • Opaque-токены, не JWT (первая сторона, ревокация важнее stateless): secrets.token_urlsafe(32) = 256 бит энтропии; в базе — только sha256(token): дамп БД ≠ угон всех сессий.
  • Двухуровневый expiry: sliding idle (14 дней для мобильных полевых ролей, 12 часов для admin/back-office — role-based TTL) + absolute cap 30–90 дней. last_seen_at троттлится (запись не чаще раза в 5 минут).
  • Cookie: __Host-session=…; HttpOnly; Secure; SameSite=Lax; Path=/ — требует поддомен staff.* (не path /staffapp); поддомен должен существовать до первого пользователя.
  • Ревокация: logout = DELETE строки; смена/сброс пароля, disable аккаунта → ревокация всех сессий пользователя одной транзакцией. Session fixation закрыт by design (токен выдаёт только сервер при логине).

2.4 CSRF

Next.js и FastAPI живут под одним origin через Caddy (/api/* → api) — CORS отсутствует как класс, SameSite работает в полную силу. Поверх — middleware по fetch-metadata: state-changing запрос с Sec-Fetch-Sitesame-origin|none → 403 (fallback — проверка Origin по allowlist). CSRF-токены / double-submit не нужны — fetch-metadata признан OWASP основной защитой для современных стеков.

2.5 Reset и invite

Один механизм на оба флоу (credential_reset, kind=invite|reset): CSPRNG-токен ≥128 бит (secrets.token_urlsafe(32)), в базе — только его sha256, single-use, TTL 15–30 минут (invite/admin-reset — 72 часа). Ответ одинаков независимо от существования аккаунта. Успешный сброс → инвалидация всех сессий + уведомление пользователю. Админ никогда не задаёт пароль сам — только выдаёт invite-ссылку (в ростере есть люди без корпоративной почты — доставка через Lark DM или лично, решение за клиентом). MFA отложен (компенсация: IP-allowlist на админ-плоскость — опционально, не жёстко).

3. Пользователи и RBAC

3.1 Иерархия ролей (решение владельца, 2026-07-27)

Четыре уровня; каждый следующий назначается уровнем (уровнями) выше:

РольКто назначаетПолномочия
Super Adminникто (создаётся при установке системы, seed)всё в сервисе; назначает админов; настраивает полномочия каждого админа; может назначать супервайзеров напрямую
Adminтолько Super Adminсоздание/управление пользователями, назначение супервайзеров в модулях (например HR) — строго в пределах полномочий, выданных суперадмином
SupervisorSuper Admin; Admin — если его полномочий хватаетуправляет своей группой ground-сотрудников: смены, заявки, посещаемость и репорты своей группы
Staffсоздаётся Admin'ом (invite)работа из мобильного приложения: clock-in/clock-out, задачи, репорты, заявки

3.2 Как это ложится на модель данных

  • Уровни ролей фиксированы, полномочия — данные. Сам список ролей — короткий и стабильный (расширение = новая строка каталога, не миграция структуры). А вот «полномочия админа настраиваются суперадмином» и «супервайзер управляет своей группой» — это гранты как данные: строка «роль/аккаунт × модуль × действие × scope». Полностью зашитая в код карта прав это требование не покрывает; канон HR-схемы v2 (core.user_role со scope-грантами department/location/subordinates + каталог permission/role_permission) — покрывает. Открытая развилка PR #73 по permission-каталогу (finding hr-schema-v2-overengineering-review) этим вводом сужается: настраиваемая часть должна жить в данных; что именно остаётся в коде (базовая карта уровней) — решается при ревью схемы.
  • Назначение выше собственного уровня запрещено структурно: admin не может создать admin'а или super admin'а; проверка — на уровне permission (users.assign_role с ограничением по рангу роли), фиксируется в audit_log.
  • Scope супервайзера — группа/департамент: ложится на существующие оси схемы (hr.employment.supervisor_id, scope-грант «мой департамент / мои подчинённые»). Состав группы меняется без миграций.
  • Динамический permission-редактор с матрицей произвольных прав по-прежнему не строим: UI настройки — это формы «полномочия админа» и «группа супервайзера», а не свободный конструктор ролей.
  • Инвариант: эндпоинты проверяют permission, а не рольrequire_permission("hr.requests.approve").

3.3 Учётные записи и жизненный цикл

  • Account ≠ Employee: core.user_account (логин/пароль/сессии/роль) отдельно от hr.employee (Lark-синхронизируемая карточка), связь nullable. Полевой сотрудник без доступа к системе — валидное состояние.
  • Lark-импорт → активация (решение владельца, 2026-07-27). Импортированный из Lark ground-сотрудник появляется в системе только как карточка hr.employeeбез аккаунта, т.е. «не активирован» (Access = none). Синк никогда не открывает доступ сам: новый сотрудник подсвечивается в admin-UI флагом «ожидает активации», и доступ открывает Admin / Super Admin явным действием — активация = invite-флоу (создание user_account с ролью Staff + set-password токен; permission core.access.manage). Состояние Access (none / active / suspended) — производное от наличия и статуса аккаунта (view v_employee_access, ADR-02 схемы), не хранимая колонка — рассинхрон «в списке активен, а аккаунт отозван» невозможен структурно. Деактивация — симметричный disable с ревокацией сессий.
  • Scope («вижу свой отдел / своих подчинённых») — фильтрация в приложении (различает 403 и «пусто»), это единственный авторитетный scope-слой. В базе — GRANT/REVOKE: append-only леджеры и read-only каталоги. RLS снят вместе с multi-tenancy-осью (schema v3, ADR-15; прежний ADR-11 superseded).
  • Admin-флоу: invite (аккаунт + set-password токен) · disable (is_active=false + ревокация сессий одной транзакцией) · admin-reset (invite-токен). Каждое действие — в audit_log с указанием, кто и в рамках каких полномочий его совершил.
  • Offboarding — риск №1 в компании на 100+ человек: сотрудник исчез из Lark-синка → авто-disable аккаунта + ревокация сессий + audit-событие (восстановление — один клик админа). Просроченный offboarding опаснее ложного disable.
  • Lark-синк employees — one-way upsert; lark_id_origin (sync/manual) гарантирует, что синк не затирает вручную заданный Lark ID, журнал прогонов — core.lark_sync_run. Синк не создаёт и не удаляет аккаунты — создание пользователей всегда идёт через Admin-флоу выше.

4. Контейнерная топология

5 сервисов, 2 сети; наружу опубликован только Caddy.

            ┌─ edge network ──────────────────────────┐
 80/443 ───▶│  caddy ──▶ web (Next.js)                │
            │    └─────▶ api (FastAPI/uvicorn) ───────┼─┐
            └─────────────────────────────────────────┘ │
            ┌─ data network (internal: true) ──────────┼─┐
            │  postgres ◀── api                        ◀─┘
            │      ▲                                     │
            │      └────── worker (pgqueuer, тот же     │
            │              образ, что api)              │
            └─────────────────────────────────────────--┘
СервисОбразmem_limitПрочее
caddypinned + digest256mединственный с портами 80/443; TLS, headers, rate limit
webNext.js next start, non-root1gedge-сеть
apipython-slim multi-stage (uv), non-root1gedge + data
workerтот же образ, command: pgqueuer512m–1gтолько data; изоляция памяти/рестартов от API
postgrespostgres:17.x pinned + digest1gтолько data; --data-checksums, scram-sha-256

Обязательный hardening каждого сервиса (house rules + консенсус 2026):

  • restart: unless-stopped, mem_limit и memswap_limit (оба, всегда), healthcheck;
  • security_opt: [no-new-privileges:true], cap_drop: [ALL]; для api/worker/web — read_only: true + tmpfs /tmp, user: "10001:10001";
  • сеть datainternal: true: postgres и worker отрезаны от интернета целиком; у postgres нет published ports (DBA-доступ — docker exec или SSH-туннель);
  • образы пинятся на точную версию + digest (защита от supply-chain подмены тега);
  • лимиты калибруются по docker stats × 2–3 от пика, не по шаблону.

5. TLS и edge

  • Caddy как единственный edge: авто-TLS (ACME, авто-renew — исчезает целый класс отказов certbot-cron; главный аргумент для клиента без ops-экспертизы), HTTP/2+3, один origin для web и api.
  • Модель экспозиции: мобильное приложение на личных телефонах 50–150 полевых сотрудников делает VPN-for-everyone непрактичным → публичный HTTPS + сильная аутентификация (argon2id + lockout + opaque-сессии + rate limit). IP-allowlist на админ-пути — опция, не жёсткое требование (админы работают из дома). DEV — отдельный hostname с гейтом (basic-auth или allowlist).
  • Security headers — глобально на Caddy (одно место на оба приложения): HSTS max-age=15552000 (без preload — необратим и не нужен внутреннему приложению), X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, X-Frame-Options: DENY + CSP frame-ancestors 'none', минимальная Permissions-Policy. Полный CSP — итеративно через Report-Only.
  • FastAPI в проде: docs_url=None, redoc_url=None, openapi_url=None.
  • Host: UFW default-deny (открыты 22/80/443), SSH keys-only, fail2ban на SSH.

6. Секреты

  • Source of truth — шифрованные *.env.enc в репозитории: sops + age (де-факто стандарт GitOps- секретов вне облака; age вместо GPG — один файл ключа, без демона, просто передать клиенту).
  • Runtime — плоский .env (chmod 600, вне git) на сервере, подключён через env_file; деплой-runbook декриптует из .env.enc.
  • Пароль Postgres — через compose file-based secrets: (не светится в docker inspect).
  • Vault/Infisical/OpenBao — не вводить: always-on сервис, бекап которого сам становится секретом.
  • Hand-off процедура: клиенту генерируется собственная age-пара → .env.enc перешифровывается на обоих получателей → ротация всех секретов в момент передачи (DB password, Lark credentials, SMTP, API-ключи) — до ротации секреты знал подрядчик.

7. Бекапы и восстановление

Два слоя — восстановимость руками непрофильного админа важнее минимального RPO:

  • Слой 1 (обязательный): nightly pg_dump -Fc → шифрование agerclone в S3-совместимый offsite-бакет (Backblaze B2 / Wasabi / Hetzner, регион SG). Retention: 30 daily + 12 monthly. Логический дамп = тривиальный restore и переезд на любой хост/версию.
  • Слой 2 (рекомендуемый): pgBackRest с WAL-архивацией в тот же бакет — weekly full + daily diff, retention 4 full (~4 недели PITR-окна). PITR закрывает сценарий «ошибочный DELETE в 14:00» почти бесплатно после настройки. pgBackRest предпочтён wal-g: документация (критично для hand-off), один инструмент на full/diff/incr, встроенное шифрование, команда verify.
  • Restore-тест — ежемесячный, скриптованный: дамп разворачивается в одноразовый postgres-контейнер на DEV + smoke-запросы (row counts ключевых таблиц); провал — алерт. Непроверенный бекап = отсутствие бекапа.
  • Файловое хранилище (S3-совместимое) — версионирование бакета + lifecycle; фото чек-инов — крупнейший объём (retention — открытый вопрос клиенту). Бакет/префикс — per instance (задаётся в .env, док 10 §8): бекапы и перенос инстанса компании не задевают соседнюю.
  • Обе процедуры восстановления — отдельные страницы runbook.

8. Данные и приватность

  • Юрисдикция — Индонезия, UU PDP 27/2022: правовое основание обработки — не «согласие», а трудовые отношения; финальная проверка — за юристом (флаг в ADR-002). Без face-recognition на чек-ине; human-in-the-loop на решения с денежными последствиями.
  • Шифрование — точечное: бекапы (age), медицинские справки — app-level AES-GCM; остальное защищается RBAC + GRANT/REVOKE + шифрованными бекапами. Тотальное шифрование колонок отклонено (ломает индексы и поиск, не добавляя защиты при скомпрометированном приложении).
  • audit_log — append-only, маскирование чувствительных полей, purge защищён триггером; двойной канал: таблица (продуктовая история для админ-UI) + структурные логи (ops).
  • Sentry EU, PII off. Retention сырых данных (10 лет UU KUP vs 90 дней) — открытый вопрос клиенту.

9. Библиотеки и стек (backend)

НазначениеВыборКомментарий
RuntimePython 3.12+ (цель 3.14), uvlockfile — источник истины
WebFastAPI + uvicornPydantic v2 DTO
ORM/DBSQLAlchemy 2 async + alembic, PostgreSQL (ADR-002 целится в 18, схема верифицирована на 16)миграция №1 = HR DDL v2
Паролиargon2-cffi§2.1
Очередь/cronpgqueuerбез Redis/брокера
Логиstructlog (JSON → stdout → Loki) + asgi-correlation-idcorrelation ID на каждый запрос
Rate limitсчётчики в Postgres + edgeslowapi (alpha) и fastapi-limiter (Redis) — не брать
Headersна Caddy; app-level — secure при необходимостиодно место, без рассинхрона
HTTP-клиентhttpxLark/OTA-интеграции
Тестыpytest + pytest-asyncio + AsyncClient, testcontainers-postgresслои unit/integration/e2e/smoke
Границы модулейimport-lintervertical slices, CI-гейт

Frontend остаётся как в прототипе: Next.js 15 + TypeScript strict + next-intl; runtime-валидация ответов API — Zod.

10. Deploy pipeline и окружения

  • CI (GitHub Actions, self-hosted runner): lint → typecheck → тесты → build → образ в GHCR по SHA.
  • Flow: main → DEV (авто) → PROD (ручной gate с явной авторизацией). DEV всегда впереди PROD; release-ветки запрещены; деплой любого фикса — через main.
  • Деплой = runbook, не импровизация: pre-flight backup → pull образа по SHA → docker compose up → alembic verify → /health → smoke-логин + ключевые эндпоинты → проверка логов. Rollback-путь — в том же runbook (образ предыдущего SHA + при необходимости restore из pre-flight бекапа).
  • DEV и PROD — раздельные серверы; параметры различаются только .env (12-factor).
  • Smoke после каждого деплоя обязателен (правило validation layers): здоровье контейнеров, логин, 1–2 критических флоу.

11. Observability

  • Логи: structlog JSON → stdout → Docker → Loki (стандартный стек хоста).
  • Ошибки: Sentry EU (PII off, correlation ID в тегах).
  • Метрики: healthchecks компоуза + docker stats-базлайн; VictoriaMetrics-экспортёры — опционально позже, не блокер запуска.
  • Алерты минимальные: провал restore-теста, провал бекапа, unhealthy-контейнер, всплеск 5xx в Sentry.

12. Hand-off чеклист (передача клиенту)

  1. Собственная age-пара клиента; перешифровка .env.enc; ротация всех секретов.
  2. Прогон restore-теста бекапа руками клиентского админа (не показ — прогон).
  3. Runbooks: deploy, rollback, restore (оба слоя), Lark-переконфигурация, добавление пользователя.
  4. Решение «кто владеет мастером сотрудников» (Lark vs staffapp) — политика направления Lark-синка (код синк-джоба + lark_id_origin); зафиксировать выбор клиента в runbook.
  5. DNS/TLS: перенос staff.* на инфраструктуру клиента (Caddy сам получит сертификаты).
  6. Доступы: GHCR read-token на pull образов, SSH-ключи, Sentry-проект.