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
+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",