From 891e17972c8e6f373526aa7cb450bb74d7ea8bd5 Mon Sep 17 00:00:00 2001 From: Yuriy Date: Sat, 14 Feb 2026 23:06:22 +0300 Subject: [PATCH] =?UTF-8?q?fix(klg):=20=D0=B1=D0=B5=D0=B7=D0=BE=D0=BF?= =?UTF-8?q?=D0=B0=D1=81=D0=BD=D0=BE=D1=81=D1=82=D1=8C,=20deps/security,=20?= =?UTF-8?q?attachments,=20CSP,=20api-client,=20=D1=83=D0=B4=D0=B0=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20lib/api.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.example | 37 +- .gitignore | 69 +--- backend/app/api/deps.py | 78 ++-- backend/app/api/routes/attachments.py | 21 +- backend/app/api/routes/cert_applications.py | 10 +- backend/app/api/routes/legal/base.py | 411 ++------------------ backend/app/core/config.py | 1 + backend/app/main.py | 85 ++-- backend/app/middleware/__init__.py | 1 + backend/app/services/security.py | 19 +- components/AircraftTable.tsx | 2 +- components/OrganizationDetailsModal.tsx | 2 +- components/SearchModal.tsx | 2 +- docker-compose.yml | 26 +- docs/ERROR_HANDLING.md | 6 +- lib/api.ts | 26 -- lib/api/api-client.ts | 33 +- middleware.ts | 14 +- 18 files changed, 227 insertions(+), 616 deletions(-) create mode 100644 backend/app/middleware/__init__.py delete mode 100644 lib/api.ts diff --git a/.env.example b/.env.example index bb2a1ea..2ed28e1 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore index 3e6929e..e9df99b 100644 --- a/.gitignore +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 9c80ee6..3fd1195 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -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 diff --git a/backend/app/api/routes/attachments.py b/backend/app/api/routes/attachments.py index 8e7d45a..ca74a37 100644 --- a/backend/app/api/routes/attachments.py +++ b/backend/app/api/routes/attachments.py @@ -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: diff --git a/backend/app/api/routes/cert_applications.py b/backend/app/api/routes/cert_applications.py index ca66b89..619f0bc 100644 --- a/backend/app/api/routes/cert_applications.py +++ b/backend/app/api/routes/cert_applications.py @@ -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: diff --git a/backend/app/api/routes/legal/base.py b/backend/app/api/routes/legal/base.py index a3bf915..13ef193 100644 --- a/backend/app/api/routes/legal/base.py +++ b/backend/app/api/routes/legal/base.py @@ -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 + } \ No newline at end of file diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 7a424e7..c102e8c 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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" diff --git a/backend/app/main.py b/backend/app/main.py index c52c326..0a90f57 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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() diff --git a/backend/app/middleware/__init__.py b/backend/app/middleware/__init__.py new file mode 100644 index 0000000..93fa6f7 --- /dev/null +++ b/backend/app/middleware/__init__.py @@ -0,0 +1 @@ +# Middleware package diff --git a/backend/app/services/security.py b/backend/app/services/security.py index 29d3646..2e82d82 100644 --- a/backend/app/services/security.py +++ b/backend/app/services/security.py @@ -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 diff --git a/components/AircraftTable.tsx b/components/AircraftTable.tsx index 8ccdf31..3d2f333 100644 --- a/components/AircraftTable.tsx +++ b/components/AircraftTable.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Aircraft } from '@/lib/api'; +import { Aircraft } from '@/lib/api/api-client'; interface AircraftTableProps { aircraft: Aircraft[]; diff --git a/components/OrganizationDetailsModal.tsx b/components/OrganizationDetailsModal.tsx index 4134339..37dfaf1 100644 --- a/components/OrganizationDetailsModal.tsx +++ b/components/OrganizationDetailsModal.tsx @@ -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; } diff --git a/components/SearchModal.tsx b/components/SearchModal.tsx index 90ab02d..ecc1e3d 100644 --- a/components/SearchModal.tsx +++ b/components/SearchModal.tsx @@ -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; } diff --git a/docker-compose.yml b/docker-compose.yml index e83d057..c5fdde4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/docs/ERROR_HANDLING.md b/docs/ERROR_HANDLING.md index 91a47d8..fc9ef4f 100644 --- a/docs/ERROR_HANDLING.md +++ b/docs/ERROR_HANDLING.md @@ -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: 'загрузке списка воздушных судов', diff --git a/lib/api.ts b/lib/api.ts deleted file mode 100644 index 7cb90ce..0000000 --- a/lib/api.ts +++ /dev/null @@ -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'; diff --git a/lib/api/api-client.ts b/lib/api/api-client.ts index 414dc40..65a5bb1 100644 --- a/lib/api/api-client.ts +++ b/lib/api/api-client.ts @@ -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(path: string, opts: RequestInit = {}): Promise { const token = getAuthToken(); diff --git a/middleware.ts b/middleware.ts index 6b7f938..8fa6ee1 100644 --- a/middleware.ts +++ b/middleware.ts @@ -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); }