feat: add portfolio ownership model, auth guards, and migration

This commit is contained in:
2026-04-14 00:13:34 +09:00
parent e89690b1dd
commit 9787cf3159
22 changed files with 681 additions and 70 deletions
+66 -16
View File
@@ -2,7 +2,7 @@
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from app.core.auth import Permission, authorize_request
from app.core.auth import AuthContext, Permission, authorize_request
from app.core.config import Settings, get_settings
from app.repositories.factory import get_portfolio_repository_from_settings
from app.schemas.portfolio import (
@@ -41,6 +41,28 @@ def _close_repository(repository: object) -> None:
repository.close()
def _ownership_required(settings: Settings) -> bool:
return settings.db_enabled
def _require_owner_context(auth_context: AuthContext) -> str:
if auth_context.user_id is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Ownership context is required")
return auth_context.user_id
def _ensure_portfolio_owned(repository: object, portfolio_id: str, auth_context: AuthContext) -> None:
if auth_context.permission == Permission.ADMIN:
return
owner_user_id = _require_owner_context(auth_context)
try:
portfolio = repository.get(portfolio_id)
except ValueError as exc:
_raise_portfolio_error(exc)
if portfolio.owner_user_id != owner_user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Portfolio ownership mismatch")
@router.get("", response_model=list[PortfolioListItem])
def list_portfolios(
request: Request,
@@ -48,10 +70,13 @@ def list_portfolios(
status_filter: str | None = None,
settings: Settings = Depends(get_settings),
) -> list[PortfolioListItem]:
authorize_request(Permission.READ, request, settings)
auth_context = authorize_request(Permission.READ, request, settings)
service, repository = _get_service(settings)
try:
return service.list_portfolios(broker=broker, status=status_filter)
owner_user_id: str | None = None
if _ownership_required(settings) and auth_context.permission != Permission.ADMIN:
owner_user_id = _require_owner_context(auth_context)
return service.list_portfolios(broker=broker, status=status_filter, owner_user_id=owner_user_id)
finally:
_close_repository(repository)
@@ -64,9 +89,11 @@ def get_portfolio(
recent_trades: int = 5,
settings: Settings = Depends(get_settings),
) -> PortfolioDetailResponse:
authorize_request(Permission.READ, request, settings)
auth_context = authorize_request(Permission.READ, request, settings)
service, repository = _get_service(settings)
try:
if _ownership_required(settings):
_ensure_portfolio_owned(repository, portfolio_id, auth_context)
return service.get_portfolio_status(portfolio_id=portfolio_id, recent_trades=recent_trades)
finally:
_close_repository(repository)
@@ -83,10 +110,13 @@ def create_portfolio(
*,
settings: Settings = Depends(get_settings),
) -> PortfolioDetailResponse:
authorize_request(Permission.TRADE_WRITE, request, settings)
auth_context = authorize_request(Permission.TRADE_WRITE, request, settings)
service, repository = _get_service(settings)
try:
return service.create_portfolio(payload)
owner_user_id = auth_context.user_id or "system"
if _ownership_required(settings):
owner_user_id = _require_owner_context(auth_context)
return service.create_portfolio(payload, owner_user_id=owner_user_id)
finally:
_close_repository(repository)
@@ -99,9 +129,11 @@ def update_portfolio(
*,
settings: Settings = Depends(get_settings),
) -> PortfolioDetailResponse:
authorize_request(Permission.TRADE_WRITE, request, settings)
auth_context = authorize_request(Permission.TRADE_WRITE, request, settings)
service, repository = _get_service(settings)
try:
if _ownership_required(settings):
_ensure_portfolio_owned(repository, portfolio_id, auth_context)
return service.update_portfolio(portfolio_id, payload)
finally:
_close_repository(repository)
@@ -115,9 +147,11 @@ def buy_portfolio_asset(
*,
settings: Settings = Depends(get_settings),
) -> PortfolioDetailResponse:
authorize_request(Permission.TRADE_WRITE, request, settings)
auth_context = authorize_request(Permission.TRADE_WRITE, request, settings)
service, repository = _get_service(settings)
try:
if _ownership_required(settings):
_ensure_portfolio_owned(repository, portfolio_id, auth_context)
return service.buy(portfolio_id, payload)
finally:
_close_repository(repository)
@@ -131,9 +165,11 @@ def sell_portfolio_asset(
*,
settings: Settings = Depends(get_settings),
) -> PortfolioDetailResponse:
authorize_request(Permission.TRADE_WRITE, request, settings)
auth_context = authorize_request(Permission.TRADE_WRITE, request, settings)
service, repository = _get_service(settings)
try:
if _ownership_required(settings):
_ensure_portfolio_owned(repository, portfolio_id, auth_context)
return service.sell(portfolio_id, payload)
finally:
_close_repository(repository)
@@ -147,9 +183,11 @@ def invest_buffer(
*,
settings: Settings = Depends(get_settings),
) -> PortfolioDetailResponse:
authorize_request(Permission.TRADE_WRITE, request, settings)
auth_context = authorize_request(Permission.TRADE_WRITE, request, settings)
service, repository = _get_service(settings)
try:
if _ownership_required(settings):
_ensure_portfolio_owned(repository, portfolio_id, auth_context)
return service.invest(portfolio_id, payload)
finally:
_close_repository(repository)
@@ -163,9 +201,11 @@ def transfer_buffer(
*,
settings: Settings = Depends(get_settings),
) -> PortfolioDetailResponse:
authorize_request(Permission.TRADE_WRITE, request, settings)
auth_context = authorize_request(Permission.TRADE_WRITE, request, settings)
service, repository = _get_service(settings)
try:
if _ownership_required(settings):
_ensure_portfolio_owned(repository, portfolio_id, auth_context)
return service.transfer(portfolio_id, payload)
finally:
_close_repository(repository)
@@ -179,9 +219,11 @@ def reset_portfolio(
*,
settings: Settings = Depends(get_settings),
) -> PortfolioDetailResponse:
authorize_request(Permission.TRADE_WRITE, request, settings)
auth_context = authorize_request(Permission.TRADE_WRITE, request, settings)
service, repository = _get_service(settings)
try:
if _ownership_required(settings):
_ensure_portfolio_owned(repository, portfolio_id, auth_context)
return service.reset(portfolio_id, payload)
finally:
_close_repository(repository)
@@ -216,9 +258,11 @@ def create_comment(
*,
settings: Settings = Depends(get_settings),
) -> CommentResponse:
authorize_request(Permission.TRADE_WRITE, request, settings)
auth_context = authorize_request(Permission.TRADE_WRITE, request, settings)
service, repository = _get_service(settings)
try:
if _ownership_required(settings):
_ensure_portfolio_owned(repository, portfolio_id, auth_context)
return service.create_comment(portfolio_id, payload)
except ValueError as exc:
_raise_portfolio_error(exc)
@@ -233,9 +277,11 @@ def list_comments(
*,
settings: Settings = Depends(get_settings),
) -> list[CommentResponse]:
authorize_request(Permission.READ, request, settings)
auth_context = authorize_request(Permission.READ, request, settings)
service, repository = _get_service(settings)
try:
if _ownership_required(settings):
_ensure_portfolio_owned(repository, portfolio_id, auth_context)
return service.list_comments(portfolio_id)
except ValueError as exc:
_raise_portfolio_error(exc)
@@ -252,9 +298,11 @@ def update_comment(
*,
settings: Settings = Depends(get_settings),
) -> CommentResponse:
authorize_request(Permission.TRADE_WRITE, request, settings)
auth_context = authorize_request(Permission.TRADE_WRITE, request, settings)
service, repository = _get_service(settings)
try:
if _ownership_required(settings):
_ensure_portfolio_owned(repository, portfolio_id, auth_context)
return service.update_comment(portfolio_id, comment_id, payload)
except ValueError as exc:
_raise_portfolio_error(exc)
@@ -270,9 +318,11 @@ def delete_comment(
*,
settings: Settings = Depends(get_settings),
) -> Response:
authorize_request(Permission.TRADE_WRITE, request, settings)
auth_context = authorize_request(Permission.TRADE_WRITE, request, settings)
service, repository = _get_service(settings)
try:
if _ownership_required(settings):
_ensure_portfolio_owned(repository, portfolio_id, auth_context)
service.delete_comment(portfolio_id, comment_id)
except ValueError as exc:
_raise_portfolio_error(exc)
+1
View File
@@ -72,6 +72,7 @@ class Portfolio:
status: str
name: str
symbol: str
owner_user_id: str | None = None
cash_balance: float = 0.0
asset_quantity: float = 0.0
price: float = 0.0
+11 -1
View File
@@ -27,12 +27,14 @@ class PortfolioDomainRepository(Protocol):
status: str = "enabled",
active_strategy_id: str | None = None,
avabile_strategies: list[str] | None = None,
owner_user_id: str | None = None,
initial_cash: float = 0.0,
) -> Portfolio: ...
def create_portfolio(
self,
portfolio_id: str,
*,
owner_user_id: str,
broker: str,
status: str,
name: str,
@@ -45,7 +47,13 @@ class PortfolioDomainRepository(Protocol):
avabile_strategies: list[str] | None,
) -> Portfolio: ...
def update_portfolio(self, portfolio_id: str, **fields: object) -> Portfolio: ...
def list_portfolios(self, *, broker: str | None = None, status: str | None = None) -> list[Portfolio]: ...
def list_portfolios(
self,
*,
broker: str | None = None,
status: str | None = None,
owner_user_id: str | None = None,
) -> list[Portfolio]: ...
def get_strategy_set(self, id: str) -> StrategySet | None: ...
def get_or_create_strategy_set(self, name: str, config: str) -> StrategySet: ...
def save(
@@ -103,6 +111,7 @@ def apply_buy(
status=portfolio.status,
name=portfolio.name,
symbol=portfolio.symbol,
owner_user_id=portfolio.owner_user_id,
cash_balance=float(portfolio.cash_balance) - total_cost,
asset_quantity=float(portfolio.asset_quantity) + quantity,
price=price,
@@ -164,6 +173,7 @@ def apply_sell(
status=portfolio.status,
name=portfolio.name,
symbol=portfolio.symbol,
owner_user_id=portfolio.owner_user_id,
cash_balance=float(portfolio.cash_balance) + net_proceeds,
asset_quantity=float(portfolio.asset_quantity) - quantity,
price=price,
+9 -1
View File
@@ -91,6 +91,7 @@ class PortfolioRepository(Protocol):
status: str = "enabled",
active_strategy_id: str | None = None,
avabile_strategies: list[str] | None = None,
owner_user_id: str | None = None,
initial_cash: float = 0.0,
) -> Portfolio: ...
@@ -98,6 +99,7 @@ class PortfolioRepository(Protocol):
self,
portfolio_id: str,
*,
owner_user_id: str,
broker: str,
status: str,
name: str,
@@ -112,7 +114,13 @@ class PortfolioRepository(Protocol):
def update_portfolio(self, portfolio_id: str, **fields: object) -> Portfolio: ...
def list_portfolios(self, *, broker: str | None = None, status: str | None = None) -> list[Portfolio]: ...
def list_portfolios(
self,
*,
broker: str | None = None,
status: str | None = None,
owner_user_id: str | None = None,
) -> list[Portfolio]: ...
def get_or_create_strategy_set(self, name: str, config: str) -> StrategySet: ...
+33 -16
View File
@@ -70,6 +70,7 @@ _SELECT_PORTFOLIO = """
profit_buffer,
active_strategy_id,
avabile_strategies,
owner_user_id,
created_at,
updated_at,
last_trade_at
@@ -217,6 +218,7 @@ class PostgresPortfolioRepository:
status TEXT NOT NULL DEFAULT 'enabled',
name TEXT NOT NULL,
symbol TEXT NOT NULL,
owner_user_id TEXT,
cash_balance DOUBLE PRECISION NOT NULL,
asset_quantity DOUBLE PRECISION NOT NULL,
asset_price DOUBLE PRECISION NOT NULL DEFAULT 0,
@@ -230,6 +232,7 @@ class PostgresPortfolioRepository:
"""
)
cur.execute("ALTER TABLE portfolios ADD COLUMN IF NOT EXISTS name TEXT")
cur.execute("ALTER TABLE portfolios ADD COLUMN IF NOT EXISTS owner_user_id TEXT")
cur.execute("UPDATE portfolios SET name = symbol WHERE name IS NULL OR name = ''")
cur.execute("ALTER TABLE portfolios ALTER COLUMN name SET NOT NULL")
cur.execute(
@@ -363,9 +366,10 @@ class PostgresPortfolioRepository:
profit_buffer=float(row[8]),
active_strategy_id=row[9] if len(row) > 9 else None,
avabile_strategies=avabile_strategies,
created_at=row[11] if len(row) > 11 else None,
updated_at=row[12] if len(row) > 12 else None,
last_trade_at=row[13] if len(row) > 13 else None,
owner_user_id=row[11] if len(row) > 11 else None,
created_at=row[12] if len(row) > 12 else None,
updated_at=row[13] if len(row) > 13 else None,
last_trade_at=row[14] if len(row) > 14 else None,
)
@staticmethod
@@ -726,6 +730,7 @@ class PostgresPortfolioRepository:
status: str = "enabled",
active_strategy_id: str | None = None,
avabile_strategies: list[str] | None = None,
owner_user_id: str | None = None,
initial_cash: float = 0.0,
) -> Portfolio:
self.ensure_schema()
@@ -734,10 +739,10 @@ class PostgresPortfolioRepository:
"""
INSERT INTO portfolios (
portfolio_id, broker, status, name, symbol,
cash_balance, asset_quantity, asset_price, profit_buffer,
owner_user_id, cash_balance, asset_quantity, asset_price, profit_buffer,
active_strategy_id, avabile_strategies
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (portfolio_id) DO NOTHING
""",
(
@@ -746,6 +751,7 @@ class PostgresPortfolioRepository:
status,
name or symbol,
symbol,
owner_user_id,
float(initial_cash),
0.0,
0.0,
@@ -762,6 +768,7 @@ class PostgresPortfolioRepository:
self,
portfolio_id: str,
*,
owner_user_id: str,
broker: str,
status: str,
name: str,
@@ -782,13 +789,13 @@ class PostgresPortfolioRepository:
"""
INSERT INTO portfolios (
portfolio_id, broker, status, name, symbol,
cash_balance, asset_quantity, asset_price, profit_buffer,
owner_user_id, cash_balance, asset_quantity, asset_price, profit_buffer,
active_strategy_id, avabile_strategies
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING
portfolio_id, broker, status, name, symbol,
cash_balance, asset_quantity, asset_price, profit_buffer,
active_strategy_id, avabile_strategies, created_at, updated_at, last_trade_at
active_strategy_id, avabile_strategies, owner_user_id, created_at, updated_at, last_trade_at
""",
(
portfolio_id,
@@ -796,6 +803,7 @@ class PostgresPortfolioRepository:
status,
name,
symbol,
owner_user_id,
float(cash_balance),
float(asset_quantity),
float(asset_price),
@@ -842,14 +850,20 @@ class PostgresPortfolioRepository:
RETURNING
portfolio_id, broker, status, name, symbol,
cash_balance, asset_quantity, asset_price, profit_buffer,
active_strategy_id, avabile_strategies, created_at, updated_at, last_trade_at
active_strategy_id, avabile_strategies, owner_user_id, created_at, updated_at, last_trade_at
""",
(*params, portfolio_id),
)
row = cur.fetchone()
return self._row_to_portfolio(row)
def list_portfolios(self, *, broker: str | None = None, status: str | None = None) -> list[Portfolio]:
def list_portfolios(
self,
*,
broker: str | None = None,
status: str | None = None,
owner_user_id: str | None = None,
) -> list[Portfolio]:
self.ensure_schema()
query = _SELECT_PORTFOLIO
clauses: list[str] = []
@@ -860,6 +874,9 @@ class PostgresPortfolioRepository:
if status is not None:
clauses.append("status = %s")
params.append(status)
if owner_user_id is not None:
clauses.append("owner_user_id = %s")
params.append(owner_user_id)
if clauses:
query += "WHERE " + " AND ".join(clauses) + " "
query += "ORDER BY updated_at DESC, portfolio_id ASC"
@@ -1041,9 +1058,9 @@ class PostgresPortfolioRepository:
last_trade_at = NULL
WHERE portfolio_id = %s
RETURNING
portfolio_id, broker, status, symbol,
portfolio_id, broker, status, name, symbol,
cash_balance, asset_quantity, asset_price, profit_buffer,
active_strategy_id, avabile_strategies, created_at, updated_at, last_trade_at
active_strategy_id, avabile_strategies, owner_user_id, created_at, updated_at, last_trade_at
""",
(portfolio_id,),
)
@@ -1080,9 +1097,9 @@ class PostgresPortfolioRepository:
updated_at = NOW()
WHERE portfolio_id = %s
RETURNING
portfolio_id, broker, status, symbol,
portfolio_id, broker, status, name, symbol,
cash_balance, asset_quantity, asset_price, profit_buffer,
active_strategy_id, avabile_strategies, created_at, updated_at, last_trade_at
active_strategy_id, avabile_strategies, owner_user_id, created_at, updated_at, last_trade_at
""",
(updated_buffer, portfolio_id),
)
@@ -1149,9 +1166,9 @@ class PostgresPortfolioRepository:
updated_at = NOW()
WHERE portfolio_id = %s
RETURNING
portfolio_id, broker, status, symbol,
portfolio_id, broker, status, name, symbol,
cash_balance, asset_quantity, asset_price, profit_buffer,
active_strategy_id, avabile_strategies, created_at, updated_at, last_trade_at
active_strategy_id, avabile_strategies, owner_user_id, created_at, updated_at, last_trade_at
""",
(cash, buf, portfolio_id),
)
+1
View File
@@ -45,6 +45,7 @@ class PortfolioSummary(BaseModel):
status: str
name: str
symbol: str
owner_user_id: str | None = None
cash_balance: float = 0.0
asset_quantity: float = 0.0
price: float = 0.0
+16 -3
View File
@@ -49,6 +49,7 @@ def _to_portfolio_summary(portfolio: Portfolio):
status=portfolio.status,
name=portfolio.name,
symbol=portfolio.symbol,
owner_user_id=portfolio.owner_user_id,
cash_balance=portfolio.cash_balance,
asset_quantity=portfolio.asset_quantity,
price=portfolio.price,
@@ -137,8 +138,14 @@ class PortfolioService:
def __init__(self, repository: PortfolioRepository) -> None:
self._repository = repository
def list_portfolios(self, *, broker: str | None = None, status: str | None = None) -> list[PortfolioListItem]:
portfolios = self._repository.list_portfolios(broker=broker, status=status)
def list_portfolios(
self,
*,
broker: str | None = None,
status: str | None = None,
owner_user_id: str | None = None,
) -> list[PortfolioListItem]:
portfolios = self._repository.list_portfolios(broker=broker, status=status, owner_user_id=owner_user_id)
return [
PortfolioListItem(
**_to_portfolio_summary(portfolio).model_dump(),
@@ -151,7 +158,12 @@ class PortfolioService:
status_result = get_portfolio_status(self._repository, portfolio_id, recent_trades=recent_trades)
return _to_detail_response(status_result)
def create_portfolio(self, payload: CreatePortfolioRequest) -> PortfolioDetailResponse:
def create_portfolio(
self,
payload: CreatePortfolioRequest,
*,
owner_user_id: str = "system",
) -> PortfolioDetailResponse:
active_strategy_id = payload.active_strategy_id
if active_strategy_id is None:
strategy_set = self._repository.get_or_create_strategy_set(
@@ -162,6 +174,7 @@ class PortfolioService:
avabile_strategies = _merge_strategy_ids(active_strategy_id, payload.avabile_strategies)
portfolio = self._repository.create_portfolio(
payload.portfolio_id,
owner_user_id=owner_user_id,
broker=payload.broker,
status=payload.status,
name=payload.name or payload.symbol,