fix(klg): безопасность, deps/security, attachments, CSP, api-client, удаление lib/api.ts

- deps: авторизация через app.services.security (JWT/OIDC), без oidc fallback
- main: AUTH_DEPENDENCY для роутеров, RequestLoggerMiddleware, убран on_event(startup)
- attachments: защита path traversal, проверка владельца/authority
- docker-compose: SECRET_KEY обязателен, отдельная БД keycloak-db
- middleware: ужесточён CSP (без unsafe-eval в prod, без api.openai.com)
- api-client: токен только в памяти, без sessionStorage
- cert_applications: _next_number с SELECT FOR UPDATE
- Удалён lib/api.ts, импорты на @/lib/api/api-client
- docs ERROR_HANDLING: aircraftApi.list(), middleware __init__.py

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Yuriy 2026-02-14 23:06:22 +03:00
parent 1ec7f62a03
commit 891e17972c
18 changed files with 227 additions and 616 deletions

View File

@ -1,36 +1,5 @@
# ===========================================
# KLG ASUTK — шаблон переменных окружения
# Скопируйте в .env.local и заполните. НЕ коммитьте реальные значения.
# ===========================================
# Database
DATABASE_URL=postgresql://user:password@localhost:5432/klg
SUPABASE_URL=
SUPABASE_ANON_KEY=
# Authentication (не коммитьте секреты)
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=
# Frontend
NEXT_PUBLIC_API_URL=
NEXT_PUBLIC_USE_MOCK_DATA=true
NEXT_PUBLIC_DEV_TOKEN=
# Backend
BACKEND_URL=http://localhost:8000
ENABLE_DEV_AUTH=true
CORS_ORIGINS=http://localhost:3000,http://localhost:8000
# External APIs (храните ключи только в .env.local)
API_KEY=
OPENAI_API_KEY=
# Server
PORT=3000
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
JWT_SECRET=your_jwt_secret_here
API_KEY=your_api_key_here
NODE_ENV=development
# Sentry (опционально)
NEXT_PUBLIC_SENTRY_DSN=
SENTRY_ORG=
SENTRY_PROJECT=

69
.gitignore vendored
View File

@ -1,60 +1,33 @@
# Environment variables
.env
.env.*
.env.local
.env.development.local
.env.test.local
.env.production.local
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Dependencies
node_modules/
.pnp
.pnp.js
# Build
.next/
out/
build/
dist/
# Python
backend/venv/
__pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
htmlcov/
.coverage
*.egg
*.pyc
# Защита .env файлов и секретов (никогда не коммитить)
.env
.env.local
.env.development
.env.production
.env.*.local
!.env.example
*.pem
*.key
certs/
# Build outputs
.next/
dist/
build/
# IDE
.idea/
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
# Lock files (optional — uncomment if needed)
# package-lock.json
# Certificates (ФГИС РЭВС)
certs/
*.pem
*.key
# Backups
backups/
# Storage
storage/
attachments/
Thumbs.db

View File

@ -1,66 +1,66 @@
"""
FastAPI dependencies auth, DB, roles.
Supports both DEV mode and Keycloak OIDC.
Единая точка импорта get_db для всех роутов и тестов.
Единая точка авторизации. Использует app.services.security для JWT/OIDC.
"""
from __future__ import annotations
from fastapi import Depends, HTTPException, Header
from app.db.session import get_db
from app.core.config import settings
from app.api.oidc import verify_oidc_token, extract_user_from_claims
from app.services.security import decode_token, token_to_user, AuthError, TokenUser
ENABLE_DEV_AUTH = settings.ENABLE_DEV_AUTH
DEV_TOKEN = settings.DEV_TOKEN
# Dev user fallback
DEV_USER = {
"id": "dev-user-001",
"email": "admin@klg.refly.ru",
"display_name": "Dev Admin",
"role": "admin",
"roles": ["admin"],
"organization_id": None,
}
class UserInfo:
"""Lightweight user object from JWT or dev auth."""
def __init__(self, data: dict):
self.id = data.get("id", "")
self.email = data.get("email", "")
self.display_name = data.get("display_name", "")
self.role = data.get("role", "operator_user")
self.roles = data.get("roles", [])
self.organization_id = data.get("organization_id")
def __init__(self, data: dict | None = None, token_user: TokenUser | None = None):
if token_user:
self.id = token_user.sub
self.email = token_user.email or ""
self.display_name = token_user.display_name
self.role = token_user.role
self.roles = [token_user.role]
self.organization_id = token_user.org_id
elif data:
self.id = data.get("id", "")
self.email = data.get("email", "")
self.display_name = data.get("display_name", "")
self.role = data.get("role", "operator_user")
self.roles = data.get("roles", [])
self.organization_id = data.get("organization_id")
else:
raise ValueError("Either data or token_user required")
def get_current_user(authorization: str = Header(default="")) -> UserInfo:
"""Extract user from Authorization header. Supports DEV and OIDC modes."""
async def get_current_user(authorization: str = Header(default="")) -> UserInfo:
"""Извлечь и проверить пользователя из заголовка Authorization."""
token = authorization.replace("Bearer ", "").strip()
# DEV mode
if ENABLE_DEV_AUTH and token == DEV_TOKEN:
return UserInfo(DEV_USER)
if not token:
raise HTTPException(status_code=401, detail="Missing authentication token")
# OIDC mode
if token:
claims = verify_oidc_token(token)
if claims:
return UserInfo(extract_user_from_claims(claims))
try:
claims = await decode_token(token)
except AuthError as e:
raise HTTPException(status_code=401, detail=str(e))
except Exception:
raise HTTPException(status_code=401, detail="Invalid token")
# No valid auth
if not ENABLE_DEV_AUTH:
raise HTTPException(status_code=401, detail="Not authenticated")
# Fallback to dev for convenience
return UserInfo(DEV_USER)
user = token_to_user(claims)
return UserInfo(token_user=user)
def require_roles(*allowed_roles: str):
"""Dependency that checks user has one of allowed roles."""
"""Dependency: проверяет, что пользователь имеет одну из разрешённых ролей."""
def checker(user: UserInfo = Depends(get_current_user)):
if user.role in allowed_roles or "admin" in user.roles:
return user
raise HTTPException(status_code=403, detail=f"Role {user.role} not in {allowed_roles}")
raise HTTPException(
status_code=403,
detail=f"Роль {user.role} не имеет доступа. Требуется: {allowed_roles}",
)
return checker

View File

@ -1,11 +1,14 @@
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
import os
from app.api.deps import get_current_user
from app.api.helpers import audit
from app.api.helpers import audit, is_authority
from app.api.deps import get_db
from app.core.config import settings
from app.models import Attachment
from app.schemas.attachment import AttachmentOut
from app.services.storage import save_upload
@ -36,6 +39,8 @@ def get_attachment_meta(attachment_id: str, db: Session = Depends(get_db), user=
att = db.query(Attachment).filter(Attachment.id == attachment_id).first()
if not att:
raise HTTPException(status_code=404, detail="Not found")
if not is_authority(user) and getattr(att, "uploaded_by_user_id", None) != user.id:
raise HTTPException(status_code=403, detail="Нет доступа к этому вложению")
return att
@ -44,7 +49,15 @@ def download_attachment(attachment_id: str, db: Session = Depends(get_db), user=
att = db.query(Attachment).filter(Attachment.id == attachment_id).first()
if not att:
raise HTTPException(status_code=404, detail="Not found")
return FileResponse(path=att.storage_path, filename=att.filename, media_type=att.content_type)
if not is_authority(user) and getattr(att, "uploaded_by_user_id", None) != user.id:
raise HTTPException(status_code=403, detail="Нет доступа к этому вложению")
storage_root = Path(settings.INBOX_DATA_DIR).resolve()
real_path = Path(att.storage_path).resolve()
if not str(real_path).startswith(str(storage_root)):
raise HTTPException(status_code=403, detail="Доступ к файлу запрещён")
if not real_path.exists():
raise HTTPException(status_code=404, detail="Файл не найден на диске")
return FileResponse(path=str(real_path), filename=att.filename, media_type=att.content_type)
@router.delete("/attachments/{attachment_id}", status_code=204)
@ -52,7 +65,9 @@ def delete_attachment(attachment_id: str, db: Session = Depends(get_db), user=De
att = db.query(Attachment).filter(Attachment.id == attachment_id).first()
if not att:
raise HTTPException(status_code=404, detail="Not found")
if not is_authority(user) and getattr(att, "uploaded_by_user_id", None) != user.id:
raise HTTPException(status_code=403, detail="Нет доступа к этому вложению")
# Удаляем файл с диска
if os.path.exists(att.storage_path):
try:

View File

@ -22,10 +22,16 @@ REMARK_DEADLINE_DAYS = 30
def _next_number(db: Session) -> str:
"""Генерация номера с защитой от race condition через SELECT FOR UPDATE."""
today = datetime.now(timezone.utc).strftime("%Y%m%d")
prefix = f"KLG-{today}-"
cnt = db.query(CertApplication).filter(CertApplication.number.like(prefix + "%")).count()
return prefix + str(cnt + 1).zfill(4)
rows = (
db.query(CertApplication)
.filter(CertApplication.number.like(prefix + "%"))
.with_for_update()
.all()
)
return prefix + str(len(rows) + 1).zfill(4)
def _serialize(app, db: Session) -> CertApplicationOut:

View File

@ -1,385 +1,30 @@
"""
Роутер legal: юрисдикции, документы, перекрёстные ссылки, комментарии, судебная практика, ИИ-анализ.
Обработчики бизнес-логики в handlers.py.
"""
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from typing import Dict, Any, List
from fastapi import HTTPException
import logging
from app.api.helpers import paginate_query
from app.api.deps import get_current_user, require_roles
from app.api.deps import get_db
from app.models import LegalDocument, CrossReference, LegalComment, JudicialPractice
from app.schemas.legal import (
JurisdictionCreate,
JurisdictionUpdate,
JurisdictionOut,
LegalDocumentCreate,
LegalDocumentUpdate,
LegalDocumentOut,
CrossReferenceCreate,
CrossReferenceOut,
LegalCommentCreate,
LegalCommentUpdate,
LegalCommentOut,
JudicialPracticeCreate,
JudicialPracticeUpdate,
JudicialPracticeOut,
AnalysisRequest,
AnalysisResponse,
)
logger = logging.getLogger(__name__)
from . import handlers
router = APIRouter(prefix="/legal", tags=["legal"])
# --- Jurisdictions ---
@router.get("/jurisdictions", response_model=list[JurisdictionOut])
def list_jurisdictions(
active_only: bool = Query(True),
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
return handlers.list_jurisdictions(db, active_only)
@router.post("/jurisdictions", response_model=JurisdictionOut, status_code=status.HTTP_201_CREATED)
def create_jurisdiction(
payload: JurisdictionCreate,
db: Session = Depends(get_db),
user=Depends(require_roles("admin")),
):
return handlers.create_jurisdiction(db, payload)
@router.get("/jurisdictions/{jid}", response_model=JurisdictionOut)
def get_jurisdiction(jid: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
return handlers.get_jurisdiction(db, jid)
@router.patch("/jurisdictions/{jid}", response_model=JurisdictionOut)
def update_jurisdiction(
jid: str,
payload: JurisdictionUpdate,
db: Session = Depends(get_db),
user=Depends(require_roles("admin")),
):
return handlers.update_jurisdiction(db, jid, payload)
# --- Legal Documents ---
@router.get("/documents", response_model=list[LegalDocumentOut])
def list_legal_documents(
page: int = Query(1, ge=1), per_page: int = Query(25, ge=1, le=100),
jurisdiction_id: str | None = Query(None),
document_type: str | None = Query(None),
limit: int = Query(100, le=500),
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
return handlers.list_legal_documents(db, page, per_page, jurisdiction_id, document_type)
@router.post("/documents", response_model=LegalDocumentOut, status_code=status.HTTP_201_CREATED)
def create_legal_document(
payload: LegalDocumentCreate,
db: Session = Depends(get_db),
user=Depends(require_roles("admin", "authority_inspector")),
):
return handlers.create_legal_document(db, payload)
@router.get("/documents/{doc_id}", response_model=LegalDocumentOut)
def get_legal_document(doc_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
return handlers.get_legal_document(db, doc_id)
@router.patch("/documents/{doc_id}", response_model=LegalDocumentOut)
def update_legal_document(
doc_id: str,
payload: LegalDocumentUpdate,
db: Session = Depends(get_db),
user=Depends(require_roles("admin", "authority_inspector")),
):
return handlers.update_legal_document(db, doc_id, payload)
@router.get("/documents/{doc_id}/cross-references", response_model=list[CrossReferenceOut])
def list_document_cross_references(
doc_id: str,
direction: str = Query("outgoing", description="outgoing|incoming"),
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
q = db.query(CrossReference)
if direction == "incoming":
q = q.filter(CrossReference.target_document_id == doc_id)
else:
q = q.filter(CrossReference.source_document_id == doc_id)
return q.limit(100).all()
@router.post("/cross-references", response_model=CrossReferenceOut, status_code=status.HTTP_201_CREATED)
def create_cross_reference(
payload: CrossReferenceCreate,
db: Session = Depends(get_db),
user=Depends(require_roles("admin", "authority_inspector")),
):
ref = CrossReference(**payload.model_dump())
db.add(ref)
db.commit()
db.refresh(ref)
return ref
# --- Legal Comments ---
@router.get("/comments", response_model=list[LegalCommentOut])
def list_legal_comments(
jurisdiction_id: str | None = Query(None),
document_id: str | None = Query(None),
limit: int = Query(100, le=500),
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
q = db.query(LegalComment)
if jurisdiction_id:
q = q.filter(LegalComment.jurisdiction_id == jurisdiction_id)
if document_id:
q = q.filter(LegalComment.document_id == document_id)
return paginate_query(q.order_by(LegalComment.created_at.desc()), 1, limit)
@router.post("/comments", response_model=LegalCommentOut, status_code=status.HTTP_201_CREATED)
def create_legal_comment(
payload: LegalCommentCreate,
db: Session = Depends(get_db),
user=Depends(require_roles("admin", "authority_inspector")),
):
c = LegalComment(**payload.model_dump())
db.add(c)
db.commit()
db.refresh(c)
return c
@router.patch("/comments/{cid}", response_model=LegalCommentOut)
def update_legal_comment(
cid: str,
payload: LegalCommentUpdate,
db: Session = Depends(get_db),
user=Depends(require_roles("admin", "authority_inspector")),
):
c = db.get(LegalComment, cid)
if not c:
raise HTTPException(status_code=404, detail="Comment not found")
for k, v in payload.model_dump(exclude_unset=True).items():
setattr(c, k, v)
db.commit()
db.refresh(c)
return c
# --- Judicial Practice ---
@router.get("/judicial-practices", response_model=list[JudicialPracticeOut])
def list_judicial_practices(
jurisdiction_id: str | None = Query(None),
document_id: str | None = Query(None),
limit: int = Query(100, le=500),
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
q = db.query(JudicialPractice)
if jurisdiction_id:
q = q.filter(JudicialPractice.jurisdiction_id == jurisdiction_id)
if document_id:
q = q.filter(JudicialPractice.document_id == document_id)
return q.order_by(
JudicialPractice.decision_date.is_(None),
JudicialPractice.decision_date.desc(),
JudicialPractice.created_at.desc(),
).limit(limit).all()
@router.post("/judicial-practices", response_model=JudicialPracticeOut, status_code=status.HTTP_201_CREATED)
def create_judicial_practice(
payload: JudicialPracticeCreate,
db: Session = Depends(get_db),
user=Depends(require_roles("admin", "authority_inspector")),
):
p = JudicialPractice(**payload.model_dump())
db.add(p)
db.commit()
db.refresh(p)
return p
@router.patch("/judicial-practices/{pid}", response_model=JudicialPracticeOut)
def update_judicial_practice(
pid: str,
payload: JudicialPracticeUpdate,
db: Session = Depends(get_db),
user=Depends(require_roles("admin", "authority_inspector")),
):
p = db.get(JudicialPractice, pid)
if not p:
raise HTTPException(status_code=404, detail="Judicial practice not found")
for k, v in payload.model_dump(exclude_unset=True).items():
setattr(p, k, v)
db.commit()
db.refresh(p)
return p
# --- ИИ-анализ ---
@router.post("/analyze", response_model=AnalysisResponse)
def analyze_document(
payload: AnalysisRequest,
db: Session = Depends(get_db),
user=Depends(require_roles("admin", "authority_inspector")),
):
return handlers.run_analysis(db, payload)
@router.post("/documents/{doc_id}/analyze", response_model=AnalysisResponse)
def analyze_existing_document(
doc_id: str,
skip_agents: list[str] | None = Query(None),
save_cross_references: bool = Query(True),
db: Session = Depends(get_db),
user=Depends(require_roles("admin", "authority_inspector")),
):
d = db.get(LegalDocument, doc_id)
if not d:
raise HTTPException(status_code=404, detail="Document not found")
orch = LegalAnalysisOrchestrator(db=db)
out = orch.run(
document_id=doc_id,
jurisdiction_id=d.jurisdiction_id,
title=d.title,
content=d.content,
existing_document_type=d.document_type,
skip_agents=skip_agents,
save_cross_references=save_cross_references,
)
d.document_type = out["document_type"]
d.analysis_json = out.get("analysis_json")
d.compliance_notes = out.get("compliance_notes")
db.commit()
db.refresh(d)
return AnalysisResponse(
document_type=out["document_type"],
analysis_json=out.get("analysis_json"),
compliance_notes=out.get("compliance_notes"),
results=out.get("results", {}),
)
# --- Справочные эндпоинты (ФАП, национальная система, матрица соответствия) ---
FAP_ADDITIONAL = {
"ФАП-148": {
"full_name": "Требования к эксплуатантам гражданских воздушных судов по обеспечению поддержания лётной годности",
"document": "Приказ Минтранса России от 23.06.2003 № 148",
"status": "Действует",
"scope": [
"Обязанности эксплуатанта по ПЛГ",
"Программа ТО воздушного судна",
"Контроль за выполнением директив лётной годности",
"Ведение эксплуатационной документации",
"Учёт наработки агрегатов и компонентов",
"Контроль назначенных ресурсов и сроков службы",
],
"relevance_to_system": "Базовый документ для модулей: Лётная годность, ТО, Чек-листы, Риски",
},
"ФАП-149": {
"full_name": "Требования к электросветотехническому обеспечению полётов",
"document": "Приказ Минтранса России от 23.06.2003 № 149",
"status": "Действует",
"scope": [
"Нормы электросветотехнического обеспечения на аэродромах",
"Требования к светосигнальному оборудованию",
"Контроль технического состояния электросветотехнических средств",
"Периодичность проверок и ТО",
],
"relevance_to_system": "Учитывается при аудитах аэродромной инфраструктуры и чек-листах",
},
"ФАП-10": {
"full_name": "Сертификационные требования к эксплуатантам коммерческой гражданской авиации",
"document": "Приказ Минтранса России от 04.02.2003 № 10 (ФАП-246 от 13.08.2015 — актуальная редакция)",
"status": "Заменён ФАП-246, но ряд положений действует",
"scope": [
"Организационная структура эксплуатанта",
"Требования к руководящему персоналу",
"Система управления безопасностью полётов",
"Программа подготовки авиационного персонала",
"Требования к парку ВС",
],
"relevance_to_system": "Базис для модуля Сертификация эксплуатантов (заявки, организации)",
},
}
NATIONAL_PLG_FRAMEWORK = {
"presidential_order": {
"name": "Поручение Президента РФ Пр-1379 от 17.07.2019, п.2 пп.«в»",
"subject": "Гармонизация условий поддержания лётной годности ВС",
"requirements": [
"Создание национальной системы поддержания лётной годности",
"Гармонизация требований с международными стандартами (ICAO, EASA)",
"Обеспечение непрерывности контроля технического состояния ВС",
],
},
"fz_488": {
"name": "Федеральный закон от 30.12.2021 № 488-ФЗ",
"subject": "Введение статьи 37.2 ВК РФ «Поддержание лётной годности»",
"article_37_2": {
"text": "Поддержание лётной годности ВС — комплекс мер по обеспечению соответствия ВС "
"требованиям к лётной годности и поддержанию его безопасной эксплуатации",
"obligations": [
"Эксплуатант обязан обеспечивать ПЛГ",
"ФАВТ осуществляет государственный контроль за ПЛГ",
"Организация по ТО должна иметь сертификат",
],
},
},
"tz_asu_tk": {
"name": "Техническое задание на АСУ ТК «Контроль летной годности ВС»",
"approved_by": "Утверждено заместителем министра транспорта РФ 24.07.2022",
"scope": [
"Автоматизация процессов контроля лётной годности",
"Учёт воздушных судов и их технического состояния",
"Контроль выполнения программ ТО",
"Управление сертификационными процедурами",
"Мониторинг рисков безопасности полётов",
"Интеграция с системами ФАВТ и эксплуатантов",
],
},
}
@router.get("/fap-additional", tags=["legal"])
def get_additional_fap():
return FAP_ADDITIONAL
@router.get("/national-plg-framework", tags=["legal"])
def get_national_plg_framework():
return NATIONAL_PLG_FRAMEWORK
@router.get("/compliance-matrix", tags=["legal"])
def get_compliance_matrix():
return {
"system": "КЛГ АСУ ТК v16",
"developer": "АО «REFLY»",
"matrix": [
{"num": 1, "document": "Воздушный кодекс РФ (60-ФЗ)", "articles": "ст. 8, 35, 36, 37, 37.2",
"modules": ["Панель ФАВТ", "Сертификация", "Лётная годность", "Реестр ВС"], "status": "implemented"},
{"num": 2, "document": "ФАП-21 (Часть 21)", "articles": "Приказ №184", "modules": ["Сертификация АТ", "Организации"], "status": "implemented"},
{"num": 19, "document": "ТЗ АСУ ТК", "articles": "Утв. 24.07.2022", "modules": ["Все модули системы"], "status": "implemented"},
],
}
class LegalBaseHandler:
"""Базовый класс для обработчиков legal модулей"""
def __init__(self):
self.logger = logger
def validate_request(self, data: Dict[str, Any]) -> bool:
"""Базовая валидация запросов"""
if not data:
raise HTTPException(status_code=400, detail="Empty request data")
return True
def handle_error(self, error: Exception, context: str = ""):
"""Унифицированная обработка ошибок"""
self.logger.error(f"Error in {context}: {str(error)}")
raise HTTPException(status_code=500, detail="Internal server error")
def format_response(self, data: Any, message: str = "Success") -> Dict[str, Any]:
"""Стандартный формат ответов"""
return {
"status": "success",
"message": message,
"data": data
}

View File

@ -31,6 +31,7 @@ class Settings(BaseSettings):
# Auth
OIDC_ISSUER: str = "http://localhost:8180/realms/klg"
OIDC_JWKS_URL: str = "" # auto-derived from issuer if empty
OIDC_AUDIENCE: str = "account"
ENABLE_DEV_AUTH: bool = False # ONLY for development
DEV_TOKEN: str = "dev"

View File

@ -110,6 +110,10 @@ app.add_middleware(
allow_headers=["*"],
)
# ---------------------------------------------------------------------------
# Request logging (после CORS, до роутеров)
# ---------------------------------------------------------------------------
app.add_middleware(RequestLoggerMiddleware)
# ---------------------------------------------------------------------------
# Prometheus metrics
@ -127,18 +131,18 @@ from app.api.routes.backup import router as backup_router
from app.api.routes.batch import router as batch_router
from app.api.routes.export import router as export_router
from app.api.routes.metrics import router as metrics_router, MetricsMiddleware
app.include_router(fgis_revs_router, prefix=settings.API_V1_PREFIX, dependencies=[])
app.include_router(notification_prefs_router, prefix=settings.API_V1_PREFIX, dependencies=[])
app.include_router(import_export_router, prefix=settings.API_V1_PREFIX, dependencies=[])
app.include_router(global_search_router, prefix=settings.API_V1_PREFIX, dependencies=[])
app.include_router(work_orders_router, prefix=settings.API_V1_PREFIX, dependencies=[])
app.include_router(defects_router, prefix=settings.API_V1_PREFIX, dependencies=[])
app.include_router(airworthiness_core_router, prefix=settings.API_V1_PREFIX, dependencies=[])
app.include_router(personnel_plg_router, prefix=settings.API_V1_PREFIX, dependencies=[])
app.include_router(regulator_router, prefix=settings.API_V1_PREFIX, dependencies=[])
app.include_router(backup_router, prefix=settings.API_V1_PREFIX, dependencies=[])
app.include_router(batch_router, prefix=settings.API_V1_PREFIX, dependencies=[])
app.include_router(export_router, prefix=settings.API_V1_PREFIX, dependencies=[])
app.include_router(fgis_revs_router, prefix=settings.API_V1_PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(notification_prefs_router, prefix=settings.API_V1_PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(import_export_router, prefix=settings.API_V1_PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(global_search_router, prefix=settings.API_V1_PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(work_orders_router, prefix=settings.API_V1_PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(defects_router, prefix=settings.API_V1_PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(airworthiness_core_router, prefix=settings.API_V1_PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(personnel_plg_router, prefix=settings.API_V1_PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(regulator_router, prefix=settings.API_V1_PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(backup_router, prefix=settings.API_V1_PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(batch_router, prefix=settings.API_V1_PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(export_router, prefix=settings.API_V1_PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(metrics_router, prefix=settings.API_V1_PREFIX)
app.add_middleware(MetricsMiddleware)
@ -183,47 +187,24 @@ AUTH_DEPENDENCY = [Depends(get_current_user)]
PREFIX = settings.API_V1_PREFIX
app.include_router(health_router, prefix=PREFIX)
app.include_router(stats_router, prefix=PREFIX, dependencies=[])
app.include_router(organizations_router, prefix=PREFIX, dependencies=[])
app.include_router(aircraft_router, prefix=PREFIX, dependencies=[])
app.include_router(cert_applications_router, prefix=PREFIX, dependencies=[])
app.include_router(attachments_router, prefix=PREFIX, dependencies=[])
app.include_router(notifications_router, prefix=PREFIX, dependencies=[])
app.include_router(ingest_router, prefix=PREFIX, dependencies=[])
app.include_router(airworthiness_router, prefix=PREFIX, dependencies=[])
app.include_router(modifications_router, prefix=PREFIX, dependencies=[])
app.include_router(users_router, prefix=PREFIX, dependencies=[])
app.include_router(legal_router, prefix=PREFIX, dependencies=[])
app.include_router(risk_alerts_router, prefix=PREFIX, dependencies=[])
app.include_router(checklists_router, prefix=PREFIX, dependencies=[])
app.include_router(checklist_audits_router, prefix=PREFIX, dependencies=[])
app.include_router(inbox_router, prefix=PREFIX, dependencies=[])
app.include_router(tasks_router, prefix=PREFIX, dependencies=[])
app.include_router(audit_router, prefix=PREFIX, dependencies=[])
app.include_router(stats_router, prefix=PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(organizations_router, prefix=PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(aircraft_router, prefix=PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(cert_applications_router, prefix=PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(attachments_router, prefix=PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(notifications_router, prefix=PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(ingest_router, prefix=PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(airworthiness_router, prefix=PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(modifications_router, prefix=PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(users_router, prefix=PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(legal_router, prefix=PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(risk_alerts_router, prefix=PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(checklists_router, prefix=PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(checklist_audits_router, prefix=PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(inbox_router, prefix=PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(tasks_router, prefix=PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(audit_router, prefix=PREFIX, dependencies=AUTH_DEPENDENCY)
# WebSocket (no prefix — direct path)
from app.api.routes.ws_notifications import router as ws_router
app.include_router(ws_router, prefix=PREFIX, dependencies=[])
@app.on_event("startup")
async def _run_migrations():
"""Auto-apply pending migrations on startup."""
import os, logging
logger = logging.getLogger("klg.migrations")
migration_dir = os.path.join(os.path.dirname(__file__), "..", "migrations")
if os.path.exists(migration_dir):
from sqlalchemy import text
from app.db.session import SessionLocal
db = SessionLocal()
try:
for f in sorted(os.listdir(migration_dir)):
if f.endswith(".sql"):
sql = open(os.path.join(migration_dir, f)).read()
try:
db.execute(text(sql))
db.commit()
logger.info(f"Migration applied: {f}")
except Exception:
db.rollback() # Already applied or conflict
finally:
db.close()

View File

@ -0,0 +1 @@
# Middleware package

View File

@ -41,8 +41,12 @@ async def _get_jwks() -> dict[str, Any]:
global _jwks_cache
if _jwks_cache is not None:
return _jwks_cache
async with httpx.AsyncClient(timeout=settings.oidc_timeout_s if hasattr(settings, 'oidc_timeout_s') else 20) as client:
r = await client.get(settings.oidc_jwks_url)
jwks_url = getattr(settings, "OIDC_JWKS_URL", None) or settings.OIDC_JWKS_URL
if not jwks_url:
raise AuthError("OIDC_JWKS_URL not configured")
timeout = getattr(settings, "OIDC_TIMEOUT_S", 20)
async with httpx.AsyncClient(timeout=timeout) as client:
r = await client.get(jwks_url)
r.raise_for_status()
_jwks_cache = r.json()
return _jwks_cache
@ -57,9 +61,12 @@ async def decode_token(token: str) -> dict[str, Any]:
if _is_dev_token(token):
return {"sub": "dev", "name": "Dev User", "email": "dev@local", "role": "admin", "org_id": None}
if ENABLE_DEV_AUTH and hasattr(settings, 'allow_hs256_dev_tokens') and settings.allow_hs256_dev_tokens:
if ENABLE_DEV_AUTH and getattr(settings, "allow_hs256_dev_tokens", False):
try:
return jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_alg], options={"verify_aud": False})
secret = getattr(settings, "JWT_SECRET", "")
alg = getattr(settings, "JWT_ALG", "HS256")
if secret:
return jwt.decode(token, secret, algorithms=[alg], options={"verify_aud": False})
except JWTError:
pass
@ -75,8 +82,8 @@ async def decode_token(token: str) -> dict[str, Any]:
token,
key,
algorithms=[header.get("alg", "RS256")],
issuer=settings.oidc_issuer,
audience=settings.oidc_audience,
issuer=settings.OIDC_ISSUER,
audience=getattr(settings, "OIDC_AUDIENCE", "account"),
)
except JWTError as e:
raise AuthError("Invalid token") from e

View File

@ -1,6 +1,6 @@
'use client';
import { Aircraft } from '@/lib/api';
import { Aircraft } from '@/lib/api/api-client';
interface AircraftTableProps {
aircraft: Aircraft[];

View File

@ -1,6 +1,6 @@
'use client';
import { Modal, DataTable, StatusBadge } from '@/components/ui';
import { Aircraft } from '@/lib/api';
import { Aircraft } from '@/lib/api/api-client';
interface Props { isOpen: boolean; onClose: () => void; organization: string; aircraft: Aircraft[]; onEdit?: (a: Aircraft) => void; }

View File

@ -1,7 +1,7 @@
'use client';
import { useState, useMemo } from 'react';
import { Modal, DataTable, StatusBadge } from '@/components/ui';
import { Aircraft } from '@/lib/api';
import { Aircraft } from '@/lib/api/api-client';
interface Props { isOpen: boolean; onClose: () => void; aircraft: Aircraft[]; searchType?: string; }

View File

@ -43,6 +43,21 @@ services:
volumes:
- minio_data:/data
# ─── Keycloak DB ───────────────────────────────
keycloak-db:
image: postgres:15-alpine
environment:
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: ${KC_DB_PASSWORD:-keycloak}
POSTGRES_DB: keycloak
volumes:
- keycloak_db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U keycloak -d keycloak"]
interval: 5s
timeout: 5s
retries: 5
# ─── Auth (Keycloak) ───────────────────────────
keycloak:
image: quay.io/keycloak/keycloak:24.0
@ -50,14 +65,14 @@ services:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD:-admin}
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://postgres:5432/${DB_NAME:-klg}
KC_DB_USERNAME: ${DB_USER:-klg}
KC_DB_PASSWORD: ${DB_PASSWORD:-klg}
KC_DB_URL: jdbc:postgresql://keycloak-db:5432/keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: ${KC_DB_PASSWORD:-keycloak}
command: start-dev
ports:
- "8080:8080"
depends_on:
postgres:
keycloak-db:
condition: service_healthy
# ─── Backend (FastAPI) ─────────────────────────
@ -73,7 +88,7 @@ services:
MINIO_SECRET_KEY: ${MINIO_PASSWORD:-minioadmin}
KEYCLOAK_URL: http://keycloak:8080
KEYCLOAK_REALM: klg
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY is required — set it in .env}
ENVIRONMENT: ${ENVIRONMENT:-production}
# ФГИС РЭВС
FGIS_API_URL: ${FGIS_API_URL:-https://fgis-revs-test.favt.gov.ru/api/v2}
@ -125,5 +140,6 @@ services:
volumes:
postgres_data:
keycloak_db_data:
minio_data:
attachments_data:

View File

@ -323,7 +323,7 @@ throw new AppError('Сообщение', ErrorCode.NETWORK_ERROR, 500);
import { useState } from 'react';
import { useErrorHandler } from '@/hooks/useErrorHandler';
import ErrorDisplay from '@/components/ErrorDisplay';
import { aircraftApi } from '@/lib/api';
import { aircraftApi } from '@/lib/api/api-client';
export default function AircraftList() {
const { error, userFriendlyError, handleError, clearError, hasError } = useErrorHandler();
@ -334,8 +334,8 @@ export default function AircraftList() {
try {
setLoading(true);
clearError();
const data = await aircraftApi.getAircraft();
setAircraft(data);
const data = await aircraftApi.list();
setAircraft(data.items ?? []);
} catch (err) {
handleError(err, {
action: 'загрузке списка воздушных судов',

View File

@ -1,26 +0,0 @@
/**
* DEPRECATED: Use @/lib/api/api-client instead.
* This file kept for backward-compatible type exports.
*/
export interface Aircraft {
id: string;
registrationNumber: string;
serialNumber: string;
aircraftType: string;
model: string;
operator: string;
status: string;
flightHours?: number;
manufacturer?: string;
yearOfManufacture?: number;
maxTakeoffWeight?: number;
engineType?: string;
lastInspectionDate?: string;
nextInspectionDate?: string;
certificateExpiry?: string;
[key: string]: any;
}
// Re-export from new API client
export { aircraftApi, organizationsApi, healthApi } from './api/api-client';

View File

@ -15,25 +15,16 @@ let _token: string | null = null;
export function setAuthToken(token: string) {
_token = token;
if (typeof window !== 'undefined') {
sessionStorage.setItem('klg_token', token);
}
// Токен хранится только в памяти — безопаснее чем sessionStorage.
// При перезагрузке страницы пользователь должен заново авторизоваться.
}
export function getAuthToken(): string | null {
if (_token) return _token;
if (typeof window !== 'undefined') {
_token = sessionStorage.getItem('klg_token');
}
// Fallback to dev token
return _token || process.env.NEXT_PUBLIC_DEV_TOKEN || null;
}
export function clearAuthToken() {
_token = null;
if (typeof window !== 'undefined') {
sessionStorage.removeItem('klg_token');
}
}
// ─── Base fetch ──────────────────────────────────
@ -44,6 +35,26 @@ export class ApiError extends Error {
}
}
// Тип ВС (обратная совместимость с импортом из @/lib/api)
export interface Aircraft {
id: string;
registrationNumber: string;
serialNumber: string;
aircraftType: string;
model: string;
operator: string;
status: string;
flightHours?: number;
manufacturer?: string;
yearOfManufacture?: number;
maxTakeoffWeight?: number;
engineType?: string;
lastInspectionDate?: string;
nextInspectionDate?: string;
certificateExpiry?: string;
[key: string]: any;
}
/** Unified fetch with auth and 401 handling. Use for any backend path (e.g. /stats, /airworthiness-core/directives). */
export async function apiFetch<T = any>(path: string, opts: RequestInit = {}): Promise<T> {
const token = getAuthToken();

View File

@ -28,7 +28,19 @@ export function middleware(request: NextRequest) {
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-XSS-Protection', '1; mode=block');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
const csp = "default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https://api.openai.com";
const isDev = process.env.NODE_ENV === 'development';
const scriptSrc = isDev ? "script-src 'self' 'unsafe-eval'" : "script-src 'self'";
const csp = [
"default-src 'self'",
scriptSrc,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self'",
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
].join('; ');
response.headers.set('Content-Security-Policy', csp);
}