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:
parent
1ec7f62a03
commit
891e17972c
37
.env.example
37
.env.example
@ -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
67
.gitignore
vendored
@ -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/
|
|
||||||
|
|||||||
@ -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):
|
|
||||||
self.id = data.get("id", "")
|
def __init__(self, data: dict | None = None, token_user: TokenUser | None = None):
|
||||||
self.email = data.get("email", "")
|
if token_user:
|
||||||
self.display_name = data.get("display_name", "")
|
self.id = token_user.sub
|
||||||
self.role = data.get("role", "operator_user")
|
self.email = token_user.email or ""
|
||||||
self.roles = data.get("roles", [])
|
self.display_name = token_user.display_name
|
||||||
self.organization_id = data.get("organization_id")
|
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:
|
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
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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),
|
return {
|
||||||
db: Session = Depends(get_db),
|
"status": "success",
|
||||||
user=Depends(get_current_user),
|
"message": message,
|
||||||
):
|
"data": data
|
||||||
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"},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@ -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"
|
||||||
|
|
||||||
|
|||||||
@ -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()
|
|
||||||
|
|||||||
1
backend/app/middleware/__init__.py
Normal file
1
backend/app/middleware/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Middleware package
|
||||||
@ -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
|
||||||
|
|||||||
@ -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[];
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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: 'загрузке списка воздушных судов',
|
||||||
|
|||||||
26
lib/api.ts
26
lib/api.ts
@ -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';
|
|
||||||
@ -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();
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user