From 2dab17cbb15337402af513c72555f305932ae230 Mon Sep 17 00:00:00 2001 From: Jinhwan Ko Date: Tue, 14 Apr 2026 00:34:44 +0900 Subject: [PATCH] feat(auth): owner-bound bot tokens and self-service bot management --- app/api/auth_routes.py | 79 ++++++++++++++- app/domain/models.py | 1 + app/repositories/portfolio_repository.py | 8 +- app/repositories/postgres.py | 90 ++++++++++++++--- app/schemas/auth.py | 30 +++++- app/services/auth_service.py | 56 ++++++++++- ...-004_portfolio-ownership-access-control.md | 45 ++++++++- docs/SRS/index.md | 2 +- docs/SRS/traceability-matrix.md | 2 +- docs/technical/api-contracts.md | 50 +++++++++- docs/technical/auth-and-token-lifecycle.md | 25 +++++ .../portfolio-ownership-and-auth-design.md | 16 ++- ...260414_0011_bot_principal_owner_user_id.py | 97 +++++++++++++++++++ tests/test_portfolio_comments.py | 2 +- tests/test_portfolio_ownership.py | 19 ++++ tests/test_users.py | 89 ++++++++++++++++- 16 files changed, 576 insertions(+), 35 deletions(-) create mode 100644 migrations/versions/20260414_0011_bot_principal_owner_user_id.py diff --git a/app/api/auth_routes.py b/app/api/auth_routes.py index 57b4756..60132eb 100644 --- a/app/api/auth_routes.py +++ b/app/api/auth_routes.py @@ -14,6 +14,7 @@ from app.schemas.auth import ( CreateBotPrincipalRequest, CreateBotTokenRequest, CreateClientTokenRequest, + CreateSelfBotPrincipalRequest, IssuedBotTokenResponse, IssuedClientTokenResponse, LoginRequest, @@ -58,6 +59,7 @@ def _to_bot_principal_response(bot: BotPrincipal) -> BotPrincipalResponse: return BotPrincipalResponse( id=bot.id, name=bot.name, + owner_user_id=bot.owner_user_id, permission=bot.permission, description=bot.description, is_active=bot.is_active, @@ -94,7 +96,7 @@ def _raise_auth_error(exc: ValueError) -> NoReturn: detail = str(exc) if "잘못된 사용자명 또는 비밀번호" in detail: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=detail) from exc - if "초과" in detail: + if "초과" in detail or "권한이 없습니다" in detail: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail) from exc if "찾을 수 없습니다" in detail: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=detail) from exc @@ -156,6 +158,81 @@ def create_bot_principal( _raise_auth_error(exc) +@router.post("/me/bots", response_model=BotPrincipalResponse, status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_read)]) +def create_owned_bot_principal( + payload: CreateSelfBotPrincipalRequest, + auth_context: AuthContext = Depends(require_read), + service: AuthService = Depends(get_auth_service), +) -> BotPrincipalResponse: + try: + return _to_bot_principal_response(service.create_owned_bot_principal(auth_context, payload)) + except ValueError as exc: + _raise_auth_error(exc) + + +@router.get("/me/bots", response_model=list[BotPrincipalResponse], dependencies=[Depends(require_read)]) +def list_owned_bot_principals( + auth_context: AuthContext = Depends(require_read), + is_active: bool | None = None, + service: AuthService = Depends(get_auth_service), +) -> list[BotPrincipalResponse]: + try: + return [ + _to_bot_principal_response(bot) + for bot in service.list_owned_bot_principals(auth_context, is_active=is_active) + ] + except ValueError as exc: + _raise_auth_error(exc) + + +@router.post( + "/me/bots/{bot_id}/tokens", + response_model=IssuedBotTokenResponse, + status_code=status.HTTP_201_CREATED, + dependencies=[Depends(require_read)], +) +def issue_owned_bot_token( + bot_id: str, + payload: CreateBotTokenRequest, + auth_context: AuthContext = Depends(require_read), + service: AuthService = Depends(get_auth_service), +) -> IssuedBotTokenResponse: + try: + return _to_issued_bot_token_response(service.issue_owned_bot_token(auth_context, bot_id, payload)) + except ValueError as exc: + _raise_auth_error(exc) + + +@router.get("/me/bots/{bot_id}/tokens", response_model=list[AuthTokenResponse], dependencies=[Depends(require_read)]) +def list_owned_bot_tokens( + bot_id: str, + auth_context: AuthContext = Depends(require_read), + service: AuthService = Depends(get_auth_service), +) -> list[AuthTokenResponse]: + try: + return [_to_auth_token_response(token) for token in service.list_owned_bot_tokens(auth_context, bot_id)] + except ValueError as exc: + _raise_auth_error(exc) + + +@router.delete( + "/me/bots/{bot_id}/tokens/{token_id}", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(require_read)], +) +def revoke_owned_bot_token( + bot_id: str, + token_id: str, + auth_context: AuthContext = Depends(require_read), + service: AuthService = Depends(get_auth_service), +) -> Response: + try: + service.revoke_owned_bot_token(auth_context, bot_id, token_id) + except ValueError as exc: + _raise_auth_error(exc) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + @router.get("/bots", response_model=list[BotPrincipalResponse], dependencies=[Depends(require_admin)]) def list_bot_principals( is_active: bool | None = None, diff --git a/app/domain/models.py b/app/domain/models.py index d78e91a..a0030ae 100644 --- a/app/domain/models.py +++ b/app/domain/models.py @@ -57,6 +57,7 @@ class TokenPrincipal: class BotPrincipal: id: str name: str + owner_user_id: str permission: str description: str | None = None is_active: bool = True diff --git a/app/repositories/portfolio_repository.py b/app/repositories/portfolio_repository.py index 187fea4..18962a0 100644 --- a/app/repositories/portfolio_repository.py +++ b/app/repositories/portfolio_repository.py @@ -63,13 +63,19 @@ class PortfolioRepository(Protocol): self, *, name: str, + owner_user_id: str, permission: str, description: str | None = None, ) -> BotPrincipal: ... def get_bot_principal(self, bot_principal_id: str) -> BotPrincipal: ... - def list_bot_principals(self, *, is_active: bool | None = None) -> list[BotPrincipal]: ... + def list_bot_principals( + self, + *, + is_active: bool | None = None, + owner_user_id: str | None = None, + ) -> list[BotPrincipal]: ... def deactivate_bot_principal(self, bot_principal_id: str) -> BotPrincipal: ... diff --git a/app/repositories/postgres.py b/app/repositories/postgres.py index 4a54005..a0a10b6 100644 --- a/app/repositories/postgres.py +++ b/app/repositories/postgres.py @@ -48,6 +48,7 @@ _SELECT_BOT_PRINCIPAL = """ SELECT id, name, + owner_user_id, permission, description, is_active, @@ -161,6 +162,7 @@ class PostgresPortfolioRepository: CREATE TABLE IF NOT EXISTS bot_principals ( id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, + owner_user_id TEXT, permission TEXT NOT NULL, description TEXT, is_active BOOLEAN NOT NULL DEFAULT TRUE, @@ -171,9 +173,47 @@ class PostgresPortfolioRepository: """ ) cur.execute("ALTER TABLE auth_tokens ADD COLUMN IF NOT EXISTS bot_principal_id TEXT") + cur.execute("ALTER TABLE bot_principals ADD COLUMN IF NOT EXISTS owner_user_id TEXT") cur.execute("ALTER TABLE bot_principals ADD COLUMN IF NOT EXISTS permission TEXT") cur.execute("UPDATE bot_principals SET permission = 'read' WHERE permission IS NULL") cur.execute("ALTER TABLE bot_principals ALTER COLUMN permission SET NOT NULL") + cur.execute( + """ + UPDATE bot_principals + SET owner_user_id = fallback.id + FROM ( + SELECT id + FROM users + ORDER BY created_at ASC, id ASC + LIMIT 1 + ) AS fallback + WHERE owner_user_id IS NULL + """ + ) + cur.execute("SELECT COUNT(*) FROM bot_principals WHERE owner_user_id IS NULL") + unresolved_bot_owners = int(cur.fetchone()[0]) + if unresolved_bot_owners > 0: + raise RuntimeError("bot_principals.owner_user_id backfill failed: users 테이블에 소유주가 필요합니다") + cur.execute("ALTER TABLE bot_principals ALTER COLUMN owner_user_id SET NOT NULL") + cur.execute( + """ + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_bot_principals_owner_user' + ) THEN + ALTER TABLE bot_principals + ADD CONSTRAINT fk_bot_principals_owner_user + FOREIGN KEY (owner_user_id) + REFERENCES users(id) + ON DELETE RESTRICT; + END IF; + END + $$ + """ + ) cur.execute( """ CREATE INDEX IF NOT EXISTS idx_auth_tokens_user_created @@ -198,6 +238,12 @@ class PostgresPortfolioRepository: ON bot_principals (is_active, created_at DESC) """ ) + cur.execute( + """ + CREATE INDEX IF NOT EXISTS idx_bot_principals_owner_active_created + ON bot_principals (owner_user_id, is_active, created_at DESC) + """ + ) cur.execute( """ CREATE TABLE IF NOT EXISTS strategy_sets ( @@ -333,12 +379,13 @@ class PostgresPortfolioRepository: return BotPrincipal( id=row[0], name=row[1], - permission=row[2], - description=row[3] if len(row) > 3 else None, - is_active=bool(row[4]) if len(row) > 4 else True, - created_at=row[5] if len(row) > 5 else None, - updated_at=row[6] if len(row) > 6 else None, - deactivated_at=row[7] if len(row) > 7 else None, + owner_user_id=row[2], + permission=row[3], + description=row[4] if len(row) > 4 else None, + is_active=bool(row[5]) if len(row) > 5 else True, + created_at=row[6] if len(row) > 6 else None, + updated_at=row[7] if len(row) > 7 else None, + deactivated_at=row[8] if len(row) > 8 else None, ) @staticmethod @@ -594,6 +641,7 @@ class PostgresPortfolioRepository: self, *, name: str, + owner_user_id: str, permission: str, description: str | None = None, ) -> BotPrincipal: @@ -602,15 +650,18 @@ class PostgresPortfolioRepository: cur.execute("SELECT 1 FROM bot_principals WHERE name = %s", (name,)) if cur.fetchone() is not None: raise ValueError(f"이미 존재하는 bot name입니다: {name}") + cur.execute("SELECT 1 FROM users WHERE id = %s", (owner_user_id,)) + if cur.fetchone() is None: + raise ValueError("사용자를 찾을 수 없습니다") bot_id = str(uuid.uuid4()) cur.execute( """ - INSERT INTO bot_principals (id, name, permission, description) - VALUES (%s, %s, %s, %s) - RETURNING id, name, permission, description, is_active, created_at, updated_at, deactivated_at + INSERT INTO bot_principals (id, name, owner_user_id, permission, description) + VALUES (%s, %s, %s, %s, %s) + RETURNING id, name, owner_user_id, permission, description, is_active, created_at, updated_at, deactivated_at """, - (bot_id, name, permission, description), + (bot_id, name, owner_user_id, permission, description), ) row = cur.fetchone() return self._row_to_bot_principal(row) @@ -622,13 +673,24 @@ class PostgresPortfolioRepository: row = cur.fetchone() return self._row_to_bot_principal(row) - def list_bot_principals(self, *, is_active: bool | None = None) -> list[BotPrincipal]: + def list_bot_principals( + self, + *, + is_active: bool | None = None, + owner_user_id: str | None = None, + ) -> list[BotPrincipal]: self.ensure_schema() query = _SELECT_BOT_PRINCIPAL params: list[object] = [] + clauses: list[str] = [] if is_active is not None: - query += "WHERE is_active = %s " + clauses.append("is_active = %s") params.append(is_active) + if owner_user_id is not None: + clauses.append("owner_user_id = %s") + params.append(owner_user_id) + if clauses: + query += "WHERE " + " AND ".join(clauses) + " " query += "ORDER BY created_at DESC, name ASC" with self._connect().cursor() as cur: cur.execute(query, tuple(params)) @@ -643,7 +705,7 @@ class PostgresPortfolioRepository: UPDATE bot_principals SET is_active = FALSE, deactivated_at = NOW(), updated_at = NOW() WHERE id = %s - RETURNING id, name, permission, description, is_active, created_at, updated_at, deactivated_at + RETURNING id, name, owner_user_id, permission, description, is_active, created_at, updated_at, deactivated_at """, (bot_principal_id,), ) @@ -691,7 +753,7 @@ class PostgresPortfolioRepository: with self._connect().cursor() as cur: cur.execute( """ - SELECT t.id, t.user_id, t.permission, t.token_type, t.bot_principal_id + SELECT t.id, COALESCE(b.owner_user_id, t.user_id), t.permission, t.token_type, t.bot_principal_id FROM auth_tokens t LEFT JOIN bot_principals b ON b.id = t.bot_principal_id WHERE token_hash = %s diff --git a/app/schemas/auth.py b/app/schemas/auth.py index 3257aa9..db11afc 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -67,7 +67,30 @@ class IssuedClientTokenResponse(BaseModel): class CreateBotPrincipalRequest(BaseModel): name: str = Field(min_length=1) - permission: Literal["read", "trade-write", "admin"] + owner_user_id: str = Field(min_length=1) + permission: Literal["read", "trade-write"] + description: str | None = None + + @field_validator("name") + @classmethod + def normalize_bot_name(cls, value: str) -> str: + normalized = value.strip() + if not normalized: + raise ValueError("name은 비어 있을 수 없습니다") + return normalized + + @field_validator("owner_user_id") + @classmethod + def normalize_owner_user_id(cls, value: str) -> str: + normalized = value.strip() + if not normalized: + raise ValueError("owner_user_id는 비어 있을 수 없습니다") + return normalized + + +class CreateSelfBotPrincipalRequest(BaseModel): + name: str = Field(min_length=1) + permission: Literal["read", "trade-write"] description: str | None = None @field_validator("name") @@ -82,7 +105,8 @@ class CreateBotPrincipalRequest(BaseModel): class BotPrincipalResponse(BaseModel): id: str name: str - permission: Literal["read", "trade-write", "admin"] + owner_user_id: str + permission: Literal["read", "trade-write"] description: str | None = None is_active: bool created_at: datetime | None = None @@ -92,7 +116,7 @@ class BotPrincipalResponse(BaseModel): class CreateBotTokenRequest(BaseModel): name: str = Field(min_length=1) - permission: Literal["read", "trade-write", "admin"] | None = None + permission: Literal["read", "trade-write"] | None = None expires_in_days: int | None = Field(default=None, ge=1, le=3650) @field_validator("name") diff --git a/app/services/auth_service.py b/app/services/auth_service.py index e238d9f..9f7ec55 100644 --- a/app/services/auth_service.py +++ b/app/services/auth_service.py @@ -13,7 +13,13 @@ from app.core.security import generate_token, hash_token, token_prefix, verify_p from app.domain.models import AuthToken, BotPrincipal, User from app.repositories.factory import get_portfolio_repository_from_settings from app.repositories.portfolio_repository import PortfolioRepository -from app.schemas.auth import CreateBotPrincipalRequest, CreateBotTokenRequest, CreateClientTokenRequest, LoginRequest +from app.schemas.auth import ( + CreateBotPrincipalRequest, + CreateBotTokenRequest, + CreateClientTokenRequest, + CreateSelfBotPrincipalRequest, + LoginRequest, +) @dataclass @@ -105,6 +111,16 @@ class AuthService: def create_bot_principal(self, payload: CreateBotPrincipalRequest) -> BotPrincipal: return self._repository.create_bot_principal( name=payload.name, + owner_user_id=payload.owner_user_id, + permission=payload.permission, + description=payload.description, + ) + + def create_owned_bot_principal(self, auth_context: AuthContext, payload: CreateSelfBotPrincipalRequest) -> BotPrincipal: + user_id = self._require_db_user_context(auth_context) + return self._repository.create_bot_principal( + name=payload.name, + owner_user_id=user_id, permission=payload.permission, description=payload.description, ) @@ -112,6 +128,10 @@ class AuthService: def list_bot_principals(self, *, is_active: bool | None = None) -> list[BotPrincipal]: return self._repository.list_bot_principals(is_active=is_active) + def list_owned_bot_principals(self, auth_context: AuthContext, *, is_active: bool | None = None) -> list[BotPrincipal]: + user_id = self._require_db_user_context(auth_context) + return self._repository.list_bot_principals(is_active=is_active, owner_user_id=user_id) + def get_bot_principal(self, bot_principal_id: str) -> BotPrincipal: return self._repository.get_bot_principal(bot_principal_id) @@ -142,14 +162,48 @@ class AuthService: ) return IssuedBotToken(token=raw_token, auth_token=auth_token) + def issue_owned_bot_token( + self, + auth_context: AuthContext, + bot_principal_id: str, + payload: CreateBotTokenRequest, + ) -> IssuedBotToken: + user_id = self._require_db_user_context(auth_context) + bot = self._repository.get_bot_principal(bot_principal_id) + if bot.owner_user_id != user_id: + raise ValueError("봇 접근 권한이 없습니다") + return self.issue_bot_token(bot_principal_id, payload) + def list_bot_tokens(self, bot_principal_id: str) -> list[AuthToken]: self._repository.get_bot_principal(bot_principal_id) return self._repository.list_bot_auth_tokens(bot_principal_id) + def list_owned_bot_tokens(self, auth_context: AuthContext, bot_principal_id: str) -> list[AuthToken]: + user_id = self._require_db_user_context(auth_context) + bot = self._repository.get_bot_principal(bot_principal_id) + if bot.owner_user_id != user_id: + raise ValueError("봇 접근 권한이 없습니다") + return self._repository.list_bot_auth_tokens(bot_principal_id) + def revoke_bot_token(self, bot_principal_id: str, token_id: str) -> None: self._repository.get_bot_principal(bot_principal_id) self._repository.revoke_bot_auth_token(bot_principal_id, token_id) + def revoke_owned_bot_token(self, auth_context: AuthContext, bot_principal_id: str, token_id: str) -> None: + user_id = self._require_db_user_context(auth_context) + bot = self._repository.get_bot_principal(bot_principal_id) + if bot.owner_user_id != user_id: + raise ValueError("봇 접근 권한이 없습니다") + self._repository.revoke_bot_auth_token(bot_principal_id, token_id) + + @staticmethod + def _require_db_user_context(auth_context: AuthContext) -> str: + if auth_context.bot_principal_id is not None: + raise ValueError("봇 토큰으로는 사용자 전용 봇 관리를 수행할 수 없습니다") + if auth_context.user_id is None: + raise ValueError("DB 사용자 컨텍스트에서만 사용자 전용 봇 관리를 수행할 수 있습니다") + return auth_context.user_id + @staticmethod def _build_expiration(expires_in_days: int | None, default_days: int) -> datetime: days = expires_in_days if expires_in_days is not None else default_days diff --git a/docs/SRS/Interface/REQ-004_portfolio-ownership-access-control.md b/docs/SRS/Interface/REQ-004_portfolio-ownership-access-control.md index a90be43..e3228ec 100644 --- a/docs/SRS/Interface/REQ-004_portfolio-ownership-access-control.md +++ b/docs/SRS/Interface/REQ-004_portfolio-ownership-access-control.md @@ -15,8 +15,10 @@ - 시스템은 `admin` 권한을 제외한 모든 사용자/토큰 요청에서 `owner_user_id`가 인증 주체와 일치하는 포트폴리오만 조회/변경/삭제 가능해야 한다. - 시스템은 `admin` 권한 요청에서 소유권 제약 없이 포트폴리오 전체를 조회/변경/삭제할 수 있어야 한다. - 시스템은 소유권 불일치로 거부된 요청에 대해 `403 Forbidden`을 반환해야 한다. -- 시스템은 인증 컨텍스트에 사용자 식별자가 없는 토큰이 소유권이 필요한 포트폴리오 작업을 요청하면 `403 Forbidden`을 반환해야 한다. +- 시스템은 봇 principal에 소유 사용자 식별자(`bot_principals.owner_user_id`)를 저장해야 하며, 봇 토큰 인증 시 해당 값을 실효 사용자 식별자로 해석해야 한다. +- 시스템은 사용자 귀속 정보가 해석되지 않는 토큰이 소유권이 필요한 포트폴리오 작업을 요청하면 `403 Forbidden`을 반환해야 한다. - 시스템은 기존 포트폴리오 데이터에 대해 마이그레이션 시 `owner_user_id`를 null 허용으로 추가한 뒤, 백필 완료 후 not null 제약을 적용할 수 있어야 한다. +- 시스템은 기존 봇 principal 데이터에 대해 마이그레이션 시 `bot_principals.owner_user_id`를 null 허용으로 추가한 뒤, 백필 완료 후 not null 및 FK(`users.id`) 제약을 적용할 수 있어야 한다. ## Acceptance Criteria 1. Given 인증된 일반 사용자(또는 해당 사용자에 귀속된 토큰)가 `POST /portfolios`를 호출할 때, When 포트폴리오가 생성되면, Then 저장된 `owner_user_id`는 요청자 사용자 식별자와 동일해야 하며 요청 본문으로 다른 소유주를 지정할 수 없어야 한다. @@ -24,12 +26,13 @@ 3. Given 일반 사용자 A가 목록 조회를 수행할 때, When `GET /portfolios`를 호출하면, Then 응답 목록에는 A 소유 포트폴리오만 포함되어야 한다. 4. Given `admin` 권한 사용자가 존재할 때, When `GET /portfolios` 또는 특정 포트폴리오 변경 API를 호출하면, Then 시스템은 소유권 검사로 요청을 거부하지 않아야 한다. 5. Given 마이그레이션 적용 전 생성된 레거시 포트폴리오가 존재할 때, When ownership 마이그레이션을 적용하면, Then 데이터 손실 없이 `owner_user_id`가 백필되고 이후 읽기/쓰기 API가 실패 없이 동작해야 한다. -6. Given 인증 컨텍스트에 사용자 식별자가 없는 bot 토큰이 포트폴리오 생성/수정/삭제를 요청할 때, When 소유권이 필요한 API를 호출하면, Then 시스템은 `403`을 반환해야 한다. +6. Given 사용자 A에 귀속된 bot 토큰이 존재할 때, When bot 토큰이 `GET /portfolios/{portfolio_id}` 또는 변경 API를 호출하면, Then A 소유 포트폴리오에는 접근 가능하고 A가 아닌 사용자 소유 포트폴리오에는 `403`을 반환해야 한다. +7. Given 인증 컨텍스트에 사용자 식별자가 해석되지 않는 토큰(예: DB 비연결 정적 토큰)이 포트폴리오 생성/수정/삭제를 요청할 때, When 소유권이 필요한 API를 호출하면, Then 시스템은 `403`을 반환해야 한다. ## Impacted Area - Docs: `docs/SRS/index.md`, `docs/SRS/traceability-matrix.md`, `docs/technical/auth-and-token-lifecycle.md`, `docs/technical/api-contracts.md`, `docs/technical/persistence-and-migrations.md`, `docs/technical/portfolio-ownership-and-auth-design.md` - Modules: `app/core/auth.py`, `app/api/portfolios.py`, `app/services/portfolio_service.py`, `app/repositories/portfolio_repository.py`, `app/repositories/postgres.py`, `app/domain/models.py`, `app/schemas/portfolio.py`, `migrations/versions/*` -- Tests: `tests/test_app.py`, `tests/test_auth.py`, `tests/test_cli.py`, `tests/test_domain_services.py` +- Tests: `tests/test_portfolio_ownership.py`, `tests/test_users.py`, `tests/test_auth.py`, `tests/test_portfolio_comments.py` ## Notes - 본 requirement는 인증 서비스 분리 여부와 무관하게 portfolio-hub 내부에서 반드시 충족되어야 하는 리소스 소유권 계약이다. @@ -72,3 +75,39 @@ #### Validation Plan - 사용자별 소유권 격리 통합 테스트, admin 우회 테스트, 마이그레이션 백필 테스트를 통해 계약 충족 여부를 확인한다. + +### CHG-20260414-01 +- Date: 2026-04-14 +- Author: GitHub Copilot + +#### Change Summary +- 봇 principal의 사용자 귀속(`bot_principals.owner_user_id`) 및 봇 토큰 실효 사용자 매핑 규칙을 requirement에 추가했다. + +#### Intent +- 사용자별 봇 bearer token이 해당 사용자 포트폴리오 경계 내에서만 동작하도록 계약을 명확히 하기 위함이다. + +#### Acceptance Criteria Delta +- Added: + - 사용자 귀속 bot 토큰의 소유권 허용/거부 기준 (AC6) + - 사용자 식별자 미해석 토큰 거부 기준 (AC7) +- Updated: + - Requirement 본문의 bot 토큰 처리 규칙 +- Removed: + - 없음 + +#### Impacted Area Delta +- Docs: + - `docs/SRS/Interface/REQ-004_portfolio-ownership-access-control.md` + - `docs/SRS/index.md` + - `docs/SRS/traceability-matrix.md` +- Modules: + - `app/repositories/postgres.py` + - `app/services/auth_service.py` + - `app/schemas/auth.py` + - `migrations/versions/20260414_0011_bot_principal_owner_user_id.py` +- Tests: + - `tests/test_portfolio_ownership.py` + - `tests/test_users.py` + +#### Validation Plan +- 사용자 귀속 bot 토큰의 owner 허용/타 사용자 거부 시나리오와 봇 owner 백필 마이그레이션 적용 검증을 수행한다. diff --git a/docs/SRS/index.md b/docs/SRS/index.md index 27cc8be..bb521e7 100644 --- a/docs/SRS/index.md +++ b/docs/SRS/index.md @@ -27,11 +27,11 @@ | REQ-004 | 포트폴리오 소유주 기반 접근 제어 | Interface | Proposed | High | Justin | `docs/SRS/Interface/REQ-004_portfolio-ownership-access-control.md` | ## Recent Change Log Summary +- 2026-04-14 — `REQ-004`에 사용자 귀속 봇 principal(`bot_principals.owner_user_id`) 및 봇 토큰 실효 사용자 매핑 규칙(AC6, AC7)을 추가했다. - 2026-04-13 — `REQ-004` 포트폴리오 소유주(`owner_user_id`) 및 소유권 기반 접근 제어 requirement를 추가했다. - 2026-04-13 — `REQ-003`에서 태그 필터 기반 조회의 결정성 유지 책임을 소비 서비스(stock-picker)로 이관했다. - 2026-04-13 — `REQ-003`에서 `sourceVersion`/`sourceTimestamp` 요구를 제거하고 태그 필터 기반 조회 결정성 계약으로 정리했다. - 2026-04-13 — `REQ-003`의 소비자 표현을 특정 서비스 전용에서 태그 기반 조회 소비 서비스 일반 계약으로 정리했다. -- 2026-04-13 — `REQ-002`의 소비자 표현을 특정 서비스 전용에서 다운스트림 소비자 일반 계약으로 정리했다. ## Status Transition Evidence - `Proposed` → `Approved`: Owner가 지정되고 requirement 문서 리뷰가 완료되며 acceptance criteria가 누락 없이 검토되어야 한다. diff --git a/docs/SRS/traceability-matrix.md b/docs/SRS/traceability-matrix.md index 30ff263..b2214fb 100644 --- a/docs/SRS/traceability-matrix.md +++ b/docs/SRS/traceability-matrix.md @@ -14,7 +14,7 @@ | REQ-001 | AC1, AC2, AC3, AC4, AC5, AC6, AC7 | `docs/SRS/Interface/REQ-001_portfolio-comment-history.md` | #2 | TBD | TBD | Approved | In Progress | 2026-04-10 | | REQ-002 | AC1, AC2, AC3, AC4, AC5, AC6, AC7, AC8, AC9 | `docs/SRS/Interface/REQ-002_portfolio-tagging-and-search.md` | TBD | TBD | TBD | Proposed | Planned | 2026-04-10 | | REQ-003 | AC1, AC2, AC3, AC4, AC5 | `docs/SRS/Data/REQ-003_portfolio-required-fields.md` | TBD | TBD | TBD | Proposed | Planned | 2026-04-10 | -| REQ-004 | AC1, AC2, AC3, AC4, AC5, AC6 | `docs/SRS/Interface/REQ-004_portfolio-ownership-access-control.md` | TBD | TBD | `tests/test_portfolio_ownership.py`, `tests/test_auth.py`, `tests/test_portfolio_comments.py`, local `pytest` pass (2026-04-14) | Proposed | In Progress | 2026-04-14 | +| REQ-004 | AC1, AC2, AC3, AC4, AC5, AC6, AC7 | `docs/SRS/Interface/REQ-004_portfolio-ownership-access-control.md` | TBD | TBD | `tests/test_portfolio_ownership.py`, `tests/test_users.py`, `tests/test_auth.py`, `tests/test_portfolio_comments.py`, local `pytest` pass (2026-04-14) | Proposed | In Progress | 2026-04-14 | ## Update Rules - REQ ID must exactly match the SRS requirement file prefix. diff --git a/docs/technical/api-contracts.md b/docs/technical/api-contracts.md index 04dfabf..547efdd 100644 --- a/docs/technical/api-contracts.md +++ b/docs/technical/api-contracts.md @@ -32,10 +32,16 @@ - `DELETE /auth/tokens/{token_id}` - `POST /auth/bots` - `GET /auth/bots` +- `GET /auth/bots/{bot_id}` - `POST /auth/bots/{bot_id}/tokens` - `GET /auth/bots/{bot_id}/tokens` - `DELETE /auth/bots/{bot_id}/tokens/{token_id}` - `DELETE /auth/bots/{bot_id}` +- `POST /auth/me/bots` +- `GET /auth/me/bots` +- `POST /auth/me/bots/{bot_id}/tokens` +- `GET /auth/me/bots/{bot_id}/tokens` +- `DELETE /auth/me/bots/{bot_id}/tokens/{token_id}` ### 포트폴리오 - `GET /portfolios` @@ -427,18 +433,46 @@ curl -X POST "http://localhost:8000/auth/tokens" \ - `GET /auth/bots/{bot_id}/tokens`: admin only, `AuthTokenResponse[]` - `DELETE /auth/bots/{bot_id}/tokens/{token_id}`: admin only, `204` +소유권/권한 제약: + +- bot principal은 `owner_user_id`를 필수로 가진다. +- bot permission은 `read` 또는 `trade-write`만 허용한다. +- bot 토큰 인증 컨텍스트의 실효 `user_id`는 bot owner(`owner_user_id`)로 해석된다. + +#### 사용자 전용(Self-service) 봇 관리 +- `POST /auth/me/bots`: DB-issued `read` 이상 사용자 토큰, `CreateSelfBotPrincipalRequest` -> `BotPrincipalResponse` +- `GET /auth/me/bots`: DB-issued `read` 이상 사용자 토큰, 본인 소유 bot 목록 +- `POST /auth/me/bots/{bot_id}/tokens`: DB-issued `read` 이상 사용자 토큰, `CreateBotTokenRequest` -> `IssuedBotTokenResponse` +- `GET /auth/me/bots/{bot_id}/tokens`: DB-issued `read` 이상 사용자 토큰, `AuthTokenResponse[]` +- `DELETE /auth/me/bots/{bot_id}/tokens/{token_id}`: DB-issued `read` 이상 사용자 토큰, `204` + +제약: + +- `/auth/me/bots*` 경로는 요청자 본인 소유 bot(`owner_user_id == auth_context.user_id`)에만 허용된다. +- 타 사용자 소유 bot 접근 시 `403`. + 대표 요청: ```bash curl -X POST "http://localhost:8000/auth/bots" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ - -d '{"name": "rebalance-bot", "permission": "trade-write", "description": "nightly jobs"}' + -d '{"name": "rebalance-bot", "owner_user_id": "user-1", "permission": "trade-write", "description": "nightly jobs"}' curl -X POST "http://localhost:8000/auth/bots/bot-1/tokens" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"name": "nightly", "permission": "trade-write", "expires_in_days": 90}' + +curl -X POST "http://localhost:8000/auth/me/bots" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"name": "my-bot", "permission": "read", "description": "personal automation"}' + +curl -X POST "http://localhost:8000/auth/me/bots/bot-1/tokens" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"name": "personal-key", "permission": "read", "expires_in_days": 30}' ``` ### 사용자 @@ -543,13 +577,20 @@ curl -X POST "http://localhost:8000/users" \ `CreateBotPrincipalRequest` - `name: str` -- `permission: "read" | "trade-write" | "admin"` +- `owner_user_id: str` +- `permission: "read" | "trade-write"` +- `description: str | null` + +`CreateSelfBotPrincipalRequest` + +- `name: str` +- `permission: "read" | "trade-write"` - `description: str | null` `CreateBotTokenRequest` - `name: str` -- `permission: "read" | "trade-write" | "admin" | null` +- `permission: "read" | "trade-write" | null` - `expires_in_days: int | null` `CreateUserRequest` @@ -628,7 +669,8 @@ curl -X POST "http://localhost:8000/users" \ - `id: str` - `name: str` -- `permission: "read" | "trade-write" | "admin"` +- `owner_user_id: str` +- `permission: "read" | "trade-write"` - `description: str | null` - `is_active: bool` - `created_at: datetime | null` diff --git a/docs/technical/auth-and-token-lifecycle.md b/docs/technical/auth-and-token-lifecycle.md index 1f96072..48160e2 100644 --- a/docs/technical/auth-and-token-lifecycle.md +++ b/docs/technical/auth-and-token-lifecycle.md @@ -33,6 +33,31 @@ - 환경변수 기반 정적 토큰: `PORTFOLIOHUB_*_TOKENS` - DB 저장 토큰: 로그인 세션/클라이언트 토큰/봇 토큰 +봇 토큰 사용자 컨텍스트 정책: + +- 봇 principal은 `owner_user_id`를 가진다. +- 봇 토큰 인증 시 실효 사용자 식별자는 `bot_principals.owner_user_id`로 해석한다. +- 따라서 소유권이 필요한 포트폴리오 API에서 봇 토큰은 소유 사용자 경계 내에서만 동작한다. + +## 봇 관리 API 정책 + +- Admin 경로: + - `POST /auth/bots` + - `GET /auth/bots` + - `GET /auth/bots/{bot_id}` + - `DELETE /auth/bots/{bot_id}` + - `POST/GET/DELETE /auth/bots/{bot_id}/tokens*` +- Self-service 경로(사용자 본인 전용): + - `POST /auth/me/bots` + - `GET /auth/me/bots` + - `POST/GET/DELETE /auth/me/bots/{bot_id}/tokens*` + +제약: + +- self-service 경로는 DB 사용자 컨텍스트가 있는 사용자 토큰에서만 허용한다. +- self-service 경로는 요청자의 소유 봇(`owner_user_id == auth_context.user_id`)에만 허용한다. +- 봇 권한은 `read` 또는 `trade-write`만 허용한다. + ## 토큰 라이프사이클 1. 사용자 로그인(`POST /auth/login`) 또는 토큰 발급 API 호출 2. 토큰 해시 저장 및 prefix 관리 diff --git a/docs/technical/portfolio-ownership-and-auth-design.md b/docs/technical/portfolio-ownership-and-auth-design.md index 9add3f2..e5b5ea5 100644 --- a/docs/technical/portfolio-ownership-and-auth-design.md +++ b/docs/technical/portfolio-ownership-and-auth-design.md @@ -75,6 +75,7 @@ FOREIGN KEY (owner_user_id) REFERENCES users(id); - 2단계: admin이 아니면 ownership 검사 수행 - ownership 검사 기준: `portfolio.owner_user_id == auth_context.user_id` - `auth_context.user_id`가 없는 요청은 ownership 필요한 엔드포인트에서 `403` +- 봇 토큰(`token_type=bot`)의 `auth_context.user_id`는 `bot_principals.owner_user_id`로 해석 ## Endpoint Policy Matrix @@ -92,6 +93,16 @@ FOREIGN KEY (owner_user_id) REFERENCES users(id); | `DELETE /portfolios/{portfolio_id}` | admin | admin only | 기존 정책 유지 | | `POST/GET/PATCH/DELETE /portfolios/{id}/comments*` | read or trade-write | 적용 | 상위 포트폴리오 ownership을 재사용 | +## Bot Principal Ownership 정책 + +- `bot_principals.owner_user_id`는 필수이며 `users.id` FK를 가진다. +- 봇 principal 생성 정책: + - Admin: 임의 사용자 owner로 생성 가능 + - Self-service 사용자: 본인 owner로만 생성 가능 +- 봇 토큰 관리 정책: + - Admin 경로는 전체 봇 대상으로 관리 가능 + - Self-service 경로는 본인 소유 봇 대상으로만 관리 가능 + ## 서비스/리포지토리 반영 포인트 - Domain model: - `Portfolio`에 `owner_user_id` 추가 @@ -116,13 +127,16 @@ FOREIGN KEY (owner_user_id) REFERENCES users(id); 1. 기존 portfolios row가 존재할 때 owner_user_id 백필 후 null row가 0건인지 검증 2. users가 없는 상태에서 migration 실행 시 실패 동작 검증 3. downgrade 시 인덱스/제약 해제가 순서대로 수행되는지 검증 +4. 기존 bot_principals row가 존재할 때 owner_user_id 백필 후 null row가 0건인지 검증 +5. bot_principals.owner_user_id FK(`users.id`) 제약 생성/해제 검증 ## 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 +5. 사용자 A에 귀속된 bot 토큰은 A 소유 portfolio만 접근 가능 +6. 사용자 B가 A 소유 bot의 self-service 토큰 발급/조회/폐기를 시도하면 403 ## API Creation 1. `POST /portfolios`에서 owner_user_id가 인증 사용자로 저장되는지 검증 diff --git a/migrations/versions/20260414_0011_bot_principal_owner_user_id.py b/migrations/versions/20260414_0011_bot_principal_owner_user_id.py new file mode 100644 index 0000000..890c35d --- /dev/null +++ b/migrations/versions/20260414_0011_bot_principal_owner_user_id.py @@ -0,0 +1,97 @@ +"""restore bot principal owner user id + +Revision ID: 20260414_0011 +Revises: 20260413_0010 +Create Date: 2026-04-14 10:30:00 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect + + +revision = "20260414_0011" +down_revision = "20260413_0010" +branch_labels = None +depends_on = None + + +def _column_exists(inspector, table_name: str, column_name: str) -> bool: + return any(column["name"] == column_name for column in inspector.get_columns(table_name)) + + +def _index_exists(inspector, table_name: str, index_name: str) -> bool: + return any(index["name"] == index_name for index in inspector.get_indexes(table_name)) + + +def _fk_exists(inspector, table_name: str, fk_name: str) -> bool: + return any(fk.get("name") == fk_name for fk in inspector.get_foreign_keys(table_name)) + + +def upgrade() -> None: + bind = op.get_bind() + inspector = inspect(bind) + + if not _column_exists(inspector, "bot_principals", "owner_user_id"): + op.add_column("bot_principals", sa.Column("owner_user_id", sa.Text(), nullable=True)) + + bind.execute( + sa.text( + """ + WITH fallback_owner AS ( + SELECT id + FROM users + ORDER BY created_at ASC, id ASC + LIMIT 1 + ) + UPDATE bot_principals b + SET owner_user_id = f.id + FROM fallback_owner f + WHERE b.owner_user_id IS NULL + """ + ) + ) + + unresolved = bind.execute(sa.text("SELECT COUNT(*) FROM bot_principals WHERE owner_user_id IS NULL")).scalar() + if unresolved and int(unresolved) > 0: + raise RuntimeError("bot_principals.owner_user_id backfill failed: users 테이블에 fallback 소유주가 필요합니다") + + op.alter_column("bot_principals", "owner_user_id", existing_type=sa.Text(), nullable=False) + + inspector = inspect(bind) + if not _index_exists(inspector, "bot_principals", "idx_bot_principals_owner_active_created"): + op.create_index( + "idx_bot_principals_owner_active_created", + "bot_principals", + ["owner_user_id", "is_active", sa.text("created_at DESC")], + unique=False, + ) + + inspector = inspect(bind) + if not _fk_exists(inspector, "bot_principals", "fk_bot_principals_owner_user"): + op.create_foreign_key( + "fk_bot_principals_owner_user", + "bot_principals", + "users", + ["owner_user_id"], + ["id"], + ondelete="RESTRICT", + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = inspect(bind) + + if _fk_exists(inspector, "bot_principals", "fk_bot_principals_owner_user"): + op.drop_constraint("fk_bot_principals_owner_user", "bot_principals", type_="foreignkey") + + inspector = inspect(bind) + if _index_exists(inspector, "bot_principals", "idx_bot_principals_owner_active_created"): + op.drop_index("idx_bot_principals_owner_active_created", table_name="bot_principals") + + inspector = inspect(bind) + if _column_exists(inspector, "bot_principals", "owner_user_id"): + op.drop_column("bot_principals", "owner_user_id") diff --git a/tests/test_portfolio_comments.py b/tests/test_portfolio_comments.py index 8e56989..be90297 100644 --- a/tests/test_portfolio_comments.py +++ b/tests/test_portfolio_comments.py @@ -119,7 +119,7 @@ class FakeRepo: def get_bot_principal(self, bot_principal_id: str): raise ValueError("봇을 찾을 수 없습니다") - def list_bot_principals(self, *, is_active=None): + def list_bot_principals(self, *, is_active=None, owner_user_id=None): return [] def deactivate_bot_principal(self, *args, **kwargs): diff --git a/tests/test_portfolio_ownership.py b/tests/test_portfolio_ownership.py index a66fe90..f346a1d 100644 --- a/tests/test_portfolio_ownership.py +++ b/tests/test_portfolio_ownership.py @@ -108,6 +108,25 @@ def test_non_owner_gets_403_on_db_auth(monkeypatch) -> None: assert response.json()["detail"] == "Portfolio ownership mismatch" +def test_owner_bound_bot_token_can_read_owned_portfolio(monkeypatch) -> None: + token = "owner-bot-token" + principal = TokenPrincipal( + token_id="t-bot-1", + user_id="owner-1", + permission="read", + token_type="bot", + bot_principal_id="bot-1", + ) + repo = FakeRepo(portfolio=_portfolio("owner-1"), principals={hash_token(token): principal}) + client = _configure_client(monkeypatch, repo) + + response = client.get("/portfolios/p1", headers={"Authorization": f"Bearer {token}"}) + + app.dependency_overrides.clear() + assert response.status_code == 200 + assert response.json()["portfolio"]["owner_user_id"] == "owner-1" + + def test_db_enabled_env_token_without_user_context_is_rejected(monkeypatch) -> None: repo = FakeRepo(portfolio=_portfolio("owner-1"), principals={}) monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true") diff --git a/tests/test_users.py b/tests/test_users.py index 72cd094..c97c108 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -163,21 +163,33 @@ class FakeUserRepo: if token.expires_at is not None and token.expires_at <= datetime.now(timezone.utc): return None token.last_used_at = datetime.now(timezone.utc) + if token.bot_principal_id is not None: + bot = self.get_bot_principal(token.bot_principal_id) + return TokenPrincipal( + token_id=principal.token_id, + user_id=bot.owner_user_id, + permission=principal.permission, + token_type=principal.token_type, + bot_principal_id=principal.bot_principal_id, + ) return principal def create_bot_principal( self, *, name: str, + owner_user_id: str, permission: str, description: str | None = None, ) -> BotPrincipal: if any(item.name == name for item in self.bots.values()): raise ValueError(f"이미 존재하는 bot name입니다: {name}") + self.get_user(owner_user_id) now = datetime.now(timezone.utc) bot = BotPrincipal( id=f"bot-{self.next_bot_id}", name=name, + owner_user_id=owner_user_id, permission=permission, description=description, is_active=True, @@ -194,10 +206,17 @@ class FakeUserRepo: raise ValueError("봇을 찾을 수 없습니다") return bot - def list_bot_principals(self, *, is_active: bool | None = None) -> list[BotPrincipal]: + def list_bot_principals( + self, + *, + is_active: bool | None = None, + owner_user_id: str | None = None, + ) -> list[BotPrincipal]: items = list(self.bots.values()) if is_active is not None: items = [item for item in items if item.is_active == is_active] + if owner_user_id is not None: + items = [item for item in items if item.owner_user_id == owner_user_id] return sorted(items, key=lambda item: item.name) def deactivate_bot_principal(self, bot_principal_id: str) -> BotPrincipal: @@ -340,7 +359,12 @@ def test_admin_can_create_bot_and_issue_token(monkeypatch) -> None: bot_response = client.post( "/auth/bots", headers={"Authorization": f"Bearer {admin_token}"}, - json={"name": "market-bot", "permission": "trade-write", "description": "external execution bot"}, + json={ + "name": "market-bot", + "owner_user_id": admin.id, + "permission": "trade-write", + "description": "external execution bot", + }, ) assert bot_response.status_code == 201 bot_id = bot_response.json()["id"] @@ -373,7 +397,7 @@ def test_deactivated_bot_token_is_rejected(monkeypatch) -> None: bot_response = client.post( "/auth/bots", headers={"Authorization": f"Bearer {admin_token}"}, - json={"name": "ops-bot", "permission": "read"}, + json={"name": "ops-bot", "owner_user_id": admin.id, "permission": "read"}, ) assert bot_response.status_code == 201 bot_id = bot_response.json()["id"] @@ -396,4 +420,61 @@ def test_deactivated_bot_token_is_rejected(monkeypatch) -> None: app.dependency_overrides.clear() assert denied_response.status_code == 401 - assert denied_response.json()["detail"] == "Invalid token" \ No newline at end of file + assert denied_response.json()["detail"] == "Invalid token" + + +def test_user_can_create_and_issue_owned_bot_token(monkeypatch) -> None: + repo = FakeUserRepo() + user = repo.create_user("alice", password_hash=hash_password("alice123!"), permission="trade-write") + client = _client_with_repo(monkeypatch, repo) + + login_response = client.post("/auth/login", json={"username": "alice", "password": "alice123!"}) + assert login_response.status_code == 200 + user_token = login_response.json()["access_token"] + + bot_response = client.post( + "/auth/me/bots", + headers={"Authorization": f"Bearer {user_token}"}, + json={"name": "alice-bot", "permission": "read", "description": "personal bot"}, + ) + assert bot_response.status_code == 201 + bot_id = bot_response.json()["id"] + assert bot_response.json()["owner_user_id"] == user.id + + list_response = client.get("/auth/me/bots", headers={"Authorization": f"Bearer {user_token}"}) + assert list_response.status_code == 200 + assert [item["id"] for item in list_response.json()] == [bot_id] + + issue_response = client.post( + f"/auth/me/bots/{bot_id}/tokens", + headers={"Authorization": f"Bearer {user_token}"}, + json={"name": "alice-key", "permission": "read"}, + ) + + app.dependency_overrides.clear() + assert issue_response.status_code == 201 + payload = issue_response.json() + assert payload["auth_token"]["token_type"] == "bot" + assert payload["auth_token"]["bot_principal_id"] == bot_id + + +def test_user_cannot_issue_token_for_other_users_bot(monkeypatch) -> None: + repo = FakeUserRepo() + owner = repo.create_user("owner", password_hash=hash_password("owner123!"), permission="trade-write") + other = repo.create_user("other", password_hash=hash_password("other123!"), permission="trade-write") + bot = repo.create_bot_principal(name="owner-bot", owner_user_id=owner.id, permission="read") + client = _client_with_repo(monkeypatch, repo) + + login_response = client.post("/auth/login", json={"username": "other", "password": "other123!"}) + assert login_response.status_code == 200 + other_token = login_response.json()["access_token"] + + issue_response = client.post( + f"/auth/me/bots/{bot.id}/tokens", + headers={"Authorization": f"Bearer {other_token}"}, + json={"name": "invalid-key", "permission": "read"}, + ) + + app.dependency_overrides.clear() + assert issue_response.status_code == 403 + assert issue_response.json()["detail"] == "봇 접근 권한이 없습니다" \ No newline at end of file