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

This commit is contained in:
2026-04-14 00:13:34 +09:00
parent e89690b1dd
commit 9787cf3159
22 changed files with 681 additions and 70 deletions
+6 -1
View File
@@ -1,11 +1,12 @@
# API Contracts
- Related REQ: `REQ-001`, `REQ-002`, `REQ-003`
- Related REQ: `REQ-001`, `REQ-002`, `REQ-003`, `REQ-004`
## 공통 원칙
- 응답은 JSON 기반으로 반환한다.
- 인증이 필요한 엔드포인트는 `Authorization: Bearer <token>`을 사용한다.
- 권한 계층은 `read` < `trade-write` < `admin`이다.
- `admin`이 아닌 요청은 포트폴리오 리소스 접근 시 `owner_user_id` 소유권 검사를 추가로 통과해야 한다.
- 에러 응답은 HTTP 상태 코드와 함께 원인 메시지를 제공한다.
- Base URL 예시는 `http://localhost:8000`을 기준으로 한다.
- JSON 예시는 대표 형태이며 일부 선택 필드를 생략할 수 있다.
@@ -95,6 +96,7 @@
#### `GET /portfolios`
- Auth: `read`
- Ownership: `admin`은 전체 조회, 그 외는 본인 소유(`owner_user_id == auth_context.user_id`)만 조회
- Query:
- `broker: string | null`
- `status_filter: string | null`
@@ -131,6 +133,7 @@ curl -X GET "http://localhost:8000/portfolios?broker=upbit&status_filter=enabled
#### `GET /portfolios/{portfolio_id}`
- Auth: `read`
- Ownership: `admin`이 아니고 소유주 불일치 시 `403`
- Query:
- `recent_trades: int = 5`
- 응답 `200`: `PortfolioDetailResponse`
@@ -148,6 +151,7 @@ curl -X GET "http://localhost:8000/portfolios/krw-btc?recent_trades=5" \
"status": "enabled",
"name": "Bitcoin",
"symbol": "KRW-BTC",
"owner_user_id": "user-1",
"cash_balance": 1000000.0,
"asset_quantity": 0.1,
"price": 100000000.0,
@@ -188,6 +192,7 @@ curl -X GET "http://localhost:8000/portfolios/krw-btc?recent_trades=5" \
#### `POST /portfolios`
- Auth: `trade-write`
- Ownership: 요청 body로 owner를 받지 않고 인증 컨텍스트 사용자로 `owner_user_id`를 강제 설정
- Body: `CreatePortfolioRequest`
- 응답 `201`: `PortfolioDetailResponse`
+6 -1
View File
@@ -1,6 +1,6 @@
# Auth and Token Lifecycle
- Related REQ: N/A
- Related REQ: `REQ-004`
## 목표
정적 토큰과 DB 발급 토큰을 병행하는 현재 인증 모델의 동작 기준을 명확히 한다.
@@ -13,6 +13,11 @@
| `trade-write` | 조회 + 포트폴리오 변경 API |
| `admin` | 전체 API + 사용자/봇 관리 |
소유권 결합 정책:
- `admin`이 아닌 주체는 포트폴리오 접근 시 리소스 소유주(`owner_user_id`) 일치 조건을 추가로 만족해야 한다.
- `admin`은 소유권 제약 없이 전체 포트폴리오를 접근할 수 있다.
## 인증 방식
- 보호된 API는 `Authorization: Bearer <token>` 헤더를 사용한다.
+2
View File
@@ -21,6 +21,7 @@
| `api-contracts.md` | HTTP API 상세 계약, request/response 모델, 대표 payload 예시 | `REQ-001`, `REQ-002`, `REQ-003` |
| `persistence-and-migrations.md` | PostgreSQL/Timescale 연동, 리포지토리 스위치, Alembic 운영, 스키마 baseline | `REQ-001`, `REQ-003` |
| `auth-and-token-lifecycle.md` | 로그인/토큰/봇 주체 라이프사이클 및 권한 모델 | N/A |
| `portfolio-ownership-and-auth-design.md` | 포트폴리오 소유주(`owner_user_id`) 도입, 소유권 접근 제어, 검증 시나리오 설계 | `REQ-004` |
| `management-cli-operations.md` | `portfoliohub` CLI 명령 책임, 종료코드, 운영 플로우 | N/A |
| `deployment-and-operations.md` | Linux systemd, Caddy reverse proxy, Docker Compose 운영 절차 | N/A |
@@ -31,6 +32,7 @@
| `REQ-001` | `api-contracts.md`, `persistence-and-migrations.md` |
| `REQ-002` | `api-contracts.md` |
| `REQ-003` | `architecture-overview.md`, `api-contracts.md`, `persistence-and-migrations.md` |
| `REQ-004` | `auth-and-token-lifecycle.md`, `api-contracts.md`, `persistence-and-migrations.md`, `portfolio-ownership-and-auth-design.md` |
## 권장 작성 순서
1. `project-structure-baseline.md`
+9 -6
View File
@@ -1,6 +1,6 @@
# Persistence and Migrations
- Related REQ: `REQ-001`, `REQ-003`
- Related REQ: `REQ-001`, `REQ-003`, `REQ-004`
## 목표
DB 기반 영속성 경로를 안정적으로 운영하고, 스키마 변경 이력과 문서 동기화를 일관되게 유지한다.
@@ -28,7 +28,7 @@ DB 기반 영속성 경로를 안정적으로 운영하고, 스키마 변경 이
## 스키마 문서 기준
- Source of truth: Alembic revisions in `migrations/versions`
- Current baseline revision: `20260410_0009`
- Current baseline revision: `20260413_0010`
- 상세 테이블/컬럼/인덱스 제약은 이 문서가 Alembic revision과 동기화된 기준 문서다.
## 마이그레이션 운영 흐름
@@ -158,7 +158,7 @@ Purpose:
Current baseline note:
- 아래 컬럼은 revision `20260403_0008`까지의 마이그레이션 기준이다.
- 아래 컬럼은 revision `20260413_0010`까지의 마이그레이션 기준이다.
| Column | Type | Null | Default | Constraints / Notes |
| --- | --- | --- | --- | --- |
@@ -167,6 +167,7 @@ Current baseline note:
| status | Text | NO | `enabled` | Portfolio status |
| name | Text | NO | - | Asset name |
| symbol | Text | NO | - | Trading symbol |
| owner_user_id | Text | NO | - | FK to `users.id` |
| cash_balance | Float | NO | - | Current cash balance |
| asset_quantity | Float | NO | - | Current asset quantity |
| asset_price | Float | NO | 0 | Current price snapshot |
@@ -180,7 +181,7 @@ Current baseline note:
Constraints:
- Primary key: `portfolio_id`
- Foreign key constraints: none in baseline
- Foreign key: `fk_portfolios_owner_user` on (`owner_user_id`) -> `users(id)`
#### portfolio_ledger
@@ -239,6 +240,7 @@ Constraints:
| `idx_bot_principals_active_created` | `bot_principals` | `is_active`, `created_at` | no | active/inactive bot filtering |
| `idx_portfolio_ledger_portfolio_time` | `portfolio_ledger` | `portfolio_id`, `recorded_at DESC` | no | recent ledger query |
| `idx_portfolios_broker_status_updated` | `portfolios` | `broker`, `status`, `updated_at DESC` | no | broker/status recency filtering |
| `idx_portfolios_owner_updated` | `portfolios` | `owner_user_id`, `updated_at DESC` | no | owner-scoped recency filtering |
| `idx_portfolio_comments_portfolio_created` | `portfolio_comments` | `portfolio_id`, `created_at DESC` | no | reverse chronological comments |
Conditional migration behavior:
@@ -248,9 +250,9 @@ Conditional migration behavior:
## Integrity Notes
현재 baseline은 DB-level FK constraint를 의도적으로 두지 않는다.
현재 baseline은 필수 ownership 제약(`portfolios.owner_user_id` -> `users.id`)만 DB-level FK를 사용한다.
- logical relation은 application/repository logic이 관리한다.
- ownership 외 logical relation은 application/repository logic이 관리한다.
- 향후 FK constraint를 추가하면 migration ID와 함께 본 문서를 갱신한다.
- application layer cascade:
- 포트폴리오 삭제 시 연관 `portfolio_comments`를 hard delete 한다.
@@ -273,6 +275,7 @@ Conditional migration behavior:
| 20260403_0007 | migrations/versions/20260403_0007_bot_principal_permission_only.py | Remove `owner_user_id` and enforce bot-local permission model |
| 20260403_0008 | migrations/versions/20260403_0008_portfolio_name_column.py | Add non-null `portfolios.name` and backfill from `symbol` |
| 20260410_0009 | migrations/versions/20260410_0009_portfolio_comments_table.py | Add `portfolio_comments` table for per-portfolio memo history |
| 20260413_0010 | migrations/versions/20260413_0010_portfolio_owner_user_id.py | Add `portfolios.owner_user_id`, backfill owner, create owner index, and add owner FK |
## Open Implementation Points
- `portfolio_ledger`의 Timescale hypertable 강제 여부와 보존 정책(retention)을 확정한다.
@@ -0,0 +1,137 @@
# Portfolio Ownership and Auth Design
- Related REQ: `REQ-004`
## 목표
- 포트폴리오 리소스에 소유주(`owner_user_id`)를 도입해 사용자 단위 데이터 격리를 강제한다.
- 기존 RBAC(`read`, `trade-write`, `admin`)와 소유권 검사 규칙을 결합해 접근 제어를 표준화한다.
- frontend와 portfolio-hub가 같은 서버에서 개별 컨테이너로 운영되는 구조에서도 단일 인증 체계로 일관된 권한 모델을 유지한다.
## 범위
- 포함:
- DB 스키마 변경 및 레거시 데이터 백필
- API/서비스/리포지토리 ownership 정책
- 통합 테스트 및 회귀 테스트 시나리오
- 제외:
- 외부 IdP/OIDC 연동
- 인증 서비스를 별도 프로세스로 분리하는 배포 작업
## DB 마이그레이션 설계
## Revision Plan
1. `owner_user_id` nullable 추가
2. 레거시 row 백필
3. 인덱스 추가
4. not null 제약 적용
5. 필요 시 FK 제약(`users.id`) 적용
## 권장 DDL 단계
1. 컬럼 추가
```sql
ALTER TABLE portfolios
ADD COLUMN owner_user_id TEXT;
```
2. 백필 기준 사용자 결정
- 환경변수 기반 bootstrap admin이 있으면 해당 사용자 id를 우선 사용한다.
- 없으면 `users.created_at ASC` 첫 사용자 id를 사용한다.
- 사용자 자체가 없으면 migration을 실패시키고 운영자가 사용자 생성 후 재시도한다.
3. 레거시 데이터 백필
```sql
UPDATE portfolios
SET owner_user_id = :fallback_user_id
WHERE owner_user_id IS NULL;
```
4. 제약 및 인덱스 적용
```sql
ALTER TABLE portfolios
ALTER COLUMN owner_user_id SET NOT NULL;
CREATE INDEX idx_portfolios_owner_updated
ON portfolios (owner_user_id, updated_at DESC);
```
5. 선택적 FK 제약
```sql
ALTER TABLE portfolios
ADD CONSTRAINT fk_portfolios_owner_user
FOREIGN KEY (owner_user_id) REFERENCES users(id);
```
## 롤백 전략
- downgrade에서는 FK, 인덱스, not null 제약 해제 후 컬럼 제거 순서로 진행한다.
- 운영 데이터 보존이 필요하면 컬럼 삭제 대신 nullable 유지 downgrade를 별도 운영 방침으로 둔다.
## API 권한 규칙 설계
## 공통 정책
- 1단계: RBAC 검사 통과 필요
- 2단계: admin이 아니면 ownership 검사 수행
- ownership 검사 기준: `portfolio.owner_user_id == auth_context.user_id`
- `auth_context.user_id`가 없는 요청은 ownership 필요한 엔드포인트에서 `403`
## Endpoint Policy Matrix
| Endpoint | RBAC 최소 권한 | Ownership 적용 | 비고 |
| --- | --- | --- | --- |
| `GET /portfolios` | read | 적용 | admin은 전체, 그 외 owner만 필터 |
| `GET /portfolios/{portfolio_id}` | read | 적용 | owner 불일치 시 403 |
| `POST /portfolios` | trade-write | 생성 시 owner 고정 | owner_user_id는 인증 주체로 강제 |
| `PATCH /portfolios/{portfolio_id}` | trade-write | 적용 | owner 불일치 시 403 |
| `POST /portfolios/{portfolio_id}/buy` | trade-write | 적용 | owner 불일치 시 403 |
| `POST /portfolios/{portfolio_id}/sell` | trade-write | 적용 | owner 불일치 시 403 |
| `POST /portfolios/{portfolio_id}/invest` | trade-write | 적용 | owner 불일치 시 403 |
| `POST /portfolios/{portfolio_id}/transfer` | trade-write | 적용 | owner 불일치 시 403 |
| `POST /portfolios/{portfolio_id}/reset` | trade-write | 적용 | owner 불일치 시 403 |
| `DELETE /portfolios/{portfolio_id}` | admin | admin only | 기존 정책 유지 |
| `POST/GET/PATCH/DELETE /portfolios/{id}/comments*` | read or trade-write | 적용 | 상위 포트폴리오 ownership을 재사용 |
## 서비스/리포지토리 반영 포인트
- Domain model:
- `Portfolio`에 `owner_user_id` 추가
- Schema:
- `PortfolioSummary`/`CreatePortfolioRequest` 응답/입력 계약에 owner 노출 정책 정의
- 외부 입력으로 owner_user_id 수동 지정은 금지
- Repository:
- create 시 owner_user_id 필수 인자
- list/get 시 요청자 컨텍스트 기반 owner 필터 함수 제공
- Service/API:
- request principal을 service 계층으로 전달
- 공통 ownership 가드 함수 도입
## frontend 연계 방침
- frontend는 credential 저장/검증 자체를 수행하지 않고 portfolio-hub 인증 API를 사용한다.
- 브라우저 경로는 HttpOnly + Secure cookie 기반 세션을 우선 고려한다.
- 동일 서버 내 별도 컨테이너 운영 시 reverse proxy에서 CORS/Cookie domain 정책을 일관되게 관리한다.
## 테스트 케이스 목록
## Migration
1. 기존 portfolios row가 존재할 때 owner_user_id 백필 후 null row가 0건인지 검증
2. users가 없는 상태에서 migration 실행 시 실패 동작 검증
3. downgrade 시 인덱스/제약 해제가 순서대로 수행되는지 검증
## API Authorization
1. 사용자 A 소유 portfolio를 사용자 B가 조회하면 403
2. 사용자 A 소유 portfolio를 사용자 B가 수정/매매/리셋하면 403
3. admin이 타 사용자 portfolio 조회/수정 가능
4. 일반 사용자의 `GET /portfolios`는 본인 소유만 반환
5. user_id 없는 bot principal 요청이 ownership 필요 API 호출 시 403
## API Creation
1. `POST /portfolios`에서 owner_user_id가 인증 사용자로 저장되는지 검증
2. 요청 body에 owner_user_id를 넣어도 무시되거나 validation 실패하는지 검증
## Regression
1. 기존 REQ-001 댓글 API가 ownership 정책 적용 후에도 owner 경계 내에서 정상 동작
2. 기존 RBAC 오류(`401`, `403`) 계약이 ownership 도입 후에도 유지
3. admin delete 동작이 ownership 변경과 충돌 없이 유지
## 추적 항목
- 본 설계 문서 구현 시 `docs/SRS/traceability-matrix.md`의 `REQ-004` 행에 Issue/PR/Test evidence를 갱신한다.