feat: implement v1 consumer API endpoints with runtime_id support (REQ-004~010)
- Generate runtime_id for service-token issuance (CLI and API)
- Implement GET /v1/assets endpoint with asset view and strategy_params
- Implement GET /v1/user_info endpoint with trading_enabled and broker credentials
- Implement POST /v1/assets/{asset_id}/ledger/trades with idempotency support
- Update auth service to support runtime_id tracking and credential encryption
- Add comprehensive test coverage for v1 endpoints (dcy-002 implementation)
- Update postgres repository for ledger and broker credential queries
- Ensure all tests pass for feature validation
This commit is contained in:
@@ -78,6 +78,7 @@ def issue_service_token(
|
|||||||
name=name,
|
name=name,
|
||||||
expires_at=issued.auth_token.expires_at,
|
expires_at=issued.auth_token.expires_at,
|
||||||
revoked=issued.auth_token.revoked_at is not None,
|
revoked=issued.auth_token.revoked_at is not None,
|
||||||
|
runtime_id=issued.auth_token.runtime_id,
|
||||||
token=issued.token,
|
token=issued.token,
|
||||||
)
|
)
|
||||||
render_service_token_result(ctx.obj.console, ctx.obj.output_format, response)
|
render_service_token_result(ctx.obj.console, ctx.obj.output_format, response)
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ class PortfolioRepository(Protocol):
|
|||||||
permission: str,
|
permission: str,
|
||||||
token_type: str,
|
token_type: str,
|
||||||
expires_at: datetime | None = None,
|
expires_at: datetime | None = None,
|
||||||
|
runtime_id: str | None = None,
|
||||||
) -> AuthToken: ...
|
) -> AuthToken: ...
|
||||||
|
|
||||||
def list_auth_tokens(self, user_id: str) -> list[AuthToken]: ...
|
def list_auth_tokens(self, user_id: str) -> list[AuthToken]: ...
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ _SELECT_AUTH_TOKEN = """
|
|||||||
expires_at,
|
expires_at,
|
||||||
last_used_at,
|
last_used_at,
|
||||||
revoked_at,
|
revoked_at,
|
||||||
created_at
|
created_at,
|
||||||
|
runtime_id
|
||||||
FROM auth_tokens
|
FROM auth_tokens
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -133,6 +134,7 @@ class PostgresPortfolioRepository:
|
|||||||
token_hash TEXT NOT NULL UNIQUE,
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
permission TEXT NOT NULL,
|
permission TEXT NOT NULL,
|
||||||
token_type TEXT NOT NULL,
|
token_type TEXT NOT NULL,
|
||||||
|
runtime_id TEXT,
|
||||||
expires_at TIMESTAMPTZ,
|
expires_at TIMESTAMPTZ,
|
||||||
last_used_at TIMESTAMPTZ,
|
last_used_at TIMESTAMPTZ,
|
||||||
revoked_at TIMESTAMPTZ,
|
revoked_at TIMESTAMPTZ,
|
||||||
@@ -140,6 +142,7 @@ class PostgresPortfolioRepository:
|
|||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
cur.execute("ALTER TABLE auth_tokens ADD COLUMN IF NOT EXISTS runtime_id TEXT")
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
CREATE INDEX IF NOT EXISTS idx_auth_tokens_user_created
|
CREATE INDEX IF NOT EXISTS idx_auth_tokens_user_created
|
||||||
@@ -300,6 +303,7 @@ class PostgresPortfolioRepository:
|
|||||||
last_used_at=row[7] if len(row) > 7 else None,
|
last_used_at=row[7] if len(row) > 7 else None,
|
||||||
revoked_at=row[8] if len(row) > 8 else None,
|
revoked_at=row[8] if len(row) > 8 else None,
|
||||||
created_at=row[9] if len(row) > 9 else None,
|
created_at=row[9] if len(row) > 9 else None,
|
||||||
|
runtime_id=row[10] if len(row) > 10 else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -532,6 +536,7 @@ class PostgresPortfolioRepository:
|
|||||||
permission: str,
|
permission: str,
|
||||||
token_type: str,
|
token_type: str,
|
||||||
expires_at: datetime | None = None,
|
expires_at: datetime | None = None,
|
||||||
|
runtime_id: str | None = None,
|
||||||
) -> AuthToken:
|
) -> AuthToken:
|
||||||
self.ensure_schema()
|
self.ensure_schema()
|
||||||
with self._connect().cursor() as cur:
|
with self._connect().cursor() as cur:
|
||||||
@@ -542,14 +547,14 @@ class PostgresPortfolioRepository:
|
|||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO auth_tokens (
|
INSERT INTO auth_tokens (
|
||||||
id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at
|
id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at, runtime_id
|
||||||
)
|
)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
RETURNING
|
RETURNING
|
||||||
id, user_id, name, token_prefix, permission, token_type,
|
id, user_id, name, token_prefix, permission, token_type,
|
||||||
expires_at, last_used_at, revoked_at, created_at
|
expires_at, last_used_at, revoked_at, created_at, runtime_id
|
||||||
""",
|
""",
|
||||||
(token_id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at),
|
(token_id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at, runtime_id),
|
||||||
)
|
)
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
return self._row_to_auth_token(row)
|
return self._row_to_auth_token(row)
|
||||||
@@ -612,7 +617,7 @@ class PostgresPortfolioRepository:
|
|||||||
with self._connect().cursor() as cur:
|
with self._connect().cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT t.id, t.user_id, t.permission, t.token_type
|
SELECT t.id, t.user_id, t.permission, t.token_type, t.runtime_id
|
||||||
FROM auth_tokens t
|
FROM auth_tokens t
|
||||||
WHERE token_hash = %s
|
WHERE token_hash = %s
|
||||||
AND t.revoked_at IS NULL
|
AND t.revoked_at IS NULL
|
||||||
@@ -629,6 +634,7 @@ class PostgresPortfolioRepository:
|
|||||||
user_id=row[1],
|
user_id=row[1],
|
||||||
permission=row[2],
|
permission=row[2],
|
||||||
token_type=row[3],
|
token_type=row[3],
|
||||||
|
runtime_id=row[4] if len(row) > 4 else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
def get(self, portfolio_id: str) -> Portfolio:
|
def get(self, portfolio_id: str) -> Portfolio:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
@@ -131,6 +132,7 @@ class AuthService:
|
|||||||
expires_at = datetime.now(timezone.utc) + timedelta(days=days)
|
expires_at = datetime.now(timezone.utc) + timedelta(days=days)
|
||||||
|
|
||||||
raw_token = generate_token()
|
raw_token = generate_token()
|
||||||
|
runtime_id = str(uuid.uuid4())
|
||||||
try:
|
try:
|
||||||
auth_token = self._repository.create_auth_token(
|
auth_token = self._repository.create_auth_token(
|
||||||
user.id,
|
user.id,
|
||||||
@@ -140,6 +142,7 @@ class AuthService:
|
|||||||
permission=permission,
|
permission=permission,
|
||||||
token_type="service",
|
token_type="service",
|
||||||
expires_at=expires_at,
|
expires_at=expires_at,
|
||||||
|
runtime_id=runtime_id,
|
||||||
)
|
)
|
||||||
except psycopg.errors.UniqueViolation as exc:
|
except psycopg.errors.UniqueViolation as exc:
|
||||||
raise ValueError(f"이미 존재하는 서비스 토큰 이름입니다: {name}") from exc
|
raise ValueError(f"이미 존재하는 서비스 토큰 이름입니다: {name}") from exc
|
||||||
|
|||||||
@@ -140,6 +140,22 @@ def test_issue_service_token_defaults_to_365_days() -> None:
|
|||||||
assert (result.auth_token.expires_at - datetime.now(timezone.utc)).days >= 364
|
assert (result.auth_token.expires_at - datetime.now(timezone.utc)).days >= 364
|
||||||
assert result.auth_token.token_type == "service"
|
assert result.auth_token.token_type == "service"
|
||||||
assert result.auth_token.name == "my-token"
|
assert result.auth_token.name == "my-token"
|
||||||
|
assert result.auth_token.runtime_id is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_issue_service_token_generates_distinct_runtime_ids() -> None:
|
||||||
|
from app.domain.models import User
|
||||||
|
|
||||||
|
repo = _AuthRepo()
|
||||||
|
repo.users["alice"] = User(id="user-1", username="alice", permission="read")
|
||||||
|
service = _service(repo)
|
||||||
|
|
||||||
|
first = service.issue_service_token("my-token-1", "alice")
|
||||||
|
second = service.issue_service_token("my-token-2", "alice")
|
||||||
|
|
||||||
|
assert first.auth_token.runtime_id is not None
|
||||||
|
assert second.auth_token.runtime_id is not None
|
||||||
|
assert first.auth_token.runtime_id != second.auth_token.runtime_id
|
||||||
|
|
||||||
|
|
||||||
def test_issue_service_token_no_expiry() -> None:
|
def test_issue_service_token_no_expiry() -> None:
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ class FakeRepo:
|
|||||||
permission: str,
|
permission: str,
|
||||||
token_type: str,
|
token_type: str,
|
||||||
expires_at: datetime | None = None,
|
expires_at: datetime | None = None,
|
||||||
|
runtime_id: str | None = None,
|
||||||
) -> AuthToken:
|
) -> AuthToken:
|
||||||
token = AuthToken(
|
token = AuthToken(
|
||||||
id=f"token-{self._token_seq}",
|
id=f"token-{self._token_seq}",
|
||||||
@@ -132,6 +133,7 @@ class FakeRepo:
|
|||||||
token_type=token_type,
|
token_type=token_type,
|
||||||
expires_at=expires_at,
|
expires_at=expires_at,
|
||||||
created_at=datetime.now(timezone.utc),
|
created_at=datetime.now(timezone.utc),
|
||||||
|
runtime_id=runtime_id,
|
||||||
)
|
)
|
||||||
self._token_seq += 1
|
self._token_seq += 1
|
||||||
self._auth_tokens[token.id] = token
|
self._auth_tokens[token.id] = token
|
||||||
@@ -851,6 +853,7 @@ def test_cli_service_token_issue_supports_json() -> None:
|
|||||||
assert payload["name"] == "nightly"
|
assert payload["name"] == "nightly"
|
||||||
assert payload["revoked"] is False
|
assert payload["revoked"] is False
|
||||||
assert payload["expires_at"] is not None
|
assert payload["expires_at"] is not None
|
||||||
|
assert payload["runtime_id"] is not None
|
||||||
|
|
||||||
|
|
||||||
def test_cli_service_token_issue_rejects_conflicting_expiry_options() -> None:
|
def test_cli_service_token_issue_rejects_conflicting_expiry_options() -> None:
|
||||||
|
|||||||
+136
-34
@@ -10,7 +10,7 @@ import pytest
|
|||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from app.core.auth import AuthContext, Permission
|
from app.core.auth import AuthContext, Permission
|
||||||
from app.core.config import Settings
|
from app.core.config import Settings, get_settings
|
||||||
from app.domain.models import AuthToken, BrokerCredential, Portfolio, TokenPrincipal, User, LedgerTrade
|
from app.domain.models import AuthToken, BrokerCredential, Portfolio, TokenPrincipal, User, LedgerTrade
|
||||||
from app.main import create_app
|
from app.main import create_app
|
||||||
|
|
||||||
@@ -31,8 +31,11 @@ def _make_settings(**kwargs) -> Settings:
|
|||||||
|
|
||||||
|
|
||||||
def _make_app(settings: Settings | None = None) -> TestClient:
|
def _make_app(settings: Settings | None = None) -> TestClient:
|
||||||
|
get_settings.cache_clear()
|
||||||
s = settings or _make_settings()
|
s = settings or _make_settings()
|
||||||
return TestClient(create_app(settings_factory=lambda: s))
|
app = create_app(settings_factory=lambda: s)
|
||||||
|
app.dependency_overrides[get_settings] = lambda: s
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
def _make_app_db(monkeypatch, **extra_settings) -> Settings:
|
def _make_app_db(monkeypatch, **extra_settings) -> Settings:
|
||||||
@@ -46,6 +49,76 @@ def _service_token_header(token: str = "svc-token") -> dict[str, str]:
|
|||||||
return {"Authorization": f"Bearer {token}"}
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
class _RuntimeIdRoundTripRepo:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.users = {
|
||||||
|
"user-1": User(
|
||||||
|
id="user-1",
|
||||||
|
username="alice",
|
||||||
|
email="alice@example.com",
|
||||||
|
permission="read",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
self.users_by_username = {"alice": self.users["user-1"]}
|
||||||
|
self.tokens_by_hash: dict[str, AuthToken] = {}
|
||||||
|
self._seq = 1
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_user_by_username(self, username: str) -> User | None:
|
||||||
|
return self.users_by_username.get(username)
|
||||||
|
|
||||||
|
def get_user(self, user_id: str) -> User:
|
||||||
|
user = self.users.get(user_id)
|
||||||
|
if user is None:
|
||||||
|
raise ValueError("사용자를 찾을 수 없습니다")
|
||||||
|
return user
|
||||||
|
|
||||||
|
def create_auth_token(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
token_hash: str,
|
||||||
|
token_prefix: str,
|
||||||
|
permission: str,
|
||||||
|
token_type: str,
|
||||||
|
expires_at: datetime | None = None,
|
||||||
|
runtime_id: str | None = None,
|
||||||
|
) -> AuthToken:
|
||||||
|
token = AuthToken(
|
||||||
|
id=f"token-{self._seq}",
|
||||||
|
user_id=user_id,
|
||||||
|
name=name,
|
||||||
|
token_prefix=token_prefix,
|
||||||
|
permission=permission,
|
||||||
|
token_type=token_type,
|
||||||
|
expires_at=expires_at,
|
||||||
|
created_at=datetime.now(timezone.utc),
|
||||||
|
runtime_id=runtime_id,
|
||||||
|
)
|
||||||
|
self._seq += 1
|
||||||
|
self.tokens_by_hash[token_hash] = token
|
||||||
|
return token
|
||||||
|
|
||||||
|
def get_token_principal(self, token_hash: str) -> TokenPrincipal | None:
|
||||||
|
token = self.tokens_by_hash.get(token_hash)
|
||||||
|
if token is None:
|
||||||
|
return None
|
||||||
|
return TokenPrincipal(
|
||||||
|
token_id=token.id,
|
||||||
|
user_id=token.user_id,
|
||||||
|
permission=token.permission,
|
||||||
|
token_type=token.token_type,
|
||||||
|
runtime_id=token.runtime_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def list_broker_credentials(self, user_id: str) -> list[BrokerCredential]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# GET /health
|
# GET /health
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -68,43 +141,69 @@ def test_health_returns_full_response() -> None:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def test_auth_issue_service_token_returns_runtime_id(monkeypatch) -> None:
|
def test_auth_issue_service_token_returns_runtime_id(monkeypatch) -> None:
|
||||||
from app.core.config import get_settings as _get_settings
|
repo = _RuntimeIdRoundTripRepo()
|
||||||
from app.core.auth import require_admin as _require_admin
|
settings = _make_settings(
|
||||||
from app.services.auth_service import IssuedServiceToken, AuthService, get_auth_service
|
db_enabled=True,
|
||||||
|
admin_tokens_raw="admin-token",
|
||||||
issued_token = AuthToken(
|
credential_encryption_key=_TEST_ENC_KEY,
|
||||||
id="tok-1", user_id="user-1", name="bot-1",
|
|
||||||
token_prefix="pfhub_", permission="read", token_type="service",
|
|
||||||
runtime_id="rt-new-123",
|
|
||||||
)
|
|
||||||
mock_service = MagicMock(spec=AuthService)
|
|
||||||
mock_service.issue_service_token.return_value = IssuedServiceToken(
|
|
||||||
token="pfhub_rawtoken",
|
|
||||||
auth_token=issued_token,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
settings = _make_settings(credential_encryption_key=_TEST_ENC_KEY)
|
with patch("app.services.auth_service.get_portfolio_repository_from_settings", return_value=repo), \
|
||||||
app_instance = create_app(settings_factory=lambda: settings)
|
patch("app.core.auth.get_portfolio_repository_from_settings", return_value=repo), \
|
||||||
admin_ctx = AuthContext(
|
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=repo):
|
||||||
token="admin-override",
|
app = create_app(settings_factory=lambda: settings)
|
||||||
user_id="user-1", token_source="env", token_type="session",
|
app.dependency_overrides[get_settings] = lambda: settings
|
||||||
permission=Permission.ADMIN,
|
client = TestClient(app)
|
||||||
)
|
issue_resp = client.post(
|
||||||
app_instance.dependency_overrides[_require_admin] = lambda: admin_ctx
|
|
||||||
|
|
||||||
from app.api.auth_routes import get_auth_service
|
|
||||||
app_instance.dependency_overrides[get_auth_service] = lambda: mock_service
|
|
||||||
|
|
||||||
client = TestClient(app_instance)
|
|
||||||
resp = client.post(
|
|
||||||
"/auth/service-tokens",
|
"/auth/service-tokens",
|
||||||
|
headers={"Authorization": "Bearer admin-token"},
|
||||||
json={"name": "bot-1", "username": "alice"},
|
json={"name": "bot-1", "username": "alice"},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert resp.status_code == 201
|
assert issue_resp.status_code == 201
|
||||||
body = resp.json()
|
issue_body = issue_resp.json()
|
||||||
assert body["runtime_id"] == "rt-new-123"
|
assert issue_body["runtime_id"] is not None
|
||||||
assert "token" in body
|
assert "token" in issue_body
|
||||||
|
|
||||||
|
user_info_resp = client.get(
|
||||||
|
"/v1/user_info",
|
||||||
|
headers=_service_token_header(issue_body["token"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert user_info_resp.status_code == 200
|
||||||
|
user_info_body = user_info_resp.json()
|
||||||
|
assert user_info_body["runtime_id"] == issue_body["runtime_id"]
|
||||||
|
assert user_info_body["username"] == "alice"
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_issue_service_token_generates_distinct_runtime_ids_per_token(monkeypatch) -> None:
|
||||||
|
repo = _RuntimeIdRoundTripRepo()
|
||||||
|
settings = _make_settings(
|
||||||
|
db_enabled=True,
|
||||||
|
admin_tokens_raw="admin-token",
|
||||||
|
credential_encryption_key=_TEST_ENC_KEY,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("app.services.auth_service.get_portfolio_repository_from_settings", return_value=repo):
|
||||||
|
app = create_app(settings_factory=lambda: settings)
|
||||||
|
app.dependency_overrides[get_settings] = lambda: settings
|
||||||
|
client = TestClient(app)
|
||||||
|
first = client.post(
|
||||||
|
"/auth/service-tokens",
|
||||||
|
headers={"Authorization": "Bearer admin-token"},
|
||||||
|
json={"name": "bot-1", "username": "alice"},
|
||||||
|
)
|
||||||
|
second = client.post(
|
||||||
|
"/auth/service-tokens",
|
||||||
|
headers={"Authorization": "Bearer admin-token"},
|
||||||
|
json={"name": "bot-2", "username": "alice"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert first.status_code == 201
|
||||||
|
assert second.status_code == 201
|
||||||
|
assert first.json()["runtime_id"] is not None
|
||||||
|
assert second.json()["runtime_id"] is not None
|
||||||
|
assert first.json()["runtime_id"] != second.json()["runtime_id"]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -612,8 +711,11 @@ def _make_settings(**kwargs) -> Settings:
|
|||||||
|
|
||||||
|
|
||||||
def _make_app(settings: Settings | None = None) -> TestClient:
|
def _make_app(settings: Settings | None = None) -> TestClient:
|
||||||
|
get_settings.cache_clear()
|
||||||
s = settings or _make_settings()
|
s = settings or _make_settings()
|
||||||
return TestClient(create_app(settings_factory=lambda: s))
|
app = create_app(settings_factory=lambda: s)
|
||||||
|
app.dependency_overrides[get_settings] = lambda: s
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
def _service_token_header(token: str = "svc-token") -> dict[str, str]:
|
def _service_token_header(token: str = "svc-token") -> dict[str, str]:
|
||||||
|
|||||||
Reference in New Issue
Block a user