From d43ff430c25d9a6538aaff2143dcdff26725c06c Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Sat, 27 Jun 2026 10:16:23 +0900 Subject: [PATCH 1/3] =?UTF-8?q?docs(dcy-009):=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=EC=9A=A9=20read=20=EC=97=94=EB=93=9C=ED=8F=AC?= =?UTF-8?q?=EC=9D=B8=ED=8A=B8=20discovery=20confirmed=20+=20REQ-044/045=20?= =?UTF-8?q?approved?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dcy-009: GET /v1/assets require_read 완화 + 사용자 가용 데몬 목록 신규 (trex-web 포트폴리오 관리 발원) - REQ-044(assets 권한 완화) / REQ-045(사용자 데몬 목록) — Approved - 전부 read 추가(쓰기·데몬관리 제외) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5 --- ...627_user-read-asset-catalog-and-daemons.md | 151 ++++++++++++++++++ docs/discovery/index.md | 1 + ...4_get-v1-assets-require-read-relaxation.md | 82 ++++++++++ .../req-045_user-daemon-list-endpoint.md | 85 ++++++++++ docs/srs/index.md | 5 +- 5 files changed, 323 insertions(+), 1 deletion(-) create mode 100644 docs/discovery/dcy-009_20260627_user-read-asset-catalog-and-daemons.md create mode 100644 docs/srs/Interface/req-044_get-v1-assets-require-read-relaxation.md create mode 100644 docs/srs/Interface/req-045_user-daemon-list-endpoint.md diff --git a/docs/discovery/dcy-009_20260627_user-read-asset-catalog-and-daemons.md b/docs/discovery/dcy-009_20260627_user-read-asset-catalog-and-daemons.md new file mode 100644 index 0000000..0470756 --- /dev/null +++ b/docs/discovery/dcy-009_20260627_user-read-asset-catalog-and-daemons.md @@ -0,0 +1,151 @@ +# 0. 문서 상태 +이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다. + +- 상태: confirmed +- 문서 ID: dcy-009_20260627_user-read-asset-catalog-and-daemons +- 이슈명: 사용자용 read 엔드포인트 2종 — asset 카탈로그 조회 권한 완화(require_read) + 사용자 가용 데몬 목록 신규 +- 작성 시각(KST): 2026-06-27 10:07 +- 마지막 갱신 시각(KST): 2026-06-27 10:30 +- 대체됨: 없음 +- 후속 Discovery 참조: 없음 +- 교차 서비스 발원(driver): **trex-web 포트폴리오 관리(예정 dcy-007)** — 사용자가 자신의 포트폴리오를 생성/변경할 때 ① asset 리스트에서 종목을 고르고 ② 자신이 사용 가능한 데몬을 선택해야 한다. 두 선택 UI에 필요한 read API가 portfolio-hub에 없다(asset 목록은 admin 전용, 사용자 데몬 목록은 라우트 미노출). 본 Discovery는 그 의존을 portfolio-hub측 변경 단위로 정식화한다. (trex-web 포트폴리오 관리 UI는 별도 trex-web dcy-007.) +- 생성된 REQ 참조: REQ-044, REQ-045 + +# 1. 계획 상태 요약 +- 연계 문서 현황: + - 관련 점수 파일: 없음 + - 관련 TODO/메모: 워크스페이스 GitHub 보드 #7 trex-web 항목. 발원 = trex-web 포트폴리오 관리. +- 해석: + - trex-web에서 "각 사용자가 자기 포트폴리오를 관리(생성/변경)"하려면 asset 선택·가용 데몬 선택이 필요한데, portfolio-hub의 해당 read 경로가 사용자에게 막혀 있다. + - 사장님 결정(2026-06-27): **GET /v1/assets를 require_read로 완화**, **사용자 가용 데몬 목록 read 엔드포인트 신규**. 백엔드를 먼저 만들고 trex-web UI를 얹는다. + +# 2. 요구사항 판정 결과 +- 판정: 신규 +- 근거 + - asset 카탈로그 조회 권한 완화는 기존 REQ(자산 카탈로그, admin 전용)의 접근 정책 변경 + 신규 사용자 데몬 목록 엔드포인트는 신규 인터페이스다. + - 포트폴리오 관리(생성/변경 시 선택 UI 공급)라는 새 소비 맥락에서 발생한 read 표면 확장이다. + +# 3. 문제점의 요약 +- 현재 상태 + - `GET /v1/assets`(`app/api/v1_assets.py:75` `list_catalog_assets`)는 **`require_admin`** — 일반 사용자가 asset 카탈로그를 읽을 수 없다. (생성/비활성/태그 등 쓰기도 require_admin.) + - **사용자 가용 데몬을 조회하는 HTTP 엔드포인트가 없다.** 도메인 `Daemon`(id·owner_user_id·daemon_slug·display_name·is_active) 존재 + 서비스/리포지토리에 `list_daemons_for_user(user_id)`·`find_candidate_daemons_for_user(user_id)`가 있으나 라우트로 노출되지 않음. + - 포트폴리오 CRUD(`POST/PATCH/DELETE /v1/portfolios`)는 이미 owner-scoped `require_read`로 존재(이번 변경 대상 아님). 포트폴리오는 `managed_by_daemon_id`로 데몬에 바인딩됨(PATCH로 설정 가능). +- 기대 상태 + - 일반 사용자(`require_read`)가 **active asset 카탈로그를 조회**할 수 있다(쓰기 권한은 admin 유지). + - 일반 사용자가 **자신이 사용 가능한 데몬 목록**(자기 소유 active 데몬)을 조회할 수 있다 — 포트폴리오의 `managed_by_daemon_id`로 선택할 후보. +- 이해관계자 + - 사장님 — 접근 정책(카탈로그 공개 범위)·우선순위 결정 + - 팀장(Claude) — Discovery/거버넌스 게이트 + - portfolio-hub-dev — delivery(백엔드) + - trex-web(소비자) / 일반 사용자 +- 제약사항 + - asset 쓰기(생성/비활성/태그 적용)는 **admin 유지**(완화는 읽기에 한정). + - 데몬 목록은 **caller 본인(user_id) 스코프**(타 사용자 데몬 노출 금지). + - 기존 admin asset 관리·서비스 토큰 daemon 바인딩 동작에 회귀 없어야 함. + +## 주요 사용자 시나리오 +- 사용자가 trex-web에서 포트폴리오를 추가할 때, asset 선택 드롭다운/검색이 `GET /v1/assets`(read)로 active 카탈로그를 불러온다. +- 사용자가 포트폴리오의 관리 데몬을 고를 때, "내 가용 데몬" 목록을 신규 엔드포인트로 불러와 선택한다. +- admin은 기존대로 asset 카탈로그를 생성/수정/비활성(쓰기)한다(권한 불변). + +# 4. 이번에 정의할 변경 +- 변경 1 — **GET /v1/assets 권한 완화**: `require_admin` → `require_read`. status 필터(기본 active)·market 필터 동작 유지. 쓰기 엔드포인트(POST/PATCH/DELETE assets, 태그)는 `require_admin` 유지. +- 변경 2 — **사용자 가용 데몬 목록 read 엔드포인트 신규**: caller(user_id) 소유 active 데몬을 반환(id·daemon_slug·display_name·is_active). `require_read`, caller 스코프. 기존 서비스 메서드(`list_daemons_for_user`) 활용. + +## 영향 범위 +- 코드 영향: `app/api/v1_assets.py`(GET 권한 완화), 데몬 목록 라우트 신규(`app/api/`—경로 design), `app/services/auth_service.py`(기존 `list_daemons_for_user` 노출용 wiring), 응답 스키마(DaemonResponse 신규 가능). +- 문서 영향: `docs/srs/`(REQ), `docs/discovery/index.md`. API 계약 문서. +- 운영 영향: asset 카탈로그가 전 인증 사용자에게 read 공개됨(쓰기 불변). 배포 시 계약 변경 고지. + +# 5. 요구사항 목록 +## 기능 요구사항 +- FR-1: `GET /v1/assets`를 `require_read`로 완화해 일반 사용자가 active asset 카탈로그를 조회할 수 있다. 기존 status/market 필터·응답 스키마 유지. asset 쓰기(생성/비활성/태그)는 `require_admin` 유지. +- FR-2: 사용자 가용 데몬 목록 read 엔드포인트를 추가한다 — `require_read`, caller(user_id) 소유 데몬(active) 반환. 응답 필드 id·daemon_slug·display_name·is_active. + +## 비기능 요구사항 +- NFR-1: 기존 admin asset 관리(쓰기)·서비스 토큰 daemon 바인딩 동작에 회귀 0. +- NFR-2: 데몬 목록은 caller 본인 데몬만 반환(타 사용자 데몬 비노출) — 권한/스코프 정확. + +## 범위 경계 +- In Scope + - GET /v1/assets require_read 완화 + - 사용자 가용 데몬 목록 read 엔드포인트 신규 +- Out of Scope + - asset 카탈로그 쓰기(생성/수정/비활성/태그) 권한 변경(admin 유지) + - 데몬 생성/수정/삭제 등 데몬 관리(이번엔 read 목록만) + - 포트폴리오 CRUD 자체(기존 존재) 및 trex-web UI(별도 trex-web dcy-007) + +# 6. 리스크/가정 목록 +## 리스크 +- R-1 (Low): asset 카탈로그를 전 사용자 read 공개 — 민감 정보 노출 우려. + - 완화 방안: 카탈로그는 거래 가능 종목 메타(민감도 낮음). 쓰기는 admin 유지. 필요 시 status=active만 기본 노출. +- R-2 (Low): 데몬 목록 스코프 오류로 타 사용자 데몬 노출. + - 완화 방안: caller user_id 스코프 강제(`list_daemons_for_user(auth.user_id)`), 테스트로 타 사용자 비노출 검증. + +## 가정 +- A-1: 도메인 `Daemon`(owner_user_id)·`list_daemons_for_user`가 사용자 소유 데몬을 정확히 반환한다. +- A-2: asset 카탈로그(거래 종목 메타)는 전 인증 사용자에게 read 공개해도 무방하다(사장님 승인). +- A-3: 포트폴리오의 `managed_by_daemon_id`에 넣을 후보 = 사용자 소유 데몬(이 목록). + +## 오픈 질문 +- OQ-1: GET /v1/assets require_read 완화 — 승인 여부. + - 상태: **Answered (2026-06-27, 사장님)** — require_read로 완화 확정(쓰기는 admin 유지). FR-1 반영. +- OQ-2: 데몬 목록 엔드포인트 경로·형태 — 예: `GET /v1/daemons`(caller 스코프) vs `GET /v1/users/me/daemons`. 응답 스키마(DaemonResponse) 형태. + - 상태: Deferred + - 처리 방안: draft-batch-design에서 기존 라우팅 컨벤션에 맞춰 확정. + - 종료 조건: design에서 경로·스키마 확정 시 Answered. +- OQ-3: "가용 데몬" 정의 — 사용자 소유 active 데몬(`list_daemons_for_user`)으로 충분한지, 아니면 미바인딩 후보(`find_candidate_daemons_for_user`) 의미가 필요한지. + - 상태: Deferred + - 처리 방안: design에서 trex-web 포트폴리오 배정 요구(managed_by_daemon_id 후보)에 맞는 의미 1개 확정. 기본안 = 사용자 소유 active 데몬 전체. + - 종료 조건: design에서 의미 확정 시 Answered. + +### 오픈 질문 처리 기준 +- `Open`/`Answered`/`Deferred`/`Dropped` 표준 처리. + +# 7. 초기 성공 기준 +- S-1: 일반 사용자(read) 세션으로 `GET /v1/assets` 호출 시 200 + active 카탈로그 반환(admin 아님). +- S-2: 일반 사용자가 asset 쓰기(POST/PATCH/DELETE) 시 여전히 403(admin 전용 유지). +- S-3: 사용자 가용 데몬 목록 엔드포인트가 caller 소유 데몬만 반환(타 사용자 데몬 비노출). +- S-4: 기존 admin asset 관리·토큰 daemon 바인딩 회귀 0. + +## 측정 방식 및 데이터 출처 +- S-1/S-2: read·admin·다른권한 토큰별 통합 테스트(권한 매트릭스) / 데이터 출처: pytest. +- S-3: 2명 사용자 데몬 fixture로 스코프 테스트 / pytest. +- S-4: 기존 v1_assets·auth 테스트 회귀 / pytest. + +# 8. REQ로 넘기기 전 확인 체크 +- [x] 범위(In/Out) 확정 — read 2종 In, 쓰기·데몬관리·UI Out (사장님 합의) +- [x] 성공 지표 측정 방식 — 권한 매트릭스·스코프 테스트 +- [x] High 리스크 없음(R-1/R-2 Low) +- [ ] OQ-2/OQ-3(엔드포인트 경로·가용데몬 의미) design 인계 +- [x] Freeze 담당자(CONFIRMED_BY) — 팀장 + +# 9. 파일 처리 결과 +- 처리 결과: 생성 +- 생성 경로: docs/discovery/dcy-009_20260627_user-read-asset-catalog-and-daemons.md +- 참조 문서: + - .github/templates/discovery/discovery.md (템플릿) + - app/api/v1_assets.py(GET /v1/assets — 완화 대상), app/domain/models.py(Daemon), app/services/auth_service.py(list_daemons_for_user) + - 발원: trex-web 포트폴리오 관리(예정 trex-web dcy-007) + +# 10. 사용자 결정 필요 항목 요약 +## DECIDE +- (해소됨, 2026-06-27) GET /v1/assets require_read 완화 — 승인. +## CONFIRM +- (해소됨, 2026-06-27) 백엔드 read 2종(asset 완화 + 사용자 데몬 목록) 단위 채택 — 쓰기·데몬관리·trex-web UI 제외. 백엔드 우선 진행. +## DATA +- (확인됨) Daemon 필드 id·owner_user_id·daemon_slug·display_name·is_active. asset GET 응답 = AssetCatalogListResponse. + +# 11. Discovery Freeze +## 섹션 참조 +- 범위: [# 5 > 범위 경계](#5-요구사항-목록) / 리스크·OQ: [# 6](#6-리스크가정-목록) / 성공: [# 7](#7-초기-성공-기준) / 인계 전 확인: [# 8](#8-req로-넘기기-전-확인-체크) / 결정: [# 10](#10-사용자-결정-필요-항목-요약) +## Freeze 플래그 +- Handoff Decision: REQ Drafting 진행 가능 +- Handoff Rationale: 범위·asset 완화 승인 완료(사장님). OQ-2(엔드포인트 경로)·OQ-3(가용데몬 의미)만 design Deferred — REQ 인계 가능. +- Ready for REQ Drafting: true +- Confirmed By: 팀장 (Claude) +- Confirmed At (KST): 2026-06-27 10:07 +- 비고: 본 Discovery는 사장님이 백엔드 변경(asset 완화 + 데몬 목록 read)을 명시 승인했으므로 작성과 동시에 confirm 처리. 상태는 confirm-discovery 게이트에서 confirmed로 전환. + +# 12. 작성 가이드 +- portfolio-hub .github/templates discovery 기반. trex-web dcy-007(UI)이 이 엔드포인트를 소비한다. design에서 OQ-2/3 확정. diff --git a/docs/discovery/index.md b/docs/discovery/index.md index 265819d..9c7e3f0 100644 --- a/docs/discovery/index.md +++ b/docs/discovery/index.md @@ -14,3 +14,4 @@ | dcy-006_20260605_ledger-trade-write-portfolio-state-return | 20260605 | ledger trade write 회계 책임 단일화 — 응답에 portfolio 상태 포함 + portfolio_ledger/ledger_trades 테이블 통합 (issue #8) | [Discovery](./dcy-006_20260605_ledger-trade-write-portfolio-state-return.md) | confirmed | | dcy-007_20260616_daily-trade-history-and-realized-pnl-query-api | 20260616 | 일일 거래내역 + 실현 PnL 조회 API (운영 리포트/프론트 데이터 소스, issue #11) | [Discovery](./dcy-007_20260616_daily-trade-history-and-realized-pnl-query-api.md) | confirmed | | dcy-008_20260625_current-holding-wac-cost-basis-read | 20260625 | 현재 보유 종목별 평단(WAC) 조회 API — trex-core 중장기 cold-start 슬롯 평단 seed 의존(교차 발원 trex-core dcy-034 FR-7). GET /v1/portfolios/{id}/holdings/cost-basis | [Discovery](./dcy-008_20260625_current-holding-wac-cost-basis-read.md) | confirmed | +| dcy-009_20260627_user-read-asset-catalog-and-daemons | 20260627 | 사용자용 read 2종 — GET /v1/assets require_read 완화 + 사용자 가용 데몬 목록 신규(교차 발원 trex-web 포트폴리오 관리 dcy-007) | [Discovery](./dcy-009_20260627_user-read-asset-catalog-and-daemons.md) | confirmed | diff --git a/docs/srs/Interface/req-044_get-v1-assets-require-read-relaxation.md b/docs/srs/Interface/req-044_get-v1-assets-require-read-relaxation.md new file mode 100644 index 0000000..5942202 --- /dev/null +++ b/docs/srs/Interface/req-044_get-v1-assets-require-read-relaxation.md @@ -0,0 +1,82 @@ +# REQ-044: GET /v1/assets 권한 완화 — require_admin → require_read + +- Status: Approved +- Approved By: 팀장 (Claude), 2026-06-27 (confirm-req) +- Type: Interface +- Priority: High +- Owner: Justin Ko + +## Intent +- `GET /v1/assets`(asset 카탈로그 목록 조회) 엔드포인트의 의존성을 `require_admin`에서 `require_read`로 완화해, 일반 인증 사용자가 active asset 카탈로그를 조회할 수 있도록 한다. +- trex-web 포트폴리오 관리 UI(dcy-007)에서 포트폴리오 생성·변경 시 종목 선택 드롭다운/검색이 이 엔드포인트를 소비한다. 현재 `require_admin`으로 막혀 있어 일반 사용자가 접근 불가한 상태다. +- asset 쓰기(POST 생성, PATCH 수정/비활성, POST/DELETE 태그 적용/해제)는 `require_admin`을 유지한다 — 이번 완화는 읽기 하나에 한정한다. + +## Requirement +- 시스템은 `GET /v1/assets` 엔드포인트의 인증 의존성을 `require_admin`에서 `require_read`로 변경해야 한다. +- 기존 동작(status 필터·market 필터·`AssetCatalogListResponse` 응답 스키마)은 변경 없이 유지해야 한다. +- asset 쓰기 엔드포인트(`POST /v1/assets`, `PATCH /v1/assets/{asset_id}`, `PATCH /v1/assets/{asset_id}/deactivate`, `POST /v1/assets/{asset_id}/tags`, `DELETE /v1/assets/{asset_id}/tags/{tag_id}`)는 `require_admin`을 유지해야 한다. +- 유효한 read 권한 이상 토큰(session, read service token, admin)으로 `GET /v1/assets`를 호출하면 HTTP 200과 카탈로그 목록을 반환해야 한다. +- 인증 토큰 없는 요청은 HTTP 401, 권한이 없는 토큰은 기존 동작대로 HTTP 403을 반환해야 한다. + +## Acceptance Criteria +1. Given read 권한 세션 토큰(`permission="read"`)을 소지한 사용자, When `GET /v1/assets`를 호출, Then HTTP 200 + `AssetCatalogListResponse` 반환. +2. Given admin 토큰, When `GET /v1/assets`를 호출, Then 기존과 동일하게 HTTP 200 반환(회귀 없음). +3. Given read 권한 사용자, When `GET /v1/assets?status=active`·`?status=inactive`·`?status=all`로 호출, Then status 필터가 적용된 응답 반환(필터 동작 유지). +4. Given read 권한 사용자, When `GET /v1/assets?market=KRX`로 호출, Then market 필터가 적용된 응답 반환(필터 동작 유지). +5. Given read 권한 사용자, When `POST /v1/assets`(asset 생성)를 호출, Then HTTP 403(쓰기는 admin 유지). +6. Given read 권한 사용자, When `PATCH /v1/assets/{asset_id}/deactivate`를 호출, Then HTTP 403. +7. Given read 권한 사용자, When `POST /v1/assets/{asset_id}/tags`를 호출, Then HTTP 403. +8. Given read 권한 사용자, When `DELETE /v1/assets/{asset_id}/tags/{tag_id}`를 호출, Then HTTP 403. +9. Given 인증 토큰 없는 요청, When `GET /v1/assets`를 호출, Then HTTP 401. + +## Impacted Area +- Docs: `docs/srs/Interface/req-044_get-v1-assets-require-read-relaxation.md` (신규) +- Modules: + - `app/api/v1_assets.py`: `list_catalog_assets` 함수의 `auth: AuthContext = Depends(require_admin)` → `Depends(require_read)` 변경. import에 `require_read` 추가. +- Tests: 기존 `tests/`의 `GET /v1/assets` 관련 테스트에서 read 토큰으로 200 반환하는 케이스 추가. 쓰기 엔드포인트 403 회귀 확인. + +## Notes +- asset 카탈로그(거래 가능 종목 메타 — symbol·display_name·markets·status·태그)는 전 인증 사용자에게 read 공개해도 무방하다고 사장님이 확인(dcy-009 OQ-1 Answered 2026-06-27). +- 코드 변경 범위는 `app/api/v1_assets.py` 단일 파일의 `Depends(require_admin)` → `Depends(require_read)` 한 줄이다. 응답 로직·필터·스키마는 변경 없다. +- `require_read`는 `app/core/auth.py`에 이미 정의(`require_read = permission_dependency(Permission.READ)`)되어 있다. +- NFR-1(기존 admin asset 관리·토큰 daemon 바인딩 회귀 0)은 AC-2, AC-5~8의 쓰기 403 확인으로 흡수한다. 별도 NFR REQ 미생성. +- NFR-2(데몬 목록 caller 스코프)는 REQ-045에서 다룬다. + +## Change Log + +### CHG-20260627-01 +- Date: 2026-06-27 +- Author: Justin Ko + +#### Change Summary +- 신규 생성 (Proposed) — dcy-009 FR-1 구현을 위한 첫 번째 REQ 초안. + +#### Intent +- dcy-009 confirmed Discovery의 FR-1을 REQ로 정규화한다. 코드·테스트 구현의 기준 문서로 사용. + +#### Acceptance Criteria Delta +- Added: + - AC-1 ~ AC-9 전체 (신규 초안) +- Updated: 없음 +- Removed: 없음 + +#### Impacted Area Delta +- Docs: + - `docs/srs/Interface/req-044_get-v1-assets-require-read-relaxation.md` 신규 생성 +- Modules: + - `app/api/v1_assets.py` (require_admin → require_read 1행) +- Tests: + - read 권한 GET 200 케이스 추가, 쓰기 403 회귀 케이스 확인 + +#### Delivery Trace Delta +- Batches: 미정 (suggest-batch-reqs 대기) +- Releases: 미정 + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: confirm-req 후 batch 배정 + +#### Validation Plan +- pytest로 read 토큰 GET /v1/assets 200 확인, admin 쓰기 403 회귀 확인. diff --git a/docs/srs/Interface/req-045_user-daemon-list-endpoint.md b/docs/srs/Interface/req-045_user-daemon-list-endpoint.md new file mode 100644 index 0000000..ff5e5b1 --- /dev/null +++ b/docs/srs/Interface/req-045_user-daemon-list-endpoint.md @@ -0,0 +1,85 @@ +# REQ-045: 사용자 가용 데몬 목록 read 엔드포인트 신설 + +- Status: Approved +- Approved By: 팀장 (Claude), 2026-06-27 (confirm-req) +- Type: Interface +- Priority: High +- Owner: Justin Ko + +## Intent +- caller(인증 사용자)가 자신이 소유한 데몬 목록을 조회할 수 있는 HTTP 엔드포인트를 신설한다. +- trex-web 포트폴리오 관리 UI(dcy-007)에서 포트폴리오를 생성하거나 관리 데몬(`managed_by_daemon_id`)을 지정할 때, 사용자가 선택 가능한 데몬 후보를 이 엔드포인트로 불러온다. +- 현재 도메인 `Daemon` 모델과 `list_daemons_for_user(user_id)` 서비스/리포지토리가 존재하나 HTTP 라우트로 노출되지 않는 상태다. 이 REQ는 그 노출 경로를 추가한다. + +## Requirement +- 시스템은 caller(인증 사용자) 소유 데몬 목록을 반환하는 GET 엔드포인트를 `require_read` 권한으로 제공해야 한다. +- 응답은 caller(`auth.user_id`) 소유 데몬만 포함해야 한다 — 타 사용자 데몬은 절대 반환하지 않는다. +- 각 응답 항목은 다음 필드를 포함해야 한다: `id`(str), `daemon_slug`(str), `display_name`(str | None), `is_active`(bool). +- 엔드포인트 경로와 응답 스키마(`DaemonResponse`) 구체 형태는 draft-batch-design에서 기존 라우팅 컨벤션에 맞춰 확정한다(OQ-2 Deferred). +- 인증 토큰 없는 요청은 HTTP 401을 반환해야 한다. +- 해당 사용자에게 데몬이 없는 경우 빈 목록(`[]`)으로 HTTP 200을 반환해야 한다(404 아님). +- 기존 서비스 메서드 `find_candidate_daemons_for_user(user_id)`(`AuthService`)를 활용한다. 이 메서드는 내부적으로 `repository.list_daemons_for_user(user_id)`를 호출한다. + +## Acceptance Criteria +1. Given read 권한 토큰을 소지한 사용자 A(데몬 2개 소유), When 데몬 목록 엔드포인트를 호출, Then HTTP 200 + 사용자 A의 데몬 2개만 포함된 목록 반환. +2. Given read 권한 토큰을 소지한 사용자 A, When 데몬 목록 엔드포인트를 호출, Then 응답 항목이 `id`, `daemon_slug`, `display_name`, `is_active` 필드를 모두 포함. +3. Given read 권한 토큰을 소지한 사용자 A, When 데몬 목록 엔드포인트를 호출, Then 사용자 B의 데몬이 응답에 포함되지 않음(타 사용자 비노출). +4. Given read 권한 사용자이고 자신에게 데몬이 없을 때, When 데몬 목록 엔드포인트를 호출, Then HTTP 200 + 빈 목록(`items: []`). +5. Given 인증 토큰 없는 요청, When 데몬 목록 엔드포인트를 호출, Then HTTP 401. +6. Given admin 토큰을 소지한 사용자, When 데몬 목록 엔드포인트를 호출, Then HTTP 200 + 해당 admin 사용자 소유 데몬만 반환(admin 권한이어도 caller 스코프 적용). +7. Given read 권한 토큰으로 기존 `GET /v1/assets`(REQ-044)를 호출, Then 회귀 없음(데몬 라우터 추가가 기존 assets 라우터에 영향 없음). + +## Impacted Area +- Docs: `docs/srs/Interface/req-045_user-daemon-list-endpoint.md` (신규) +- Modules: + - 신규 라우터 파일 또는 기존 라우터에 엔드포인트 추가(경로·파일 위치는 design 확정 — OQ-2). + - `app/schemas/v1.py` 또는 별도 스키마 파일: `DaemonResponse` Pydantic 모델 신규 정의. + - `app/services/auth_service.py`: `find_candidate_daemons_for_user` 기존 활용(변경 없거나 최소). + - `app/main.py` 또는 라우터 등록: 신규 라우터 include. +- Tests: 2사용자 fixture로 caller 스코프 격리 테스트(AC-3). 빈 목록·401 케이스. 기존 assets/auth 회귀. + +## Notes +- OQ-2(엔드포인트 경로·응답 스키마 형태): design 단계에서 기존 라우팅 컨벤션(`/v1/daemons` vs `/v1/users/me/daemons` 등)을 분석해 확정. 이 REQ에서는 경로를 확정하지 않으며 design Deferred. confirm-req 시 blocker 아님 — design 단계 위임 가능. +- OQ-3("가용 데몬" 의미): 현재 기본안은 사용자 소유 데몬 전체(`find_candidate_daemons_for_user`). active 여부 필터링이 필요한지(소유 active 전체 vs 미바인딩 후보) design에서 확정. 이 REQ에서는 "caller 소유 데몬"으로만 명시하며 active 필터는 design 위임. +- env 기반 token(`token_source="env"`)은 `user_id`가 None이다. 이 경우 `auth.user_id`가 없어 caller 스코프 적용이 불가 — design에서 env 토큰의 처리 방침(거부 vs 전체 반환)을 확정할 것. 기본안: DB 토큰만 허용(user_id 없으면 HTTP 403). +- NFR-1(기존 admin asset 관리·토큰 daemon 바인딩 회귀 0)은 AC-7 회귀 확인으로 흡수한다. +- NFR-2(caller 스코프 정확)는 AC-1/3/6으로 흡수한다. + +## Change Log + +### CHG-20260627-01 +- Date: 2026-06-27 +- Author: Justin Ko + +#### Change Summary +- 신규 생성 (Proposed) — dcy-009 FR-2 구현을 위한 REQ 초안. + +#### Intent +- dcy-009 confirmed Discovery의 FR-2를 REQ로 정규화한다. 코드·테스트 구현의 기준 문서로 사용. + +#### Acceptance Criteria Delta +- Added: + - AC-1 ~ AC-7 전체 (신규 초안) +- Updated: 없음 +- Removed: 없음 + +#### Impacted Area Delta +- Docs: + - `docs/srs/Interface/req-045_user-daemon-list-endpoint.md` 신규 생성 +- Modules: + - 신규 라우터 파일(경로 design 확정 후), `DaemonResponse` 스키마 신규, `app/main.py` 라우터 등록 +- Tests: + - 2사용자 스코프 격리 테스트, 빈 목록·401 케이스 신규 + +#### Delivery Trace Delta +- Batches: 미정 (suggest-batch-reqs 대기) +- Releases: 미정 + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: confirm-req 후 batch 배정. OQ-2/OQ-3는 design 단계에서 해소. + +#### Validation Plan +- pytest로 AC-1~7 각 케이스 확인. 특히 AC-3(2사용자 fixture 사용해 타 사용자 데몬 비노출) 강제 검증. diff --git a/docs/srs/index.md b/docs/srs/index.md index 90cd449..bceb8b2 100644 --- a/docs/srs/index.md +++ b/docs/srs/index.md @@ -74,7 +74,7 @@ ## Next Requirement ID -- Current: `REQ-044` +- Current: `REQ-046` - Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다. ## Requirement Register @@ -124,9 +124,12 @@ | REQ-041 | WAC 기반 cost_basis 산출 및 실현 PnL 계산 로직 (service/domain 계층 격리) | Integration | Implemented | High | Justin Ko | [req-041](./Integration/req-041_wac-cost-basis-pnl-calculation.md) | | REQ-042 | 체결 내역/실현 PnL API 테스트 커버리지 (WAC 시나리오 기댓값 대조, 날짜 필터, 422) | Testing | Implemented | High | Justin Ko | [req-042](./Testing/req-042_trade-pnl-api-test-coverage.md) | | REQ-043 | GET /v1/portfolios/{id}/holdings/cost-basis 현재 보유 평단(WAC) read 엔드포인트 신설 | Interface | Implemented | High | Justin Ko | [req-043](./Interface/req-043_holdings-cost-basis-read-endpoint.md) | +| REQ-044 | GET /v1/assets 권한 완화 — require_admin → require_read | Interface | Approved | High | Justin Ko | [req-044](./Interface/req-044_get-v1-assets-require-read-relaxation.md) | +| REQ-045 | 사용자 가용 데몬 목록 read 엔드포인트 신설 | Interface | Approved | High | Justin Ko | [req-045](./Interface/req-045_user-daemon-list-endpoint.md) | ## Recent Change Log Summary +- 2026-06-27: REQ-044/045 신규 (Proposed) — dcy-009 FR-1(GET /v1/assets require_read 완화)·FR-2(사용자 가용 데몬 목록 엔드포인트 신설) REQ 초안 작성. 엔드포인트 경로·스키마(OQ-2) 및 가용 데몬 의미(OQ-3)는 design Deferred. - 2026-06-26: REQ-041 WAC 공식 change-req(CHG-20260626-01) — 매수 수수료 cost basis 편입(사장님 결정). 신 공식: `running_wac = (running_wac*qty + price*qty + buy_fee) / new_total_qty`. `calculate_realized_pnl`과 `calculate_current_wac` 양 함수 적용. AC-9/AC-10 추가. 신규 테스트 9개 포함 456 passed / 0 failed. REQ-040/REQ-043 Notes 참조 갱신. - 2026-06-26: REQ-043 `Approved` → `Implemented` — BAT-018 PR #14 squash-merge(d93c0bc) 팀장 QC PASS 후 origin/main 머지 완료. AC-1~11 전부 PASS(447 passed / 0 failed). 배포 별도(release-candidate). - 2026-06-25: REQ-043 `Proposed` → `Approved` (confirm-req 팀장 승인) — dcy-008 근거 Discovery 양방향 링크 확정. OQ-1(service token 소유권 blocker) 해소: cold-start는 `managed_by_daemon_id=daemon` 포트폴리오만 조회하므로 null 케이스 blocker 아님, daemon scope 소유권 정책(REQ-029 선례) 유지 확정. OQ-2(domain 함수) 구현 단계 재량 위임. AC-1~11 유지. From c5288a8c42ebd263996613455c3bae93db69f02b Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Sat, 27 Jun 2026 10:27:06 +0900 Subject: [PATCH 2/3] feat(bat-019): REQ-044 GET /v1/assets require_read + REQ-045 GET /v1/daemons caller-scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit REQ-044: list_catalog_assets 의존성을 require_admin → require_read로 완화. asset 쓰기(POST/PATCH/DELETE)는 require_admin 유지. REQ-045: GET /v1/daemons 신규 엔드포인트 — caller 소유 데몬만 반환(require_read). user_id=None(env 토큰)은 빈 목록 200. DaemonResponse/DaemonListResponse 스키마 추가. 기존 테스트(test_v1.py, test_asset_catalog.py, test_daemon_assignment.py, test_tags_master.py) REQ-044 권한 완화에 맞게 업데이트. test_v1_daemons.py 신규 추가(13 케이스). pytest 472 passed, 0 failed. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5 --- app/api/v1_assets.py | 4 +- app/api/v1_daemons.py | 56 +++ app/main.py | 2 + app/schemas/v1.py | 16 + .../design.md | 77 ++++ .../implementation.md | 39 ++ .../index.md | 22 ++ .../planning.md | 62 ++++ tests/test_asset_catalog.py | 25 +- tests/test_daemon_assignment.py | 22 +- tests/test_tags_master.py | 62 +++- tests/test_v1.py | 72 +++- tests/test_v1_daemons.py | 349 ++++++++++++++++++ 13 files changed, 769 insertions(+), 39 deletions(-) create mode 100644 app/api/v1_daemons.py create mode 100644 docs/batches/bat-019_20260627_assets-read-permission-and-daemon-list/design.md create mode 100644 docs/batches/bat-019_20260627_assets-read-permission-and-daemon-list/implementation.md create mode 100644 docs/batches/bat-019_20260627_assets-read-permission-and-daemon-list/index.md create mode 100644 docs/batches/bat-019_20260627_assets-read-permission-and-daemon-list/planning.md create mode 100644 tests/test_v1_daemons.py diff --git a/app/api/v1_assets.py b/app/api/v1_assets.py index 0a546b8..4e3a53b 100644 --- a/app/api/v1_assets.py +++ b/app/api/v1_assets.py @@ -6,7 +6,7 @@ import logging from fastapi import APIRouter, Depends, HTTPException, Query, status -from app.core.auth import AuthContext, require_admin +from app.core.auth import AuthContext, require_admin, require_read from app.repositories.factory import get_portfolio_repository_from_settings from app.schemas.v1 import ( AssetCatalogCreateRequest, @@ -76,7 +76,7 @@ _CATALOG_STATUS_FILTERS = {"active", "inactive", "all"} def list_catalog_assets( status_filter: str = Query(default="active", alias="status"), market: str | None = Query(default=None), - auth: AuthContext = Depends(require_admin), + auth: AuthContext = Depends(require_read), ) -> AssetCatalogListResponse: if status_filter not in _CATALOG_STATUS_FILTERS: raise HTTPException( diff --git a/app/api/v1_daemons.py b/app/api/v1_daemons.py new file mode 100644 index 0000000..ef0b7fc --- /dev/null +++ b/app/api/v1_daemons.py @@ -0,0 +1,56 @@ +"""v1 daemon list endpoint — caller-scoped daemon list (REQ-045).""" + +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Depends + +from app.core.auth import AuthContext, require_read +from app.repositories.factory import get_portfolio_repository_from_settings +from app.schemas.v1 import DaemonListResponse, DaemonResponse + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/v1", tags=["v1"]) + + +def _get_repo(settings=None): + from app.core.config import get_settings + return get_portfolio_repository_from_settings(settings or get_settings()) + + +# --------------------------------------------------------------------------- +# GET /v1/daemons — caller-scoped daemon list (REQ-045) +# --------------------------------------------------------------------------- + +@router.get("/daemons", response_model=DaemonListResponse) +def list_daemons( + auth: AuthContext = Depends(require_read), +) -> DaemonListResponse: + """Return all daemons owned by the caller (auth.user_id). + + - Caller scope only: returns daemons belonging to auth.user_id. + - admin token with user_id also returns only that user's daemons. + - env/service tokens with user_id=None return an empty list (HTTP 200). + - Returns all daemons (active and inactive) with is_active field for distinction. + """ + if auth.user_id is None: + return DaemonListResponse(daemons=[], total=0) + + repo = _get_repo() + try: + daemons = repo.list_daemons_for_user(auth.user_id) + finally: + if callable(getattr(type(repo), "close", None)): + repo.close() + + items = [ + DaemonResponse( + id=d.id, + daemon_slug=d.daemon_slug, + display_name=d.display_name, + is_active=d.is_active, + ) + for d in daemons + ] + return DaemonListResponse(daemons=items, total=len(items)) diff --git a/app/main.py b/app/main.py index 4595c86..f5429c8 100644 --- a/app/main.py +++ b/app/main.py @@ -12,6 +12,7 @@ from app.api.portfolios import router as portfolio_router from app.api.strategy_sets import router as strategy_sets_router from app.api.users import router as users_router from app.api.v1_assets import router as v1_assets_router +from app.api.v1_daemons import router as v1_daemons_router from app.api.v1_portfolios import router as v1_portfolios_router from app.api.v1_tags import router as v1_tags_router from app.api.v1_user_info import router as v1_user_info_router @@ -81,6 +82,7 @@ def create_app( app.include_router(strategy_sets_router) app.include_router(users_router) app.include_router(v1_assets_router) + app.include_router(v1_daemons_router) app.include_router(v1_portfolios_router) app.include_router(v1_tags_router) app.include_router(v1_user_info_router) diff --git a/app/schemas/v1.py b/app/schemas/v1.py index f116465..b2971dd 100644 --- a/app/schemas/v1.py +++ b/app/schemas/v1.py @@ -310,3 +310,19 @@ class HoldingsCostBasisResponse(BaseModel): total_cost_basis: float | None = None cost_basis_from: datetime | None = None data_completeness_warning: str | None = None + + +# --------------------------------------------------------------------------- +# GET /v1/daemons (REQ-045) +# --------------------------------------------------------------------------- + +class DaemonResponse(BaseModel): + id: str + daemon_slug: str + display_name: str | None = None + is_active: bool + + +class DaemonListResponse(BaseModel): + daemons: list[DaemonResponse] + total: int diff --git a/docs/batches/bat-019_20260627_assets-read-permission-and-daemon-list/design.md b/docs/batches/bat-019_20260627_assets-read-permission-and-daemon-list/design.md new file mode 100644 index 0000000..2d037b7 --- /dev/null +++ b/docs/batches/bat-019_20260627_assets-read-permission-and-daemon-list/design.md @@ -0,0 +1,77 @@ +# BAT-019 Design + +- Status: confirmed +- Batch: BAT-019 + +## Architecture Summary + +이 batch는 두 가지 독립적인 변경으로 구성된다. + +1. **REQ-044**: `GET /v1/assets`의 인증 게이트를 `require_admin` → `require_read`로 완화. + 기존 `AssetCatalogListResponse` 응답 구조, 필터 파라미터, 라우터 등록은 모두 불변. + +2. **REQ-045**: `GET /v1/daemons` 신규 엔드포인트. + caller(`auth.user_id`) 소유 데몬만 반환. `require_read` 인증. + 신규 파일: `app/api/v1_daemons.py`, 신규 스키마: `DaemonResponse`, `DaemonListResponse`. + `app/main.py`에 라우터 등록. + +## Changed Areas + +- Docs: `docs/batches/bat-019_20260627_assets-read-permission-and-daemon-list/` +- Modules: + - `app/api/v1_assets.py` (require_admin → require_read, list_catalog_assets만) + - `app/api/v1_daemons.py` (신규) + - `app/schemas/v1.py` (DaemonResponse, DaemonListResponse 추가) + - `app/main.py` (v1_daemons 라우터 include_router 추가) +- Interfaces: + - `GET /v1/assets`: 기존 인터페이스 불변, 인증 게이트만 완화 + - `GET /v1/daemons`: 신규 엔드포인트 + +## Key Decisions + +1. **REQ-044 — 권한 완화 범위**: `list_catalog_assets`(GET /v1/assets) 1개만 `require_read`로 교체. + POST /v1/assets, PATCH /{id}, PATCH /{id}/deactivate, POST /{id}/tags, DELETE /{id}/tags/{tid}는 `require_admin` 유지. + 응답 스키마 `AssetCatalogListResponse`는 불변. + +2. **REQ-045 — 경로**: `GET /v1/daemons`. + `/v1/user/daemons` 대신 `/v1/daemons` 채택 — 기존 `/v1/tags`, `/v1/assets`, `/v1/portfolios` 패턴과 일치. + +3. **REQ-045 — 응답 스키마**: `DaemonListResponse{daemons: list[DaemonResponse], total: int}`. + `DaemonResponse` 필드: `id`(str), `daemon_slug`(str), `display_name`(str|None), `is_active`(bool). + 기존 `TagListResponse{tags: list[TagItem], total: int}` 패턴과 동일 구조. + +4. **REQ-045 — env/service 토큰(user_id=None) 처리**: `auth.user_id`가 None이면 빈 목록 `[]` + `total: 0`을 HTTP 200으로 반환. + 이유: 환경 변수 토큰 또는 user_id 없는 서비스 토큰은 특정 사용자 소유 데몬이 없으므로 에러보다 빈 응답이 자연스럽다. + 기존 `/v1/user_info`의 env 토큰 처리(403 Service token required)와 다른 접근이며, 데몬 목록은 사용자 식별 없이도 빈 목록을 반환하는 것이 호출자 친화적이다. + +5. **REQ-045 — active 필터**: 기본적으로 소유 데몬 전체 반환(active/inactive 모두). `is_active` 필드로 구분 표시. + 이유: caller가 비활성 데몬도 인지해야 할 수 있으며(재활성화 등), 필터는 UI 레이어에서 처리 가능. + +6. **REQ-045 — AuthService 활용**: `AuthService.find_candidate_daemons_for_user(user_id)` 호출. + 단, 라우터에서 직접 repository를 호출하는 기존 패턴(v1_assets, v1_tags 등)과 일관성을 위해 + 라우터에서 직접 `repo.list_daemons_for_user(user_id)`를 호출하는 방식 채택 + (service 레이어 DI overhead 없이 기존 패턴 유지). + +7. **REQ-044 테스트 영향**: 기존 테스트 중 `test_get_v1_assets_user_token_returns_403`, + `test_get_v1_assets_service_token_returns_403`는 이번 완화로 동작이 바뀜. + — user 토큰(permission="read"): 이제 200이어야 함 → 테스트 업데이트. + — service 토큰(permission="trade-write"): trade-write는 read 포함(_has_permission 로직상)이므로 200이어야 함 → 테스트 업데이트. + 또한 `test_v1_assets_rejects_env_token`(test_v1.py): env read 토큰도 이제 200이어야 함 → 업데이트. + `test_v1_assets_returns_asset_list`: read 토큰이 이제 403이 아닌 200 → 업데이트. + `test_v1_assets_market_filter`, `test_v1_assets_asset_id_is_uuid`: 동일. + +## Edge Cases + +- `user_id=None`(env 토큰): 빈 목록 200 반환 (결정 #4). +- 데몬 없는 사용자: 빈 목록 `[]` 200 반환. +- admin 토큰도 caller 스코프: admin 토큰의 `user_id`가 None이면 빈 목록, user_id가 있으면 해당 user 소유 데몬만. +- `list_daemons_for_user` DB 미활성(db_enabled=False): 빈 목록 반환 또는 InMemory 구현에 따름. + +## Architecture Impact + +- none (라우터 추가는 runtime-flows 수준 변경이 아님; project-structure에 신규 파일 기록 수준) + +## Reference Doc Update Plan + +- project-structure.md: none (파일 추가 수준, 별도 문서화 불필요) +- runtime-flows.md: none diff --git a/docs/batches/bat-019_20260627_assets-read-permission-and-daemon-list/implementation.md b/docs/batches/bat-019_20260627_assets-read-permission-and-daemon-list/implementation.md new file mode 100644 index 0000000..0dff2e3 --- /dev/null +++ b/docs/batches/bat-019_20260627_assets-read-permission-and-daemon-list/implementation.md @@ -0,0 +1,39 @@ +# BAT-019 Implementation + +- Status: completed +- Batch: BAT-019 + +## Plan Summary + +- REQ-044: `app/api/v1_assets.py`의 `list_catalog_assets` 의존성을 `require_admin` → `require_read`로 교체. +- REQ-045: `app/schemas/v1.py`에 `DaemonResponse`/`DaemonListResponse` 추가, `app/api/v1_daemons.py` 신규 생성, `app/main.py`에 라우터 등록. +- 테스트: `tests/test_v1.py` 기존 403 기대 케이스 업데이트, `tests/test_asset_catalog.py` 기존 403 기대 케이스 업데이트, `tests/test_v1_daemons.py` 신규 생성. + +## Changed Files + +- `app/api/v1_assets.py`: `require_read` import 추가, `list_catalog_assets` 의존성 교체 (require_admin → require_read) +- `app/api/v1_daemons.py`: 신규 생성 — GET /v1/daemons 라우터 +- `app/schemas/v1.py`: `DaemonResponse`, `DaemonListResponse` 스키마 추가 +- `app/main.py`: `v1_daemons_router` import 및 `include_router` 추가 +- `tests/test_v1.py`: 5개 테스트 업데이트 (403→200 기대치 변경, 일부 내용/이름 변경) +- `tests/test_asset_catalog.py`: 2개 테스트 삭제 후 3개로 교체 (REQ-044 반영 + 무토큰 401 추가) +- `tests/test_v1_daemons.py`: 신규 생성 — REQ-045 AC 전체 + REQ-044 회귀 커버 + +## Execution Log + +1. `app/api/v1_assets.py`: `require_read` import, `list_catalog_assets` Depends 교체 +2. `app/schemas/v1.py`: DaemonResponse, DaemonListResponse 스키마 추가 (파일 말미) +3. `app/api/v1_daemons.py`: 신규 작성 (GET /v1/daemons, user_id=None → 빈 목록 200) +4. `app/main.py`: v1_daemons_router import + include_router +5. `tests/test_v1.py`: 영향받는 5개 테스트 업데이트 +6. `tests/test_asset_catalog.py`: 영향받는 2개 테스트 → 3개로 교체 +7. `tests/test_v1_daemons.py`: 신규 생성 (13개 테스트) +8. pytest 전체 실행 → 결과 verification.md에 기록 + +## Validation + +- pytest 전체 실행 결과: verification.md 참조 + +## Remaining Risks + +- `list_daemons_for_user`는 DB 라이브 테스트 없이 mock으로만 검증됨 (DB 통합 테스트는 비활성화 환경 밖에서 필요) diff --git a/docs/batches/bat-019_20260627_assets-read-permission-and-daemon-list/index.md b/docs/batches/bat-019_20260627_assets-read-permission-and-daemon-list/index.md new file mode 100644 index 0000000..0ad0296 --- /dev/null +++ b/docs/batches/bat-019_20260627_assets-read-permission-and-daemon-list/index.md @@ -0,0 +1,22 @@ +# BAT-019 Index + +- Status: in-delivery +- Profile: standard +- Batch: BAT-019 +- Discovery: dcy-009 +- Created: 2026-06-27 + +## Included REQ + +- REQ-044: GET /v1/assets 권한 완화 (require_admin → require_read) +- REQ-045: 사용자 가용 데몬 목록 엔드포인트 신규 (GET /v1/daemons) + +## Scope Summary + +GET /v1/assets 엔드포인트의 인증 의존성을 `require_admin`에서 `require_read`로 완화한다. +신규로 GET /v1/daemons 엔드포인트를 추가해 caller 소유 데몬 목록을 반환한다(require_read). +asset 쓰기 엔드포인트는 require_admin을 유지한다. + +## Folder + +[bat-019](./bat-019_20260627_assets-read-permission-and-daemon-list/) diff --git a/docs/batches/bat-019_20260627_assets-read-permission-and-daemon-list/planning.md b/docs/batches/bat-019_20260627_assets-read-permission-and-daemon-list/planning.md new file mode 100644 index 0000000..f450d71 --- /dev/null +++ b/docs/batches/bat-019_20260627_assets-read-permission-and-daemon-list/planning.md @@ -0,0 +1,62 @@ +# BAT-019 Planning + +- Status: confirmed +- Batch: BAT-019 +- Profile: standard + +## Included REQ + +- REQ-044: GET /v1/assets 권한 완화 (require_admin → require_read) +- REQ-045: 사용자 가용 데몬 목록 엔드포인트 신규 (GET /v1/daemons) + +## Delivery Plan + +### REQ-044 순서 + +1. `app/api/v1_assets.py`의 `list_catalog_assets` 의존성을 `require_read`로 교체. +2. 나머지 쓰기 엔드포인트(POST/PATCH/DELETE)는 `require_admin` 유지. +3. 기존 test_asset_catalog.py의 `test_get_v1_assets_user_token_returns_403`, + `test_get_v1_assets_service_token_returns_403` 등 403 기대 테스트를 + 200/403 신규 기대치로 업데이트. + +### REQ-045 순서 + +1. `app/schemas/v1.py`에 `DaemonResponse`, `DaemonListResponse` 스키마 추가. +2. `app/api/v1_daemons.py` 신규 생성: GET /v1/daemons 라우터. +3. `app/main.py`에 v1_daemons 라우터 include_router 등록. +4. `tests/test_v1_daemons.py` 신규 생성: AC 전체 커버. + +### 구현 순서 + +REQ-044(단순 1줄 변경) → REQ-045(스키마 → 라우터 → 등록 → 테스트) → 테스트 전체 실행 + +## Dependencies + +- Internal: + - `app/core/auth.py`: `require_read` 의존성 (이미 존재) + - `app/domain/models.py`: `Daemon` 도메인 모델 (이미 존재, line 66) + - `app/services/auth_service.py`: `find_candidate_daemons_for_user` (이미 존재, line 161) + - `app/repositories/portfolio_repository.py`: `list_daemons_for_user` (이미 프로토콜 선언됨, line 103) +- External: + - 없음 + +## Design Gate + +- Design Required Before Implementation: yes +- Reason: REQ-045 신규 엔드포인트이므로 경로 컨벤션, 응답 래퍼 스키마, env/service 토큰(user_id=None) 처리, active 필터 여부를 design에서 확정해야 안전하게 구현할 수 있다. REQ-044는 단순 교체지만 같은 batch이므로 design에 함께 기록한다. + +## Milestones + +1. BAT-019 batch 폴더 및 문서 생성 완료 +2. Planning 확정 +3. Design 확정 (경로·스키마·env토큰 처리) +4. REQ-044 구현 + 테스트 조정 +5. REQ-045 구현 + 신규 테스트 +6. pytest 전체 통과 +7. Verification 완료 + +## Risks + +- REQ-044: 기존 테스트 중 `require_admin` 기대 403 케이스가 다수 있어 신규 200 기대로 업데이트 필요. 누락 시 기존 테스트 깨짐. +- REQ-045: `user_id=None`(env/service 토큰) 처리를 design에서 명확히 결정해야 구현이 일관됨. +- REQ-045: `AuthService.find_candidate_daemons_for_user`가 `list_daemons_for_user`를 호출하는데, InMemoryRepository 미구현 시 서비스 레이어를 직접 mock해야 함. diff --git a/tests/test_asset_catalog.py b/tests/test_asset_catalog.py index fe27e26..c78b0dd 100644 --- a/tests/test_asset_catalog.py +++ b/tests/test_asset_catalog.py @@ -1023,25 +1023,40 @@ def test_get_v1_assets_market_filter() -> None: assert items[0]["symbol"] == "005930" -# REQ-010 AC-12: non-admin → HTTP 403 -def test_get_v1_assets_user_token_returns_403() -> None: +# REQ-044: GET /v1/assets is now require_read — user/service tokens return 200 +def test_get_v1_assets_user_token_returns_200() -> None: + """REQ-044: read user token accepted by GET /v1/assets.""" client, _, patches = _make_catalog_get_client(_USER_KW, [], {}) try: resp = client.get("/v1/assets", headers={"Authorization": "Bearer user"}) finally: for p in patches: p.stop() - assert resp.status_code == 403 + assert resp.status_code == 200 + assert resp.json()["total"] == 0 -def test_get_v1_assets_service_token_returns_403() -> None: +def test_get_v1_assets_service_token_returns_200() -> None: + """REQ-044: trade-write service token accepted by GET /v1/assets (trade-write >= read).""" client, _, patches = _make_catalog_get_client(_SERVICE_KW, [], {}) try: resp = client.get("/v1/assets", headers={"Authorization": "Bearer svc"}) finally: for p in patches: p.stop() - assert resp.status_code == 403 + assert resp.status_code == 200 + assert resp.json()["total"] == 0 + + +def test_get_v1_assets_no_token_returns_401() -> None: + """GET /v1/assets without token returns 401 (require_read still gates auth).""" + client, _, patches = _make_catalog_get_client(_ADMIN_KW, [], {}) + try: + resp = client.get("/v1/assets") # no Authorization header + finally: + for p in patches: + p.stop() + assert resp.status_code == 401 # --------------------------------------------------------------------------- diff --git a/tests/test_daemon_assignment.py b/tests/test_daemon_assignment.py index b2df8c8..a6c41df 100644 --- a/tests/test_daemon_assignment.py +++ b/tests/test_daemon_assignment.py @@ -64,30 +64,38 @@ def _ledger_trade_payload() -> dict: # (daemon-scoped portfolio list moved to GET /v1/portfolios) # --------------------------------------------------------------------------- -def test_assets_403_when_token_has_no_daemon_id(monkeypatch) -> None: - """GET /v1/assets requires admin — service token with no daemon_id gets 403 (Insufficient permission).""" +def test_assets_200_when_token_has_no_daemon_id(monkeypatch) -> None: + """REQ-044: GET /v1/assets now requires only read — trade-write service token (no daemon_id) gets 200.""" client = _app_with_db(monkeypatch) principal = _principal(daemon_id=None) mock_repo = MagicMock() mock_repo.get_token_principal.return_value = principal + mock_repo.list_assets.return_value = [] + mock_repo.get_tags_by_ids.return_value = {} - with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo): + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ + patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo): resp = client.get("/v1/assets", headers=_svc_header()) - assert resp.status_code == 403 + assert resp.status_code == 200 + assert resp.json()["total"] == 0 def test_assets_200_with_daemon_scoped_filter(monkeypatch) -> None: - """GET /v1/assets requires admin — service token with daemon_id is also rejected.""" + """REQ-044: GET /v1/assets now requires only read — trade-write service token with daemon_id also gets 200.""" client = _app_with_db(monkeypatch) principal = _principal(daemon_id="d-1") mock_repo = MagicMock() mock_repo.get_token_principal.return_value = principal + mock_repo.list_assets.return_value = [] + mock_repo.get_tags_by_ids.return_value = {} - with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo): + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ + patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo): resp = client.get("/v1/assets", headers=_svc_header()) - assert resp.status_code == 403 + assert resp.status_code == 200 + assert resp.json()["total"] == 0 # --------------------------------------------------------------------------- diff --git a/tests/test_tags_master.py b/tests/test_tags_master.py index 7176f2c..bb8761a 100644 --- a/tests/test_tags_master.py +++ b/tests/test_tags_master.py @@ -320,54 +320,98 @@ def test_asset_item_empty_tag_ids_returns_none() -> None: def test_v1_assets_active_tag_not_archived(monkeypatch) -> None: - """GET /v1/assets는 admin 전용 — user token은 403 반환 (archived 표시는 GET /v1/portfolios로 이관).""" + """REQ-044: GET /v1/assets now accepts read token — active tag returns archived=False.""" + from app.domain.models import Asset, Tag + import datetime as _dt settings = _make_v1_settings(monkeypatch) principal = TokenPrincipal( token_id="t1", user_id=_USER_ID, permission="read", token_type="user", runtime_id="rt-1", ) + asset = Asset( + asset_id="a-1", symbol="005930", markets=["KRX"], + status="active", tag_ids=[_TEST_TAG_ID_1], + created_at=_dt.datetime.now(_dt.timezone.utc), + updated_at=_dt.datetime.now(_dt.timezone.utc), + ) + active_tag = Tag(tag_id=_TEST_TAG_ID_1, name="kospi200", created_by_user_id="admin", is_active=True) mock_repo = MagicMock() mock_repo.get_token_principal.return_value = principal + mock_repo.list_assets.return_value = [asset] + mock_repo.get_tags_by_ids.return_value = {_TEST_TAG_ID_1: active_tag} - with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo): + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ + patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo): client = _make_v1_client(settings) resp = client.get("/v1/assets", headers={"Authorization": "Bearer svc-token"}) - assert resp.status_code == 403 + assert resp.status_code == 200 + item = resp.json()["items"][0] + assert item["tag_ids"] == [{"tag_id": _TEST_TAG_ID_1, "archived": False}] def test_v1_assets_inactive_tag_archived_true(monkeypatch) -> None: - """GET /v1/assets는 admin 전용 — user token은 403 반환.""" + """REQ-044: GET /v1/assets now accepts read token — inactive tag returns archived=True.""" + from app.domain.models import Asset, Tag + import datetime as _dt settings = _make_v1_settings(monkeypatch) principal = TokenPrincipal( token_id="t1", user_id=_USER_ID, permission="read", token_type="user", runtime_id="rt-1", ) + asset = Asset( + asset_id="a-1", symbol="005930", markets=["KRX"], + status="active", tag_ids=[_TEST_TAG_ID_1, _TEST_TAG_ID_2], + created_at=_dt.datetime.now(_dt.timezone.utc), + updated_at=_dt.datetime.now(_dt.timezone.utc), + ) + active_tag = Tag(tag_id=_TEST_TAG_ID_1, name="kospi200", created_by_user_id="admin", is_active=True) + inactive_tag = Tag(tag_id=_TEST_TAG_ID_2, name="legacy", created_by_user_id="admin", is_active=False) mock_repo = MagicMock() mock_repo.get_token_principal.return_value = principal + mock_repo.list_assets.return_value = [asset] + mock_repo.get_tags_by_ids.return_value = {_TEST_TAG_ID_1: active_tag, _TEST_TAG_ID_2: inactive_tag} - with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo): + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ + patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo): client = _make_v1_client(settings) resp = client.get("/v1/assets", headers={"Authorization": "Bearer svc-token"}) - assert resp.status_code == 403 + assert resp.status_code == 200 + item = resp.json()["items"][0] + by_id = {t["tag_id"]: t for t in item["tag_ids"]} + assert by_id[_TEST_TAG_ID_1]["archived"] is False + assert by_id[_TEST_TAG_ID_2]["archived"] is True def test_v1_assets_empty_tag_ids_returns_none(monkeypatch) -> None: - """GET /v1/assets는 admin 전용 — user token은 403 반환.""" + """REQ-044: GET /v1/assets now accepts read token — asset with no tags returns empty tag_ids.""" + from app.domain.models import Asset + import datetime as _dt settings = _make_v1_settings(monkeypatch) principal = TokenPrincipal( token_id="t1", user_id=_USER_ID, permission="read", token_type="user", runtime_id="rt-1", ) + asset = Asset( + asset_id="a-1", symbol="005930", markets=["KRX"], + status="active", tag_ids=[], + created_at=_dt.datetime.now(_dt.timezone.utc), + updated_at=_dt.datetime.now(_dt.timezone.utc), + ) mock_repo = MagicMock() mock_repo.get_token_principal.return_value = principal + mock_repo.list_assets.return_value = [asset] + mock_repo.get_tags_by_ids.return_value = {} - with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo): + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ + patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo): client = _make_v1_client(settings) resp = client.get("/v1/assets", headers={"Authorization": "Bearer svc-token"}) - assert resp.status_code == 403 + assert resp.status_code == 200 + item = resp.json()["items"][0] + assert item["tag_ids"] == [] # --------------------------------------------------------------------------- diff --git a/tests/test_v1.py b/tests/test_v1.py index f84ba6d..c3163bd 100644 --- a/tests/test_v1.py +++ b/tests/test_v1.py @@ -610,18 +610,26 @@ def test_v1_assets_requires_service_token(monkeypatch) -> None: assert resp.status_code == 401 -def test_v1_assets_rejects_env_token() -> None: +def test_v1_assets_env_read_token_returns_200(monkeypatch) -> None: + """REQ-044: env read token is now accepted (require_read) — list_catalog_assets.""" from app.core.config import get_settings + from app.domain.models import Asset + import datetime as _dt settings = _make_settings(db_enabled=False, read_tokens_raw="env-token") + mock_repo = MagicMock() + mock_repo.list_assets.return_value = [] + mock_repo.get_tags_by_ids.return_value = {} app = create_app(settings_factory=lambda: settings) app.dependency_overrides[get_settings] = lambda: settings client = TestClient(app) - resp = client.get("/v1/assets", headers=_service_token_header("env-token")) - assert resp.status_code == 403 + with patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo): + resp = client.get("/v1/assets", headers=_service_token_header("env-token")) + assert resp.status_code == 200 + assert resp.json()["total"] == 0 def test_v1_assets_returns_asset_list(monkeypatch) -> None: - """GET /v1/assets requires admin — service token is rejected.""" + """REQ-044: GET /v1/assets now accepts read service token — returns 200 with catalog list.""" monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true") principal = TokenPrincipal( token_id="t1", user_id="user-1", permission="read", @@ -629,12 +637,16 @@ def test_v1_assets_returns_asset_list(monkeypatch) -> None: ) mock_repo = MagicMock() mock_repo.get_token_principal.return_value = principal + mock_repo.list_assets.return_value = [] + mock_repo.get_tags_by_ids.return_value = {} - with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo): + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ + patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo): client = _make_app() resp = client.get("/v1/assets", headers=_service_token_header()) - assert resp.status_code == 403 + assert resp.status_code == 200 + assert resp.json()["total"] == 0 # --------------------------------------------------------------------------- @@ -642,7 +654,7 @@ def test_v1_assets_returns_asset_list(monkeypatch) -> None: # --------------------------------------------------------------------------- def test_v1_assets_user_token(monkeypatch) -> None: - """User token is rejected — admin catalog endpoint requires admin permission.""" + """REQ-044: user read token is now accepted (require_read) — returns 200.""" settings = _make_app_db(monkeypatch) principal = TokenPrincipal( token_id="t1", user_id="user-1", permission="read", @@ -650,12 +662,16 @@ def test_v1_assets_user_token(monkeypatch) -> None: ) mock_repo = MagicMock() mock_repo.get_token_principal.return_value = principal + mock_repo.list_assets.return_value = [] + mock_repo.get_tags_by_ids.return_value = {} - with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo): + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ + patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo): client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY)) resp = client.get("/v1/assets", headers=_service_token_header()) - assert resp.status_code == 403 + assert resp.status_code == 200 + assert resp.json()["total"] == 0 def test_v1_assets_admin_token_default(monkeypatch) -> None: @@ -716,24 +732,44 @@ def test_v1_assets_admin_token_user_id_param(monkeypatch) -> None: def test_v1_assets_market_filter(monkeypatch) -> None: - """Service token is rejected by admin catalog endpoint — REQ-010 AC-12.""" + """REQ-044: read service token with market filter now returns 200 (require_read).""" + from app.domain.models import Asset + import datetime as _dt settings = _make_app_db(monkeypatch) + krx_asset = Asset( + asset_id="a-krx", symbol="005930", markets=["KRX"], + display_name="Samsung", status="active", + created_at=_dt.datetime.now(_dt.timezone.utc), + updated_at=_dt.datetime.now(_dt.timezone.utc), + ) + nasdaq_asset = Asset( + asset_id="a-nasdaq", symbol="AAPL", markets=["NASDAQ"], + display_name="Apple", status="active", + created_at=_dt.datetime.now(_dt.timezone.utc), + updated_at=_dt.datetime.now(_dt.timezone.utc), + ) principal = TokenPrincipal( token_id="t1", user_id="user-1", permission="read", token_type="service", runtime_id="rt-1", daemon_id="d-1", ) mock_repo = MagicMock() mock_repo.get_token_principal.return_value = principal + mock_repo.list_assets.return_value = [krx_asset, nasdaq_asset] + mock_repo.get_tags_by_ids.return_value = {} - with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo): + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ + patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo): client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY)) resp = client.get("/v1/assets?market=KRX", headers=_service_token_header()) - assert resp.status_code == 403 + assert resp.status_code == 200 + items = resp.json()["items"] + assert len(items) == 1 + assert items[0]["symbol"] == "005930" -def test_v1_assets_asset_id_is_uuid(monkeypatch) -> None: - """Service token is rejected by admin catalog endpoint.""" +def test_v1_assets_read_token_200(monkeypatch) -> None: + """REQ-044: read service token accepted by GET /v1/assets (require_read).""" settings = _make_app_db(monkeypatch) principal = TokenPrincipal( token_id="t1", user_id="user-1", permission="read", @@ -741,12 +777,16 @@ def test_v1_assets_asset_id_is_uuid(monkeypatch) -> None: ) mock_repo = MagicMock() mock_repo.get_token_principal.return_value = principal + mock_repo.list_assets.return_value = [] + mock_repo.get_tags_by_ids.return_value = {} - with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo): + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ + patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo): client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY)) resp = client.get("/v1/assets", headers=_service_token_header()) - assert resp.status_code == 403 + assert resp.status_code == 200 + assert resp.json()["total"] == 0 # --------------------------------------------------------------------------- diff --git a/tests/test_v1_daemons.py b/tests/test_v1_daemons.py new file mode 100644 index 0000000..262a985 --- /dev/null +++ b/tests/test_v1_daemons.py @@ -0,0 +1,349 @@ +"""Tests for BAT-019: GET /v1/daemons — caller-scoped daemon list (REQ-045).""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from app.core.config import Settings, get_settings +from app.domain.models import Daemon, TokenPrincipal +from app.main import create_app + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_TEST_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + + +def _make_settings(**kwargs) -> Settings: + return Settings( + allowed_origins_raw="http://localhost:3000", + **kwargs, + ) + + +def _make_app(settings: Settings) -> TestClient: + get_settings.cache_clear() + app = create_app(settings_factory=lambda: settings) + app.dependency_overrides[get_settings] = lambda: settings + return TestClient(app) + + +def _auth_header(token: str = "test-token") -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +def _make_daemon( + daemon_id: str = "d-1", + owner_user_id: str = "user-1", + daemon_slug: str = "trex-orb-v1", + display_name: str | None = "ORB v1", + is_active: bool = True, +) -> Daemon: + return Daemon( + id=daemon_id, + owner_user_id=owner_user_id, + daemon_slug=daemon_slug, + display_name=display_name, + is_active=is_active, + ) + + +def _make_mock_repo_with_principal(principal: TokenPrincipal, daemons: list[Daemon]) -> MagicMock: + mock_repo = MagicMock() + mock_repo.get_token_principal.return_value = principal + mock_repo.list_daemons_for_user.return_value = daemons + return mock_repo + + +_SETTINGS_DB = _make_settings(db_enabled=True, credential_encryption_key=_TEST_ENC_KEY) + +# --------------------------------------------------------------------------- +# REQ-045 AC: 사용자A(데몬2) → 자기 2개만 +# --------------------------------------------------------------------------- + +def test_daemons_returns_caller_owned_daemons() -> None: + """REQ-045 AC: 사용자A 소유 2개 데몬 → 2개 반환.""" + principal = TokenPrincipal( + token_id="t1", user_id="user-1", permission="read", + token_type="service", runtime_id="rt-1", + ) + daemons = [ + _make_daemon("d-1", "user-1", "trex-orb-v1", "ORB v1", is_active=True), + _make_daemon("d-2", "user-1", "trex-tsl-v1", "TSL v1", is_active=True), + ] + mock_repo = _make_mock_repo_with_principal(principal, daemons) + + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ + patch("app.api.v1_daemons.get_portfolio_repository_from_settings", return_value=mock_repo): + client = _make_app(_SETTINGS_DB) + resp = client.get("/v1/daemons", headers=_auth_header()) + + assert resp.status_code == 200 + body = resp.json() + assert body["total"] == 2 + assert len(body["daemons"]) == 2 + slugs = {d["daemon_slug"] for d in body["daemons"]} + assert slugs == {"trex-orb-v1", "trex-tsl-v1"} + + +# --------------------------------------------------------------------------- +# REQ-045 AC: 필드 4개 포함 +# --------------------------------------------------------------------------- + +def test_daemons_response_has_required_fields() -> None: + """REQ-045 AC: id, daemon_slug, display_name, is_active 필드 포함.""" + principal = TokenPrincipal( + token_id="t1", user_id="user-1", permission="read", + token_type="service", runtime_id="rt-1", + ) + daemon = _make_daemon("d-1", "user-1", "trex-orb-v1", "ORB v1", is_active=True) + mock_repo = _make_mock_repo_with_principal(principal, [daemon]) + + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ + patch("app.api.v1_daemons.get_portfolio_repository_from_settings", return_value=mock_repo): + client = _make_app(_SETTINGS_DB) + resp = client.get("/v1/daemons", headers=_auth_header()) + + assert resp.status_code == 200 + item = resp.json()["daemons"][0] + assert item["id"] == "d-1" + assert item["daemon_slug"] == "trex-orb-v1" + assert item["display_name"] == "ORB v1" + assert item["is_active"] is True + + +# --------------------------------------------------------------------------- +# REQ-045 AC: display_name None 허용 +# --------------------------------------------------------------------------- + +def test_daemons_display_name_can_be_none() -> None: + """REQ-045: display_name=None인 데몬도 정상 반환.""" + principal = TokenPrincipal( + token_id="t1", user_id="user-1", permission="read", + token_type="service", runtime_id="rt-1", + ) + daemon = _make_daemon("d-3", "user-1", "trex-custom", None, is_active=True) + mock_repo = _make_mock_repo_with_principal(principal, [daemon]) + + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ + patch("app.api.v1_daemons.get_portfolio_repository_from_settings", return_value=mock_repo): + client = _make_app(_SETTINGS_DB) + resp = client.get("/v1/daemons", headers=_auth_header()) + + assert resp.status_code == 200 + item = resp.json()["daemons"][0] + assert item["display_name"] is None + + +# --------------------------------------------------------------------------- +# REQ-045 AC: 타사용자 데몬 비노출 (사용자B 데몬은 user-2 소유) +# --------------------------------------------------------------------------- + +def test_daemons_does_not_expose_other_user_daemons() -> None: + """REQ-045 AC: 사용자B 데몬은 사용자A 조회 시 반환되지 않는다.""" + principal_a = TokenPrincipal( + token_id="t1", user_id="user-1", permission="read", + token_type="service", runtime_id="rt-1", + ) + # list_daemons_for_user("user-1")은 user-1 소유만 반환 (repository 레벨 격리) + daemons_for_user_a = [ + _make_daemon("d-1", "user-1", "trex-orb-v1", "ORB v1", is_active=True), + ] + mock_repo = _make_mock_repo_with_principal(principal_a, daemons_for_user_a) + + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ + patch("app.api.v1_daemons.get_portfolio_repository_from_settings", return_value=mock_repo): + client = _make_app(_SETTINGS_DB) + resp = client.get("/v1/daemons", headers=_auth_header()) + + assert resp.status_code == 200 + body = resp.json() + assert body["total"] == 1 + # user-2 소유 데몬은 없음 + owner_ids = {d.get("owner_user_id") for d in body["daemons"]} + # DaemonResponse는 owner_user_id 미포함이므로, repository 호출 인수 검증 + mock_repo.list_daemons_for_user.assert_called_once_with("user-1") + + +# --------------------------------------------------------------------------- +# REQ-045 AC: 데몬 없으면 빈 목록 200 +# --------------------------------------------------------------------------- + +def test_daemons_empty_list_returns_200() -> None: + """REQ-045 AC: 데몬 없는 사용자 → 빈 목록 200.""" + principal = TokenPrincipal( + token_id="t1", user_id="user-1", permission="read", + token_type="service", runtime_id="rt-1", + ) + mock_repo = _make_mock_repo_with_principal(principal, []) + + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ + patch("app.api.v1_daemons.get_portfolio_repository_from_settings", return_value=mock_repo): + client = _make_app(_SETTINGS_DB) + resp = client.get("/v1/daemons", headers=_auth_header()) + + assert resp.status_code == 200 + body = resp.json() + assert body["daemons"] == [] + assert body["total"] == 0 + + +# --------------------------------------------------------------------------- +# REQ-045 AC: 무토큰 → 401 +# --------------------------------------------------------------------------- + +def test_daemons_no_token_returns_401() -> None: + """REQ-045 AC: 무토큰 → 401.""" + settings = _make_settings(db_enabled=False) + client = _make_app(settings) + resp = client.get("/v1/daemons") + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# REQ-045 AC: admin도 caller 스코프 (admin user_id=None → 빈 목록) +# --------------------------------------------------------------------------- + +def test_daemons_admin_token_with_user_id_none_returns_empty() -> None: + """REQ-045: env admin 토큰(user_id=None)은 빈 목록 200 반환.""" + settings = _make_settings(db_enabled=False, admin_tokens_raw="admin-env-token") + mock_repo = MagicMock() + mock_repo.list_daemons_for_user.return_value = [] + + with patch("app.api.v1_daemons.get_portfolio_repository_from_settings", return_value=mock_repo): + client = _make_app(settings) + resp = client.get("/v1/daemons", headers=_auth_header("admin-env-token")) + + assert resp.status_code == 200 + body = resp.json() + assert body["daemons"] == [] + assert body["total"] == 0 + # user_id=None이므로 list_daemons_for_user는 호출되지 않아야 함 + mock_repo.list_daemons_for_user.assert_not_called() + + +def test_daemons_admin_token_with_user_id_returns_caller_daemons() -> None: + """REQ-045: admin DB 토큰(user_id='admin-1')은 admin-1 소유 데몬만 반환.""" + principal = TokenPrincipal( + token_id="t-admin", user_id="admin-1", permission="admin", + token_type="admin", runtime_id="rt-admin", + ) + daemons = [ + _make_daemon("d-admin-1", "admin-1", "admin-bot", "Admin Bot", is_active=True), + ] + mock_repo = _make_mock_repo_with_principal(principal, daemons) + + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ + patch("app.api.v1_daemons.get_portfolio_repository_from_settings", return_value=mock_repo): + client = _make_app(_SETTINGS_DB) + resp = client.get("/v1/daemons", headers=_auth_header()) + + assert resp.status_code == 200 + body = resp.json() + assert body["total"] == 1 + assert body["daemons"][0]["id"] == "d-admin-1" + mock_repo.list_daemons_for_user.assert_called_once_with("admin-1") + + +# --------------------------------------------------------------------------- +# REQ-045: is_active=False인 데몬도 반환 (소유 데몬 전체) +# --------------------------------------------------------------------------- + +def test_daemons_includes_inactive_daemons() -> None: + """REQ-045: inactive 데몬도 is_active=False로 반환됨.""" + principal = TokenPrincipal( + token_id="t1", user_id="user-1", permission="read", + token_type="service", runtime_id="rt-1", + ) + daemons = [ + _make_daemon("d-1", "user-1", "trex-orb-v1", "ORB v1", is_active=True), + _make_daemon("d-2", "user-1", "old-bot", "Old Bot", is_active=False), + ] + mock_repo = _make_mock_repo_with_principal(principal, daemons) + + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ + patch("app.api.v1_daemons.get_portfolio_repository_from_settings", return_value=mock_repo): + client = _make_app(_SETTINGS_DB) + resp = client.get("/v1/daemons", headers=_auth_header()) + + assert resp.status_code == 200 + body = resp.json() + assert body["total"] == 2 + active_flags = {d["daemon_slug"]: d["is_active"] for d in body["daemons"]} + assert active_flags["trex-orb-v1"] is True + assert active_flags["old-bot"] is False + + +# --------------------------------------------------------------------------- +# REQ-044 회귀: GET /v1/assets read 토큰 → 200, 쓰기는 admin 필요 → 403 +# --------------------------------------------------------------------------- + +def test_assets_read_token_200_regression() -> None: + """REQ-044 회귀: read 토큰으로 GET /v1/assets → 200.""" + principal = TokenPrincipal( + token_id="t1", user_id="user-1", permission="read", + token_type="service", runtime_id="rt-1", + ) + mock_repo = MagicMock() + mock_repo.get_token_principal.return_value = principal + mock_repo.list_assets.return_value = [] + mock_repo.get_tags_by_ids.return_value = {} + + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ + patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo): + client = _make_app(_SETTINGS_DB) + resp = client.get("/v1/assets", headers=_auth_header()) + + assert resp.status_code == 200 + assert resp.json()["total"] == 0 + + +def test_assets_write_requires_admin_regression() -> None: + """REQ-044 회귀: read 토큰으로 POST /v1/assets → 403 (쓰기는 require_admin 유지).""" + principal = TokenPrincipal( + token_id="t1", user_id="user-1", permission="read", + token_type="service", runtime_id="rt-1", + ) + mock_repo = MagicMock() + mock_repo.get_token_principal.return_value = principal + + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo): + client = _make_app(_SETTINGS_DB) + resp = client.post( + "/v1/assets", + json={"symbol": "005930", "markets": ["KRX"]}, + headers=_auth_header(), + ) + + assert resp.status_code == 403 + + +def test_assets_no_token_returns_401_regression() -> None: + """REQ-044 회귀: 무토큰 GET /v1/assets → 401.""" + settings = _make_settings(db_enabled=False) + client = _make_app(settings) + resp = client.get("/v1/assets") + assert resp.status_code == 401 + + +def test_assets_admin_get_200_regression() -> None: + """REQ-044 회귀: admin 토큰 GET /v1/assets → 200 (기존 동작 유지).""" + principal = TokenPrincipal( + token_id="t-admin", user_id="admin-1", permission="admin", + token_type="admin", runtime_id="rt-1", + ) + mock_repo = MagicMock() + mock_repo.get_token_principal.return_value = principal + mock_repo.list_assets.return_value = [] + mock_repo.get_tags_by_ids.return_value = {} + + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ + patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo): + client = _make_app(_SETTINGS_DB) + resp = client.get("/v1/assets", headers=_auth_header()) + + assert resp.status_code == 200 From 7581ec2f27cea07c310c9b9ce1344c6b72fee03d Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Sat, 27 Jun 2026 10:27:49 +0900 Subject: [PATCH 3/3] =?UTF-8?q?docs(bat-019):=20verification=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20+=20batch=20index=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 472 passed, 0 failed. confirm-batch-verification 팀장 대기. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5 --- .../verification.md | 94 +++++++++++++++++++ docs/batches/index.md | 1 + 2 files changed, 95 insertions(+) create mode 100644 docs/batches/bat-019_20260627_assets-read-permission-and-daemon-list/verification.md diff --git a/docs/batches/bat-019_20260627_assets-read-permission-and-daemon-list/verification.md b/docs/batches/bat-019_20260627_assets-read-permission-and-daemon-list/verification.md new file mode 100644 index 0000000..72eae13 --- /dev/null +++ b/docs/batches/bat-019_20260627_assets-read-permission-and-daemon-list/verification.md @@ -0,0 +1,94 @@ +# BAT-019 Verification + +- Status: draft +- Batch: BAT-019 + +## Acceptance Mapping + +### REQ-044: GET /v1/assets 권한 완화 + +- AC: read 토큰 GET /v1/assets → 200 + - Evidence: `test_v1_daemons.py::test_assets_read_token_200_regression` PASSED + - Evidence: `test_v1.py::test_v1_assets_returns_asset_list` PASSED (200, total=0) + - Evidence: `test_v1.py::test_v1_assets_env_read_token_returns_200` PASSED + - Evidence: `test_v1.py::test_v1_assets_user_token` PASSED (200, total=0) + - Evidence: `test_asset_catalog.py::test_get_v1_assets_user_token_returns_200` PASSED + - Evidence: `test_asset_catalog.py::test_get_v1_assets_service_token_returns_200` PASSED + +- AC: admin GET 200(회귀) + - Evidence: `test_v1_daemons.py::test_assets_admin_get_200_regression` PASSED + - Evidence: `test_v1.py::test_v1_assets_admin_token_default` PASSED + - Evidence: `test_asset_catalog.py::test_get_v1_assets_admin_returns_catalog_response` PASSED + +- AC: status·market 필터 유지 + - Evidence: `test_v1.py::test_v1_assets_market_filter` PASSED (read 토큰 + market=KRX → 1개 반환) + - Evidence: `test_asset_catalog.py::test_get_v1_assets_market_filter` PASSED + - Evidence: `test_asset_catalog.py::test_get_v1_assets_status_filter_all_includes_inactive` PASSED + - Evidence: `test_asset_catalog.py::test_get_v1_assets_status_filter_inactive_only` PASSED + - Evidence: `test_asset_catalog.py::test_get_v1_assets_status_filter_default_active_only` PASSED + +- AC: read 토큰 쓰기 403 + - Evidence: `test_v1_daemons.py::test_assets_write_requires_admin_regression` PASSED (POST /v1/assets read → 403) + - Evidence: `test_asset_catalog.py::test_asset_http_non_admin_403` PASSED (POST /v1/assets read → 403) + +- AC: 무토큰 401 + - Evidence: `test_v1_daemons.py::test_assets_no_token_returns_401_regression` PASSED + - Evidence: `test_v1.py::test_v1_assets_requires_service_token` PASSED + - Evidence: `test_asset_catalog.py::test_get_v1_assets_no_token_returns_401` PASSED + +### REQ-045: GET /v1/daemons 신규 엔드포인트 + +- AC: 사용자A(데몬2) → 자기 2개만 + - Evidence: `test_v1_daemons.py::test_daemons_returns_caller_owned_daemons` PASSED + +- AC: 필드 4개(id, daemon_slug, display_name, is_active) 포함 + - Evidence: `test_v1_daemons.py::test_daemons_response_has_required_fields` PASSED + +- AC: 사용자B 데몬 비노출 + - Evidence: `test_v1_daemons.py::test_daemons_does_not_expose_other_user_daemons` PASSED (list_daemons_for_user("user-1") 호출 검증) + +- AC: 데몬 없으면 빈 목록 200 + - Evidence: `test_v1_daemons.py::test_daemons_empty_list_returns_200` PASSED + +- AC: 무토큰 401 + - Evidence: `test_v1_daemons.py::test_daemons_no_token_returns_401` PASSED + +- AC: admin도 caller 스코프 + - Evidence: `test_v1_daemons.py::test_daemons_admin_token_with_user_id_returns_caller_daemons` PASSED (admin user_id="admin-1" → admin-1 소유만) + - Evidence: `test_v1_daemons.py::test_daemons_admin_token_with_user_id_none_returns_empty` PASSED (env admin 토큰 user_id=None → 빈 목록) + +### Design 결정 AC 검증 + +- OQ-2 경로: GET /v1/daemons 사용 확인 (라우터 prefix="/v1", path="/daemons") +- OQ-3 스키마: DaemonListResponse{daemons: [...], total: int} 사용 확인 +- env토큰 처리: user_id=None → 빈 목록 200 (`test_daemons_admin_token_with_user_id_none_returns_empty` PASSED) +- active 필터: 소유 전체 반환, is_active로 구분 (`test_daemons_includes_inactive_daemons` PASSED) + +## Evidence + +- Test: + - 전체 실행: `pytest tests/ -v` + - 결과: **472 passed, 0 failed, 11 warnings** (2026-06-27) + - 신규 테스트: `tests/test_v1_daemons.py` 13개 전부 PASSED + - 업데이트된 테스트: `test_v1.py` 5개, `test_asset_catalog.py` 2→3개, `test_daemon_assignment.py` 2개, `test_tags_master.py` 3개 + - 기존 v1_assets 회귀: 0 (모두 PASSED) + - 기존 auth 회귀: 0 (모두 PASSED) + +``` +pytest 실행 원문 마지막 줄: +====================================================== 472 passed, 11 warnings in 24.83s ====================================================== +``` + +- Manual: + - 없음 (DB 연결 없는 환경에서 mock 기반 단위 테스트로 모든 AC 커버) + +- Logs: + - 없음 + +## Result + +- Ready for Release: false +- Blocking Issues: + - 없음 (모든 AC 충족, 테스트 전체 통과) + - DB 통합 테스트는 로컬 인프라 필요(비활성 환경) — mock 기반 커버로 대체 + - `confirm-batch-verification` 팀장 승인 대기 diff --git a/docs/batches/index.md b/docs/batches/index.md index c578c59..c46a2cb 100644 --- a/docs/batches/index.md +++ b/docs/batches/index.md @@ -26,3 +26,4 @@ | BAT-016 | standard | released | REQ-036, REQ-037, REQ-038 | dcy-006 | [bat-016](./bat-016_20260605_ledger-accounting-consolidation/) | | BAT-017 | standard | released | REQ-039, REQ-040, REQ-041, REQ-042 | dcy-007 | [bat-017](./bat-017_20260618_trade-history-and-pnl-api/) | | BAT-018 | batch-lite | release-candidate | REQ-043 | dcy-008 | [bat-018](./bat-018_20260625_holdings-cost-basis-endpoint/) | +| BAT-019 | standard | in-delivery | REQ-044, REQ-045 | dcy-009 | [bat-019](./bat-019_20260627_assets-read-permission-and-daemon-list/) |