Ревью кодовой базы (docs/reviews/2026-09-26-codebase-review.md) и планы по каждой находке:
011 IP уникален в VRF и хранится в самом узком префиксе (addresses.vrf_id, составной FK
с каскадом при переносе VRF, миграция 0007 с остановкой на дублях).
012 Ограничение попыток входа (login_attempts, 429 + Retry-After), выравнивание времени
ответа, журнал без вытеснения анонимными событиями (миграция 0006).
013 Границы пагинации: отрицательные/чрезмерные limit/offset дают 422 вместо 500.
014 Экран адресов: страница свободных адресов арифметикой, пагинация в SQL.
015 Запрет адреса сети/broadcast, загрузка не выше 100 %.
016 Роль по умолчанию — viewer.
017 Проверка JWT_SECRET/ADMIN_PASSWORD при старте.
018 null в PATCH очищает текстовые поля; нейтральный текст конфликта БД.
019 Пакетная загрузка в списках вместо N+1.
020 Автоназначение адреса вне вложенных префиксов, с блокировкой префикса.
021 Advisory-lock при снятии прав администратора, уникальный lower(username) (миграция 0008).
022 Контейнер не от root, healthcheck, блокировка миграций, requirements.lock.
023 Экранирование LIKE, журнал отказов очистки, заголовки безопасности, учёт force-удаления,
отзыв токенов при смене пароля (claim pv, миграция 0005).
024 Исправление находок ревью 011-023 (docs/reviews/2026-09-26-changes-011-023-review.md):
сериализация попыток входа, запрет переноса адресов в адрес сети/broadcast, журнал входов,
запрет смены своего пароля через PATCH, валидация PATCH устройства, обновлён тест токенов.
Тесты: 14 passed. Документация: README.md, docs/changes/011-024, docs/reviews.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
116 lines
5.0 KiB
Python
116 lines
5.0 KiB
Python
import asyncio
|
|
import logging
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
|
|
from fastapi import APIRouter, FastAPI, HTTPException, Request
|
|
from fastapi.exceptions import RequestValidationError
|
|
from fastapi.responses import JSONResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from sqlalchemy import select
|
|
from sqlalchemy.exc import IntegrityError
|
|
|
|
from app.api.v1 import auth, journal, overview, prefixes, refs, users
|
|
from app.config import settings, validate_secrets
|
|
from app.db import SessionLocal
|
|
from app.models import DeviceType, Role, User
|
|
from app.request_context import RequestContextMiddleware
|
|
from app.rotation import rotation_loop
|
|
from app.security import hash_password
|
|
|
|
validate_secrets() # приложение не стартует с небезопасной конфигурацией (изменение 017)
|
|
|
|
log = logging.getLogger("ipam")
|
|
DEFAULT_TYPES = ["Сервер", "Сетевое оборудование", "Сетевое хранилище", "Рабочая станция", "Другое"]
|
|
|
|
|
|
def seed():
|
|
with SessionLocal() as db:
|
|
if not db.scalar(select(User.id).limit(1)):
|
|
if settings.admin_password:
|
|
db.add(User(username=settings.admin_username, password_hash=hash_password(settings.admin_password), role=Role.admin))
|
|
else:
|
|
log.warning("В БД нет пользователей и ADMIN_PASSWORD не задан: администратор не создан, войти в UI нельзя")
|
|
if not db.scalar(select(DeviceType.id).limit(1)):
|
|
db.add_all(DeviceType(name=n, is_default=(n == "Другое")) for n in DEFAULT_TYPES)
|
|
db.commit()
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(_: FastAPI):
|
|
seed()
|
|
task = asyncio.create_task(rotation_loop())
|
|
yield
|
|
task.cancel()
|
|
|
|
|
|
CSP = "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self'; frame-ancestors 'none'"
|
|
DOCS_PATHS = ("/docs", "/redoc", "/openapi.json") # Swagger UI грузит ресурсы с CDN — CSP для него не ставим
|
|
|
|
|
|
class SecurityHeadersMiddleware:
|
|
"""ASGI-middleware: заголовки безопасности на все ответы (изменение 023)."""
|
|
|
|
def __init__(self, app):
|
|
self.app = app
|
|
|
|
async def __call__(self, scope, receive, send):
|
|
if scope["type"] != "http":
|
|
return await self.app(scope, receive, send)
|
|
docs = scope["path"].startswith(DOCS_PATHS)
|
|
|
|
async def send_with_headers(message):
|
|
if message["type"] == "http.response.start":
|
|
extra = [(b"x-content-type-options", b"nosniff"), (b"referrer-policy", b"no-referrer")]
|
|
if not docs:
|
|
extra += [(b"content-security-policy", CSP.encode()), (b"x-frame-options", b"DENY")]
|
|
message["headers"] = [*message.get("headers", []), *extra]
|
|
await send(message)
|
|
|
|
await self.app(scope, receive, send_with_headers)
|
|
|
|
|
|
app = FastAPI(title="IPAM Manager API", version="1.0.0", lifespan=lifespan)
|
|
app.add_middleware(RequestContextMiddleware)
|
|
app.add_middleware(SecurityHeadersMiddleware)
|
|
|
|
api = APIRouter(prefix="/api/v1")
|
|
for r in (auth.router, overview.router, refs.router, prefixes.router, journal.router, users.router):
|
|
api.include_router(r)
|
|
app.include_router(api)
|
|
|
|
|
|
@app.exception_handler(HTTPException)
|
|
async def http_error(_: Request, exc: HTTPException):
|
|
extra = exc.detail if isinstance(exc.detail, dict) else {"message": exc.detail} # dict — доп. поля (attempts_left и т.п.)
|
|
return JSONResponse({"code": exc.status_code, "fields": {}, **extra}, status_code=exc.status_code, headers=exc.headers)
|
|
|
|
|
|
@app.exception_handler(IntegrityError)
|
|
async def integrity_error(_: Request, exc: IntegrityError): # страховка: нарушение ограничения БД не должно давать 500
|
|
return JSONResponse({"code": 409, "message": "Конфликт с существующими данными", "fields": {}}, status_code=409)
|
|
|
|
|
|
@app.exception_handler(RequestValidationError)
|
|
async def validation_error(_: Request, exc: RequestValidationError):
|
|
fields = {".".join(str(x) for x in e["loc"][1:]): e["msg"].removeprefix("Value error, ") for e in exc.errors()}
|
|
return JSONResponse({"code": 422, "message": "Ошибка валидации", "fields": fields}, status_code=422)
|
|
|
|
|
|
@app.get("/healthz", include_in_schema=False)
|
|
def healthz():
|
|
return {"status": "ok"}
|
|
|
|
|
|
web = Path(__file__).resolve().parent.parent / "web"
|
|
if web.is_dir():
|
|
class RevalidatedStatic(StaticFiles):
|
|
"""Статика с обязательной ревалидацией по ETag: браузер не держит устаревший UI после обновления."""
|
|
|
|
async def get_response(self, path, scope):
|
|
response = await super().get_response(path, scope)
|
|
response.headers["Cache-Control"] = "no-cache"
|
|
return response
|
|
|
|
app.mount("/", RevalidatedStatic(directory=web, html=True), name="web")
|