Appearance
Админ-сервис (admin.partizap.ru)
Админ-панель выделена в отдельный сервис, изолированный от маркетплейса на 4 уровнях:
| Уровень | Механизм |
|---|---|
| Сеть | mTLS — без клиентского сертификата nginx возвращает 400 |
| Приложение | APP_MODE=admin, отдельные routes (marketplace routes недоступны) |
| БД | PostgreSQL roles с column-level grants |
| Сессии | Redis DB 4, cookie PARTIZAP_ADMIN_SESSION, SameSite=Strict |
Схема деплоя
VPS 1 (Production, 85.239.48.136)
├── /var/www/partizap/production # APP_MODE=marketplace, partizap.ru
├── /var/www/partizap/admin-production # APP_MODE=admin, admin.partizap.ru
└── nginx: mTLS + SSL termination
VPS 2 (DevOps, 192.168.0.5)
├── /var/www/partizap/development # APP_MODE=marketplace, dev.partizap.ru
├── /var/www/partizap/admin-development # APP_MODE=admin, dev-admin.partizap.ru
└── nginx: порт 8084 (reverse proxy через VPS 1)| Dev | Prod | |
|---|---|---|
| URL | https://dev-admin.partizap.ru | https://admin.partizap.ru |
| Branch | develop | main |
| DB user | partizap_admin_dev | partizap_admin_prod |
| .env | APP_MODE=admin, REDIS_SESSION_DB=4 | APP_MODE=admin, REDIS_SESSION_DB=4 |
Аутентификация: три фактора
| Фактор | Тип | Уровень |
|---|---|---|
| Клиентский сертификат (.p12) | Устройство | TLS (Nginx) |
| Пароль admin_users | Знание | App (PHP) |
| TOTP-код | Устройство (телефон) | App (PHP) |
Brute-force защита
| Механизм | Значение |
|---|---|
| Login lockout | 3 попытки / 30 мин (по email) |
| Progressive delays | 2→2s, 3→5s |
| Account lockout | 3 попытки → locked_until +30 мин |
| TOTP attempts | Max 3 в сессии, затем re-login |
| TOTP expiry | 5 минут после ввода пароля |
| Alert email | Каждая неудачная попытка |
| New IP alert | При логине с неизвестного IP |
| Rate limit (login) | 20 неуд. попыток / 1800s (по IP, для защиты от email-rotation brute force на shared NAT) |
| Rate limit (global) | 30 req / 60s |
INFO
Lockout привязан к email, а не к паре (IP, email) — иначе пользователи за shared NAT блокировали друг друга, когда один набирал пароль неверно. IP-адрес определяется через ClientIpResolver (см. Архитектура → Client IP и TRUSTED_PROXIES), поэтому заголовок X-Real-IP не подделывается произвольным клиентом.
Операции
Добавление нового админа
Шаг 1: Создать учётную запись в admin_users
bash
# Dev
cd /var/www/partizap/admin-development
./bin/console app:create-admin --email=newadmin@partizap.ru --name="Имя Фамилия"
# Пароль: интерактивный ввод (минимум 16 символов, 3 из 4 классов: a-z, A-Z, 0-9, спецсимволы)
# Prod
ssh serpens@192.168.0.4
cd /var/www/partizap/admin-production
./bin/console app:create-admin --email=newadmin@partizap.ru --name="Имя Фамилия"Шаг 2: Выпустить клиентский сертификат
На VPS 1 (production):
bash
ssh serpens@192.168.0.4
sudo -i
cd /etc/nginx/ssl/admin-mtls
ADMIN_EMAIL="newadmin@partizap.ru"
ADMIN_NAME="newadmin"
# Генерация ключа и CSR
openssl genrsa -out ${ADMIN_NAME}.key 2048
openssl req -new -key ${ADMIN_NAME}.key \
-out ${ADMIN_NAME}.csr \
-subj "/CN=${ADMIN_EMAIL}/O=Partizap"
# Подпись CA (1 год)
openssl x509 -req -days 365 \
-in ${ADMIN_NAME}.csr \
-CA ca.crt -CAkey ca.key -CAcreateserial \
-out ${ADMIN_NAME}.crt
# Упаковка в .p12 для браузера (формат совместимый с Chrome/Linux)
openssl pkcs12 -export \
-out ${ADMIN_NAME}.p12 \
-inkey ${ADMIN_NAME}.key \
-in ${ADMIN_NAME}.crt \
-name "Partizap Admin (${ADMIN_EMAIL})" \
-passout pass:SECURE_PASSWORD_HERE \
-certpbe PBE-SHA1-3DES \
-keypbe PBE-SHA1-3DES \
-macalg SHA1
# Убрать промежуточные файлы
rm ${ADMIN_NAME}.csr
chmod 600 ${ADMIN_NAME}.key
chmod 644 ${ADMIN_NAME}.p12Важно: -certpbe PBE-SHA1-3DES -keypbe PBE-SHA1-3DES -macalg SHA1 — обязательно для совместимости с Chrome/Chromium на Linux. OpenSSL 3.0 по умолчанию использует алгоритмы, которые Chrome не понимает.
Важно: НЕ включать CA cert в .p12 (не использовать -certfile ca.crt), иначе Chrome импортирует его как Intermediate CA вместо клиентского сертификата.
Шаг 3: Передать .p12 файл и пароль
- Файл
.p12передать лично или через защищённый канал - Пароль сообщить отдельно, не в том же канале
- Не отправлять через мессенджер вместе
Шаг 4: Импорт в браузер
Linux (Chromium/Chrome snap)
bash
# 1. Установить NSS tools (если нет)
sudo apt install libnss3-tools
# 2. Найти NSS DB браузера
# Для snap Chromium:
find ~/snap/chromium -name "cert9.db" 2>/dev/null
# Для обычного Chrome:
ls ~/.pki/nssdb/cert9.db
# 3. Импортировать (подставить правильный путь к nssdb)
pk12util -d sql:$HOME/snap/chromium/common/.pki/nssdb \
-i ~/Downloads/newadmin.p12 \
-W "ПАРОЛЬ_ОТ_P12"
# 4. Проверить
certutil -d sql:$HOME/snap/chromium/common/.pki/nssdb -L
# Должен показать "Partizap Admin (...)" в списке
# 5. Перезапустить браузерВнимание: snap-версия Chromium использует изолированный NSS store. Путь ~/.pki/nssdb не работает — нужен ~/snap/chromium/common/.pki/nssdb.
Linux (Firefox)
- Settings → Privacy & Security → Certificates → View Certificates
- Your Certificates → Import
- Выбрать
.p12файл → ввести пароль - Перезапустить Firefox
macOS (Chrome/Safari)
bash
# Через терминал:
security import ~/Downloads/newadmin.p12 -k ~/Library/Keychains/login.keychain-db -P "ПАРОЛЬ"
# Или через Keychain Access:
# 1. Двойной клик на .p12 файл
# 2. Ввести пароль
# 3. Сертификат появится в login keychainДля Chrome — перезапустить после импорта.
Windows (Chrome/Edge)
- Двойной клик на
.p12файл → запустится Certificate Import Wizard - Store Location: Current User → Next
- Ввести пароль → Next
- "Automatically select the certificate store" → Next → Finish
- Перезапустить Chrome
Или через PowerShell:
powershell
Import-PfxCertificate -FilePath .\newadmin.p12 -CertStoreLocation Cert:\CurrentUser\My -Password (ConvertTo-SecureString -String "ПАРОЛЬ" -AsPlainText -Force)Android (Chrome)
- Передать
.p12файл на устройство (через email, облако, USB) - Settings → Security → Encryption & credentials → Install a certificate → VPN & app user certificate
- Выбрать
.p12файл → ввести пароль - При заходе на admin.partizap.ru Chrome предложит выбрать сертификат
На Android 11+ путь может быть: Settings → Security → More security settings → Encryption & credentials → Install a certificate.
iOS (Safari/Chrome)
- Передать
.p12файл на устройство (AirDrop, email, Files) - Открыть файл → iOS предложит установить профиль
- Settings → General → VPN & Device Management → установить профиль
- Ввести пароль от
.p12 - Settings → General → About → Certificate Trust Settings → включить доверие для Partizap Admin CA
- Safari: при заходе на admin.partizap.ru предложит выбрать сертификат
Chrome на iOS использует системное хранилище сертификатов, поэтому тоже будет работать после установки профиля.
Шаг 5: Проверить доступ
Открыть в браузере: https://admin.partizap.ru/health
- Браузер предложит выбрать сертификат → выбрать
Partizap Admin (...) - Ответ:
{"data":{"status":"healthy"}}
Без сертификата → 400 Bad Request: No required SSL certificate was sent
Шаг 6: Настроить TOTP
- Войти: POST
/admin/auth/loginс email и паролем - GET
/admin/auth/totp-setup→ получить QR-код (provisioning URI) - Отсканировать QR в приложении (Google Authenticator, Authy, 1Password)
- POST
/admin/auth/totp-confirmс 6-значным кодом - Последующие входы: email+password → TOTP код
Отзыв сертификата
При компрометации устройства или увольнении:
bash
ssh serpens@192.168.0.4
sudo -i
cd /etc/nginx/ssl/admin-mtls
# Инициализация CRL (только при первом отзыве)
touch index.txt
echo 01 > crlnumber
# Отозвать сертификат
openssl ca -revoke newadmin.crt \
-keyfile ca.key -cert ca.crt \
-config <(echo -e "[ca]\ndefault_ca=CA_default\n[CA_default]\ndatabase=index.txt\ncrlnumber=crlnumber")
# Сгенерировать CRL
openssl ca -gencrl \
-keyfile ca.key -cert ca.crt \
-out ca.crl \
-config <(echo -e "[ca]\ndefault_ca=CA_default\n[CA_default]\ndatabase=index.txt\ncrlnumber=crlnumber\ndefault_crl_days=3650")
# Включить CRL в nginx (раскомментировать в admin-partizap конфиге)
# ssl_crl /etc/nginx/ssl/admin-mtls/ca.crl;
# Деплой через nginx repo
cd ~/Projects/Partizap/nginx
# Раскомментировать ssl_crl, commit, push → CI deploy
# Или экстренно:
nginx -t && systemctl reload nginxПосле reload — отозванный сертификат мгновенно перестаёт работать.
Перевыпуск сертификата
Клиентские сертификаты выдаются на 1 год. За 2 недели до истечения:
bash
# Проверить срок
openssl x509 -in serpens.crt -noout -enddate
# Перевыпустить (те же команды, что в Шаг 2)Деактивация админа (без отзыва сертификата)
bash
cd /var/www/partizap/admin-production
./bin/console app:create-admin --email=admin@partizap.ru --name="Admin"
# Затем в БД: UPDATE admin_users SET is_active = false WHERE email = '...';PostgreSQL роли
| Роль | Назначение |
|---|---|
partizap_owner | DDL, миграции. Владеет всеми таблицами |
partizap_dev / partizap_prod | Marketplace runtime. Полный доступ ко всем таблицам, кроме admin_users, admin_sessions, admin_audit_log |
partizap_admin_dev / partizap_admin_prod | Admin runtime. Column-level grants |
Cross-side CLI-команды
Маркетплейс-роль не видит admin-таблицы, а admin-роль не видит marketplace-таблицы. Консольные команды, которым нужны обе стороны (например, app:clean-expired-sessions — чистит user_sessions и admin_sessions), не могут быть запущены из одного деплоя и обязаны gracefully skip недоступную таблицу при SQLSTATE[42501].
Паттерн: одна команда — два запуска по cron, по одному на каждое развёртывание, каждый чистит свою таблицу, другую пропускает с note. Реальное расписание: docs/dev/architecture.md#очистка-истёкших-сессий и deploy/cron/partizap-session-gc.{dev,prod} в server-репо.
Column-level grants для admin role
| Таблица | SELECT | INSERT | UPDATE | DELETE |
|---|---|---|---|---|
| users | ALL | - | is_active only | - |
| products | ALL | - | status, published_at, rejection_reason | Yes |
| admin_users | ALL | Yes | Yes | No (деактивация через is_active) |
| admin_sessions | ALL | Yes | Yes | Yes |
| admin_audit_log | ALL | Yes | No (immutable) | No (immutable) |
| categories, car_*, regions, cities, districts, metro_stations | ALL | Yes | Yes | Yes |
| Остальные | ALL | - | - | - |
Redis DB layout
| DB | Назначение |
|---|---|
| 0 | Marketplace sessions + Doctrine metadata cache (dc2_*) |
| 1 | Application cache (categories, car_makes, geo, products) |
| 2 | Queues |
| 3 | Rate limits |
| 4 | Admin sessions |
Nginx конфигурация
Версионируется в git@127.0.0.1:d_vyaznikov/nginx.git:
| Файл | Сервер | Описание |
|---|---|---|
main/sites-available/admin-partizap | VPS 1 | admin.partizap.ru (mTLS + PHP-FPM) + dev-admin reverse proxy |
dev/sites-available/admin-partizap-dev | VPS 2 | dev-admin backend (порт 8084) |
main/nginx.conf | VPS 1 | map $ssl_client_s_dn → $ssl_client_cn, log_format admin_access |
Не редактировать конфиги напрямую на серверах. Push в nginx repo → CI deploy автоматически.
Cache flush после деплоя
При изменении entity/миграций — полный сброс кеша:
bash
DEPLOY_DIR=/var/www/partizap/production # или development, admin-production, admin-development
PREFIX=prod # или dev
# Doctrine metadata (Redis DB 0)
redis-cli -n 0 KEYS "dc2_*" | xargs -r redis-cli -n 0 DEL
# DI container + Doctrine proxies
sudo rm -rf ${DEPLOY_DIR}/var/cache/* ${DEPLOY_DIR}/var/doctrine/proxies/*
sudo chown -R www-data:www-data ${DEPLOY_DIR}/var/
# Regenerate proxies (production only, APP_DEBUG=false)
cd ${DEPLOY_DIR}
sudo -u www-data php -r 'require "vendor/autoload.php"; $s=require "config/app.php"; $d=require "config/doctrine.php"; $em=$d($s); $em->getProxyFactory()->generateProxyClasses($em->getMetadataFactory()->getAllMetadata());'
# OPcache
sudo systemctl restart php8.3-fpm
# Application cache (если изменились справочные данные)
# redis-cli -n 1 KEYS "${PREFIX}:*" | xargs -r redis-cli -n 1 DELAPI endpoints (admin mode)
Auth (public, rate limited)
| Method | Path | Описание |
|---|---|---|
| POST | /admin/auth/login | Email + password → session (или totp_required) |
| POST | /admin/auth/totp-verify | TOTP код → полная сессия |
Auth (authenticated)
| Method | Path | Описание |
|---|---|---|
| GET | /admin/auth/me | Текущий admin user |
| POST | /admin/auth/logout | Завершить сессию |
| GET | /admin/auth/totp-setup | Получить QR для настройки TOTP |
| POST | /admin/auth/totp-confirm | Подтвердить TOTP setup |
Admin CRUD (authenticated + admin middleware)
Все существующие /admin/* endpoints (stats, users, products, reference data CRUD).
Store read-only (authenticated)
GET-only endpoints из /store/* для просмотра справочных данных (categories, cars, geo).
Frontend (Nuxt admin app)
Admin frontend — отдельное Nuxt-приложение в монорепо partizap-frontend/apps/admin/.
ADR: ADR-011
Архитектура
partizap-frontend/
├── apps/main/ ← marketplace (partizap.ru)
├── apps/admin/ ← admin panel (admin.partizap.ru) ← THIS
└── packages/shared/ ← shared entities, API client, utils- SPA-only (
ssr: false) — админка не нуждается в SSR - Shared code через symlinks:
apps/admin/app/entities/*→packages/shared/ - Отдельный auth flow:
useAdminAuthStore+useAdminApi(cookiePARTIZAP_ADMIN_SESSION) - Dev server:
https://localhost:3001(main на 3000)
Auth flow
/login → email + password
↓ requires_totp=true
/login/totp → 6-digit TOTP code (3 попытки, 5 мин)
↓ totp_enabled=false (первый вход)
/login/totp-setup → QR-код + подтверждение
↓ success
/ → dashboardРазделы
| Раздел | Route | Описание |
|---|---|---|
| Dashboard | / | Статистика: пользователи, продукты, модерация |
| Products | /admin/products | Модерация: approve/reject, polling 30s |
| Users | /admin/users | Список, блокировка, поиск, фильтры |
| References | /admin/references | Справочники: авто, категории, гео |
Команды разработки
bash
pnpm dev:admin # Dev server (https://localhost:3001)
pnpm build:admin # Production build
pnpm --filter @partizap/admin run final # typecheck + test + lintCI/CD
TODO — admin Dockerfile + pipeline job для деплоя на admin.partizap.ru. На текущий момент бэкенд деплоится вручную, фронт через monorepo CI (main app only).