feat(bat-020): daemon-scoped 브로커 인증정보 (REQ-046~051)
- REQ-046: BrokerCredential.daemon_id 필드 추가, repository 시그니처 갱신 - REQ-047: alembic revision Step1(daemon_id NULL 컬럼 추가), Step2(PK 전환, dev/test DB 한정) - REQ-048: backfill-daemon-id CLI subcommand (--dry-run/--force/--broker 지원) - REQ-049: GET /v1/user_info daemon-scoped 단일 broker 전달 (brokers→broker, daemon_id 없으면 403) - REQ-050: credential set/list/remove --daemon-id 옵션 추가 (daemon_slug 해석) - REQ-051: 보안 격리 - service-token 전용, web 토큰 차단, 로그 app_secret 비노출, graceful degrade 비가역 결재 게이트: REQ-047 Step2(PK 전환)는 사장님 결재 후 실DB 적용 예정(이 batch는 revision 코드만) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
# BAT-020 Design
|
||||
|
||||
## Design 위임 2건 확정
|
||||
|
||||
### D-1: REQ-048 백필 구현방식 — CLI subcommand vs alembic data migration revision
|
||||
|
||||
**결정: CLI subcommand (`pfhub credential backfill-daemon-id`)**
|
||||
|
||||
근거:
|
||||
1. **운영자 제공 매핑 필요**: 어떤 user가 어떤 daemon_id에 매핑되는가는 인프라/운영 도메인 지식이 필요하며 자동화 불가 (REQ-048 Notes). alembic data migration revision은 매핑을 하드코딩하거나 env 변수에서 읽어야 해 유연성이 낮다.
|
||||
2. **--dry-run 필수 (REQ-048 AC-3)**: CLI subcommand는 typer option으로 dry-run을 자연스럽게 표현하고, 운영자가 실행 전 확인 후 적용 가능. alembic revision에 dry-run을 구현하면 비표준 패턴이 된다.
|
||||
3. **기존 CLI 패턴과 일관성**: 현재 `pfhub credential set/list/remove`가 이미 CLI로 구현돼 있으며, `--user` → user_id 변환 패턴을 그대로 활용 가능 (`get_user_by_username`).
|
||||
4. **반복 실행 가능**: 1회성 운영 작업이지만 개발 환경에서 반복 실행 가능하도록 CLI가 더 적합. alembic은 이미 적용된 revision을 재실행하지 않아 개발 테스트에 불편.
|
||||
|
||||
구현: `pfhub credential backfill-daemon-id --user <username> --daemon-id <daemon_slug_or_id> [--dry-run] [--force]`
|
||||
|
||||
---
|
||||
|
||||
### D-2: REQ-050 --daemon-id 입력 타입 + 과도기 정책
|
||||
|
||||
**결정 (1): `--daemon-id`는 daemon_slug를 받는다 (UUID도 허용, 내부에서 해석)**
|
||||
|
||||
근거:
|
||||
- 기존 `daemons` 테이블에 `daemon_slug` (TEXT, UNIQUE per owner)와 `id` (UUID TEXT) 두 식별자가 있다.
|
||||
- 기존 CLI 패턴: `--user`가 username(사람이 읽기 쉬운 식별자)을 받아 내부에서 `get_user_by_username()`으로 user_id 변환. 이 패턴과 동일하게 `--daemon-id`도 daemon_slug를 우선 받는다.
|
||||
- `get_daemon_by_slug(owner_user_id, daemon_slug)` 메서드가 이미 존재함 (postgres.py:2204). `--user`로 먼저 owner user를 조회하고, 그 user_id + daemon_slug로 daemon을 조회한다.
|
||||
- UUID 형식이면 `get_daemon_by_id()`로도 조회 시도 가능하나, 이 batch에서는 **daemon_slug 우선 + user 소유 검증**으로 단순화한다. UUID 직접 지정은 미지원(오운 user에서 slug로 찾지 못하면 에러).
|
||||
- 입력 파라미터 이름은 `--daemon-id`로 유지(REQ-050 명세)하되, 값은 daemon_slug로 해석.
|
||||
|
||||
**결정 (2): REQ-047 과도기(daemon_id nullable 구간)의 --daemon-id 필수/선택 정책**
|
||||
|
||||
- `pfhub credential set` — REQ-047 Step1 이후(nullable 구간): `--daemon-id` 미제공 시에도 upsert 허용 (기존 user-scoped 동작 유지). **단, 경고 메시지 출력**: "daemon_id 없이 등록된 인증정보는 REQ-047 Step2 PK 전환 후 backfill이 필요합니다."
|
||||
- 이유: 단계적 마이그레이션 과도기에 운영자가 기존 방식으로도 인증정보를 등록할 수 있어야 함. Step2 PK 전환 이후에는 daemon_id NOT NULL 제약이 DB 레벨에서 강제된다.
|
||||
- REQ-050 AC-6 ("--daemon-id 미제공 시 exit 1")은 REQ-047 Step2 PK 전환 완료 이후 상태 기준. 이 batch에서는 미제공 시 경고만 출력하고 허용. (AC-6은 Step2 이후 환경에서 DB 제약으로 자동 강제됨)
|
||||
- `pfhub credential remove` — `--daemon-id` 제공 시 (daemon_id, broker) 기반 삭제, 미제공 시 기존 (user_id, broker) 기반 삭제 (하위 호환).
|
||||
- `pfhub credential list` — `--daemon-id` 제공 시 해당 daemon_id 필터, 미제공 시 user 전체 (기존 동작).
|
||||
|
||||
---
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
### DS-1: UserInfoResponse.brokers → broker 단일화 (REQ-049)
|
||||
|
||||
- `brokers: list[BrokerItem]` 필드 제거, `broker: BrokerItem | None = None` 추가
|
||||
- 기존 소비자(trex-core hub_read.whoami)가 `daemon_id`만 읽고 `brokers` 미참조 → 파괴적 변경이나 실질 영향 없음 (dcy-010 OQ-2)
|
||||
- 이 batch의 기존 테스트 중 `body["brokers"]`를 참조하는 테스트는 `body["broker"]`로 갱신 필요
|
||||
|
||||
### DS-2: daemon_id 없음 → 403 (REQ-049, REQ-051)
|
||||
|
||||
- `auth.daemon_id is None`이면 HTTP 403 Forbidden 반환
|
||||
- 기존 `_require_service_token()` 함수는 token_type 검사만 함 → daemon_id 검사는 엔드포인트 핸들러 초입에서 추가
|
||||
|
||||
### DS-3: graceful degrade 계층 (REQ-049, REQ-051)
|
||||
|
||||
- `list_broker_credentials` DB 오류 → except → `broker=None` 반환 (기존 패턴 유지, 확장)
|
||||
- 복호화 실패 → `credential_payload=None`인 BrokerItem 반환 (기존 패턴 유지)
|
||||
- 어느 단계에서도 `app_key`, `app_secret` 값은 로그에 출력하지 않음
|
||||
|
||||
### DS-4: _row_to_broker_credential 하위호환 (REQ-046 AC-4)
|
||||
|
||||
- 기존 DB(daemon_id 컬럼 없음): 6컬럼 행 (user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at)
|
||||
- REQ-047 Step1 이후: 7컬럼 행 (위 + daemon_id)
|
||||
- `_row_to_broker_credential(row)` — `len(row) > 6`이면 `row[6]`을 daemon_id로 읽고, 없으면 None. 단, SELECT 쿼리에서 daemon_id를 명시적으로 포함하는 것이 더 안전. 따라서 쿼리에 daemon_id 컬럼을 추가하되, 컬럼 미존재 시 에러가 나지 않도록 Step1 revision 이후에만 활성화.
|
||||
- 실용적 접근: 현재 `_SELECT_*` 상수 방식이 아니라 inline SQL이므로, `list_broker_credentials`에서 daemon_id를 선택적으로 포함하는 것이 복잡. 대신 `_row_to_broker_credential`에서 인덱스 기반 조회 시 `len(row) > 6`으로 분기.
|
||||
- 단, `list_broker_credentials` 쿼리에 daemon_id 컬럼이 없으면 row에 포함되지 않아 항상 6컬럼 → daemon_id 항상 None. 이는 Step1 이전에는 올바른 동작이나 Step1 이후엔 정보 손실.
|
||||
- **최종 결정**: `list_broker_credentials` 및 `upsert_broker_credential` 쿼리에 `daemon_id` 컬럼을 포함하고, `_row_to_broker_credential`이 7컬럼 행을 처리. 단 ensure_schema의 broker_credentials CREATE TABLE은 수정하지 않음 (Step2 이후 ensure_schema 갱신은 별도 주석).
|
||||
|
||||
### DS-5: upsert_broker_credential ON CONFLICT 처리 (REQ-046 Notes)
|
||||
|
||||
- REQ-047 Step1 이후 daemon_id 컬럼 존재하나 PK는 여전히 (user_id, broker) → ON CONFLICT 대상 불변
|
||||
- REQ-047 Step2 이후 PK가 (daemon_id, broker)로 변경 → ON CONFLICT 절이 달라짐
|
||||
- 이 batch에서는 Step2 이후 ON CONFLICT 절은 TODO 주석으로 표시하고, 현재는 기존 (user_id, broker) ON CONFLICT 유지 + daemon_id INSERT 컬럼 추가
|
||||
|
||||
### DS-6: backfill CLI `get_daemon` 조회 방식 (REQ-048)
|
||||
|
||||
- `--daemon-id <value>`: daemon_slug로 먼저 시도 (`get_daemon_by_slug(owner_user_id, value)`), 실패 시 `get_daemon_by_id(value)` 재시도
|
||||
- `--user <username>`으로 user_id를 먼저 조회한 후 get_daemon_by_slug 호출
|
||||
- 단, backfill은 user-scoped 인증정보를 특정 daemon에 할당하는 작업이므로 `--user`와 `--daemon-id`를 함께 받음
|
||||
|
||||
## 기존 테스트 영향 분석
|
||||
|
||||
| 테스트 | 영향 | 조치 |
|
||||
| --- | --- | --- |
|
||||
| `test_v1.py::test_v1_user_info_returns_user` | `body["brokers"] == []` 참조 | `body["broker"]` is None 으로 변경 |
|
||||
| `test_v1.py::test_v1_user_info_returns_decrypted_broker_credentials` | `body["brokers"]` 리스트 참조 | `body["broker"]` 단일 필드 검증으로 변경 |
|
||||
| `test_v1.py::test_v1_user_info_degrades_when_broker_credentials_lookup_fails` | `body["brokers"] == []` 참조 | `body["broker"]` is None 으로 변경 |
|
||||
| `test_cli.py::FakeRepo.upsert_broker_credential` | daemon_id 파라미터 없음 | 시그니처에 daemon_id 추가 |
|
||||
| `test_cli.py::FakeRepo.list_broker_credentials` | daemon_id 필터 없음 | daemon_id 필터 추가 |
|
||||
| `test_cli.py::FakeRepo.delete_broker_credential` | daemon_id 파라미터 없음 | 시그니처에 daemon_id 추가 |
|
||||
@@ -0,0 +1,39 @@
|
||||
# BAT-020 — daemon-scoped 브로커 인증정보
|
||||
|
||||
- Batch ID: BAT-020
|
||||
- Profile: standard
|
||||
- Status: in-progress
|
||||
- Discovery: dcy-010
|
||||
- Included REQ: REQ-046, REQ-047, REQ-048, REQ-049, REQ-050, REQ-051
|
||||
- Folder: docs/batches/bat-020_20260628_daemon-scoped-broker-credential/
|
||||
- Created: 2026-06-28
|
||||
|
||||
## Summary
|
||||
dcy-010(daemon-scoped 브로커 인증정보 소유·전달)을 portfolio-hub에 구현한다.
|
||||
BrokerCredential 도메인 모델에 daemon_id 필드 추가, broker_credentials 테이블 PK 전환 마이그레이션(Step1/Step2), 백필 CLI, GET /v1/user_info daemon-scoped 단일 broker 전달, CLI --daemon-id 옵션, 보안 격리 검증.
|
||||
|
||||
## Included REQ
|
||||
|
||||
| REQ | 제목 | Type | 의존 |
|
||||
| --- | --- | --- | --- |
|
||||
| REQ-046 | BrokerCredential daemon_id 필드 추가 | Data | 없음 |
|
||||
| REQ-047 | broker_credentials PK 전환 마이그레이션 | Migration | REQ-046 |
|
||||
| REQ-048 | 기존 user-scoped 인증정보 daemon_id 백필 | Migration | REQ-047 Step1 |
|
||||
| REQ-049 | GET /v1/user_info daemon-scoped 단일 broker 전달 | Interface | REQ-046, REQ-047 |
|
||||
| REQ-050 | credential CLI --daemon-id 옵션 추가 | Interface | REQ-046, REQ-047 |
|
||||
| REQ-051 | 브로커 인증정보 보안 격리 | Non-Functional | REQ-049 |
|
||||
|
||||
## DoR (진입 조건)
|
||||
- [x] 6개 REQ 전건 Approved 상태 확인
|
||||
- [x] dcy-010 confirmed Discovery 확인
|
||||
- [x] 구현 대상 파일 존재 확인(app/domain/models.py, app/repositories/postgres.py, app/api/v1_user_info.py, app/schemas/v1.py, app/cli/commands/credential.py, migrations/versions/)
|
||||
- [x] design 위임 2건 식별 완료(REQ-048 구현방식, REQ-050 --daemon-id 입력 타입·과도기 정책)
|
||||
|
||||
## DoD (완료 조건)
|
||||
- [ ] REQ-046~051 전 AC 테스트 통과
|
||||
- [ ] alembic revision Step1/Step2 작성 완료(dev DB 검증)
|
||||
- [ ] 기존 테스트 회귀 없음
|
||||
- [ ] draft-batch-verification 문서에 evidence 기록
|
||||
|
||||
## 비가역 결재 게이트
|
||||
REQ-047 Step2(PK 전환) 실DB 적용은 사장님 결재 대상. 이 batch에서는 revision 코드 작성 + dev/test DB 검증까지만 수행.
|
||||
@@ -0,0 +1,73 @@
|
||||
# BAT-020 Planning
|
||||
|
||||
## 구현 의존 순서
|
||||
|
||||
1. **REQ-046** — BrokerCredential 도메인 모델 + repository 시그니처 변경 (daemon_id 필드)
|
||||
2. **REQ-047 Step1** — alembic revision: broker_credentials에 daemon_id TEXT NULL 컬럼 추가
|
||||
3. **REQ-048** — backfill-daemon-id CLI subcommand 추가
|
||||
4. **REQ-047 Step2** — alembic revision: PK (user_id,broker) → (daemon_id,broker) 전환 (dev/test DB만)
|
||||
5. **REQ-049** — GET /v1/user_info daemon-scoped 단일 broker 전달 (스키마 + 엔드포인트)
|
||||
6. **REQ-050** — credential CLI --daemon-id 옵션 추가 (set/list/remove)
|
||||
7. **REQ-051** — 보안 격리 테스트: service-token 전용, web 토큰 차단, 로그 비노출 검증
|
||||
|
||||
## Task Breakdown
|
||||
|
||||
### T-1: REQ-046 모델·repository 변경
|
||||
- `app/domain/models.py` — `BrokerCredential.daemon_id: str | None = None` 추가
|
||||
- `app/repositories/postgres.py` — `_row_to_broker_credential` daemon_id 처리 (7컬럼/6컬럼 하위호환)
|
||||
- `app/repositories/postgres.py` — `upsert_broker_credential(daemon_id=None)` 파라미터 추가
|
||||
- `app/repositories/postgres.py` — `list_broker_credentials(daemon_id=None)` 파라미터 + 필터 추가
|
||||
- `app/repositories/postgres.py` — `delete_broker_credential(daemon_id=None)` 파라미터 추가
|
||||
- Tests: REQ-046 AC-1~6 단위 테스트 신규
|
||||
|
||||
### T-2: REQ-047 Step1 alembic revision
|
||||
- `migrations/versions/20260628_0026_broker_credential_daemon_id_step1.py`
|
||||
- 내용: `broker_credentials`에 `daemon_id TEXT NULL` 컬럼 추가 + downgrade(컬럼 제거, PK 복원)
|
||||
- Tests: AC-1 (컬럼 존재), AC-2 (기존 데이터 NULL 보존), AC-6 (downgrade)
|
||||
|
||||
### T-3: REQ-048 backfill CLI
|
||||
- `app/cli/commands/credential.py` — `backfill-daemon-id` subcommand 추가
|
||||
- Tests: AC-1~6 단위 테스트 (mock repo 기반)
|
||||
|
||||
### T-4: REQ-047 Step2 alembic revision
|
||||
- `migrations/versions/20260628_0027_broker_credential_daemon_id_step2.py`
|
||||
- 내용: PK 전환 (user_id,broker) → (daemon_id,broker), daemon_id NOT NULL 제약, downgrade 안전장치
|
||||
- `app/repositories/postgres.py` — `ensure_schema()` broker_credentials 정의 최종 스키마로 갱신 (Step2 이후 활성화 주석)
|
||||
- Tests: AC-3~5 (dev DB에서 Step1→백필→Step2 순서 검증), AC-7 (downgrade 안전장치)
|
||||
|
||||
### T-5: REQ-049 user_info daemon-scoped 확장
|
||||
- `app/schemas/v1.py` — `UserInfoResponse.broker: BrokerItem | None = None`, `brokers: list` 제거
|
||||
- `app/api/v1_user_info.py` — daemon_id 없으면 403, daemon_id 기반 list_broker_credentials 호출, 단일 broker 반환
|
||||
- Tests: REQ-049 AC-1~9 (daemon_id 있음/없음, broker 있음/없음, 2데몬 격리, 복호화 실패, 회귀)
|
||||
|
||||
### T-6: REQ-050 credential CLI --daemon-id
|
||||
- `app/cli/commands/credential.py` — set/list/remove 에 `--daemon-id` 옵션 추가
|
||||
- `tests/test_cli.py` FakeRepo — upsert/list/delete에 daemon_id 파라미터 반영
|
||||
- Tests: REQ-050 AC-1~7
|
||||
|
||||
### T-7: REQ-051 보안 격리 테스트
|
||||
- web 토큰 403 (AC-1), 비인증 401 (AC-2), daemon_id=None 403 (AC-3), 타데몬 null (AC-4)
|
||||
- 타 엔드포인트 credential 미노출 (AC-5)
|
||||
- DB 오류 graceful degrade (AC-6), 복호화 실패 degrade (AC-7)
|
||||
- 로그 비노출 (AC-8, caplog fixture)
|
||||
|
||||
## 파일 변경 대상 요약
|
||||
|
||||
| 파일 | 변경 내용 |
|
||||
| --- | --- |
|
||||
| `app/domain/models.py` | BrokerCredential.daemon_id 추가 |
|
||||
| `app/repositories/postgres.py` | _row_to_broker_credential, upsert/list/delete 시그니처, ensure_schema broker_credentials |
|
||||
| `app/schemas/v1.py` | UserInfoResponse brokers→broker 단일화 |
|
||||
| `app/api/v1_user_info.py` | daemon-scoped 조회, daemon_id 없음 403, 로그 비노출 확인 |
|
||||
| `app/cli/commands/credential.py` | set/list/remove --daemon-id, backfill-daemon-id subcommand |
|
||||
| `migrations/versions/20260628_0026_*.py` | Step1 revision |
|
||||
| `migrations/versions/20260628_0027_*.py` | Step2 revision |
|
||||
| `tests/test_v1.py` | user_info 테스트 갱신 + 신규(daemon-scoped, 보안) |
|
||||
| `tests/test_cli.py` | credential CLI 테스트 갱신 + 신규 |
|
||||
| `tests/test_bat020_broker_credential_domain.py` | REQ-046 단위 테스트 신규 |
|
||||
| `tests/test_bat020_broker_credential_migration.py` | REQ-047 마이그레이션 테스트 신규 |
|
||||
|
||||
## 위험
|
||||
- REQ-049: `brokers` → `broker` 응답 변경으로 기존 `test_v1.py`의 `brokers` 필드 참조 테스트가 실패할 수 있음 → 갱신 필수
|
||||
- REQ-047 Step2: 실 DB 적용 금지(게이트). dev/test DB에서만 검증.
|
||||
- `get_daemon_by_slug`은 `owner_user_id` + `daemon_slug` 조합으로 조회 → REQ-050에서 daemon_slug로 받으면 user 조회 먼저 필요
|
||||
Reference in New Issue
Block a user