feat: 멀티테넌트 1단계 — 사용자별 KIS 계좌·포트폴리오·수동매매 (MULTI_TENANT_ENABLED 기본 OFF)

설계: docs/multi_tenant_design.md / 검수: docs/multi_tenant_phase1_review.md (High 0, MT=False 배포 가능)

데이터·인프라
- user_kis_credentials(Fernet 암호화, PBKDF2 키 파생, key_version), stock_bans_v2 신설,
  portfolio/trade_history/active_trades/trade_journals/trading_sessions 등 user_id nullable 추가
- alembic 0009~0011(idempotent, legacy owner 백필). main.py 기동 시 portfolio.user_id 스키마 검사(H1)
- Jenkinsfile: DB Migration 을 Deploy 앞으로, alembic 이력 없으면 stamp 0008 후 upgrade(N1)
- bootstrap: users 비어 있으면 BOOTSTRAP_ADMIN_* 로 관리자 시드 + .env KIS 자격증명 이관 + 백필(SAVEPOINT)

백엔드
- core/crypto.py, core/tenant_auth.py(get_current_user — 비활성 사용자 401, API 키→legacy owner,
  deny_non_owner_session_when_mt), core/dt_utils.py
- services/tenant: context, kis_pool, registry(UserContextRegistry, 락 최소화·복호화 to_thread), risk_factory
- KoreaInvestmentAPI.for_user(사용자별 인스턴스, 토큰캐시 users/{id}/, 시세는 공용 위임)
- trade_executor.safe_place_order_v2(사용자별 락/세마포어, legacy ctx 는 기존 키)
- mdd_manager/loss_tracker _v2 + ban_until tz-aware/naive 경계 정규화(레거시 경로 버그 포함 수정)
- db_worker: payload user_id 옵션 필터, upsert_stock_ban_v2, legacy ActiveTrade 조회 LIMIT 1
- API: /account/credentials(등록·마스킹조회·삭제·연결테스트·자동매매), /auth/admin/tenant/*,
  /portfolio/v2(조회·요약·sync·buy·sell), /trades/v2(+pending). PortfolioResponse Optional 수정

프론트
- /account 내 계좌 설정, useTenantMode(세션=v2 / API키=legacy, 미확정 시 쿼리·버튼 비활성),
  계좌 미등록 인라인 안내, v2 매수/매도/거래이력 분기

테스트: tests/integration(격리 Postgres, KIS 전면 mock) 20 passed / 1 xfailed(0012 이전 제약)
2단계(스케줄러 사용자화·legacy 라우터 격리) 전까지 MULTI_TENANT_ENABLED=True 전환 금지

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-21 10:26:51 +09:00
parent dc128ae4cf
commit b4a1fc79ab
52 changed files with 5614 additions and 96 deletions

View File

@@ -32,6 +32,13 @@ class Settings(BaseSettings):
BACKEND_HOST: str = "0.0.0.0"
BACKEND_PORT: int = 8000
# ── 멀티테넌트(사용자별 매매) 1단계 ─────────────────────────────────
MULTI_TENANT_ENABLED: bool = False # True 전환 시 사용자별 KIS 계좌·리스크 상태 분리 (기본 False = 기존 단일 계좌 동작)
CREDENTIALS_ENCRYPTION_KEY: str = "" # user_kis_credentials 암복호화 키 파생 소스. 비어있으면 SECRET_KEY 폴백
CREDENTIALS_ENCRYPTION_KEY_PREVIOUS: str = "" # 키 로테이션 중 구 키(복호화만 허용)
BOOTSTRAP_ADMIN_USERNAME: str = "admin" # 앱 최초 기동 시 users 테이블이 비어있으면 시드할 관리자 계정명
BOOTSTRAP_ADMIN_PASSWORD: str = "" # 비어있으면 부트스트랩 관리자 시드 건너뜀
# ═════════════════════════════════════════════════════════════════════════════
# 데이터베이스 & 메시지 큐
# ═════════════════════════════════════════════════════════════════════════════

View File

@@ -0,0 +1,95 @@
"""사용자 KIS 자격증명 암복호화 (멀티테넌트 1단계).
- 키 파생: PBKDF2-HMAC-SHA256(200_000 회) → Fernet 키.
소스는 settings.CREDENTIALS_ENCRYPTION_KEY, 비어있으면 settings.SECRET_KEY 폴백.
- 로테이션: CREDENTIALS_ENCRYPTION_KEY_PREVIOUS 가 설정되면 MultiFernet 으로
구 키 복호화(읽기)만 허용, 암호화(쓰기)는 항상 현재 키(version 1)를 사용.
버전 2단계 확장 시 key_version 값을 늘리고 _derive_key(version=N) 로 파생하면 됨.
사용:
from app.core.crypto import encrypt, decrypt, current_key_version, is_encryption_configured
token = encrypt("plain-app-key")
plain = decrypt(token, key_version=1)
"""
import base64
import functools
import hashlib
import logging
from cryptography.fernet import Fernet, InvalidToken, MultiFernet
from app.core.config import settings
logger = logging.getLogger(__name__)
_KDF_ITERATIONS = 200_000
_DEFAULT_SECRET = "change-this-secret-key"
class CredentialDecryptError(ValueError):
"""자격증명 복호화 실패 — 키 로테이션/설정 변경 등 서버 측 원인 진단용 (L2).
기존 호출부가 `ValueError`를 기대할 수 있으므로 하위 호환을 위해 `ValueError`를 상속한다.
"""
@functools.lru_cache(maxsize=8)
def _derive_key(secret: str, version: int) -> bytes:
"""PBKDF2 로 Fernet 대칭키(urlsafe base64, 32바이트) 파생.
PBKDF2 200_000회 반복은 호출당 ~0.1~0.2초 CPU 연산이라(M4) (secret, version) 조합별로
캐시한다 — secret 후보는 CREDENTIALS_ENCRYPTION_KEY/SECRET_KEY(+PREVIOUS) 뿐이라
프로세스 수명 동안 조합 수가 매우 작다.
"""
salt = f"autostock-kis-cred-v{version}".encode()
raw = hashlib.pbkdf2_hmac("sha256", secret.encode(), salt, _KDF_ITERATIONS)
return base64.urlsafe_b64encode(raw)
def _key_source() -> str:
return settings.CREDENTIALS_ENCRYPTION_KEY or settings.SECRET_KEY
def is_encryption_configured() -> bool:
"""SECRET_KEY 가 기본값이거나 너무 짧으면(길이<32) 암호화 저장을 거부."""
secret = _key_source()
if secret == _DEFAULT_SECRET:
return False
if len(secret) < 32:
return False
return True
def current_key_version() -> int:
"""현재 암호화에 사용하는 키 버전. 1단계는 항상 1(로테이션은 §9 2단계에서 증가)."""
return 1
def _fernet_for_write() -> Fernet:
return Fernet(_derive_key(_key_source(), current_key_version()))
def _fernet_for_read(key_version: int) -> MultiFernet:
"""복호화용 — 현재 키 + (있으면) 이전 키 순으로 시도."""
keys = [Fernet(_derive_key(_key_source(), key_version))]
if settings.CREDENTIALS_ENCRYPTION_KEY_PREVIOUS:
keys.append(Fernet(_derive_key(settings.CREDENTIALS_ENCRYPTION_KEY_PREVIOUS, key_version)))
return MultiFernet(keys)
def encrypt(plain: str) -> str:
"""평문을 암호화해 저장용 문자열(토큰)로 반환. 항상 current_key_version() 기준."""
if not is_encryption_configured():
raise RuntimeError(
"CREDENTIALS_ENCRYPTION_KEY/SECRET_KEY 미설정 또는 기본값 — 자격증명 암호화 저장 불가"
)
return _fernet_for_write().encrypt(plain.encode()).decode()
def decrypt(token: str, key_version: int = 1) -> str:
"""저장된 토큰을 평문으로 복호화. 실패 시 `CredentialDecryptError`(L2, `ValueError` 서브클래스)."""
try:
return _fernet_for_read(key_version).decrypt(token.encode()).decode()
except InvalidToken as e:
logger.error("[Crypto] 자격증명 복호화 실패 (key_version=%s)", key_version)
raise CredentialDecryptError("자격증명 복호화 실패 — 암호화 키 불일치") from e

View File

@@ -0,0 +1,27 @@
"""tz-aware/naive datetime 경계 정규화 헬퍼.
stock_bans / stock_bans_v2 등 일부 테이블의 DateTime 컬럼은 tz-naive인 반면,
`loss_tracker.record_trade_result()`가 만드는 ban_until은 tz-aware(UTC)이다.
asyncpg는 offset-naive/aware 혼용 시 DataError(저장)·TypeError(비교)를 던지므로,
DB 경계(저장 직전/로드 직후)에서 반드시 이 헬퍼로 정규화한다.
"""
from datetime import datetime, timezone
from typing import Optional
def to_naive_utc(dt: Optional[datetime]) -> Optional[datetime]:
"""DB 저장 직전 정규화: tz-aware → UTC 변환 후 tzinfo 제거. naive는 그대로 반환."""
if dt is None:
return None
if dt.tzinfo is not None:
return dt.astimezone(timezone.utc).replace(tzinfo=None)
return dt
def to_aware_utc(dt: Optional[datetime]) -> Optional[datetime]:
"""DB에서 읽은 직후 정규화: tz-naive → UTC로 간주해 tzinfo 부착. aware는 그대로 반환."""
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)

View File

@@ -0,0 +1,134 @@
"""멀티테넌트 사용자 컨텍스트 의존성 (§5.4).
기존 `require_api_key`(security.py)는 라우터 등록에 그대로 유지하고, 이 모듈은
그 위에서 "누구인지"를 해석하는 추가 레이어다. 엔드포인트 함수 시그니처에
`user: CurrentUser = Depends(get_current_user)` 파라미터를 새로 추가하는 것은
기존 함수 시그니처 변경에 해당하므로, 사용자 스코프가 필요한 엔드포인트는
`_v2` 신규 함수/신규 라우터에서만 이 의존성을 사용할 것.
MULTI_TENANT_ENABLED=False 인 동안에도 세션 사용자의 `effective_user_id`는
legacy owner id 를 반환해 하위 호환을 유지한다.
"""
import hmac
import logging
from dataclasses import dataclass
from fastapi import Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.core.security import _get_api_key, require_api_key
from app.models.models import User
logger = logging.getLogger(__name__)
@dataclass
class CurrentUser:
"""인증된 호출 주체. API 키 호출자는 legacy owner 로 매핑된다."""
id: int | None # None = legacy owner 도 없음(관리자 미존재 상태)
username: str
is_admin: bool
is_api_key: bool
_legacy_owner_id: int | None = None # MULTI_TENANT_ENABLED=False 일 때 세션 사용자도 이 id 로 폴백
@property
def effective_user_id(self) -> int | None:
"""실제 DB 필터/컨텍스트 조회에 쓸 user_id.
MULTI_TENANT_ENABLED=True 면 본인 id 그대로, False 면 legacy owner id
(본인이 곧 legacy owner 인 세션 사용자·API 키 호출자는 동일).
"""
if settings.MULTI_TENANT_ENABLED:
return self.id
return self._legacy_owner_id if self._legacy_owner_id is not None else self.id
async def get_legacy_owner(db: AsyncSession) -> User | None:
"""is_admin=True 이고 id 가 가장 작은 사용자(레거시 단일 계좌 소유자). 없으면 None."""
result = await db.execute(
select(User).where(User.is_admin.is_(True)).order_by(User.id.asc()).limit(1)
)
return result.scalar_one_or_none()
async def _resolve_session_user(credential: str, db: AsyncSession) -> User | None:
"""auth.py._resolve_user 와 동일 로직(순환 임포트 회피를 위해 여기서 재사용)."""
from app.core.security import verify_session_token
username = verify_session_token(credential)
if not username:
return None
result = await db.execute(select(User).where(User.username == username))
return result.scalar_one_or_none()
async def get_current_user(
credential: str = Depends(require_api_key), db: AsyncSession = Depends(get_db)
) -> CurrentUser:
"""세션 토큰 → User. API 키 → legacy owner(없으면 id=None, is_api_key=True)."""
legacy_owner = await get_legacy_owner(db)
legacy_owner_id = legacy_owner.id if legacy_owner else None
if hmac.compare_digest(credential.encode(), _get_api_key().encode()):
if legacy_owner is not None:
return CurrentUser(
id=legacy_owner.id,
username=legacy_owner.username,
is_admin=True,
is_api_key=True,
_legacy_owner_id=legacy_owner_id,
)
return CurrentUser(id=None, username="api-key", is_admin=True, is_api_key=True, _legacy_owner_id=None)
user = await _resolve_session_user(credential, db)
if user is not None and user.is_active:
return CurrentUser(
id=user.id,
username=user.username,
is_admin=user.is_admin,
is_api_key=False,
_legacy_owner_id=legacy_owner_id,
)
# 세션 사용자가 없거나(계정 삭제) 비활성(회수/미승인)이면 legacy owner 로 폴백하지 않고
# 명시적으로 거부한다(H2) — require_api_key 는 JWT 서명/exp/aud 만 검증하고 is_active 를
# 보지 않으므로, revoke 직후 토큰 만료 전까지는 이 분기가 유일한 방어선이다.
if user is not None and not user.is_active:
logger.warning("[TenantAuth] 비활성 사용자 세션 토큰 사용 시도: user=%s", user.username)
else:
logger.warning("[TenantAuth] require_api_key 통과 후 사용자 해석 실패(계정 삭제 등)")
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="세션이 유효하지 않습니다.")
async def deny_non_owner_session_when_mt(user: CurrentUser = Depends(get_current_user)) -> None:
"""M5: `MULTI_TENANT_ENABLED=True`일 때 legacy 전용 라우터(`portfolio`/`trades`/
`manual_buy` 등, `/portfolio/v2`·`/trades/v2` 로 전환되지 않은 구 엔드포인트)를
legacy owner가 아닌 세션 사용자가 호출하지 못하게 막는다.
- API 키 호출자(`is_api_key=True`, 예: MCP 서버)와 legacy owner 본인은 통과.
- `MULTI_TENANT_ENABLED=False`(기본)이면 하위 호환을 위해 항상 통과.
- 기존 `require_api_key` 의존성 위에 추가로 거는 것이므로 라우터 시그니처는 바꾸지 않는다.
"""
if not settings.MULTI_TENANT_ENABLED:
return
if user.is_api_key:
return
if user._legacy_owner_id is not None and user.id == user._legacy_owner_id:
return
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="멀티테넌트 모드에서는 본인 계좌 API(/portfolio/v2, /trades/v2)를 사용하세요.",
)
async def require_current_admin(user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
"""관리자 전용 `_v2` 엔드포인트에서 사용. auth.py.require_admin 과 별도(반환 타입이 CurrentUser)."""
from fastapi import HTTPException, status
if not user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="관리자 권한이 필요합니다.")
return user