Files
Test1/docs/architecture.md
T

224 lines
17 KiB
Markdown
Raw Normal View History

2026-08-31 16:18:11 +03:00
# Архитектура решения: портал администрирования HAProxy + Django + AD
## Вариант развёртывания: одна ВМ, без контейнеризации
## 1. Назначение
Единая точка входа для администраторов инфраструктуры: пользователь логинится
своей доменной учётной записью, видит только те административные ресурсы,
которые открыты его AD-группе, и одним кликом переходит в конкретную
систему по адресу `https://<FQDN>/<adminusergroup>/<resource>/...`, не
запоминая адреса и не имея прямого доступа к внутренним админкам, минуя
портал.
Этот документ описывает вариант развёртывания **без Docker** — все
компоненты ставятся как обычные пакеты дистрибутива и управляются через
`systemd` на одной виртуальной машине. Это упрощает эксплуатацию в
организациях, где контейнеризация не принята, и снимает зависимость от
Docker-инфраструктуры.
## 2. Компоненты и то, как они запущены на ВМ
| Компонент | Как установлен | Как запущен | Слушает |
|---|---|---|---|
| **HAProxy** | пакет `haproxy` из репозитория дистрибутива | systemd-юнит `haproxy.service` (штатный, из пакета) | `0.0.0.0:80`, `0.0.0.0:443`, `127.0.0.1:8404` (статистика) |
| **Django frontend (portal)** | Python venv в `/opt/admin-portal/venv`, код в `/opt/admin-portal/django_app` | Gunicorn под собственным systemd-юнитом `admin-portal.service`, от имени системного пользователя `adminportal` | `127.0.0.1:8000` |
| **Active Directory** | внешняя система, ничего не устанавливаем | — | LDAP/LDAPS (389/636) на контроллере домена |
| **PostgreSQL** | пакет `postgresql` | systemd-юнит `postgresql.service` (штатный) | `127.0.0.1:5432` |
| **Redis** | пакет `redis-server` | systemd-юнит `redis-server.service` (штатный) | `127.0.0.1:6379` |
| **Backend admin panels** | в решении — 2 демо-заглушки на Nginx; в проде — реальные системы | демо: `nginx.service`, отдельные `server`-блоки на `127.0.0.1:8081/8082`; прод: на других хостах сети | только внутри периметра, не публикуются наружу |
Все процессы, кроме HAProxy, слушают исключительно `127.0.0.1` — наружу
из ВМ открыты только порты 80/443 (см. `ufw` в README, шаг 11). Это заменяет
собой изоляцию, которую в контейнерном варианте давала Docker-сеть.
## 3. Общая схема
```
HTTPS
Браузер администратора ────────────────► HAProxy :443 (systemd: haproxy)
│
TLS termination, ACL-роутинг по path, HSTS, rate-limit
│
┌──────────────────────────────┼───────────────────────────────┐
│ путь НЕ ресурсный, │ путь ресурсный + нет │ путь ресурсный + есть
│ или /login /static/... │ resource_token │ resource_token
▼ ▼ ▼
┌─────────────────────────────────────────┐ ┌──────────────────────┐
│ Gunicorn 127.0.0.1:8000 (systemd: │ │ Nginx 127.0.0.1:808x │
│ admin-portal.service) │ │ (демо) или реальная │
│ - django-auth-ldap -> AD bind │ │ админка на др. хосте │
│ - каталог ресурсов из PostgreSQL │ └──────────────────────┘
│ - сессии в Redis │ ▲
│ - выпуск JWT для перехода на ресурс │──── redirect + cookie ───┘
└─────────────────────────────────────────┘ resource_token
│
▼
PostgreSQL 127.0.0.1:5432 (ResourceGroup, Resource, AccessLog)
Redis 127.0.0.1:6379 (сессии Django, кэш групп LDAP)
```
## 4. Поток аутентификации
1. Браузер отправляет запрос на `https://fqdn/`.
2. HAProxy (см. `haproxy.cfg`) не находит cookie `resource_token` на
нересурсном пути → направляет на `backend be_django` (Gunicorn,
`127.0.0.1:8000`).
3. Django рендерит форму логина. Пользователь вводит `sAMAccountName` + пароль.
4. `django-auth-ldap`:
- выполняет bind сервисным аккаунтом (`AUTH_LDAP_BIND_DN` из
`/etc/admin-portal/admin-portal.env`),
- ищет пользователя по `AUTH_LDAP_USER_SEARCH` (`sAMAccountName=%(user)s`),
- выполняет повторный bind уже от имени найденного DN с введённым паролем —
это и есть проверка пароля,
- при успехе подтягивает атрибуты (`givenName`, `sn`, `mail`) и список
групп (`AUTH_LDAP_GROUP_SEARCH`), кладёт их в `request.user.ldap_user`.
5. Django создаёт сессию в Redis (локальный, `127.0.0.1:6379`), выставляет
cookie `sessionid`.
6. Пользователь видит дашборд со списком `Resource`, чьи
`ResourceGroup.ad_group_name` входят в набор групп пользователя из AD.
## 5. Поток перехода на ресурс
1. Клик по карточке ресурса → `GET /{group}/{resource}/`.
2. Первый переход всегда обрабатывается Django (`open_resource` view),
поскольку у HAProxy ещё нет cookie `resource_token` для этого пути —
маршрут `is_resource_path !has_resource_token` в `haproxy.cfg` уводит
запрос на `be_django`.
3. `open_resource` view:
- проверяет, что ресурс существует, активен и открыт AD-группе
пользователя,
- пишет запись в `AccessLog` (кто, когда, какой ресурс, IP — таблица в
PostgreSQL),
- выпускает JWT (`RESOURCE_TOKEN_SECRET`, TTL по умолчанию 60 секунд),
- кладёт токен в cookie `resource_token`, ограниченную `path` конкретного
ресурса,
- делает 302 редирект на тот же путь.
4. HAProxy на повторном запросе видит `cook(resource_token) -m found` →
маршрутизация по `group_map.cfg` на нужный `backend` (демо-Nginx на
`127.0.0.1:808x` или реальная система на другом хосте). Перед
проксированием HAProxy снимает cookie в заголовок `X-Resource-Token` и
удаляет исходный `Cookie` — backend никогда не видит `sessionid` портала.
5. Backend admin panel (при наличии соответствующей интеграции) валидирует
JWT тем же `RESOURCE_TOKEN_SECRET` и на основании `sub`
логинит/находит пользователя в своей системе.
### Важно: токен — это разовый хэндшейк, а не сессия внутри backend'а
`resource_token` живёт всего 60 секунд и привязан к `path` конкретного
ресурса. Backend admin panel, приняв и провалидировав токен один раз,
должен завести собственную сессию для дальнейшей работы пользователя — так
делает подавляющее большинство систем при интеграции с внешним SSO. Если
полагаться на `resource_token` при каждом запросе, через 60 секунд любой
запрос без свежего токена уйдёт обратно в Django на переоформление. При
необходимости TTL можно увеличить через `RESOURCE_TOKEN_TTL_SECONDS` в
`.env`, но это не заменяет полноценную сессию backend'а.
## 6. Варианты передачи identity в backend (сравнение)
| Вариант | Как работает | Когда применять |
|---|---|---|
| **A. Доверенный заголовок** (`X-Remote-User`) | HAProxy/Django прокидывает имя пользователя как есть, backend доверяет заголовку | Полностью изолированный сегмент сети, backend не имеет других входов |
| **B. Подписанный JWT** (реализовано в этом решении) | Django выпускает короткоживущий токен, backend проверяет подпись | Рекомендуемый вариант по умолчанию |
| **C. Полноценный SSO (SAML/OIDC)** | Backend сам является Service Provider, IdP — AD FS/Keycloak | Backend уже умеет SAML/OIDC "из коробки" (Grafana, GitLab и т.п.) |
## 7. Модель данных
```
ResourceGroup
ad_group_name (slug, unique) -- реальное имя группы AD, сегмент пути
display_name (str)
Resource
group -> ResourceGroup
slug (str) -- второй сегмент пути
name (str)
description (text)
is_active (bool)
unique(group, slug)
AccessLog
user -> auth.User (nullable)
resource -> Resource (nullable)
ip_address (inet)
created_at (datetime, auto)
```
Хранится в PostgreSQL, установленном локально на ВМ (`/var/lib/postgresql`).
## 8. Наполнение и сопровождение каталога ресурсов
1. **Django admin** (`https://<FQDN>/django-admin/`) — для точечных правок.
2. **Декларативный YAML + management-команда** `sync_resources`:
```bash
sudo -u adminportal bash -c \
'set -a; source /etc/admin-portal/admin-portal.env; set +a; \
/opt/admin-portal/venv/bin/python /opt/admin-portal/django_app/manage.py \
sync_resources /opt/admin-portal/django_app/resources.example.yaml'
```
Команда идемпотентна, можно хранить YAML в git и гонять через CI/CD по SSH.
## 9. Безопасность
- TLS 1.2+/1.3, HSTS, современные cipher suites — терминируются на HAProxy;
все внутренние компоненты общаются по `127.0.0.1` в открытом виде (трафик
не покидает ВМ) либо по внутренней сети без выхода наружу.
- Пароли пользователей не хранятся на портале — только bind-запрос к AD.
- CSRF-защита на всех формах Django.
- Сессионные cookie: `Secure`, `HttpOnly`, `SameSite=Lax`.
- `resource_token` — отдельный секрет, TTL 60 секунд, `path`-scope,
`HttpOnly`.
- HAProxy снимает cookie сессии портала перед проксированием на backend.
- Аудит: каждый переход на ресурс логируется в `AccessLog`.
- Rate-limit на `/login/` через `stick-table` в HAProxy.
- Файл `/etc/admin-portal/admin-portal.env` с секретами — права `640`,
владелец `root:adminportal`, читать может только процесс приложения.
- systemd-юнит `admin-portal.service` запущен от непривилегированного
пользователя `adminportal` (`nologin`), с `NoNewPrivileges`, `PrivateTmp`,
`ProtectSystem=full`.
- `ufw` открывает наружу только 80/443; PostgreSQL, Redis, Gunicorn и
демо-Nginx слушают исключительно loopback — недоступны из сети в принципе,
даже без дополнительных правил фаервола.
- Backend admin panels в проде должны быть в отдельном сетевом сегменте без
прямого доступа из интернета — HAProxy как единственная точка входа
снаружи.
## 10. Отказоустойчивость и масштабирование без Docker
Однонодовый вариант (этот пакет) подходит для старта и для организаций
среднего размера. Пути роста при увеличении нагрузки:
- **HAProxy**: поднять второй экземпляр HAProxy на второй ВМ + `keepalived`
(VRRP) для плавающего IP, либо использовать внешний L4-балансировщик перед
парой ВМ с HAProxy.
- **Django/Gunicorn**: поскольку сессии и кэш групп уже вынесены в Redis,
можно поднять `admin-portal.service` на второй ВМ тем же способом
(шаги 3–8 из README) и добавить в `backend be_django` вторую строку
`server django2 10.0.0.11:8000 check` — балансировка без изменения
остальной конфигурации.
- **PostgreSQL**: для прод-контура — вынести на отдельную ВМ с репликой
(потоковая репликация PostgreSQL), поменять `POSTGRES_HOST` в `.env` на
обеих Django-нодах.
- **Redis**: при росте нагрузки — Redis Sentinel (2–3 ВМ), либо вынос на
отдельную ВМ без кластеризации, если нагрузка умеренная (сессии — лёгкая
нагрузка для Redis).
- **LDAP**: кэш групп на `AUTH_LDAP_CACHE_TIMEOUT` (по умолчанию 600 секунд)
снижает нагрузку на контроллеры домена независимо от количества Django-нод.
## 11. Ограничения и что осознанно вынесено за рамки
- Установочный скрипт (`scripts/install.sh`) рассчитан на Ubuntu/Debian
(`apt`); для RHEL/CentOS/Alma потребуется адаптировать имена пакетов
(`dnf install haproxy nginx postgresql-server redis python3 ...`) и путь
до `python3-venv` — сама архитектура и код Django не меняются.
- TLS-сертификат от внешнего центра сертификации подкладывается вручную —
автоматический выпуск/продление (например, через `certbot`) в рамках
данного пакета не настраивается, так как процедура сильно зависит от
того, публичный это FQDN или внутренний корпоративный CA.
- Мультиузловая отказоустойчивость (второй HAProxy, реплика PostgreSQL,
Redis Sentinel) описана на уровне принципов в разделе 10, но конкретные
Ansible/Terraform-плейбуки под неё не входят в этот пакет — количество ВМ
и топология сильно зависят от инфраструктуры конкретной организации.
- Интеграция конкретных backend admin panel с валидацией JWT специфична для
каждой системы; в решении заложен универсальный контракт (заголовок
`X-Resource-Token`, алгоритм `HS256`, общий секрет `RESOURCE_TOKEN_SECRET`),
но код валидации на стороне внутренних систем — задача их владельцев.