feat: add service token rename command (#40)

* docs: preserve original issue 5 body

* feat: add service token rename command

* test: normalize rich option errors
This commit is contained in:
2026-09-19 21:19:39 +09:00
committed by GitHub
parent 996127f215
commit deaaa96c46
20 changed files with 882 additions and 7 deletions
+1 -1
View File
@@ -42,7 +42,7 @@ jobs:
POSTGRES_PASSWORD: change-me
run: |
export PORTFOLIOHUB_TEST_POSTGRES_DSN="postgresql://portfolio_hub_user:${POSTGRES_PASSWORD}@127.0.0.1:5432/postgres"
uv run python -m pytest -q tests/test_corporate_action_atomic_recovery_postgres.py
uv run python -m pytest -q tests/test_corporate_action_atomic_recovery_postgres.py tests/test_service_token_rename_postgres.py
- name: Honest scoped lint for release additions
run: uv run ruff check --select F,I app/db/migrations.py scripts/image_release.py scripts/ruff_ratchet.py tests/release
- name: Reject Ruff findings introduced by v0.3.0 or later patches
+16
View File
@@ -0,0 +1,16 @@
---
id: "ac-0042"
status: active
---
# 활성 service token name-only rename와 identity 보존
## Criterion
존재하는 활성 service token을 기존 이름에서 새 이름으로 rename하면 동일한 durable token identity의 `name`만 변경되어야 한다. 성공 직후 기존 이름의 active lookup은 더 이상 해당 token을 반환하지 않고 새 이름의 lookup은 동일한 token을 반환해야 하며, rename 전에 유효했던 bearer token은 같은 principal·permission·runtime/daemon scope로 계속 인증되어야 한다. Rename transaction 자체는 `id`, token secret/hash/prefix, owner, permission, token type, expiry, `last_used_at`/revocation timestamps, runtime/daemon binding을 포함한 `name` 외 모든 저장 값을 변경하지 않아야 한다. 후속 인증이 기존 인증 계약에 따라 `last_used_at`을 갱신하는 것은 rename mutation이 아니다. Revoked token은 rename 대상이 아니다.
## Verification
- 성공 조건: 실제 PostgreSQL에서 활성 token을 rename한 직후, 후속 인증 전에 before/after row를 비교하여 `name` 외 모든 column이 동일하고 old-name lookup이 비어 있으며 new-name lookup이 같은 token identity를 반환한다. 그 다음 기존 bearer token으로 인증하여 같은 principal을 확인하며, 이 인증으로 인한 기존 계약상의 `last_used_at` 갱신만 허용한다.
- 실패·경계 조건: old name의 활성 token이 없으면 `서비스 토큰을 찾을 수 없습니다: NAME`으로 실패하고 active/revoked row가 모두 변경되지 않는다. 같은 이름의 revoked row만 존재해도 그 row를 rename 대상으로 선택하지 않는다.
- 검사 방법: repository/service unit test와 loopback real-PostgreSQL integration test로 persisted row, lookup, authentication principal 및 protected-field 불변성을 검증한다. Rename 경로가 token 생성·rotation·revoke 함수를 호출하면 test가 실패해야 한다.
+16
View File
@@ -0,0 +1,16 @@
---
id: "ac-0043"
status: active
---
# rename 충돌·동시성·same-name 원자성
## Criterion
Service-token rename은 active service-token name uniqueness를 원자적으로 유지해야 한다. 새 이름을 다른 active service token이 사용 중이면 `이미 존재하는 서비스 토큰 이름입니다: NEW_NAME`으로 실패하며 어느 token도 일부 변경되지 않아야 한다. 같은 target name을 두 rename 또는 rename과 issue가 동시에 획득하려 할 때 database uniqueness가 순서를 결정하여 최대 한 작업만 성공하고 나머지는 같은 domain conflict로 귀결되어야 한다. 새 이름과 기존 이름이 같으면 활성 token의 존재를 확인한 뒤 성공 no-op(`changed=false`)로 처리하고 durable write를 수행하지 않아야 한다. 동일 이름의 revoked token만 존재하는 경우에는 활성 rename을 막지 않아야 한다.
## Verification
- 성공 조건: unused target과 revoked-only target rename이 성공하고, same-name request는 동일 token 정보를 `changed=false`로 반환하며 repository write count가 0이다.
- 실패·경계 조건: active target collision 및 concurrent target collision은 명시된 duplicate-name error를 반환하고 source/target row의 `name`과 보호 필드를 rollback 전후 동일하게 유지한다. Missing same-name request는 no-op 성공이 아니라 missing-token error다.
- 검사 방법: service fake/spy test로 no-op write absence를 검증하고, loopback real-PostgreSQL test에서 active/revoked collision과 독립 connection을 사용한 rename-vs-rename 및 rename-vs-issue 경쟁을 실행하여 성공 수, conflict message, persisted final rows를 검증한다.
+16
View File
@@ -0,0 +1,16 @@
---
id: "ac-0044"
status: active
---
# service-token rename CLI 확인·출력·회귀 계약
## Criterion
CLI는 `pfhub service-token rename NAME --new-name NEW_NAME [--force/-f]`를 제공해야 한다. 기본 실행은 old/new name을 식별하는 확인 prompt를 표시하고 거부 시 성공 exit로 종료하면서 어떠한 rename도 수행하지 않아야 하며, `--force/-f`는 prompt 없이 같은 동작을 수행해야 한다. 성공한 table 및 JSON 출력은 `token_id`, `old_name`, `new_name`, `changed`만 표현하고 raw/masked token, token hash/prefix나 다른 secret-derived 값을 포함하지 않아야 한다. Missing/duplicate domain error는 기존 CLI error contract에 따라 non-zero로 반환되어야 하며 기존 `list`, `issue`, `rotate`, `rebind`, `revoke`, `migrate-null-daemon`의 동작은 유지되어야 한다.
## Verification
- 성공 조건: command help와 required `--new-name`가 노출되고, 승인 prompt와 `--force` 실행의 table/JSON이 정확한 네 필드 및 `changed` 상태를 반환한다. Same-name 결과도 `changed=false`로 직렬화된다.
- 실패·경계 조건: prompt 거부는 exit code 0과 zero service/repository call을 보장한다. Missing/duplicate는 exit code 1과 지정된 message를 반환한다. 모든 success/error 출력에 raw token, hash, prefix와 credential이 없어야 한다.
- 검사 방법: Typer CLI runner test로 help, missing option, prompt accept/reject, `--force`와 `-f`, table/JSON, same-name, missing/duplicate를 검증하고 기존 service-token CLI regression suite 및 full test suite를 실행한다.
+2 -2
View File
@@ -1,5 +1,5 @@
{
"schema_version": 1,
"next_line_number": 10,
"next_ac_number": 42
"next_line_number": 11,
"next_ac_number": 45
}
+51
View File
@@ -0,0 +1,51 @@
---
id: "dcy-0010"
status: confirmed
---
# Service token rename without secret rotation
## Problem
`pfhub service-token`은 활성 service token을 이름으로 조회해 rotate, rebind, revoke할 수 있지만 이름만 바꾸는 명령은 제공하지 않는다. 이름 오타나 명명 규칙을 교정하려면 새 token을 발급하고 기존 token을 revoke해야 하므로, label 변경만 필요한 경우에도 raw bearer token과 client 배포를 교체해야 한다.
기대 상태는 활성 service token의 동일한 durable row에서 `name`만 원자적으로 변경하고 기존 bearer token, identity, owner, permission, expiry, runtime/daemon binding을 유지하는 것이다. CLI는 확인 prompt와 non-interactive `--force`를 제공하고 table/JSON에서 secret 없이 변경 결과를 반환해야 한다.
## Evidence
- `E-001` GitHub Issue #5 (`https://github.com/fourblocks2020/portfolio-hub/issues/5`)는 `pfhub service-token rename NAME --new-name NEW_NAME [--force]`와 name-only 변경, active-name uniqueness, missing/duplicate/no-op 오류, table/JSON 출력을 요구한다. 원문은 commit `17d6cbe3ba4ec10842bafb4af90709ef83fe721f`의 `docs/issues/issue-0005-original.md`에 byte-for-byte 보존되어 있다.
- `E-002` `app/cli/commands/service_tokens.py`의 current command set은 `list`, `issue`, `rotate`, `rebind`, `revoke`, `migrate-null-daemon`이며 rename path가 없다. `revoke_service_token()`은 `--force/-f`와 `typer.confirm()`의 기존 UX를 제공한다.
- `E-003` `app/services/auth_service.py::AuthService`는 active service token lookup, issue/rotate/rebind/revoke lifecycle을 소유한다. `issue_service_token()`은 PostgreSQL `UniqueViolation`을 `이미 존재하는 서비스 토큰 이름입니다: {name}`으로 변환한다.
- `E-004` `app/repositories/postgres.py::get_service_token_by_name()`은 `name`, `token_type='service'`, `revoked_at IS NULL`로 active row만 조회한다. 같은 파일의 cold-start schema와 migration `app/db/alembic/versions/20260520_0022_service_token_unique_index_fix.py`는 `idx_auth_tokens_service_name_unique` partial unique index로 active service-token name uniqueness를 이미 강제한다.
- `E-005` `app/repositories/portfolio_repository.py::PortfolioRepository`에는 rename contract가 없으며 `app/schemas/auth.py`에도 rename CLI response가 없다.
- `E-006` `AuthToken`/PostgreSQL row는 token identity와 secret-derived 값, permission, owner, expiry, runtime/daemon binding을 name과 별도 column으로 보존한다. raw token은 저장하지 않으므로 name-only invariant는 row before/after 비교와 기존 bearer token 재인증으로 검증해야 한다.
- `E-007` current `main` baseline `996127f2155a71c1085c4fbf35b504cc3c43439c`에서 관련 구현이 없고, Issue #5 및 `service token rename` keyword의 기존 구현 PR도 없다.
- `E-008` 현재 ProofLine active AC 검색 결과 service-token label rename lifecycle을 소유하는 criterion은 없다. service-token authorization 및 daemon-scope criteria는 회귀 대상일 뿐 이 신규 capability를 satisfy하지 않는다.
## Scope
- `S-001` AC admission은 durable CLI behavior를 소유하는 신규 REQ/AC `create`로 분류한다. Approved REQ 원문과 기존 active AC 의미는 변경하지 않는다.
- `S-002` `PortfolioRepository`와 PostgreSQL adapter에 active service token의 `name`만 바꾸는 단일 `UPDATE ... RETURNING` contract를 추가한다. 새 이름 사전 조회를 uniqueness 권위로 사용하지 않고 기존 partial unique index가 concurrent conflict를 결정한다.
- `S-003` `AuthService`에 missing active token, duplicate active name, same-name success no-op를 구분하는 rename contract를 추가한다. `new_name == name`도 active row 존재를 확인하며 repository write는 하지 않는다.
- `S-004` `pfhub service-token rename NAME --new-name NEW_NAME [--force/-f]`를 추가한다. prompt 취소는 success exit와 zero write이고, 결과는 `token_id`, `old_name`, `new_name`, `changed`만 table/JSON으로 출력한다.
- `S-005` 성공 rename은 동일 row의 `name` 외 모든 column과 기존 bearer-token authentication principal을 보존한다. raw/masked token을 출력하거나 token generation/hash/revoke 경로를 호출하지 않는다.
- `S-006` unit/service/CLI와 loopback real-PostgreSQL integration으로 success, missing, active collision, revoked-name reuse, same-name no-op, prompt/force, secret non-disclosure, protected-field invariants와 concurrent conflict를 검증한다.
- `S-007` existing interface documentation과 CLI examples를 additive contract에 맞춰 갱신하며 기존 service-token lifecycle/auth/daemon-scope 회귀를 유지한다.
## Out of Scope
- `OS-001` raw token rotation, reissue, revoke 또는 secret 표시
- `OS-002` token owner, permission, type, expiry, runtime/daemon binding 변경
- `OS-003` revoked token row 자체의 rename
- `OS-004` service-token name 문자셋·길이·정규화에 대한 신규 정책
- `OS-005` HTTP API endpoint, database schema migration, historical backfill
- `OS-006` 다른 repository 변경, production rollout 또는 production token mutation
## Risks and Unknowns
### Risks
- `R-001` 애플리케이션의 “이름 사용 가능” 사전 조회만으로 충돌을 판단하면 concurrent rename/issue가 race한다. single-statement update와 partial unique index를 authority로 사용하고 실제 PostgreSQL 경쟁 테스트로 완화한다.
- `R-002` update projection이나 CLI response에 secret-derived/internal field가 포함되면 정보 노출 또는 unintended mutation이 생긴다. canonical auth-token projection을 반환하되 CLI response를 네 개의 비밀이 아닌 필드로 제한하고 negative test를 둔다.
- `R-003` same-name no-op가 존재 확인 없이 성공하면 missing token을 정상 처리하거나 revoked row를 오인할 수 있다. active lookup 성공을 no-op의 precondition으로 고정한다.
- `R-004` application rollback은 이미 완료된 metadata rename을 자동으로 되돌리지 않는다. 필요한 경우 동일 command로 이전 이름에 다시 rename하며 production 실행은 별도 운영 승인으로 둔다.
@@ -0,0 +1,25 @@
# Activity Log
## 2026-09-19 — implementation start
- 사용자 승인: `req-0010` approved, `ac-0042`~`ac-0044` active.
- 계획: repository protocol/PostgreSQL atomic rename, service same-name no-op, CLI response/command, docs와 unit/real-PostgreSQL tests를 구현한다.
- 범위 제외: schema migration, HTTP API, production token mutation/rollout.
- 다음 행동: 실패하는 focused tests를 먼저 추가하고 최소 구현 후 전체 검증한다.
## 2026-09-19T21:00:42+09:00 — implementation verified
- 구현: active service token을 단일 PostgreSQL `UPDATE ... RETURNING`으로 rename하고 service same-name no-op, duplicate domain error, Typer confirmation/`--force/-f`, secret-free table/JSON response, README 예시를 추가했다.
- focused test: rename service/CLI selector `9 passed`; real-PostgreSQL rename suite `5 passed`.
- 실제 backend: current migrations 적용 후 보호 필드·bearer principal 보존, active/revoked/missing 경계, duplicate rollback, rename-vs-rename 및 rename-vs-issue independent-connection 경쟁을 검증했다.
- CI selector: 기존 PostgreSQL workflow 단계에 `tests/test_service_token_rename_postgres.py`를 등록하고 release contract test로 runtime password-variable 조합과 selector 존재를 검증했다. Corporate-action 및 rename PostgreSQL selector 합계 `11 passed`.
- sabotage: SQL rename parameter를 일시적으로 old name으로 대체했을 때 persisted-name assertion이 의도대로 실패했고 복원 후 동일 selector `1 passed`였다.
- 전체 회귀: `742 passed, 26 skipped`; `proofline validate`, `git diff --check`, compileall 및 신규 PostgreSQL 파일 Ruff F/I 검사가 통과했다. Ruff ratchet은 commit 전 working tree를 포함하지 않으므로 commit 후 canonical selectors를 다시 실행한다.
- 독립 리뷰: runtime/test/security 두 검토의 구현 의미·coverage 평가는 적합했다. DSN 표시 관련 blocker는 tool credential-redaction을 literal로 오인한 false positive였으며, byte-level non-secret re-review에서 `${POSTGRES_PASSWORD}` 사용과 literal placeholder 부재를 확인하고 최종 `PASS`했다.
- lifecycle: approved `req-0010`과 active `ac-0042`~`ac-0044`는 기존 ProofLine 관례대로 유지한다. production token mutation 및 rollout은 수행하지 않았다.
## 2026-09-19T21:13:04+09:00 — CI portability correction
- PR #40 최초 CI는 Python 3.11/Rich의 ANSI escape가 required-option stderr의 `--new-name` 내부에 삽입되어 literal substring assertion 한 건이 실패했다. CLI exit code와 required-option 동작은 정상이며 production implementation 결함은 아니었다.
- 테스트를 `click.unstyle()`로 stderr 정규화 후 option contract를 확인하도록 수정했다.
- CI와 같은 Python 3.11 및 `uv sync --extra dev --locked` 환경에서 실패 selector `1 passed`, 전체 suite `731 passed, 37 skipped`, 실제 PostgreSQL selector `11 passed`, Ruff ratchet 3종 `introduced_findings=0`, ProofLine validation과 diff check 통과를 확인했다.
+4
View File
@@ -0,0 +1,4 @@
---
id: "line-0010"
status: discovery
---
+44
View File
@@ -0,0 +1,44 @@
---
id: "req-0010"
status: approved
discovery: "dcy-0010"
criteria:
create:
- "ac-0042"
- "ac-0043"
- "ac-0044"
update: []
retire: []
satisfy: []
---
# Service token rename without secret rotation
## Objective
운영자가 활성 service token의 bearer token과 권한·binding을 교체하지 않고 식별용 이름만 안전하게 변경할 수 있게 한다. 변경은 concurrent issue/rename과 경쟁하더라도 기존 active-name uniqueness를 지키며, CLI는 interactive와 automation 환경 모두에서 secret을 노출하지 않는 명확한 결과를 제공해야 한다.
## Scope
- 활성 service token 한 건의 `name`만 동일 durable identity에서 변경한다.
- 기존 active service-token name uniqueness를 concurrent conflict의 최종 권위로 사용하고 충돌은 전체 rename을 실패시킨다.
- 존재하지 않는 활성 이름, 같은 이름, revoked-name reuse를 서로 구분해 안정적으로 처리한다.
- `pfhub service-token rename NAME --new-name NEW_NAME [--force/-f]`와 table/JSON 결과를 제공한다.
- repository, service, CLI, 문서와 unit/real-PostgreSQL 검증을 함께 갱신한다.
## Constraints
- Rename transaction 자체는 `id`, token secret/hash/prefix, owner, permission, token type, expiry, `last_used_at`/revocation timestamps, runtime/daemon binding 등 `name` 외 저장 필드를 변경하지 않는다.
- rename은 token rotation, issue, revoke 경로를 호출하지 않고 기존 bearer token의 authentication principal을 보존한다. Rename 이후의 인증은 기존 인증 계약에 따라 `last_used_at`을 갱신할 수 있으며 이는 rename mutation에 포함되지 않는다.
- duplicate-name 판단은 애플리케이션의 사전 조회가 아니라 PostgreSQL partial unique index를 최종 권위로 삼으며, 실패 시 partial mutation이 없어야 한다.
- raw token, token hash 또는 connection credential은 CLI table/JSON, error, log, test evidence에 출력하지 않는다.
- 기존 migration `20260520_0022`의 partial unique index를 사용하며 신규 schema migration이나 backfill을 추가하지 않는다.
- 현재 `service-token` command와 HTTP API의 기존 의미 및 응답 계약을 변경하지 않는다.
## Non-Goals
- revoked token row rename
- service-token name의 신규 문자셋·길이·정규화 정책
- owner, permission, expiry, runtime/daemon binding 변경
- HTTP rename endpoint 또는 다른 repository의 producer/consumer 변경
- production token rename 또는 production rollout
+2
View File
@@ -109,6 +109,7 @@ pfhub service-token list --user alice
pfhub service-token issue nightly --user alice --ttl-days 30
pfhub service-token issue permanent --user alice --no-expiry
pfhub service-token issue my-daemon-token --user alice --daemon-id <daemon_id>
pfhub service-token rename nightly --new-name nightly-kis
pfhub service-token revoke nightly --force
pfhub service-token rotate permanent --force
pfhub daemon create alpha-bot --user alice --display-name "Alpha Trading Bot"
@@ -137,6 +138,7 @@ Supported commands:
- `user delete`
- `service-token list`
- `service-token issue`
- `service-token rename`
- `service-token revoke`
- `service-token rotate`
- `daemon create`
+33 -1
View File
@@ -8,7 +8,13 @@ import typer
from app.cli.core.output import mask_token_text, render_service_token_result
from app.cli.core.utils import render_output
from app.schemas.auth import AuthTokenResponse, ServiceTokenIssueCliResponse, ServiceTokenRebindCliResponse, ServiceTokenRevokeCliResponse
from app.schemas.auth import (
AuthTokenResponse,
ServiceTokenIssueCliResponse,
ServiceTokenRebindCliResponse,
ServiceTokenRenameCliResponse,
ServiceTokenRevokeCliResponse,
)
from app.services.auth_service import AuthService
token_app = typer.Typer(help="Service token lifecycle commands.")
@@ -92,6 +98,32 @@ def issue_service_token(
ctx.obj.console.print("[yellow]⚠ 이 토큰은 다시 표시되지 않습니다. 지금 복사해 안전한 곳에 저장하세요.[/yellow]")
@token_app.command("rename")
def rename_service_token(
ctx: typer.Context,
name: str = typer.Argument(..., help="Active service token name to rename."),
new_name: str = typer.Option(..., "--new-name", help="New unique service token name."),
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation prompt."),
) -> None:
"""Rename an active service token without rotating its bearer token."""
if not force:
confirmed = typer.confirm(f"서비스 토큰 이름을 '{name}'에서 '{new_name}'(으)로 변경하시겠습니까?")
if not confirmed:
raise typer.Exit(code=0)
def _rename(service: AuthService, app_context):
return service.rename_service_token(name, new_name)
renamed = _with_service(ctx, _rename)
response = ServiceTokenRenameCliResponse(
token_id=renamed.auth_token.id,
old_name=name,
new_name=renamed.auth_token.name,
changed=renamed.changed,
)
render_service_token_result(ctx.obj.console, ctx.obj.output_format, response)
@token_app.command("rotate")
def rotate_service_token(
ctx: typer.Context,
+2
View File
@@ -87,6 +87,8 @@ class PortfolioRepository(Protocol):
def get_service_token_by_name(self, name: str) -> AuthToken | None: ...
def rename_service_token(self, name: str, new_name: str) -> AuthToken: ...
def get_token_principal(self, token_hash: str) -> TokenPrincipal | None: ...
def rotate_service_token(self, name: str) -> tuple[str, AuthToken]: ...
+31
View File
@@ -1128,6 +1128,37 @@ class PostgresPortfolioRepository:
return None
return self._row_to_auth_token(row)
def rename_service_token(self, name: str, new_name: str) -> AuthToken:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
UPDATE auth_tokens
SET name = %s
WHERE name = %s
AND token_type = 'service'
AND revoked_at IS NULL
RETURNING
id,
user_id,
name,
token_prefix,
permission,
token_type,
expires_at,
last_used_at,
revoked_at,
created_at,
runtime_id,
daemon_id
""",
(new_name, name),
)
row = cur.fetchone()
if row is None:
raise ValueError(f"서비스 토큰을 찾을 수 없습니다: {name}")
return self._row_to_auth_token(row)
def get_token_principal(self, token_hash: str) -> TokenPrincipal | None:
self.ensure_schema()
with self._connect().cursor() as cur:
+7
View File
@@ -90,6 +90,13 @@ class ServiceTokenRebindCliResponse(BaseModel):
previous_daemon_id: str | None = None
class ServiceTokenRenameCliResponse(BaseModel):
token_id: str
old_name: str
new_name: str
changed: bool
class ServiceTokenRevokeCliResponse(BaseModel):
token_id: str
name: str
+19
View File
@@ -35,6 +35,12 @@ class IssuedServiceToken:
auth_token: AuthToken
@dataclass
class RenamedServiceToken:
auth_token: AuthToken
changed: bool
def get_auth_service_repository(settings: Settings = Depends(get_settings)) -> PortfolioRepository:
return get_portfolio_repository_from_settings(settings)
@@ -130,6 +136,19 @@ class AuthService:
raw_token, auth_token = self._repository.rotate_service_token(name)
return IssuedServiceToken(token=raw_token, auth_token=auth_token)
def rename_service_token(self, name: str, new_name: str) -> RenamedServiceToken:
if name == new_name:
existing = self._repository.get_service_token_by_name(name)
if existing is None:
raise ValueError(f"서비스 토큰을 찾을 수 없습니다: {name}")
return RenamedServiceToken(auth_token=existing, changed=False)
try:
renamed = self._repository.rename_service_token(name, new_name)
except psycopg.errors.UniqueViolation as exc:
raise ValueError(f"이미 존재하는 서비스 토큰 이름입니다: {new_name}") from exc
return RenamedServiceToken(auth_token=renamed, changed=True)
def rebind_service_token(self, name: str, daemon_id: str) -> tuple[str | None, AuthToken]:
existing = self._repository.get_service_token_by_name(name)
if existing is None:
+68
View File
@@ -0,0 +1,68 @@
- **유형**: Feature / Enhancement
- **우선순위**: Medium
- **영역**: CLI / 인증 (`pfhub service-token`)
- **대상 버전**: portfoliohub 0.1.0 (commit `58899b1`)
## 배경 / 문제
현재 `pfhub service-token` 하위 명령은 `list` / `issue` / `rotate` / `rebind` /
`revoke` / `migrate-null-daemon` 6개뿐이며, **기존 서비스 토큰의 `name` 을 변경하는 명령이 없다.**
- `name` 은 `rotate` / `rebind` / `revoke` 의 **조회 키**로 쓰인다
(`app/cli/commands/service_tokens.py` — 각 명령이 `name` 인자로 토큰을 찾음).
- 이름을 바꾸려면 현재로선 `issue`(새 이름으로 신규 발급) → `revoke`(기존 폐기)가 유일한 우회 경로다.
- 이 우회는 **토큰 값(평문)까지 새로 바뀌므로**, 단순히 라벨만 정리하려는 경우에도
모든 클라이언트·데몬의 토큰을 재배포해야 한다. 이름 오타 수정, 명명 규칙 정비 등에 과도한 비용이 든다.
## 요구사항
기존 서비스 토큰의 `name` 만 변경하고 **토큰 값·바인딩은 그대로 유지**하는 CLI 명령을 추가한다.
### 제안 인터페이스
```
pfhub service-token rename NAME --new-name NEW_NAME [--force]
```
- `NAME` (인자): 현재 서비스 토큰 이름 (조회 키)
- `--new-name` (필수 옵션): 변경할 새 이름
- `--force` (옵션): 확인 프롬프트 생략 (`revoke` 명령과 동일 패턴)
## 수용 기준 (Acceptance Criteria)
1. `NAME` 으로 활성(비폐기) 서비스 토큰을 찾아 `name` 을 `NEW_NAME` 으로 변경한다.
2. 다음 값은 **변경되지 않는다**: 토큰 값(평문)·`token_hash`·`token_prefix`·
`permission`·`token_type`·`daemon_id`·`runtime_id`·`expires_at`·`token_id`.
3. 이름 유일성 검증: `NEW_NAME` 이 다른 활성 서비스 토큰과 충돌하면 명확한 에러로 거부한다.
- DB에는 이미 부분 유니크 인덱스가 존재한다:
`idx_auth_tokens_service_name_unique ON auth_tokens(name) WHERE token_type='service' AND revoked_at IS NULL`
(`app/repositories/postgres.py:190-193`) — `UniqueViolation` 을 사용자 메시지로 변환할 것.
4. 에러 처리:
- `NAME` 에 해당하는 활성 서비스 토큰이 없으면 → "서비스 토큰을 찾을 수 없습니다: NAME"
- `NEW_NAME` 이 이미 사용 중이면 → "이미 존재하는 서비스 토큰 이름입니다: NEW_NAME"
- `NEW_NAME == NAME` 이면 → 변경 없음 안내 후 정상 종료
5. 출력: `token_id`, 기존 `name`, 변경된 `name` 을 표시 (table / json 양쪽 지원).
6. `--force` 미지정 시 변경 전 확인 프롬프트를 띄운다.
## 구현 범위 (참고)
- CLI: `app/cli/commands/service_tokens.py` 에 `rename` 명령 추가.
- 서비스: `app/services/auth_service.py` 에 `rename_service_token(name, new_name)` 추가
— `issue_service_token` 과 동일하게 `psycopg.errors.UniqueViolation` 을 `ValueError` 로 변환.
- 리포지토리: `app/repositories/postgres.py` 에 `UPDATE auth_tokens SET name=%s WHERE ...`
메서드 추가 및 `app/repositories/portfolio_repository.py` 프로토콜에 시그니처 추가.
- 스키마: `app/schemas/auth.py` 에 rename 응답용 CLI 모델 추가 (기존 `ServiceToken*CliResponse` 패턴 따름).
- 테스트: 정상 변경 / 미존재 토큰 / 이름 충돌 / 동일 이름 케이스.
## 범위 외 (Out of Scope)
- 토큰 값 회전(rotation)은 본 기능에 포함하지 않는다. 값 변경이 필요하면 기존 `rotate` 를 사용한다.
- 폐기된(revoked) 토큰의 이름 변경은 지원하지 않는다 (유일성 인덱스가 활성 토큰만 대상으로 함).
- 서비스 토큰 이름 형식(문자셋) 검증 규칙은 현재 `issue` 에도 없으므로 본 이슈에서 신규 도입하지 않는다.
(별도 정비가 필요하면 분리 이슈로 다룬다.)
## 비고
- 관련: service-token 마스킹 이슈(해결 완료). 본 기능은 토큰 값을 노출/변경하지 않으므로 마스킹과 무관하다.
- governance: 신규 CLI 명령 추가이므로 `draft-req` → batch delivery 흐름 대상으로 검토.
+1
View File
@@ -19,6 +19,7 @@ def test_branch_verification_has_no_registry_write_authority_or_path():
assert "uv sync --extra dev --locked" in text and "uv run python -m pytest -q" in text
assert "Verify corporate-action atomicity and guarded recovery in PostgreSQL" in text
assert "tests/test_corporate_action_atomic_recovery_postgres.py" in text
assert "tests/test_service_token_rename_postgres.py" in text
assert "${POSTGRES_PASSWORD}@127.0.0.1:5432/postgres" in text
assert "***@" not in text
+91 -1
View File
@@ -4,6 +4,7 @@ from dataclasses import dataclass
from datetime import datetime, timezone
from fastapi.testclient import TestClient
import psycopg.errors
from app.core.auth import AuthContext, Permission
from app.core.config import Settings
@@ -96,6 +97,7 @@ class _AuthRepo:
self.tokens: dict[str, AuthToken] = {}
self._daemons: dict[str, object] = {}
self._seq = 1
self.rename_calls = 0
def get_user_by_username(self, username: str):
return self.users.get(username)
@@ -105,10 +107,20 @@ class _AuthRepo:
def get_service_token_by_name(self, name: str) -> AuthToken | None:
for token in self.tokens.values():
if token.name == name and token.token_type == "service":
if token.name == name and token.token_type == "service" and token.revoked_at is None:
return token
return None
def rename_service_token(self, name: str, new_name: str) -> AuthToken:
self.rename_calls += 1
token = self.get_service_token_by_name(name)
if token is None:
raise ValueError(f"서비스 토큰을 찾을 수 없습니다: {name}")
if self.get_service_token_by_name(new_name) is not None:
raise psycopg.errors.UniqueViolation("duplicate service-token name")
token.name = new_name
return token
def create_auth_token(
self,
user_id: str,
@@ -231,6 +243,84 @@ def test_revoke_service_token_not_found_raises() -> None:
raise AssertionError("Expected ValueError for missing service token")
def test_rename_service_token_changes_only_name() -> None:
from app.domain.models import User
repo = _AuthRepo()
repo.users["alice"] = User(id="user-1", username="alice", permission="read")
issued = _service(repo).issue_service_token("old-name", "alice")
before = issued.auth_token
protected = (
before.id,
before.user_id,
before.token_prefix,
before.permission,
before.token_type,
before.expires_at,
before.last_used_at,
before.revoked_at,
before.created_at,
before.runtime_id,
before.daemon_id,
)
result = _service(repo).rename_service_token("old-name", "new-name")
assert result.changed is True
assert result.auth_token.name == "new-name"
assert repo.get_service_token_by_name("old-name") is None
assert repo.get_service_token_by_name("new-name") is result.auth_token
assert repo.rename_calls == 1
assert protected == (
result.auth_token.id,
result.auth_token.user_id,
result.auth_token.token_prefix,
result.auth_token.permission,
result.auth_token.token_type,
result.auth_token.expires_at,
result.auth_token.last_used_at,
result.auth_token.revoked_at,
result.auth_token.created_at,
result.auth_token.runtime_id,
result.auth_token.daemon_id,
)
def test_rename_service_token_same_name_is_noop_without_write() -> None:
from app.domain.models import User
repo = _AuthRepo()
repo.users["alice"] = User(id="user-1", username="alice", permission="read")
issued = _service(repo).issue_service_token("unchanged", "alice")
result = _service(repo).rename_service_token("unchanged", "unchanged")
assert result.changed is False
assert result.auth_token is issued.auth_token
assert repo.rename_calls == 0
def test_rename_service_token_missing_and_duplicate_errors() -> None:
from app.domain.models import User
repo = _AuthRepo()
repo.users["alice"] = User(id="user-1", username="alice", permission="read")
service = _service(repo)
service.issue_service_token("source", "alice")
service.issue_service_token("target", "alice")
for old_name, new_name, message in (
("missing", "new", "서비스 토큰을 찾을 수 없습니다: missing"),
("source", "target", "이미 존재하는 서비스 토큰 이름입니다: target"),
):
try:
service.rename_service_token(old_name, new_name)
except ValueError as exc:
assert str(exc) == message
else:
raise AssertionError("Expected rename failure")
# REQ-018: daemon_id forward 보정 (service layer)
def test_issue_service_token_with_daemon_id_stores_it() -> None:
+168 -2
View File
@@ -4,13 +4,25 @@ import json
import uuid
from datetime import datetime, timezone
import app.cli.commands.health as health_commands
from click import unstyle
from typer.testing import CliRunner
import app.cli.commands.health as health_commands
from app.cli.main import create_app
from app.core.config import Settings
from app.core.security import hash_token, token_prefix
from app.domain.models import Asset, AuthToken, BrokerCredential, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, User
from app.domain.models import (
Asset,
AuthToken,
BrokerCredential,
Daemon,
LedgerEntry,
Portfolio,
PortfolioComment,
PortfolioTag,
StrategySet,
User,
)
class FakeRepo:
@@ -126,6 +138,15 @@ class FakeRepo:
None,
)
def rename_service_token(self, name: str, new_name: str) -> AuthToken:
token = self.get_service_token_by_name(name)
if token is None:
raise ValueError(f"서비스 토큰을 찾을 수 없습니다: {name}")
if self.get_service_token_by_name(new_name) is not None:
raise ValueError(f"이미 존재하는 서비스 토큰 이름입니다: {new_name}")
token.name = new_name
return token
def create_daemon(
self,
owner_user_id: str,
@@ -1055,6 +1076,7 @@ def test_cli_help_exposes_bot_token_commands() -> None:
assert result.exit_code == 0
assert "issue" in result.stdout
assert "rename" in result.stdout
assert "rebind" in result.stdout
assert "revoke" in result.stdout
@@ -1258,6 +1280,150 @@ def test_cli_service_token_revoke_can_abort() -> None:
assert repo._auth_tokens[token.id].revoked_at is None
def test_cli_service_token_rename_can_abort_without_mutation() -> None:
repo = FakeRepo(_portfolio())
user = repo.create_user("alice", permission="read")
token = repo.create_auth_token(
user.id,
name="old-name",
token_hash=hash_token("rename-token"),
token_prefix=token_prefix("rename-token"),
permission="read",
token_type="service",
)
runner = build_runner(repo)
result = runner.invoke(
runner.app,
["service-token", "rename", "old-name", "--new-name", "new-name"],
input="n\n",
)
assert result.exit_code == 0
assert repo._auth_tokens[token.id].name == "old-name"
def test_cli_service_token_rename_force_json_has_only_safe_fields() -> None:
repo = FakeRepo(_portfolio())
user = repo.create_user("alice", permission="read")
token = repo.create_auth_token(
user.id,
name="old-name",
token_hash=hash_token("rename-token"),
token_prefix=token_prefix("rename-token"),
permission="read",
token_type="service",
)
runner = build_runner(repo)
result = runner.invoke(
runner.app,
["--format", "json", "service-token", "rename", "old-name", "--new-name", "new-name", "--force"],
)
assert result.exit_code == 0
assert json.loads(result.stdout) == {
"token_id": token.id,
"old_name": "old-name",
"new_name": "new-name",
"changed": True,
}
assert repo._auth_tokens[token.id].name == "new-name"
def test_cli_service_token_rename_prompt_accepts_and_table_is_secret_free() -> None:
repo = FakeRepo(_portfolio())
user = repo.create_user("alice", permission="read")
token = repo.create_auth_token(
user.id,
name="old-name",
token_hash=hash_token("rename-secret-value"),
token_prefix=token_prefix("rename-secret-value"),
permission="read",
token_type="service",
)
runner = build_runner(repo)
result = runner.invoke(
runner.app,
["service-token", "rename", "old-name", "--new-name", "new-name"],
input="y\n",
)
assert result.exit_code == 0
for field in ("token_id", "old_name", "new_name", "changed"):
assert field in result.stdout
assert "rename-secret-value" not in result.stdout
assert token.token_prefix not in result.stdout
assert repo._auth_tokens[token.id].name == "new-name"
def test_cli_service_token_rename_requires_new_name_and_reports_domain_errors() -> None:
repo = FakeRepo(_portfolio())
user = repo.create_user("alice", permission="read")
repo.create_auth_token(
user.id,
name="source",
token_hash=hash_token("rename-source"),
token_prefix=token_prefix("rename-source"),
permission="read",
token_type="service",
)
repo.create_auth_token(
user.id,
name="target",
token_hash=hash_token("rename-target"),
token_prefix=token_prefix("rename-target"),
permission="read",
token_type="service",
)
runner = build_runner(repo)
missing_option = runner.invoke(runner.app, ["service-token", "rename", "source", "--force"])
missing_token = runner.invoke(
runner.app,
["service-token", "rename", "missing", "--new-name", "new", "--force"],
)
duplicate = runner.invoke(
runner.app,
["service-token", "rename", "source", "--new-name", "target", "--force"],
)
assert missing_option.exit_code == 2
assert "--new-name" in unstyle(missing_option.stderr)
assert missing_token.exit_code == 1
assert "서비스 토큰을 찾을 수 없습니다: missing" in missing_token.stdout
assert duplicate.exit_code == 1
assert "이미 존재하는 서비스 토큰 이름입니다: target" in duplicate.stdout
def test_cli_service_token_rename_same_name_short_force_alias() -> None:
repo = FakeRepo(_portfolio())
user = repo.create_user("alice", permission="read")
token = repo.create_auth_token(
user.id,
name="same-name",
token_hash=hash_token("rename-token"),
token_prefix=token_prefix("rename-token"),
permission="read",
token_type="service",
)
runner = build_runner(repo)
result = runner.invoke(
runner.app,
["--format", "json", "service-token", "rename", "same-name", "--new-name", "same-name", "-f"],
)
assert result.exit_code == 0
assert json.loads(result.stdout) == {
"token_id": token.id,
"old_name": "same-name",
"new_name": "same-name",
"changed": False,
}
def test_cli_service_token_revoke_force_succeeds() -> None:
repo = FakeRepo(_portfolio())
user = repo.create_user("alice", permission="read")
+285
View File
@@ -0,0 +1,285 @@
from __future__ import annotations
import os
from collections.abc import Iterator
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
from urllib.parse import unquote, urlparse
from uuid import uuid4
import psycopg
import pytest
from psycopg import sql
from app.core.config import Settings
from app.core.security import hash_token
from app.db.migrations import upgrade_to_head
from app.repositories.postgres import PortfolioDBConfig, PostgresPortfolioRepository
from app.services.auth_service import AuthService
_LOCAL_DSN_ENV = "PORTFOLIOHUB_TEST_POSTGRES_DSN"
_LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1"}
def _maintenance_dsn() -> str:
dsn = os.environ.get(_LOCAL_DSN_ENV)
if not dsn:
pytest.skip(
f"{_LOCAL_DSN_ENV} is required for real PostgreSQL integration tests"
)
parsed = urlparse(dsn)
if parsed.scheme not in {"postgres", "postgresql"}:
pytest.fail("test DSN must use postgres/postgresql")
if parsed.hostname not in _LOOPBACK_HOSTS or parsed.path.lstrip("/") != "postgres":
pytest.fail(
"tests require a loopback PostgreSQL maintenance database named postgres"
)
return dsn
def _settings(dsn: str, database: str) -> Settings:
parsed = urlparse(dsn)
assert parsed.hostname is not None
return Settings(
db_enabled=True,
timescaledb_host=parsed.hostname,
timescaledb_port=parsed.port or 5432,
timescaledb_name=database,
timescaledb_user=unquote(parsed.username or ""),
timescaledb_password=unquote(parsed.password or ""),
timescaledb_sslmode="disable",
timescaledb_connect_timeout_sec=3,
timescaledb_application_name="service-token-rename-test",
)
def _repo(settings: Settings) -> PostgresPortfolioRepository:
return PostgresPortfolioRepository(
PortfolioDBConfig(
host=settings.active_db_host,
port=settings.active_db_port,
dbname=settings.active_db_name,
user=settings.active_db_user,
password=settings.active_db_password,
sslmode=settings.active_db_sslmode,
connect_timeout_sec=settings.active_db_connect_timeout_sec,
application_name=settings.active_db_application_name,
)
)
@pytest.fixture()
def postgres_context() -> Iterator[tuple[Settings, PostgresPortfolioRepository]]:
maintenance_dsn = _maintenance_dsn()
database = f"portfolio_hub_token_rename_{uuid4().hex}"
with (
psycopg.connect(maintenance_dsn, autocommit=True) as conn,
conn.cursor() as cur,
):
cur.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(database)))
settings = _settings(maintenance_dsn, database)
upgrade_to_head(settings)
repo = _repo(settings)
repo.create_user("alice", permission="read")
try:
yield settings, repo
finally:
repo.close()
with (
psycopg.connect(maintenance_dsn, autocommit=True) as conn,
conn.cursor() as cur,
):
cur.execute(
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
"WHERE datname = %s AND pid <> pg_backend_pid()",
(database,),
)
cur.execute(
sql.SQL("DROP DATABASE IF EXISTS {}").format(sql.Identifier(database))
)
def _stored_row(repo: PostgresPortfolioRepository, token_id: str) -> tuple:
with repo._connect().cursor() as cur:
cur.execute(
"""
SELECT id, user_id, name, token_prefix, token_hash, permission, token_type,
expires_at, last_used_at, revoked_at, created_at, runtime_id, daemon_id
FROM auth_tokens
WHERE id = %s
""",
(token_id,),
)
row = cur.fetchone()
assert row is not None
return row
def test_rename_persists_only_name_and_preserves_bearer_authentication(
postgres_context,
) -> None:
settings, repo = postgres_context
service = AuthService(repo, settings)
issued = service.issue_service_token("old-name", "alice")
before = _stored_row(repo, issued.auth_token.id)
renamed = service.rename_service_token("old-name", "new-name")
after = _stored_row(repo, issued.auth_token.id)
assert renamed.changed is True
assert renamed.auth_token.id == issued.auth_token.id
assert after[2] == "new-name"
assert before[:2] + before[3:] == after[:2] + after[3:]
assert repo.get_service_token_by_name("old-name") is None
assert repo.get_service_token_by_name("new-name").id == issued.auth_token.id
principal = repo.get_token_principal(hash_token(issued.token))
assert principal is not None
assert principal.user_id == issued.auth_token.user_id
assert principal.permission == issued.auth_token.permission
assert principal.runtime_id == issued.auth_token.runtime_id
assert principal.daemon_id == issued.auth_token.daemon_id
def test_rename_collision_rolls_back_and_revoked_name_is_reusable(
postgres_context,
) -> None:
settings, repo = postgres_context
service = AuthService(repo, settings)
source = service.issue_service_token("source", "alice")
target = service.issue_service_token("target", "alice")
source_before = _stored_row(repo, source.auth_token.id)
target_before = _stored_row(repo, target.auth_token.id)
with pytest.raises(
ValueError, match="이미 존재하는 서비스 토큰 이름입니다: target"
):
service.rename_service_token("source", "target")
assert _stored_row(repo, source.auth_token.id) == source_before
assert _stored_row(repo, target.auth_token.id) == target_before
repo.revoke_auth_token(target.auth_token.user_id, target.auth_token.id)
renamed = service.rename_service_token("source", "target")
assert renamed.changed is True
assert renamed.auth_token.id == source.auth_token.id
def test_same_name_is_noop_and_missing_same_name_fails(postgres_context) -> None:
settings, repo = postgres_context
service = AuthService(repo, settings)
issued = service.issue_service_token("same-name", "alice")
before = _stored_row(repo, issued.auth_token.id)
result = service.rename_service_token("same-name", "same-name")
assert result.changed is False
assert _stored_row(repo, issued.auth_token.id) == before
with pytest.raises(ValueError, match="서비스 토큰을 찾을 수 없습니다: missing"):
service.rename_service_token("missing", "missing")
revoked = service.issue_service_token("revoked-source", "alice")
repo.revoke_auth_token(revoked.auth_token.user_id, revoked.auth_token.id)
revoked_before = _stored_row(repo, revoked.auth_token.id)
with pytest.raises(
ValueError, match="서비스 토큰을 찾을 수 없습니다: revoked-source"
):
service.rename_service_token("revoked-source", "new-name")
assert _stored_row(repo, revoked.auth_token.id) == revoked_before
def test_concurrent_renames_to_same_target_have_one_winner(postgres_context) -> None:
settings, repo = postgres_context
service = AuthService(repo, settings)
first = service.issue_service_token("race-first", "alice")
second = service.issue_service_token("race-second", "alice")
barrier = Barrier(2)
def worker(source: str) -> str:
worker_repo = _repo(settings)
try:
barrier.wait()
AuthService(worker_repo, settings).rename_service_token(
source, "race-target"
)
return source
except ValueError as exc:
return str(exc)
finally:
worker_repo.close()
with ThreadPoolExecutor(max_workers=2) as executor:
futures = [
executor.submit(worker, "race-first"),
executor.submit(worker, "race-second"),
]
results = [future.result(timeout=15) for future in futures]
winners = [result for result in results if result in {"race-first", "race-second"}]
conflicts = [
result
for result in results
if result == "이미 존재하는 서비스 토큰 이름입니다: race-target"
]
assert len(winners) == 1
assert len(conflicts) == 1
active_target = repo.get_service_token_by_name("race-target")
assert active_target is not None
expected_id = (
first.auth_token.id if winners == ["race-first"] else second.auth_token.id
)
assert active_target.id == expected_id
def test_concurrent_rename_and_issue_have_one_winner(postgres_context) -> None:
settings, repo = postgres_context
service = AuthService(repo, settings)
source = service.issue_service_token("race-source", "alice")
barrier = Barrier(2)
def rename_worker() -> str:
worker_repo = _repo(settings)
try:
barrier.wait()
AuthService(worker_repo, settings).rename_service_token(
"race-source", "race-target"
)
return "renamed"
except ValueError as exc:
return str(exc)
finally:
worker_repo.close()
def issue_worker() -> str:
worker_repo = _repo(settings)
try:
barrier.wait()
AuthService(worker_repo, settings).issue_service_token(
"race-target", "alice"
)
return "issued"
except ValueError as exc:
return str(exc)
finally:
worker_repo.close()
with ThreadPoolExecutor(max_workers=2) as executor:
outcomes = [executor.submit(rename_worker), executor.submit(issue_worker)]
results = [future.result(timeout=15) for future in outcomes]
winners = [result for result in results if result in {"renamed", "issued"}]
conflicts = [
result
for result in results
if result == "이미 존재하는 서비스 토큰 이름입니다: race-target"
]
assert len(winners) == 1
assert len(conflicts) == 1
active_target = repo.get_service_token_by_name("race-target")
assert active_target is not None
if winners == ["renamed"]:
assert active_target.id == source.auth_token.id
assert repo.get_service_token_by_name("race-source") is None
else:
assert active_target.id != source.auth_token.id
assert repo.get_service_token_by_name("race-source").id == source.auth_token.id