Статус: предложение (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 правила.
audit_log + структурные логи; админ-действия — всегда след.m=19456 KiB, t=2, p=1 — канонический минимум OWASP Password Storage CS
(актуален на mid-2026). Библиотека — argon2-cffi.check_needs_rehash() + прозрачный re-hash: путь апгрейда параметров
без миграции.locked_until = now() + min(2^fails сек, 20 мин)), авто-разблокировка.invalid credentials + dummy-verify (заранее посчитанный хеш константного пароля
проверяется в ветке «нет такого пользователя») — защита от user enumeration по таймингу.secrets.token_urlsafe(32) =
256 бит энтропии; в базе — только sha256(token): дамп БД ≠ угон всех сессий.last_seen_at троттлится (запись не чаще раза в 5 минут).__Host-session=…; HttpOnly; Secure; SameSite=Lax; Path=/ — требует поддомен staff.*
(не path /staffapp); поддомен должен существовать до первого пользователя.Next.js и FastAPI живут под одним origin через Caddy (/api/* → api) — CORS отсутствует как класс,
SameSite работает в полную силу. Поверх — middleware по fetch-metadata: state-changing запрос с
Sec-Fetch-Site ≠ same-origin|none → 403 (fallback — проверка Origin по allowlist). CSRF-токены /
double-submit не нужны — fetch-metadata признан OWASP основной защитой для современных стеков.
Один механизм на оба флоу (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 на админ-плоскость — опционально, не жёстко).
Четыре уровня; каждый следующий назначается уровнем (уровнями) выше:
| Роль | Кто назначает | Полномочия |
|---|---|---|
| Super Admin | никто (создаётся при установке системы, seed) | всё в сервисе; назначает админов; настраивает полномочия каждого админа; может назначать супервайзеров напрямую |
| Admin | только Super Admin | создание/управление пользователями, назначение супервайзеров в модулях (например HR) — строго в пределах полномочий, выданных суперадмином |
| Supervisor | Super Admin; Admin — если его полномочий хватает | управляет своей группой ground-сотрудников: смены, заявки, посещаемость и репорты своей группы |
| Staff | создаётся Admin'ом (invite) | работа из мобильного приложения: clock-in/clock-out, задачи, репорты, заявки |
core.user_role со scope-грантами department/location/subordinates +
каталог permission/role_permission) — покрывает. Открытая развилка PR #73 по
permission-каталогу (finding hr-schema-v2-overengineering-review) этим вводом сужается: настраиваемая
часть должна жить в данных; что именно остаётся в коде (базовая карта уровней) — решается при ревью схемы.users.assign_role с ограничением по рангу роли),
фиксируется в audit_log.hr.employment.supervisor_id, scope-грант «мой департамент / мои подчинённые»). Состав группы меняется
без миграций.require_permission("hr.requests.approve").core.user_account (логин/пароль/сессии/роль) отдельно от hr.employee
(Lark-синхронизируемая карточка), связь nullable. Полевой сотрудник без доступа к системе — валидное
состояние.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 с ревокацией
сессий.is_active=false + ревокация сессий
одной транзакцией) · admin-reset (invite-токен). Каждое действие — в audit_log с указанием, кто и
в рамках каких полномочий его совершил.lark_id_origin (sync/manual) гарантирует, что синк не
затирает вручную заданный Lark ID, журнал прогонов — core.lark_sync_run. Синк не создаёт и не удаляет
аккаунты — создание пользователей всегда идёт через Admin-флоу выше.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 | Прочее |
|---|---|---|---|
| caddy | pinned + digest | 256m | единственный с портами 80/443; TLS, headers, rate limit |
| web | Next.js next start, non-root | 1g | edge-сеть |
| api | python-slim multi-stage (uv), non-root | 1g | edge + data |
| worker | тот же образ, command: pgqueuer | 512m–1g | только data; изоляция памяти/рестартов от API |
| postgres | postgres:17.x pinned + digest | 1g | только 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";data — internal: true: postgres и worker отрезаны от интернета целиком; у postgres нет
published ports (DBA-доступ — docker exec или SSH-туннель);docker stats × 2–3 от пика, не по шаблону.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.docs_url=None, redoc_url=None, openapi_url=None.*.env.enc в репозитории: sops + age (де-факто стандарт GitOps-
секретов вне облака; age вместо GPG — один файл ключа, без демона, просто передать клиенту)..env (chmod 600, вне git) на сервере, подключён через env_file; деплой-runbook
декриптует из .env.enc.secrets: (не светится в docker inspect)..env.enc перешифровывается на обоих
получателей → ротация всех секретов в момент передачи (DB password, Lark credentials, SMTP, API-ключи) —
до ротации секреты знал подрядчик.Два слоя — восстановимость руками непрофильного админа важнее минимального RPO:
pg_dump -Fc → шифрование age → rclone в S3-совместимый
offsite-бакет (Backblaze B2 / Wasabi / Hetzner, регион SG). Retention: 30 daily + 12 monthly.
Логический дамп = тривиальный restore и переезд на любой хост/версию.verify..env,
док 10 §8): бекапы и перенос инстанса компании не задевают соседнюю.age), медицинские справки — app-level AES-GCM; остальное защищается
RBAC + GRANT/REVOKE + шифрованными бекапами. Тотальное шифрование колонок отклонено (ломает индексы и поиск, не
добавляя защиты при скомпрометированном приложении).audit_log — append-only, маскирование чувствительных полей, purge защищён триггером; двойной канал:
таблица (продуктовая история для админ-UI) + структурные логи (ops).| Назначение | Выбор | Комментарий |
|---|---|---|
| Runtime | Python 3.12+ (цель 3.14), uv | lockfile — источник истины |
| Web | FastAPI + uvicorn | Pydantic v2 DTO |
| ORM/DB | SQLAlchemy 2 async + alembic, PostgreSQL (ADR-002 целится в 18, схема верифицирована на 16) | миграция №1 = HR DDL v2 |
| Пароли | argon2-cffi | §2.1 |
| Очередь/cron | pgqueuer | без Redis/брокера |
| Логи | structlog (JSON → stdout → Loki) + asgi-correlation-id | correlation ID на каждый запрос |
| Rate limit | счётчики в Postgres + edge | slowapi (alpha) и fastapi-limiter (Redis) — не брать |
| Headers | на Caddy; app-level — secure при необходимости | одно место, без рассинхрона |
| HTTP-клиент | httpx | Lark/OTA-интеграции |
| Тесты | pytest + pytest-asyncio + AsyncClient, testcontainers-postgres | слои unit/integration/e2e/smoke |
| Границы модулей | import-linter | vertical slices, CI-гейт |
Frontend остаётся как в прототипе: Next.js 15 + TypeScript strict + next-intl; runtime-валидация ответов API — Zod.
docker compose up →
alembic verify → /health → smoke-логин + ключевые эндпоинты → проверка логов. Rollback-путь — в том же
runbook (образ предыдущего SHA + при необходимости restore из pre-flight бекапа)..env (12-factor).docker stats-базлайн; VictoriaMetrics-экспортёры — опционально позже,
не блокер запуска..env.enc; ротация всех секретов.lark_id_origin); зафиксировать выбор клиента в runbook.staff.* на инфраструктуру клиента (Caddy сам получит сертификаты).