From 65de34ba4ad2e26a1f1f8deab9d80e73c401fa79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Fri, 29 May 2026 19:54:41 +0300 Subject: [PATCH] =?UTF-8?q?init:=20=D0=A2=D0=97,=20=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D0=BB=D0=B0=20copilot,=20=D0=BF=D0=BB=D0=B0=D0=BD?= =?UTF-8?q?=D1=8B=20=D0=BE=D1=82=20=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D0=B5?= =?UTF-8?q?=D0=B9=20(DeepSeek,=20Claude,=20GPT-5.4,=20Gemini)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/copilot-instructions.md | 27 ++ WhiteIPlist.docx | Bin 0 -> 27316 bytes docs/plan-gemini.md | 86 ++++++ docs/plan-gpt54.md | 529 ++++++++++++++++++++++++++++++++ docs/plan-v2.md | 188 ++++++++++++ docs/plan.md | 118 +++++++ 6 files changed, 948 insertions(+) create mode 100644 .github/copilot-instructions.md create mode 100755 WhiteIPlist.docx create mode 100644 docs/plan-gemini.md create mode 100644 docs/plan-gpt54.md create mode 100644 docs/plan-v2.md create mode 100644 docs/plan.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..9338e3b --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,27 @@ +# IPWhiteList Copilot Instructions + +## Главное правило +**НЕ ПЕРЕУСЛОЖНЯТЬ.** Это CRUD-сервис на 3 таблицы. Минимум абстракций, минимум папок, минимум зависимостей. + +## Коммуникация +- Продвигаемся вместе шаг за шагом +- ВСЕГДА спрашивать если что-то неясно — не додумывать, не фантазировать +- **НЕ ВРАТЬ.** Если не знаешь — скажи «не знаю» +- Пользователь объяснит устройство личного кабинета облака, Keycloak и т.д. — не придумывать + +## Стек (согласован) +- Python 3.11+ / FastAPI +- PostgreSQL +- Jinja2 + простые HTML-формы (без SPA, без тяжёлого JS) +- Минимум зависимостей + +## Принципы разработки +- Не плодить слои (services/, repositories/, routers/ отдельно) без реальной необходимости +- Сначала ядро (валидация, CRUD), потом UI, потом Keycloak +- Никаких ORM без явного запроса пользователя +- Никакого async без реальной необходимости +- Любое изменение кода — только после явного «делай» от пользователя + +## Процесс +- Сначала уточнить непонятное → предложить простой шаг → сделать → повторить +- Не пытаться сделать всё сразу diff --git a/WhiteIPlist.docx b/WhiteIPlist.docx new file mode 100755 index 0000000000000000000000000000000000000000..12e8fc6b34d57f750795e575ecc338b2c1455c97 GIT binary patch literal 27316 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 w8w~)68v+3Tt*7%C{?G5ypYVR;Kj6Q **Автор:** GitHub Copilot (Gemini 3.1 Pro Preview) +> **Дата:** 2026-05-29 + +--- + +## 1. Анализ предыдущих планов и моё видение + +Я изучил ТЗ и варианты коллег (DeepSeek, Claude, GPT-5.4). +- **DeepSeek** предложил избыточный Async/ORM подход. +- **Claude** упростил до psycopg2 и HTMX, но оставил много "белых пятен" в работе с Keycloak. +- **GPT-5.4** дал отличный продуктовый разбор рисков (гонки, лимиты, нормализация), но оставил проект заблокированным до "уточнения с командой Keycloak". + +**Мой подход (Gemini 3.1 Pro):** +Мы не будем блокировать разработку в ожидании ответов от админов Keycloak. Разночтения с форматами claims (как выглядит админ, как выглядит мульти-аккаунт) мы вынесем в **гибкую конфигурацию (.env)**. Если формат токена изменится, нам не придется править код, мы просто поменяем переменные окружения. +Также мы откажемся от сторонней библиотеки `netaddr`, так как встроенный модуль Python `ipaddress` имеет встроенную функцию `collapse_addresses()`, которая идеально решает задачу агрегации по ТЗ. + +--- + +## 2. Технологический стек + +* **Бэкенд:** FastAPI (Python 3.11+). Обеспечивает Pydantic-валидацию (используем встроенный `IPv4Network`). +* **СУБД:** PostgreSQL. +* **Доступ к данным:** SQLModel (надстройка над SQLAlchemy). Дает удобство ORM без избыточной сложности, синхронный режим. +* **Суммаризация (Агрегация):** Standard Library Python `ipaddress.collapse_addresses`. +* **Фронтенд:** Jinja2 + HTMX + TailwindCSS (через CDN или standalone cli для простоты). +* **Авторизация:** Dependency injection в FastAPI для OIDC/JWT. Заглушка (MockOIDC) для локальной разработки. + +--- + +## 3. Решение узких мест (Архитектурные решения) + +**Проблема 1: Как определять администратора и принадлежность к компаниям из токена?** +*Решение:* Выносим структуру токена в `Config`. +```env +OIDC_COMPANY_CLAIM="client_id" # Может быть списком или строкой, обработаем оба варианта +OIDC_ADMIN_CLAIM_KEY="roles" +OIDC_ADMIN_CLAIM_VALUE="whitelist-admin" +``` +Код будет динамически проверять, совпали ли значения, указанные в конфиге, с данными из токена. + +**Проблема 2: Гонки при записи и лимиты** +*Решение:* +1. Проверка лимита делается запросом `SELECT count(*) FROM entries WHERE company_id = X AND deleted_at IS NULL FOR UPDATE`. Блокировка на чтение защитит транзакцию от гонок. +2. В БД создадим уникальный индекс `CREATE UNIQUE INDEX unique_active_cidr ON entries (company_id, value_cidr) WHERE deleted_at IS NULL;` для защиты от дубликатов на уровне СУБД. + +**Проблема 3: Пересечения внутри компании** +*Решение:* Перед INSERT/UPDATE выгружаем все активные подсети компании и проверяем через `new_cidr.overlaps(existing_cidr)`. Выгрузка делается в рамках заблокированной транзакции (см. пункт выше). + +--- + +## 4. Поэтапный план реализации + +### Этап 1. Ядро и База данных (Бизнес-логика) +- [ ] Инициализация FastAPI проекта, настройка SQLModel. +- [ ] Определение сущностей БД: `Company`, `WhitelistEntry` (CIDR хранится как `String`, но Pydantic проверяет `IPv4Network`), `AuditLog`. +- [ ] Валидаторы (запрещенные списки Приложения А, маска /32 - /22). Нормализация `strict=False` в `ipaddress`, чтобы `192.168.1.5/24` автоматически перегонялось в `192.168.1.0/24`. +- [ ] Написание Unit-тестов для валидаторов. + +### Этап 2. Слой данных (CRUD) и защита от гонок +- [ ] Сервис создания записи: проверка макс. лимита (15 по умолчанию или `company.custom_limit`), поиск пересечений, запись AuditLog. +- [ ] Сервис Soft-Delete и редактирования. +- [ ] Тесты CRUD-сервисов. + +### Этап 3. Авторизация (Keycloak) +- [ ] Настройка `auth/jwt.py` для валидации RS256 подписей. +- [ ] Парсинг токена на основе гибких правил из `.env` (роли, список компаний). +- [ ] FastAPI Security Depends (`get_current_user`). + +### Этап 4. Внешний API (Export Endpoint) +- [ ] Роут `GET /api/v1/export/whitelist.txt`. +- [ ] Выборка всех `value_cidr` где `deleted_at IS NULL`. +- [ ] Агрегация: `[str(net) for net in ipaddress.collapse_addresses(net_list)]`. +- [ ] Middleware для ограничения доступа по списку разрешенных `EXPORT_ALLOWED_IPS`. + +### Этап 5. Пользовательский Интерфейс (UI) +- [ ] Jinja2 шаблоны и использование HTMX для добавления/удаления строк таблицы без перезагрузки всей страницы. +- [ ] Отображение предупреждений (нормализация, превышение лимита). +- [ ] Селектор активной компании. +- [ ] Панель администратора (все компании, настройка `custom_limit`, просмотр аудита). + +### Этап 6. Инфраструктура +- [ ] Dockerfile. +- [ ] docker-compose окружение (App + Postgres). +- [ ] Документация (README с описанием переменных окружения). \ No newline at end of file diff --git a/docs/plan-gpt54.md b/docs/plan-gpt54.md new file mode 100644 index 0000000..58caaf6 --- /dev/null +++ b/docs/plan-gpt54.md @@ -0,0 +1,529 @@ +# Полный план реализации — IP WhiteList Microservice + +> Автор: GitHub Copilot (GPT-5.4) +> Дата: 2026-05-29 +> Статус: рабочий план для реализации + +## 1. Цель системы + +Нужно реализовать внутренний веб-сервис, в котором клиенты облачного провайдера смогут самостоятельно управлять доверенными IPv4-адресами и подсетями. Эти записи должны исключаться из блокировки во время DDoS-митигции. + +Сервис должен решать 3 задачи: + +1. Дать клиенту self-service интерфейс для управления whitelist. +2. Дать администраторам и сетевым инженерам централизованный просмотр и контроль. +3. Отдавать агрегированный экспорт всех активных записей в текстовом формате для внешних систем фильтрации. + +## 2. Что именно должно быть в первой рабочей версии + +Первая версия должна включать: + +1. Авторизацию через Keycloak OIDC. +2. Разделение прав client/admin. +3. Поддержку одной или нескольких компаний у пользователя. +4. Таблицу whitelist-записей. +5. Создание, редактирование и soft delete записей. +6. Проверку лимитов по компаниям. +7. Аудит всех изменяющих действий. +8. Экспорт агрегированного списка активных CIDR в text/plain. +9. Серверную валидацию IPv4 и CIDR по правилам ТЗ. +10. Клиентскую валидацию формы для UX. + +## 3. Обязательные уточнения до начала интеграции с Keycloak + +До кодирования OIDC-части нужно получить точные ответы на 3 вопроса: + +1. Какой claim или role означает администратора. +Сейчас в ТЗ сказано: clientId = WZ01112 и отдельный чек-бокс. Нужно точно знать, во что это превращается в токене. + +2. Как кодируется принадлежность к нескольким компаниям. +В ТЗ упомянут мультикомпанейный сценарий, но в claims перечислен только clientID. Нужно уточнить, это строка, массив, groups или другой формат. + +3. Где именно ограничивается внешний экспортный endpoint по IP. +Нужно решить, это делает приложение, Nginx/Ingress, или оба уровня сразу. + +Без этих 3 ответов можно делать каркас, БД, валидацию, CRUD и UI, но нельзя окончательно зафиксировать auth-слой. + +## 4. Рекомендуемый стек + +### Бэкенд + +- Python 3.11+ +- FastAPI +- Uvicorn + +Причина: Python понятен команде, FastAPI даёт простой роутинг, типизацию, dependency injection и удобную основу для API и HTML-эндпоинтов. + +### База данных + +- PostgreSQL +- psycopg2-binary +- Alembic для миграций + +Причина: нужны надёжные транзакции, аудит, индексы и понятный деплой. Здесь нет выгоды от тяжёлой ORM-магии, поэтому лучше простой и читаемый SQL. + +### UI + +- Jinja2 +- обычные HTML-формы +- HTMX по желанию, только если реально упрощает частичные обновления +- минимальный JS для inline-валидации и уведомлений + +Причина: задача не требует SPA. Простая серверная отрисовка снизит сложность и упростит поддержку. + +### Авторизация + +- Keycloak OIDC +- JWT-проверка по JWKS +- отдельный dev-режим без Keycloak только для локальной разработки + +### Работа с IP + +- стандартный модуль ipaddress +- netaddr только если стандартной библиотеки окажется недостаточно для агрегирования + +Примечание: начать можно вообще без netaddr. Для суммаризации сначала стоит проверить, хватает ли ipaddress.collapse_addresses. + +## 5. Архитектурные принципы + +1. Синхронный код по умолчанию. +Для этой системы async не нужен. Он только повысит стоимость поддержки. + +2. Серверная валидация является источником истины. +Клиентская валидация только помогает пользователю. + +3. Аудит append-only. +Записи аудита нельзя изменять и удалять. + +4. Все проверки прав и лимитов выполняются на сервере внутри транзакций. + +5. Soft delete обязателен для whitelist-записей. + +6. Значение лимита по умолчанию должно меняться через конфиг без пересборки. + +7. Dev-заглушка авторизации должна быть жёстко отключаемой в production. + +## 6. Предлагаемая структура проекта + +```text +IPWhiteList/ +├── app/ +│ ├── main.py +│ ├── config.py +│ ├── db.py +│ ├── security.py +│ ├── validators.py +│ ├── cidr_utils.py +│ ├── services/ +│ │ ├── entries.py +│ │ ├── companies.py +│ │ ├── audit.py +│ │ └── export.py +│ ├── repositories/ +│ │ ├── entries.py +│ │ ├── companies.py +│ │ └── audit.py +│ ├── routers/ +│ │ ├── ui.py +│ │ ├── admin.py +│ │ └── export.py +│ ├── auth/ +│ │ ├── oidc.py +│ │ ├── dev_stub.py +│ │ └── deps.py +│ └── templates/ +│ ├── base.html +│ ├── index.html +│ ├── entry_form.html +│ ├── login_error.html +│ └── admin/ +│ ├── audit.html +│ └── limits.html +├── static/ +│ └── style.css +├── migrations/ +│ └── versions/ +├── tests/ +│ ├── test_validators.py +│ ├── test_entries_service.py +│ ├── test_export.py +│ └── test_auth_mapping.py +├── docs/ +│ ├── plan.md +│ ├── plan-v2.md +│ └── plan-gpt54.md +├── requirements.txt +├── .env.example +├── alembic.ini +├── docker-compose.yml +├── Dockerfile +└── README.md +``` + +## 7. Модель данных + +### Таблица companies + +Назначение: хранение компаний и переопределённых лимитов. + +Поля: + +1. id +2. client_id +3. name +4. custom_limit +5. created_at +6. updated_at + +Правила: + +1. client_id уникален. +2. custom_limit может быть null, тогда используется глобальный лимит. + +### Таблица whitelist_entries + +Назначение: активные и удалённые whitelist-записи. + +Поля: + +1. id +2. company_id +3. value_cidr +4. comment +5. created_by +6. created_at +7. updated_by +8. updated_at +9. deleted_by +10. deleted_at + +Правила: + +1. value_cidr хранится только в нормализованном виде. +2. deleted_at is null означает активную запись. +3. comment ограничен 255 символами. + +### Таблица audit_log + +Назначение: неизменяемый журнал действий. + +Поля: + +1. id +2. user_email +3. company_id +4. action +5. old_value +6. new_value +7. created_at + +Дополнительно желательно хранить: + +1. target_entry_id +2. request_id +3. actor_role + +Это не противоречит ТЗ и упростит разбор инцидентов. + +## 8. Правила авторизации и ролей + +### Клиент + +1. Видит только записи своей активной компании. +2. Может создавать, редактировать и удалять записи только в допустимом контексте компании. +3. Может переключать активную компанию, если в токене действительно есть доступ к нескольким компаниям. + +### Администратор + +1. Видит записи всех компаний. +2. Может менять записи всех компаний. +3. Может видеть удалённые записи. +4. Может смотреть аудит. +5. Может менять custom_limit для компании. + +### Что нужно реализовать в коде + +1. Унифицированную модель текущего пользователя. +2. Отдельную функцию маппинга claims в внутреннюю роль. +3. Жёсткие проверки прав на уровне service-слоя, не только роутеров. + +## 9. Валидация IPv4 и CIDR + +Эта часть критична. Её нужно делать одной из первых и сразу покрывать тестами. + +### Поддерживаемый ввод + +1. Одиночный IPv4 адрес, который трактуется как /32. +2. IPv4 подсеть в CIDR нотации. + +### Запрещённый ввод + +1. IPv6. +2. Доменное имя. +3. Маска шире допустимой. +4. Любые private или special ranges из приложения А. + +### Правила маски + +Допустимы только /22 ... /32. + +### Нормализация + +Если пользователь ввёл адрес с host-битами, сервис должен: + +1. Нормализовать значение до адреса сети. +2. Сохранить нормализованное значение. +3. Вернуть пользователю явное сообщение, что адрес был нормализован. + +### Проверки в пределах компании + +1. Запрет полного дубликата активной записи. +2. Запрет любого пересечения активной записи с существующими активными записями той же компании. +3. Между разными компаниями пересечения допускаются. + +### Список запрещённых диапазонов + +Нужно захардкодить как конфигурацию приложения и покрыть тестами: + +1. 10.0.0.0/8 +2. 172.16.0.0/12 +3. 192.168.0.0/16 +4. 100.64.0.0/10 +5. 127.0.0.0/8 +6. 169.254.0.0/16 +7. 192.0.0.0/24 +8. 192.0.2.0/24 +9. 198.51.100.0/24 +10. 203.0.113.0/24 +11. 198.18.0.0/15 +12. 224.0.0.0/4 +13. 240.0.0.0/4 +14. 255.255.255.255/32 + +## 10. Лимиты и конкурентность + +Это важное место, которого обычно недооценивают. + +### Правила лимитов + +1. Есть глобальный DEFAULT_LIMIT, по умолчанию 15. +2. Для компании может быть custom_limit. +3. При снижении лимита ниже текущего количества записей существующие записи не удаляются. +4. Пока число активных записей больше лимита, новые записи создавать нельзя. + +### Риск гонок + +Если два запроса одновременно создают записи в одной компании, возможны: + +1. Пробитие лимита. +2. Пропуск пересечения. +3. Пропуск дубликата. + +### Что делать + +Операцию создания и обновления записи нужно делать в транзакции с сериализацией логики на уровне компании. Практически это можно решить так: + +1. Брать advisory lock по company_id перед проверками и записью. +2. Либо делать SELECT ... FOR UPDATE по строке компании, если этого достаточно для вашей схемы доступа. + +Для первой версии я бы выбрал advisory lock по company_id. Это проще и надёжнее для бизнес-ограничений, которые нельзя полностью выразить обычным unique index. + +## 11. Экспорт агрегированного списка + +### Требования + +1. В экспорт попадают только активные записи. +2. Данные берутся по всем компаниям. +3. Пересечения между компаниями допустимы на уровне хранения, но в export должны агрегироваться в минимальный набор CIDR. +4. Формат ответа: text/plain. +5. Одна строка = один CIDR. + +### Что нужно зафиксировать реализационно + +1. Результат должен быть отсортирован для стабильности. +2. В ответе должен быть завершающий перевод строки. +3. Content-Type должен быть text/plain; charset=utf-8. +4. Желательно отдавать Content-Disposition с понятным именем файла. + +### Защита endpoint + +Если endpoint на старте работает без auth, то доступ надо ограничить минимум одним из способов: + +1. Проверка client IP в приложении. +2. Ограничение на reverse proxy. +3. Оба сразу. + +## 12. UI-потоки + +### Экран клиента + +Должны быть: + +1. Селектор активной компании, если компаний несколько. +2. Таблица записей. +3. Индикатор использовано X из N. +4. Форма создания записи. +5. Возможность редактирования. +6. Возможность soft delete. + +### Экран администратора + +Должны быть: + +1. Таблица по всем компаниям. +2. Фильтр по компании. +3. Фильтр показа удалённых записей. +4. Просмотр журнала аудита. +5. Управление лимитами компании. + +### UX-детали, которые обязательно сделать + +1. Понятные сообщения об ошибках валидации. +2. Явное сообщение о нормализации адреса. +3. Явное сообщение о превышении лимита. +4. Явное сообщение о пересечении с существующей записью. + +## 13. Пошаговый план реализации + +### Этап 1. Каркас проекта + +1. Создать структуру каталогов. +2. Подготовить requirements.txt. +3. Подготовить .env.example. +4. Подключить FastAPI, Jinja2, static. +5. Подготовить docker-compose.yml с PostgreSQL. + +Результат этапа: приложение стартует, открывается базовая страница, есть подключение к БД. + +### Этап 2. Схема БД и миграции + +1. Настроить Alembic. +2. Создать initial migration. +3. Поднять таблицы companies, whitelist_entries, audit_log. +4. Добавить нужные индексы. + +Результат этапа: схема БД фиксирована и воспроизводима. + +### Этап 3. Валидатор CIDR + +1. Реализовать разбор IPv4 и CIDR. +2. Реализовать проверку маски. +3. Реализовать нормализацию. +4. Реализовать проверку запрещённых диапазонов. +5. Написать тесты на валидатор. + +Результат этапа: независимый, протестированный модуль бизнес-валидации. + +### Этап 4. Сервисный слой для записей + +1. Реализовать list. +2. Реализовать create. +3. Реализовать update. +4. Реализовать soft delete. +5. Реализовать проверки лимитов, дубликатов и пересечений. +6. Добавить транзакционную защиту от гонок. + +Результат этапа: бизнес-операции работают без UI. + +### Этап 5. Аудит + +1. Добавить запись CREATE. +2. Добавить запись UPDATE со старым и новым состоянием. +3. Добавить запись DELETE. +4. Добавить интерфейс чтения для admin. + +Результат этапа: все изменяющие действия фиксируются. + +### Этап 6. Авторизация + +1. Реализовать dev-заглушку. +2. Реализовать чтение и валидацию JWT из Keycloak. +3. Реализовать преобразование claims в current user. +4. Реализовать проверки client/admin. +5. Реализовать переключение компании. + +Результат этапа: права и контекст пользователя работают сквозным образом. + +### Этап 7. HTML-интерфейс + +1. Реализовать страницу списка. +2. Реализовать формы создания и редактирования. +3. Реализовать soft delete из UI. +4. Реализовать админские экраны. + +Результат этапа: сервис пригоден для ручной эксплуатации. + +### Этап 8. Экспорт + +1. Реализовать сбор всех активных CIDR. +2. Реализовать агрегацию. +3. Реализовать endpoint export. +4. Реализовать сетевое ограничение. + +Результат этапа: внешняя система может забирать текстовый агрегированный whitelist. + +### Этап 9. Финализация + +1. Написать README. +2. Подготовить Dockerfile. +3. Подготовить пример systemd unit при необходимости. +4. Прогнать ручной smoke-test. + +Результат этапа: сервис можно разворачивать и передавать коллегам. + +## 14. Тестовая стратегия + +Минимально обязательные тесты: + +1. Валидный одиночный IPv4 превращается в /32. +2. Валидная подсеть принимается. +3. Host-биты нормализуются. +4. Маски шире допустимой границы отклоняются. +5. IPv6 отклоняется. +6. Все запрещённые диапазоны отклоняются. +7. Дубликат в одной компании запрещён. +8. Пересечение в одной компании запрещено. +9. Тот же CIDR в другой компании разрешён. +10. Soft delete освобождает лимит. +11. Export не включает soft-deleted записи. +12. Export агрегирует CIDR корректно. +13. Client не видит чужие компании. +14. Admin видит все компании. +15. Аудит создаётся для create, update, delete. + +## 15. Что можно отложить после первой версии + +Это не нужно тащить в MVP: + +1. Полноценный SPA. +2. Сложная ORM. +3. WebSocket. +4. Фоновая очередь. +5. Исторические версии записей кроме audit log. +6. Автоматическое уведомление по email. + +## 16. Главные риски проекта + +1. Неясный формат claims из Keycloak. +2. Гонки при одновременном создании записей. +3. Ошибки в трактовке пересечений CIDR. +4. Неправильная нормализация адресов без понятного сообщения пользователю. +5. Слишком раннее усложнение фронтенда. + +## 17. Что я бы делал первым + +Если начинать реализацию прямо сейчас, порядок такой: + +1. Каркас проекта. +2. Схема БД. +3. Валидатор и тесты. +4. Сервис create/update/delete с транзакциями. +5. Только потом UI и Keycloak. + +Это самый безопасный путь: сначала фиксируется ядро бизнес-логики, потом уже внешний слой. + +## 18. Итоговое решение + +За основу реализации стоит брать простой Python/FastAPI сервис с PostgreSQL, синхронной серверной логикой, жёсткой серверной валидацией, транзакционной защитой от гонок и минималистичным HTML UI. + +Главная мысль: сложность здесь не во фронтенде и не в фреймворке, а в корректной реализации правил CIDR, лимитов, ролей и аудита. План должен защищать именно эти части, а не раздувать стек. \ No newline at end of file diff --git a/docs/plan-v2.md b/docs/plan-v2.md new file mode 100644 index 0000000..1f52b11 --- /dev/null +++ b/docs/plan-v2.md @@ -0,0 +1,188 @@ +# План разработки — IP WhiteList Microservice v2 + +> **Автор:** GitHub Copilot (Claude Sonnet 4.6) +> **Дата:** 2026-05-29 + +--- + +## Стек + +| Слой | Технология | Обоснование | +|---|---|---| +| Бэкенд | Python 3.11+ / FastAPI | Коллеги знают Python, авто-документация | +| БД | PostgreSQL | Надёжно, поддерживает аудит и сложные запросы | +| Работа с БД | psycopg2 + сырой SQL | Проще чем ORM, понятно всем, никакой магии | +| Миграции | Alembic | Только для версионирования схемы | +| Фронтенд | Jinja2 + обычные HTML-формы | Без JS-фреймворков, минимум зависимостей | +| Авторизация | Keycloak OIDC (JWT) | Заглушка только в dev через `.env` флаг | +| IP-логика | stdlib `ipaddress` + `netaddr` | Суммаризация CIDR через `netaddr` | + +--- + +## Открытые вопросы (нужно прояснить до кодирования) + +1. **Чекбокс администратора** — ТЗ: admin = `clientId == WZ01112` + «отдельный чек-бокс». Что это: отдельный claim в Keycloak-токене (`is_admin: true`)? Роль? Нужно уточнить у команды Keycloak. +2. **Создание Company в БД** — когда появляется запись: при первом входе пользователя автоматически, или администратор заводит вручную? +3. **Кто потребляет внешний endpoint** — endpoint без авторизации, доступ по IP. Список доверенных IP задаётся конфигом? Nginx ACL? + +--- + +## Файловая структура + +``` +IPWhiteList/ +├── app/ +│ ├── main.py # FastAPI app, роутеры, startup +│ ├── config.py # Настройки из .env (DEFAULT_LIMIT, DEV_MODE, DB_DSN и др.) +│ ├── db.py # psycopg2 connection pool +│ ├── models/ +│ │ └── sql.py # DDL-схема (только для документации, не ORM) +│ ├── validators.py # IPv4/CIDR: формат, маска, серые адреса, нормализация +│ ├── crud/ +│ │ ├── entries.py # CRUD whitelist_entries +│ │ ├── companies.py # Компании и лимиты +│ │ └── audit.py # Запись в audit_log +│ ├── auth/ +│ │ ├── oidc.py # Валидация JWT Keycloak +│ │ ├── stub.py # Dev-заглушка (только при DEV_MODE=true) +│ │ └── deps.py # FastAPI Depends: current_user +│ ├── routers/ +│ │ ├── entries.py # CRUD UI-роуты + HTMX-фрагменты +│ │ ├── admin.py # Аудит, лимиты (только admin) +│ │ └── external.py # GET /api/v1/export — txt-файл +│ └── cidr_utils.py # Суммаризация через netaddr +├── templates/ +│ ├── base.html +│ ├── index.html # Таблица записей + индикатор лимита +│ ├── partials/ +│ │ ├── table.html # HTMX-фрагмент таблицы +│ │ └── form.html # Форма создания/редактирования +│ └── admin/ +│ ├── audit.html +│ └── limits.html +├── static/ +│ └── style.css +├── migrations/ +│ ├── env.py +│ └── versions/ +├── tests/ +│ ├── test_validators.py # Юниты для IPv4-валидации (критично!) +│ └── test_crud.py +├── docs/ +│ ├── plan.md # LEGACY +│ ├── plan-v2.md # Этот файл +│ └── WhiteIPlist.docx # Исходное ТЗ +├── .env.example +├── alembic.ini +├── docker-compose.yml # PostgreSQL для dev +├── requirements.txt +└── README.md +``` + +--- + +## Схема БД + +```sql +-- Компании (создаются автоматически при первом входе или вручную админом — уточнить) +CREATE TABLE companies ( + id SERIAL PRIMARY KEY, + client_id VARCHAR(64) UNIQUE NOT NULL, -- из Keycloak claim + name VARCHAR(255), + custom_limit INTEGER DEFAULT NULL -- NULL = использовать глобальный DEFAULT_LIMIT +); + +-- Whitelist-записи +CREATE TABLE whitelist_entries ( + id SERIAL PRIMARY KEY, + company_id INTEGER NOT NULL REFERENCES companies(id), + value CIDR NOT NULL, -- нормализованный CIDR + comment VARCHAR(255), + created_by VARCHAR(255) NOT NULL, -- email из токена + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_by VARCHAR(255), + updated_at TIMESTAMPTZ, + deleted_by VARCHAR(255), + deleted_at TIMESTAMPTZ -- NULL = активная запись +); + +-- Аудит (только append, без UPDATE/DELETE) +CREATE TABLE audit_log ( + id SERIAL PRIMARY KEY, + user_email VARCHAR(255) NOT NULL, + company_id INTEGER NOT NULL, + action VARCHAR(32) NOT NULL, -- CREATE | UPDATE | DELETE + old_value TEXT, + new_value TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Индексы +CREATE INDEX ON whitelist_entries(company_id) WHERE deleted_at IS NULL; +CREATE INDEX ON audit_log(company_id); +``` + +--- + +## Этапы + +### Этап 1 — Каркас + конфиг +- [ ] Структура папок +- [ ] `requirements.txt`: fastapi, uvicorn, psycopg2-binary, alembic, jinja2, python-jose, netaddr +- [ ] `.env.example` со всеми переменными: `DB_DSN`, `DEFAULT_LIMIT=15`, `DEV_MODE=false`, `KEYCLOAK_URL`, `KEYCLOAK_REALM`, `KEYCLOAK_CLIENT_ID`, `ALLOWED_EXPORT_IPS` +- [ ] `config.py` — читает `.env`, все параметры типизированы +- [ ] `db.py` — psycopg2 connection pool (SimpleConnectionPool) +- [ ] `docker-compose.yml` с PostgreSQL + +### Этап 2 — Миграции (схема БД) +- [ ] Alembic init +- [ ] Initial migration: `companies`, `whitelist_entries`, `audit_log` + индексы +- [ ] Проверка `alembic upgrade head` + +### Этап 3 — Валидатор IPv4 (с тестами) +- [ ] `validators.py`: принимает строку → возвращает нормализованный CIDR или ошибку +- [ ] Проверка формата: одиночный IP или CIDR +- [ ] Проверка маски: /22 – /32 (шире /21 — `ValidationError`) +- [ ] Нормализация host-битов: `192.168.1.5/24` → `192.168.1.0/24` + флаг `was_normalized=True` +- [ ] Запрет серых диапазонов (все из Приложения А ТЗ) +- [ ] `tests/test_validators.py` — покрыть все граничные случаи + +### Этап 4 — CRUD-логика +- [ ] `crud/companies.py`: get_or_create по client_id, get_limit (custom_limit ?? DEFAULT_LIMIT) +- [ ] `crud/entries.py`: список активных, создание (лимит + дубликаты + пересечения), редактирование, soft-delete +- [ ] `crud/audit.py`: append-only запись + +### Этап 5 — Авторизация +- [ ] `auth/oidc.py` — валидация JWT через JWKS Keycloak, извлечение `clientID`, `email`, определение роли +- [ ] Логика роли admin: `clientID == WZ01112` + (claim `is_admin == true` — **уточнить**) +- [ ] `auth/stub.py` — только при `DEV_MODE=true`: читает `X-Dev-User` из заголовка +- [ ] `auth/deps.py` — `Depends(current_user)` для роутеров + +### Этап 6 — Роутеры + UI +- [ ] `routers/entries.py`: список, форма создания, форма редактирования, удаление, переключатель компании +- [ ] `routers/admin.py`: журнал аудита, управление лимитами +- [ ] Шаблоны Jinja2: base.html, index.html, form.html, admin/audit.html, admin/limits.html +- [ ] Индикатор лимита «X из N» на странице +- [ ] Уведомление о нормализации адреса пользователю + +### Этап 7 — Внешний endpoint +- [ ] `GET /api/v1/export` — только активные записи всех компаний +- [ ] Суммаризация через `netaddr.cidr_merge()` +- [ ] Ответ: `text/plain`, одна строка — один CIDR +- [ ] IP-фильтр из `ALLOWED_EXPORT_IPS` (middleware или Depends) + +### Этап 8 — Деплой +- [ ] `Dockerfile` (python:3.11-slim, uvicorn) +- [ ] Systemd unit как альтернатива +- [ ] Nginx конфиг: reverse proxy + location для static +- [ ] README: как поднять с нуля + +--- + +## Ключевые принципы + +- **Синхронный код везде** — никакого async/await. FastAPI поддерживает синхронные роутеры. +- **Серверная валидация — авторитетная**. Клиентская — только UX. +- **`DEV_MODE=true`** — единственный способ обойти Keycloak. В prod недоступен. +- **Audit log — append only**. Никаких UPDATE/DELETE в `audit_log`. +- **Лимит `DEFAULT_LIMIT`** — всегда из `config.py`, который читает `.env`. Без пересборки. diff --git a/docs/plan.md b/docs/plan.md new file mode 100644 index 0000000..5fd3e8b --- /dev/null +++ b/docs/plan.md @@ -0,0 +1,118 @@ +# ~~План разработки — IP WhiteList Microservice~~ [LEGACY] + +> ⚠️ **УСТАРЕЛО.** Этот план содержит ошибки (async ORM, неполные требования, отсутствие тестов). +> Актуальный план: `plan-v2.md` + +> **Автор:** GitHub Copilot (DeepSeek V4 Flash) +> **Дата:** 2026-05-29 + +--- + +## Стек + +| Слой | Технология | +|---|---| +| Бэкенд | Python 3.11+ / FastAPI | +| БД | PostgreSQL | +| ORM | SQLAlchemy (async) + Alembic (миграции) | +| Фронтенд | Jinja2 + HTMX + минимальный CSS | +| Авторизация | Keycloak OIDC (на старте — заглушка/мок) | +| Валидация | Pydantic + встроенный `ipaddress` | + +--- + +## Этапы + +### Этап 1 — Каркас проекта +- [ ] Структура проекта: `app/`, `templates/`, `static/`, `migrations/` +- [ ] `requirements.txt` (FastAPI, SQLAlchemy, asyncpg, Alembic, Jinja2, python-keycloak) +- [ ] Конфигурация (`.env`, `config.py`) +- [ ] `docker-compose.yml` с PostgreSQL + +### Этап 2 — Модели БД и миграции +- [ ] Модель `Company` (id, clientId, name, individual_limit) +- [ ] Модель `WhitelistEntry` (id, company_id, value, comment, created_by, created_at, updated_at, deleted_at, deleted_by) +- [ ] Модель `AuditLog` (id, user_email, company_id, action, old_value, new_value, timestamp) +- [ ] Alembic initial migration + +### Этап 3 — Валидация IPv4 +- [ ] Валидатор: одиночный IPv4 / CIDR +- [ ] Проверка маски: /32 – /22 (шире /21 — отказ) +- [ ] Нормализация host-битов в 0 +- [ ] Запрет серых/приватных диапазонов (Приложение А из ТЗ) +- [ ] Проверка дубликатов и пересечений в пределах компании + +### Этап 4 — CRUD + Бизнес-логика +- [ ] Создание записи (с проверкой лимита) +- [ ] Просмотр таблицы записей (для клиента — свои компании, для админа — все) +- [ ] Редактирование (с повторной валидацией) +- [ ] Soft delete (deleted_at, deleted_by) +- [ ] Лимиты: глобальный default 15, индивидуальный per-company + +### Этап 5 — Аудит +- [ ] Запись всех изменяющих операций в `AuditLog` +- [ ] Просмотр журнала (только админ) + +### Этап 6 — Внешний endpoint +- [ ] `GET /api/v1/whitelist/aggregated` — txt-файл +- [ ] Суммаризация (агрегация) CIDR всех компаний +- [ ] Только активные (не soft-deleted) записи + +### Этап 7 — Авторизация (заглушка → Keycloak) +- [ ] Заглушка: header `X-Client-ID`, `X-User-Email`, `X-Role` +- [ ] Роли: client / admin +- [ ] Переключатель компаний (для пользователей в нескольких компаниях) +- [ ] Позже: полноценный OIDC через Keycloak + +### Этап 8 — UI (Jinja2 + HTMX) +- [ ] Страница входа / редирект на Keycloak +- [ ] Таблица записей с фильтрами +- [ ] Форма создания/редактирования (с клиентской валидацией) +- [ ] Индикатор лимита: «использовано X из N» +- [ ] Админка: фильтр по компаниям, просмотр удалённых, журнал аудита + +### Этап 9 — Деплой +- [ ] Systemd unit / Dockerfile +- [ ] Nginx reverse proxy (если нужно) +- [ ] CI/CD или ручная инструкция + +--- + +## Файловая структура (план) + +``` +IPWhiteList/ +├── app/ +│ ├── __init__.py +│ ├── main.py # FastAPI app +│ ├── config.py # Настройки из .env +│ ├── models.py # SQLAlchemy модели +│ ├── schemas.py # Pydantic схемы +│ ├── validators.py # IPv4/CIDR валидация +│ ├── crud.py # CRUD-операции +│ ├── auth.py # Авторизация (заглушка → Keycloak) +│ ├── routers/ +│ │ ├── __init__.py +│ │ ├── entries.py # CRUD whitelist +│ │ ├── admin.py # Админка +│ │ └── external.py # Внешний endpoint +│ └── utils.py # Суммаризация CIDR, лимиты +├── templates/ +│ ├── base.html +│ ├── index.html # Таблица записей +│ ├── entry_form.html # Форма создания/редактирования +│ └── admin/ +│ ├── audit.html # Журнал аудита +│ └── limits.html # Управление лимитами +├── static/ +│ └── style.css +├── migrations/ +│ └── alembic/ +├── docs/ +│ ├── plan.md # Этот файл +│ └── WhiteIPlist.docx # Исходное ТЗ +├── .env.example +├── docker-compose.yml +├── requirements.txt +└── README.md +```