fix(daemons): block owner transfer while assigned

This commit is contained in:
2026-08-15 13:20:02 +09:00
parent e2102b6f2b
commit e90519ad39
9 changed files with 284 additions and 16 deletions
+3
View File
@@ -8,6 +8,7 @@ from fastapi import APIRouter, Depends, HTTPException, Response, status
from app.core.auth import AuthContext, require_admin
from app.core.config import Settings, get_settings
from app.domain.errors import DaemonInUseError
from app.repositories.factory import get_portfolio_repository_from_settings
from app.schemas.v1 import (
DaemonAdminResponse,
@@ -38,6 +39,8 @@ def _to_response(daemon) -> DaemonAdminResponse:
def _daemon_error_to_http(exc: ValueError) -> HTTPException:
detail = str(exc)
if isinstance(exc, DaemonInUseError):
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=detail)
if "찾을 수 없습니다" in detail:
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=detail)
if "이미 존재하는 daemon slug" in detail:
+4
View File
@@ -15,3 +15,7 @@ class DaemonNotFoundError(ValueError):
class DaemonInactiveError(ValueError):
"""Raised when a new portfolio assignment targets an inactive daemon."""
class DaemonInUseError(ValueError):
"""Raised when an owner transfer targets a daemon assigned to a portfolio."""
+12 -9
View File
@@ -13,7 +13,7 @@ from typing import Any
import psycopg
from psycopg import sql
from app.domain.errors import DaemonInactiveError, DaemonNotFoundError, StrategyInactiveError, StrategyNotFoundError
from app.domain.errors import DaemonInactiveError, DaemonInUseError, DaemonNotFoundError, StrategyInactiveError, StrategyNotFoundError
from app.domain.models import Asset, AuthToken, BrokerCredential, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, Tag, TokenPrincipal, User
logger = logging.getLogger(__name__)
@@ -2843,13 +2843,10 @@ class PostgresPortfolioRepository:
transaction is rolled back, preventing the state where the owner has changed but
the previous owner's credentials remain (credential leak — dcy-011 OQ-3).
Execution order inside the transaction:
1. DELETE FROM broker_credentials WHERE daemon_id = %s (credential first)
2. UPDATE daemons SET owner_user_id = %s ... (owner second)
This order is also safe if the transaction machinery is somehow bypassed: if
only step 1 completes the owner is unchanged and credentials are gone (no leak);
if only step 2 completes the old credentials are still present but the caller
still owns them (no cross-owner leak).
The daemon row is locked FOR UPDATE before checking portfolio references.
Portfolio assignment takes the same daemon row FOR SHARE before writing, so
either an earlier assignment makes this transfer fail or an earlier transfer
makes the pending assignment fail its same-owner validation.
"""
self.ensure_schema()
conn = self._connect()
@@ -2858,11 +2855,17 @@ class PostgresPortfolioRepository:
with conn.transaction():
with conn.cursor() as cur:
cur.execute(
"SELECT id FROM daemons WHERE id = %s",
"SELECT id FROM daemons WHERE id = %s FOR UPDATE",
(daemon_id,),
)
if cur.fetchone() is None:
raise ValueError("데몬을 찾을 수 없습니다")
cur.execute(
"SELECT 1 FROM portfolios WHERE managed_by_daemon_id = %s LIMIT 1",
(daemon_id,),
)
if cur.fetchone() is not None:
raise DaemonInUseError("배정된 포트폴리오가 있는 daemon의 소유자는 변경할 수 없습니다")
# Step 1: delete credentials first (safe-order)
cur.execute(
"DELETE FROM broker_credentials WHERE daemon_id = %s",