Files
auto-stock/backend/app/core/tenant_auth.py
shuim b4a1fc79ab 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>
2026-09-21 10:26:51 +09:00

135 lines
5.9 KiB
Python

"""멀티테넌트 사용자 컨텍스트 의존성 (§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