# REL-003: daemon-scoped 브로커 인증정보 + 관리 API (dcy-010 + dcy-011) - Status: draft - Profile: app-service - Included Batch: BAT-018 (REQ-043), BAT-019 (REQ-044, REQ-045), BAT-020 (REQ-046~051), BAT-021 (REQ-047 Step2 통합 및 런북), BAT-022 (REQ-052~054) --- ## Scope ### Included - **BAT-018** (dcy-008, batch-lite, release-candidate) — REQ-043: `GET /v1/portfolios/{id}/holdings/cost-basis` 엔드포인트 신설 (WAC/평단 조회) - **BAT-019** (dcy-009, standard, confirmed) — REQ-044/045: `GET /v1/assets` 권한 완화(require_read) + `GET /v1/daemons` 신설 - **BAT-020** (dcy-010, standard, release-candidate) — REQ-046~051: BrokerCredential daemon_id 도메인 모델 + Step1 마이그레이션 + backfill CLI + `/v1/user_info` daemon-scoped 확장 + CLI `--daemon-id` 옵션 + 보안 격리 - **BAT-021** (dcy-010, standard, release-candidate) — REQ-047: Step2 동반 코드 수정(ensure_schema/upsert ON CONFLICT) + 실 PostgreSQL 통합검증 + Step2 런북 (`docs/runbooks/bat-021-req047-step2-migration-runbook.md`) - **BAT-022** (dcy-011, standard, release-candidate) — REQ-052~054: 사용자 소유 스코프 인증정보 관리 API + admin 데몬 CRUD API + 인증정보 보안 격리(쓰기전용·secret 미반환·로그 미노출) - **(동반 배포) dcy-035 WAC #15** (`3a6adc9`, REQ-041 change-req CHG-20260626-01) — `/pnl/realized`·holdings cost-basis의 **매수 수수료를 WAC cost basis에 편입**. 이미 origin/main 머지됨 → 이번 portfolio-hub 코드 배포(main)에 함께 실린다. trex-core 운영리포트(dcy-035, REL-010)와 짝을 이루는 멀티데이 WAC 정확값의 portfolio-hub 측. (별도 배포·재시작 불필요 — 동일 배포에 포함) ### Excluded - REQ-047 Step2(PK 전환) 운영 DB 실제 적용 — **이번 릴리즈의 코드 배포 이전** ops 윈도우에서 별도 수행 (런북 `docs/runbooks/bat-021-req047-step2-migration-runbook.md` 엄수, 사장님 결재 게이트) - trex-core 소비자 어댑터 (KISConfig fetch 경로) — 별도 trex-core Discovery (dcy-010 out-of-scope) - trex-web UI (dcy-009) — trex-web repo 별도 진행 - 서비스 토큰 발급·폐기 (dcy-011 out-of-scope) --- ## 전제 조건 (배포 시작 전 필수 확인) 1. **`CREDENTIAL_ENCRYPTION_KEY`** — 운영 환경 env에 Fernet key 값이 설정돼 있어야 한다. 미설정 시 인증정보 암·복호화 전체가 동작하지 않는다. - 확인: 서비스 실행 환경에서 `echo $CREDENTIAL_ENCRYPTION_KEY | head -c 10` (공백이면 미설정) - 최초 설정: `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"` 결과를 `CREDENTIAL_ENCRYPTION_KEY`에 등록 - **주의**: 기존 운영 인스턴스에 이미 설정돼 있으면 변경 금지 (기존 암호화 payload를 복호화할 수 없게 됨) 2. **Step2 마이그레이션 완료 확인** — 코드 배포 전에 REQ-047 Step2(alembic revision `20260628_0027`) 적용이 완료되어야 한다. Step2 미적용 상태로 BAT-021 코드를 배포하면 `upsert_broker_credential`의 `ON CONFLICT (daemon_id, broker)` 절이 구 스키마(PK=`(user_id, broker)`)와 맞지 않아 인증정보 등록/회전 실패. - 검증 쿼리: `SELECT pg_get_constraintdef(oid) FROM pg_constraint WHERE conname = 'broker_credentials_pkey';` - 기대값: `PRIMARY KEY (daemon_id, broker)` 3. **alembic 상태 사전 확인** — 현행 운영 DB의 alembic head가 `20260628_0027` (Step2 완료 후) 인지 확인: ```bash alembic current # 기대: 20260628_0027 (head) ``` 4. **DB 백업** — Step2 마이그레이션 적용 전 운영 DB 전체 스냅샷 백업 (런북 1단계 참조). 5. **pytest 통과 확인** — main HEAD (`9fe6f3b`) 기준으로 `pytest` 전체가 통과해야 한다. - BAT-022 기준 565 passed + 핫픽스 커밋 포함 최신 스위트 통과 확인 필요. --- ## Rollout Plan > **배포 순서 엄수. 특히 Step2 미적용 상태에서 코드 배포하면 upsert 실패가 발생한다.** ``` [ops 윈도우] DB 백업 → Step1 upgrade → backfill CLI → NULL 0건 확인 → Step2 upgrade (사장님 결재 필수) → Step2 스키마 검증 [코드 배포] → portfolio-hub 서비스 rolling restart [smoke] → 헬스체크 → 스모크 테스트 체크리스트 실행 ``` ### 단계별 상세 **[ops 윈도우 전용 — 코드 배포 전 수행]** 1. **DB 백업**: 운영 DB 전체 스냅샷 (`pg_dump`) — 런북 1단계 참조 2. **Step1 upgrade** (`alembic upgrade 20260628_0026`): `broker_credentials`에 `daemon_id TEXT NULL` 컬럼 추가. 기존 PK `(user_id, broker)` 유지. 3. **backfill CLI**: `pfhub credential backfill-daemon-id --user --daemon-id ` — 기존 user-scoped 인증정보 행에 daemon_id 설정. 사용자별 반복. `--dry-run` 선 확인 권장. 4. **NULL 0건 검증**: `SELECT COUNT(*) FROM broker_credentials WHERE daemon_id IS NULL;` → 반드시 0 확인. 1건이라도 있으면 Step2 진행 금지 (upgrade가 RuntimeError로 자동 거부함). 5. **Step2 upgrade** (`alembic upgrade 20260628_0027`, **사장님 결재 필수**): PK를 `(user_id, broker)` → `(daemon_id, broker)`로 전환. `daemon_id NOT NULL` 제약 추가. **비가역 작업.** 6. **Step2 스키마 검증**: PK 전환·daemon_id NOT NULL·user_id 유지를 SQL 쿼리로 확인 — 런북 4단계 검증 SQL 참조. **[코드 배포]** 7. **portfolio-hub 서비스 rolling restart**: freeze 커밋 `9fe6f3b` (main HEAD) 이미지로 교체. - 포함 변경 모듈: `app/repositories/postgres.py` (ensure_schema·upsert ON CONFLICT), `app/api/v1_daemons.py` (admin CRUD·daemon 목록), `app/api/v1_credentials.py` (소유 스코프 관리 API), `app/api/v1_portfolios.py` (holdings/cost-basis), `app/api/v1_user_info.py` (daemon-scoped broker 전달), `app/cli/commands/credential.py` (--daemon-id·backfill), `app/domain/pnl.py` (calculate_current_wac) **[배포 후 smoke]** 8. 아래 Verification Checklist 항목 순서대로 실행. --- ## Rollback Plan ### 트리거 조건 - `GET /health` HTTP 200 실패 - `GET /v1/user_info` (service token) HTTP 500 또는 broker 필드 누락 - 인증정보 PUT/GET 연속 500 발생 - 기존 API(`/v1/portfolios`, `/v1/assets`, `/v1/daemons`) 회귀 발생 ### 코드 롤백 (1순위) BAT-022/021/020은 기존 기능의 additive 추가 및 REQ-049 동작 변경(brokers→broker 단일화)이 포함된다. 코드 롤백 시 REL-002 freeze 이미지(커밋 `d89f137`)로 복원하면 된다. DB는 Step2 이후 상태 그대로 둬도 무방 (코드가 구 스키마를 참조하지 않는 방향으로 하위호환 불가하여 DB 롤백 필요 여부를 판단). ``` portfolio-hub → REL-002 이미지(커밋 d89f137)로 rolling restart ``` ### DB 롤백 (Step2 롤백 — 조건부) Step2 downgrade는 같은 user의 여러 daemon이 동일 broker를 쓰는 경우 RuntimeError로 자동 거부된다. 단일 daemon 1계좌 환경이면 downgrade 가능: ```bash alembic downgrade 20260628_0026 # Step2 → Step1 상태 alembic downgrade 20260605_0025 # Step1 → 이전 상태 (daemon_id 컬럼 제거) ``` **경고**: Step2 downgrade는 조건부 가역이다. 백업 복원이 최후 수단. ```bash pg_restore -h -U -d -c backup_pre_bat021_.dump ``` ### 롤백 영향 분석 - REQ-049 (`/v1/user_info` daemon-scoped): brokers 리스트→단일 broker 변경. trex-core 소비자는 `daemon_id`만 읽으므로 무영향(소비자 인벤토리 확인 완료). - REQ-052/053: HTTP API 추가분 — additive. 롤백 시 엔드포인트 제거만 발생. 기존 운영 영향 없음. - REQ-043/044/045 (BAT-018/019): additive 추가·권한 완화. 롤백 시 신규 엔드포인트 제거 + GET /v1/assets가 require_admin으로 복원. --- ## Verification Checklist ### 배포 전 - [ ] Step2 완료 확인: `SELECT pg_get_constraintdef(oid) FROM pg_constraint WHERE conname = 'broker_credentials_pkey';` → `PRIMARY KEY (daemon_id, broker)` - [ ] alembic head 확인: `alembic current` → `20260628_0027 (head)` - [ ] NULL 잔존 0건: `SELECT COUNT(*) FROM broker_credentials WHERE daemon_id IS NULL;` → `0` - [ ] `CREDENTIAL_ENCRYPTION_KEY` 운영 환경 설정 확인 - [ ] pytest 전체 통과 확인 (freeze 커밋 `9fe6f3b` 기준) - [ ] DB 백업 완료 확인 (백업 파일 경로/사이즈 기록) ### 배포 직후 #### 기본 헬스 - [ ] `GET /health` → HTTP 200, `db_enabled: true` #### admin 데몬 CRUD (REQ-053 — BAT-022) - [ ] `POST /v1/admin/daemons` (admin-token) — 데몬 생성 → HTTP 201 + `DaemonAdminResponse` (id, owner_user_id, daemon_slug, is_active) - [ ] `GET /v1/admin/daemons` (admin-token) — 데몬 목록 → HTTP 200 + 데몬 목록 반환 - [ ] `GET /v1/admin/daemons/{daemon_id}` (admin-token) → HTTP 200 - [ ] `PATCH /v1/admin/daemons/{daemon_id}` (admin-token) — display_name 변경 → HTTP 200 - [ ] `PATCH /v1/admin/daemons/{daemon_id}` (admin-token, is_active=false) — 비활성화 → HTTP 200 + is_active=false - [ ] `DELETE /v1/admin/daemons/{daemon_id}` (admin-token) → HTTP 405 (하드 삭제 금지) - [ ] 비admin user-token으로 admin CRUD 시도 → HTTP 403 #### 인증정보 관리 API (REQ-052 — BAT-022) - [ ] `PUT /v1/daemons/{daemon_id}/credential` (user-token, 소유 데몬) → HTTP 200 + 메타데이터(account_ref, credential_version, last_rotated_at, broker) 응답; **app_key/app_secret 미포함 확인 (필드 부재)** - [ ] `GET /v1/daemons/{daemon_id}/credential` (user-token, 소유 데몬) → HTTP 200 + 메타데이터; **secret 미포함 확인** - [ ] `DELETE /v1/daemons/{daemon_id}/credential` (user-token, 소유 데몬) → HTTP 204 - [ ] 타 사용자 user-token으로 소유 스코프 외 데몬 인증정보 조회/수정 → HTTP 403 #### service-token `/v1/user_info` daemon-scoped (REQ-049 — BAT-020) - [ ] service-token(daemon_id 바인딩)으로 `GET /v1/user_info` → `broker` 단일 객체(account_ref, credential_payload 포함), `brokers` 리스트 키 없음 확인 - [ ] `CREDENTIAL_ENCRYPTION_KEY`와 일치하는 키로 인증정보를 등록한 경우 `credential_payload` 복호화 값(app_key 포함) 반환 확인 - [ ] daemon_id 없는 service-token으로 `/v1/user_info` → HTTP 403 - [ ] user-token으로 `/v1/user_info` → HTTP 403 (REQ-051 보안 격리 회귀) #### WAC 조회 엔드포인트 (REQ-043 — BAT-018) - [ ] `GET /v1/portfolios/{id}/holdings/cost-basis` (read-token, 소유 portfolio) → HTTP 200 + `items`, `cost_basis_from` 포함 #### 자산/데몬 목록 (REQ-044/045 — BAT-019) - [ ] `GET /v1/assets` (read-token, non-admin) → HTTP 200 (권한 완화 확인) - [ ] `GET /v1/daemons` (user-token) → HTTP 200 + 소유 데몬 목록 #### 기존 API 회귀 - [ ] `GET /v1/portfolios` (read-token 이상) → HTTP 200 - [ ] `POST /v1/portfolios/{id}/ledger/trades` (trade-write-token) → 기존 동작 유지 - [ ] `GET /v1/portfolios/{id}/trades` (read-token 이상) → HTTP 200 - [ ] `GET /v1/portfolios/{id}/pnl/realized` (read-token 이상) → HTTP 200 --- ## Known Issues (릴리즈 전 인지 사항) 1. **BAT-022 AC-3 통합 수준**: REQ-052 AC-3(CLI·API·service-token 3경로 정합)이 unit-level 검증. 실 DB 통합 환경에서 재확인 권장(비가역 아님, 선택적). 2. ~~owner 재할당 원자성~~ **(해소됨 — 오기 정정)**: `update_daemon_owner()`는 **단일 `conn.transaction()` 트랜잭션**으로 credential DELETE(선행) → owner UPDATE를 원자 처리한다(BAT-022 QC Major fix, 커밋 83e6dde). 어느 한쪽 실패 시 전체 롤백되어 "owner만 바뀌고 구 인증정보 잔존"하는 leak 상태가 발생하지 않는다. 라이브 통합검증(2026-06-28)에서 owner 재할당 → 인증정보 무효화 PASS. **리스크 아님.** 3. **DELETE credential broker 고정**: `DELETE /v1/daemons/{daemon_id}/credential`은 broker="kis" 고정. 다중 broker 지원 시 별도 설계 필요(현재 단일 broker 가정 설계). 4. **REL-002 Known Issues 이월**: symbol 필터 edge 테스트, HTTP_422 deprecated 경고, 성능 미검증 — 여전히 비차단 잔여. --- ## Release Execution Evidence - Release Status Transition Target: released ### Deployment Facts - Executed At: - (미실행 — 배포 전) - Completed At: - (미실행) - Environment: - portfolio-hub 운영 인스턴스 - Operator: - (ops 윈도우 담당자 기입) - Revision or Artifact: - 배포 커밋: `9fe6f3b` (main HEAD, 2026-06-28) - alembic head (코드 배포 후 기대): `20260628_0027` - Commands or Procedure: - 상세는 위 Rollout Plan 참조 - DB 마이그레이션: `docs/runbooks/bat-021-req047-step2-migration-runbook.md` 엄수 - 코드 배포: portfolio-hub 서비스 rolling restart (freeze 커밋 `9fe6f3b`) ### Profile-Specific Checks - app-service: - Health Endpoint: - (배포 후 기입) - Startup Log Fields: - (배포 후 기입) - Request Log Fields: - (배포 후 기입) - Smoke or Manual Verification: - (배포 후 Verification Checklist 결과 기입) ### Rollback Outcome - Rollback Required: - (배포 후 기입) - Rollback Trigger: - (배포 후 기입) - Rollback Action: - (배포 후 기입) ### Release Decision Evidence - Released Decision: - (배포 후 팀장 기입) - Rationale: - (배포 후 기입) - Recorded By: - (배포 후 기입) - Recorded At: - (배포 후 기입) --- ## Feedback Handoff - Observation Summary: - (배포 후 운영 관찰 기입) - Discovery Input: - trex-core 소비자 어댑터 (KISConfig fetch, dcy-010 out-of-scope) — 별도 trex-core Discovery (dcy-036/BAT-054로 진행, PR #35) - REQ Input: - (배포 후 기입) - Change Request Input: - (배포 후 기입)