Initial commit: import from sinmb79/Gov-chat-bot
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# 보안
|
||||
SECRET_KEY: str = "change-this-in-production-32chars"
|
||||
JWT_EXPIRE_HOURS: int = 24
|
||||
|
||||
# 데이터베이스
|
||||
DATABASE_URL: str = "postgresql+asyncpg://botuser:botpass@db:5432/smartbot"
|
||||
REDIS_URL: str = "redis://redis:6379"
|
||||
|
||||
# 벡터DB
|
||||
VECTOR_DB: str = "chromadb"
|
||||
CHROMA_HOST: str = "chromadb"
|
||||
CHROMA_PORT: int = 8000
|
||||
|
||||
# Provider 기본값 (테넌트별 TenantConfig로 오버라이드 가능)
|
||||
LLM_PROVIDER: str = "none"
|
||||
EMBEDDING_PROVIDER: str = "local"
|
||||
EMBEDDING_MODEL: str = "jhgan/ko-sroberta-multitask"
|
||||
|
||||
# 개인정보
|
||||
CHAT_LOG_RETENTION_DAYS: int = 30
|
||||
|
||||
# Idempotency
|
||||
IDEMPOTENCY_TTL_SECONDS: int = 60
|
||||
|
||||
# Admin 초기값
|
||||
ADMIN_DEFAULT_EMAIL: str = "admin@smartbot.kr"
|
||||
ADMIN_DEFAULT_PASSWORD: str = "changeme123!"
|
||||
|
||||
# CORS
|
||||
ALLOWED_ORIGINS: list[str] = ["http://localhost"]
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = True
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
# 설정 우선순위:
|
||||
# 1 (최고) TenantConfig DB 값 → 해당 테넌트에만 적용
|
||||
# 2 환경변수 (.env) → 서버 전체 기본값
|
||||
# 3 (최저) 코드 하드코딩 → 폴백
|
||||
|
||||
|
||||
async def get_tenant_config(tenant_id: str, db) -> dict:
|
||||
"""TenantConfig에서 테넌트별 설정 로드. 없으면 전역 settings 사용."""
|
||||
from app.models.tenant import TenantConfig
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await db.execute(
|
||||
select(TenantConfig).where(TenantConfig.tenant_id == tenant_id)
|
||||
)
|
||||
configs = result.scalars().all()
|
||||
|
||||
base = settings.model_dump()
|
||||
if not configs:
|
||||
return base
|
||||
|
||||
overrides = {cfg.key: cfg.value for cfg in configs}
|
||||
return {**base, **overrides}
|
||||
@@ -0,0 +1,28 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||
|
||||
# Phase 0B에서 settings로 교체 예정 — 현재는 하드코딩 허용
|
||||
DATABASE_URL = "sqlite+aiosqlite:///:memory:"
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
engine = create_async_engine(DATABASE_URL, echo=False)
|
||||
AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
async def get_db():
|
||||
async with AsyncSessionLocal() as session:
|
||||
yield session
|
||||
|
||||
|
||||
async def init_db():
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
|
||||
async def startup_hook():
|
||||
"""Phase 0B startup 훅 — DB 초기화 + 추가 작업 예정."""
|
||||
await init_db()
|
||||
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
FastAPI 공통 의존성.
|
||||
JWT 인증, 역할 검증.
|
||||
"""
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Depends, HTTPException, Header, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import decode_token
|
||||
from app.models.admin import AdminUser, AdminRole, SystemAdmin
|
||||
|
||||
|
||||
async def get_current_admin(
|
||||
authorization: Optional[str] = Header(default=None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> AdminUser:
|
||||
"""JWT Bearer 토큰에서 AdminUser 추출."""
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
|
||||
token = authorization.removeprefix("Bearer ").strip()
|
||||
payload = decode_token(token)
|
||||
if not payload:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
|
||||
if payload.get("type") == "system_admin":
|
||||
# SystemAdmin은 별도 처리 — 여기선 tenant-scoped API 접근 거부
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Use system admin endpoints")
|
||||
|
||||
user_id = payload.get("sub")
|
||||
result = await db.execute(select(AdminUser).where(AdminUser.id == user_id, AdminUser.is_active.is_(True)))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||
return user
|
||||
|
||||
|
||||
def require_role(*roles: AdminRole):
|
||||
"""역할 검증 의존성 팩토리."""
|
||||
async def _check(user: AdminUser = Depends(get_current_admin)) -> AdminUser:
|
||||
if user.role not in roles:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
|
||||
return user
|
||||
return _check
|
||||
|
||||
|
||||
# 편의 의존성
|
||||
require_editor = require_role(AdminRole.admin, AdminRole.editor)
|
||||
require_admin = require_role(AdminRole.admin)
|
||||
@@ -0,0 +1,49 @@
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
EXEMPT_PATHS = {"/health", "/ready", "/engine/query", "/api/docs", "/openapi.json", "/redoc"}
|
||||
EXEMPT_PREFIXES = ("/skill/", "/api/admin/") # 채널 API + 관리자 API (JWT로 tenant 검증)
|
||||
|
||||
|
||||
class TenantMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
path = request.url.path
|
||||
if path in EXEMPT_PATHS or any(path.startswith(p) for p in EXEMPT_PREFIXES):
|
||||
request.state.tenant_id = None
|
||||
return await call_next(request)
|
||||
|
||||
tenant_id = await self._resolve_tenant(request)
|
||||
if not tenant_id:
|
||||
return JSONResponse({"error": "tenant_required"}, status_code=400)
|
||||
|
||||
request.state.tenant_id = tenant_id
|
||||
return await call_next(request)
|
||||
|
||||
async def _resolve_tenant(self, request: Request):
|
||||
# 현재는 X-Tenant-Slug 헤더만 처리 (Phase 0B에서 JWT 추가)
|
||||
slug = request.headers.get("X-Tenant-Slug")
|
||||
if slug:
|
||||
return slug
|
||||
return None
|
||||
|
||||
|
||||
def tenanted_query(query, model, tenant_id):
|
||||
"""
|
||||
주의: model 파라미터가 두 번째 인자다. (v2.0 오류 수정)
|
||||
tenant_id가 None 또는 빈 문자열이면 RuntimeError 발생.
|
||||
사용 예: tenanted_query(select(FAQ), FAQ, request.state.tenant_id)
|
||||
"""
|
||||
if not tenant_id:
|
||||
raise RuntimeError(
|
||||
f"tenant_id is required for {model.__tablename__} queries. "
|
||||
"Check TenantMiddleware is applied."
|
||||
)
|
||||
return query.where(model.tenant_id == tenant_id)
|
||||
|
||||
|
||||
def system_query(query):
|
||||
"""SystemAdmin 전용 쿼리. tenant 필터 없음. 일반 서비스에서 호출 금지."""
|
||||
return query
|
||||
@@ -0,0 +1,44 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import bcrypt
|
||||
import jwt
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return bcrypt.checkpw(plain.encode(), hashed.encode())
|
||||
|
||||
|
||||
def create_admin_token(user_id: str, tenant_id: str, role: str) -> str:
|
||||
payload = {
|
||||
"sub": user_id,
|
||||
"tenant_id": tenant_id,
|
||||
"role": role,
|
||||
"type": "admin_user",
|
||||
"exp": datetime.now(timezone.utc) + timedelta(hours=settings.JWT_EXPIRE_HOURS),
|
||||
}
|
||||
return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
|
||||
|
||||
|
||||
def create_system_token(sys_admin_id: str) -> str:
|
||||
payload = {
|
||||
"sub": sys_admin_id,
|
||||
"tenant_id": None,
|
||||
"role": "system",
|
||||
"type": "system_admin",
|
||||
"exp": datetime.now(timezone.utc) + timedelta(hours=settings.JWT_EXPIRE_HOURS),
|
||||
}
|
||||
return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
|
||||
|
||||
|
||||
def decode_token(token: str) -> Optional[dict]:
|
||||
try:
|
||||
return jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
|
||||
except Exception:
|
||||
return None
|
||||
Reference in New Issue
Block a user