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
DATABASE_URL=postgresql://user:password@localhost:5432/klg DATABASE_URL=postgresql://user:password@localhost:5432/dbname
SUPABASE_URL= JWT_SECRET=your_jwt_secret_here
SUPABASE_ANON_KEY= API_KEY=your_api_key_here
# 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
NODE_ENV=development NODE_ENV=development
# Sentry (опционально)
NEXT_PUBLIC_SENTRY_DSN=
SENTRY_ORG=
SENTRY_PROJECT=

67
.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 # Dependencies
node_modules/ node_modules/
.pnp
.pnp.js
# Build
.next/
out/
build/
dist/
# Python
backend/venv/
__pycache__/ __pycache__/
*.py[cod] *.pyc
*.egg-info/
.pytest_cache/
htmlcov/
.coverage
*.egg
# Защита .env файлов и секретов (никогда не коммитить) # Build outputs
.env .next/
.env.local dist/
.env.development build/
.env.production
.env.*.local
!.env.example
*.pem
*.key
certs/
# IDE # IDE
.idea/
.vscode/ .vscode/
.idea/
*.swp *.swp
*.swo *.swo
# OS # OS
.DS_Store .DS_Store
Thumbs.db 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/

View File

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

View File

@ -1,11 +1,14 @@
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
import os import os
from app.api.deps import get_current_user 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.api.deps import get_db
from app.core.config import settings
from app.models import Attachment from app.models import Attachment
from app.schemas.attachment import AttachmentOut from app.schemas.attachment import AttachmentOut
from app.services.storage import save_upload 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() att = db.query(Attachment).filter(Attachment.id == attachment_id).first()
if not att: if not att:
raise HTTPException(status_code=404, detail="Not found") 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 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() att = db.query(Attachment).filter(Attachment.id == attachment_id).first()
if not att: if not att:
raise HTTPException(status_code=404, detail="Not found") 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) @router.delete("/attachments/{attachment_id}", status_code=204)
@ -52,6 +65,8 @@ def delete_attachment(attachment_id: str, db: Session = Depends(get_db), user=De
att = db.query(Attachment).filter(Attachment.id == attachment_id).first() att = db.query(Attachment).filter(Attachment.id == attachment_id).first()
if not att: if not att:
raise HTTPException(status_code=404, detail="Not found") 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): if os.path.exists(att.storage_path):

View File

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

View File

@ -1,385 +1,30 @@
""" from typing import Dict, Any, List
Роутер legal: юрисдикции, документы, перекрёстные ссылки, комментарии, судебная практика, ИИ-анализ. from fastapi import HTTPException
Обработчики бизнес-логики в handlers.py. import logging
"""
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from app.api.helpers import paginate_query logger = logging.getLogger(__name__)
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,
)
from . import handlers class LegalBaseHandler:
"""Базовый класс для обработчиков legal модулей"""
router = APIRouter(prefix="/legal", tags=["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
# --- Jurisdictions --- 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")
@router.get("/jurisdictions", response_model=list[JurisdictionOut]) def format_response(self, data: Any, message: str = "Success") -> Dict[str, Any]:
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 { return {
"system": "КЛГ АСУ ТК v16", "status": "success",
"developer": "АО «REFLY»", "message": message,
"matrix": [ "data": data
{"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"},
],
} }

View File

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

View File

@ -110,6 +110,10 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
# ---------------------------------------------------------------------------
# Request logging (после CORS, до роутеров)
# ---------------------------------------------------------------------------
app.add_middleware(RequestLoggerMiddleware)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Prometheus metrics # 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.batch import router as batch_router
from app.api.routes.export import router as export_router from app.api.routes.export import router as export_router
from app.api.routes.metrics import router as metrics_router, MetricsMiddleware 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(fgis_revs_router, prefix=settings.API_V1_PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(notification_prefs_router, prefix=settings.API_V1_PREFIX, dependencies=[]) 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=[]) 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=[]) 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=[]) 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=[]) 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=[]) 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=[]) 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=[]) app.include_router(regulator_router, prefix=settings.API_V1_PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(backup_router, prefix=settings.API_V1_PREFIX, dependencies=[]) app.include_router(backup_router, prefix=settings.API_V1_PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(batch_router, prefix=settings.API_V1_PREFIX, dependencies=[]) app.include_router(batch_router, prefix=settings.API_V1_PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(export_router, prefix=settings.API_V1_PREFIX, dependencies=[]) app.include_router(export_router, prefix=settings.API_V1_PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(metrics_router, prefix=settings.API_V1_PREFIX) app.include_router(metrics_router, prefix=settings.API_V1_PREFIX)
app.add_middleware(MetricsMiddleware) app.add_middleware(MetricsMiddleware)
@ -183,47 +187,24 @@ AUTH_DEPENDENCY = [Depends(get_current_user)]
PREFIX = settings.API_V1_PREFIX PREFIX = settings.API_V1_PREFIX
app.include_router(health_router, prefix=PREFIX) app.include_router(health_router, prefix=PREFIX)
app.include_router(stats_router, prefix=PREFIX, dependencies=[]) app.include_router(stats_router, prefix=PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(organizations_router, prefix=PREFIX, dependencies=[]) app.include_router(organizations_router, prefix=PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(aircraft_router, prefix=PREFIX, dependencies=[]) app.include_router(aircraft_router, prefix=PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(cert_applications_router, prefix=PREFIX, dependencies=[]) app.include_router(cert_applications_router, prefix=PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(attachments_router, prefix=PREFIX, dependencies=[]) app.include_router(attachments_router, prefix=PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(notifications_router, prefix=PREFIX, dependencies=[]) app.include_router(notifications_router, prefix=PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(ingest_router, prefix=PREFIX, dependencies=[]) app.include_router(ingest_router, prefix=PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(airworthiness_router, prefix=PREFIX, dependencies=[]) app.include_router(airworthiness_router, prefix=PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(modifications_router, prefix=PREFIX, dependencies=[]) app.include_router(modifications_router, prefix=PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(users_router, prefix=PREFIX, dependencies=[]) app.include_router(users_router, prefix=PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(legal_router, prefix=PREFIX, dependencies=[]) app.include_router(legal_router, prefix=PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(risk_alerts_router, prefix=PREFIX, dependencies=[]) app.include_router(risk_alerts_router, prefix=PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(checklists_router, prefix=PREFIX, dependencies=[]) app.include_router(checklists_router, prefix=PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(checklist_audits_router, prefix=PREFIX, dependencies=[]) app.include_router(checklist_audits_router, prefix=PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(inbox_router, prefix=PREFIX, dependencies=[]) app.include_router(inbox_router, prefix=PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(tasks_router, prefix=PREFIX, dependencies=[]) app.include_router(tasks_router, prefix=PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(audit_router, prefix=PREFIX, dependencies=[]) app.include_router(audit_router, prefix=PREFIX, dependencies=AUTH_DEPENDENCY)
# WebSocket (no prefix — direct path) # WebSocket (no prefix — direct path)
from app.api.routes.ws_notifications import router as ws_router from app.api.routes.ws_notifications import router as ws_router
app.include_router(ws_router, prefix=PREFIX, dependencies=[]) 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 global _jwks_cache
if _jwks_cache is not None: if _jwks_cache is not None:
return _jwks_cache return _jwks_cache
async with httpx.AsyncClient(timeout=settings.oidc_timeout_s if hasattr(settings, 'oidc_timeout_s') else 20) as client: jwks_url = getattr(settings, "OIDC_JWKS_URL", None) or settings.OIDC_JWKS_URL
r = await client.get(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() r.raise_for_status()
_jwks_cache = r.json() _jwks_cache = r.json()
return _jwks_cache return _jwks_cache
@ -57,9 +61,12 @@ async def decode_token(token: str) -> dict[str, Any]:
if _is_dev_token(token): if _is_dev_token(token):
return {"sub": "dev", "name": "Dev User", "email": "dev@local", "role": "admin", "org_id": None} 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: 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: except JWTError:
pass pass
@ -75,8 +82,8 @@ async def decode_token(token: str) -> dict[str, Any]:
token, token,
key, key,
algorithms=[header.get("alg", "RS256")], algorithms=[header.get("alg", "RS256")],
issuer=settings.oidc_issuer, issuer=settings.OIDC_ISSUER,
audience=settings.oidc_audience, audience=getattr(settings, "OIDC_AUDIENCE", "account"),
) )
except JWTError as e: except JWTError as e:
raise AuthError("Invalid token") from e raise AuthError("Invalid token") from e

View File

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

View File

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

View File

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

View File

@ -43,6 +43,21 @@ services:
volumes: volumes:
- minio_data:/data - 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) ─────────────────────────── # ─── Auth (Keycloak) ───────────────────────────
keycloak: keycloak:
image: quay.io/keycloak/keycloak:24.0 image: quay.io/keycloak/keycloak:24.0
@ -50,14 +65,14 @@ services:
KEYCLOAK_ADMIN: admin KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD:-admin} KEYCLOAK_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD:-admin}
KC_DB: postgres KC_DB: postgres
KC_DB_URL: jdbc:postgresql://postgres:5432/${DB_NAME:-klg} KC_DB_URL: jdbc:postgresql://keycloak-db:5432/keycloak
KC_DB_USERNAME: ${DB_USER:-klg} KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: ${DB_PASSWORD:-klg} KC_DB_PASSWORD: ${KC_DB_PASSWORD:-keycloak}
command: start-dev command: start-dev
ports: ports:
- "8080:8080" - "8080:8080"
depends_on: depends_on:
postgres: keycloak-db:
condition: service_healthy condition: service_healthy
# ─── Backend (FastAPI) ───────────────────────── # ─── Backend (FastAPI) ─────────────────────────
@ -73,7 +88,7 @@ services:
MINIO_SECRET_KEY: ${MINIO_PASSWORD:-minioadmin} MINIO_SECRET_KEY: ${MINIO_PASSWORD:-minioadmin}
KEYCLOAK_URL: http://keycloak:8080 KEYCLOAK_URL: http://keycloak:8080
KEYCLOAK_REALM: klg 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} ENVIRONMENT: ${ENVIRONMENT:-production}
# ФГИС РЭВС # ФГИС РЭВС
FGIS_API_URL: ${FGIS_API_URL:-https://fgis-revs-test.favt.gov.ru/api/v2} FGIS_API_URL: ${FGIS_API_URL:-https://fgis-revs-test.favt.gov.ru/api/v2}
@ -125,5 +140,6 @@ services:
volumes: volumes:
postgres_data: postgres_data:
keycloak_db_data:
minio_data: minio_data:
attachments_data: attachments_data:

View File

@ -323,7 +323,7 @@ throw new AppError('Сообщение', ErrorCode.NETWORK_ERROR, 500);
import { useState } from 'react'; import { useState } from 'react';
import { useErrorHandler } from '@/hooks/useErrorHandler'; import { useErrorHandler } from '@/hooks/useErrorHandler';
import ErrorDisplay from '@/components/ErrorDisplay'; import ErrorDisplay from '@/components/ErrorDisplay';
import { aircraftApi } from '@/lib/api'; import { aircraftApi } from '@/lib/api/api-client';
export default function AircraftList() { export default function AircraftList() {
const { error, userFriendlyError, handleError, clearError, hasError } = useErrorHandler(); const { error, userFriendlyError, handleError, clearError, hasError } = useErrorHandler();
@ -334,8 +334,8 @@ export default function AircraftList() {
try { try {
setLoading(true); setLoading(true);
clearError(); clearError();
const data = await aircraftApi.getAircraft(); const data = await aircraftApi.list();
setAircraft(data); setAircraft(data.items ?? []);
} catch (err) { } catch (err) {
handleError(err, { handleError(err, {
action: 'загрузке списка воздушных судов', 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) { export function setAuthToken(token: string) {
_token = token; _token = token;
if (typeof window !== 'undefined') { // Токен хранится только в памяти — безопаснее чем sessionStorage.
sessionStorage.setItem('klg_token', token); // При перезагрузке страницы пользователь должен заново авторизоваться.
}
} }
export function getAuthToken(): string | null { 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; return _token || process.env.NEXT_PUBLIC_DEV_TOKEN || null;
} }
export function clearAuthToken() { export function clearAuthToken() {
_token = null; _token = null;
if (typeof window !== 'undefined') {
sessionStorage.removeItem('klg_token');
}
} }
// ─── Base fetch ────────────────────────────────── // ─── 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). */ /** 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> { export async function apiFetch<T = any>(path: string, opts: RequestInit = {}): Promise<T> {
const token = getAuthToken(); 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-Frame-Options', 'DENY');
response.headers.set('X-XSS-Protection', '1; mode=block'); response.headers.set('X-XSS-Protection', '1; mode=block');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); 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); response.headers.set('Content-Security-Policy', csp);
} }