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
+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