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:
@@ -42,7 +42,7 @@ jobs:
|
|||||||
POSTGRES_PASSWORD: change-me
|
POSTGRES_PASSWORD: change-me
|
||||||
run: |
|
run: |
|
||||||
export PORTFOLIOHUB_TEST_POSTGRES_DSN="postgresql://portfolio_hub_user:${POSTGRES_PASSWORD}@127.0.0.1:5432/postgres"
|
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
|
- 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
|
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
|
- name: Reject Ruff findings introduced by v0.3.0 or later patches
|
||||||
|
|||||||
@@ -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가 실패해야 한다.
|
||||||
@@ -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를 검증한다.
|
||||||
@@ -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를 실행한다.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"next_line_number": 10,
|
"next_line_number": 11,
|
||||||
"next_ac_number": 42
|
"next_ac_number": 45
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 통과를 확인했다.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
id: "line-0010"
|
||||||
|
status: discovery
|
||||||
|
---
|
||||||
@@ -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
|
||||||
@@ -109,6 +109,7 @@ pfhub service-token list --user alice
|
|||||||
pfhub service-token issue nightly --user alice --ttl-days 30
|
pfhub service-token issue nightly --user alice --ttl-days 30
|
||||||
pfhub service-token issue permanent --user alice --no-expiry
|
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 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 revoke nightly --force
|
||||||
pfhub service-token rotate permanent --force
|
pfhub service-token rotate permanent --force
|
||||||
pfhub daemon create alpha-bot --user alice --display-name "Alpha Trading Bot"
|
pfhub daemon create alpha-bot --user alice --display-name "Alpha Trading Bot"
|
||||||
@@ -137,6 +138,7 @@ Supported commands:
|
|||||||
- `user delete`
|
- `user delete`
|
||||||
- `service-token list`
|
- `service-token list`
|
||||||
- `service-token issue`
|
- `service-token issue`
|
||||||
|
- `service-token rename`
|
||||||
- `service-token revoke`
|
- `service-token revoke`
|
||||||
- `service-token rotate`
|
- `service-token rotate`
|
||||||
- `daemon create`
|
- `daemon create`
|
||||||
|
|||||||
@@ -8,7 +8,13 @@ import typer
|
|||||||
|
|
||||||
from app.cli.core.output import mask_token_text, render_service_token_result
|
from app.cli.core.output import mask_token_text, render_service_token_result
|
||||||
from app.cli.core.utils import render_output
|
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
|
from app.services.auth_service import AuthService
|
||||||
|
|
||||||
token_app = typer.Typer(help="Service token lifecycle commands.")
|
token_app = typer.Typer(help="Service token lifecycle commands.")
|
||||||
@@ -92,6 +98,32 @@ def issue_service_token(
|
|||||||
ctx.obj.console.print("[yellow]⚠ 이 토큰은 다시 표시되지 않습니다. 지금 복사해 안전한 곳에 저장하세요.[/yellow]")
|
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")
|
@token_app.command("rotate")
|
||||||
def rotate_service_token(
|
def rotate_service_token(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ class PortfolioRepository(Protocol):
|
|||||||
|
|
||||||
def get_service_token_by_name(self, name: str) -> AuthToken | None: ...
|
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 get_token_principal(self, token_hash: str) -> TokenPrincipal | None: ...
|
||||||
|
|
||||||
def rotate_service_token(self, name: str) -> tuple[str, AuthToken]: ...
|
def rotate_service_token(self, name: str) -> tuple[str, AuthToken]: ...
|
||||||
|
|||||||
@@ -1128,6 +1128,37 @@ class PostgresPortfolioRepository:
|
|||||||
return None
|
return None
|
||||||
return self._row_to_auth_token(row)
|
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:
|
def get_token_principal(self, token_hash: str) -> TokenPrincipal | None:
|
||||||
self.ensure_schema()
|
self.ensure_schema()
|
||||||
with self._connect().cursor() as cur:
|
with self._connect().cursor() as cur:
|
||||||
|
|||||||
@@ -90,6 +90,13 @@ class ServiceTokenRebindCliResponse(BaseModel):
|
|||||||
previous_daemon_id: str | None = None
|
previous_daemon_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceTokenRenameCliResponse(BaseModel):
|
||||||
|
token_id: str
|
||||||
|
old_name: str
|
||||||
|
new_name: str
|
||||||
|
changed: bool
|
||||||
|
|
||||||
|
|
||||||
class ServiceTokenRevokeCliResponse(BaseModel):
|
class ServiceTokenRevokeCliResponse(BaseModel):
|
||||||
token_id: str
|
token_id: str
|
||||||
name: str
|
name: str
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ class IssuedServiceToken:
|
|||||||
auth_token: AuthToken
|
auth_token: AuthToken
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RenamedServiceToken:
|
||||||
|
auth_token: AuthToken
|
||||||
|
changed: bool
|
||||||
|
|
||||||
|
|
||||||
def get_auth_service_repository(settings: Settings = Depends(get_settings)) -> PortfolioRepository:
|
def get_auth_service_repository(settings: Settings = Depends(get_settings)) -> PortfolioRepository:
|
||||||
return get_portfolio_repository_from_settings(settings)
|
return get_portfolio_repository_from_settings(settings)
|
||||||
|
|
||||||
@@ -130,6 +136,19 @@ class AuthService:
|
|||||||
raw_token, auth_token = self._repository.rotate_service_token(name)
|
raw_token, auth_token = self._repository.rotate_service_token(name)
|
||||||
return IssuedServiceToken(token=raw_token, auth_token=auth_token)
|
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]:
|
def rebind_service_token(self, name: str, daemon_id: str) -> tuple[str | None, AuthToken]:
|
||||||
existing = self._repository.get_service_token_by_name(name)
|
existing = self._repository.get_service_token_by_name(name)
|
||||||
if existing is None:
|
if existing is None:
|
||||||
|
|||||||
@@ -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 흐름 대상으로 검토.
|
||||||
@@ -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 "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 "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_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 "${POSTGRES_PASSWORD}@127.0.0.1:5432/postgres" in text
|
||||||
assert "***@" not in text
|
assert "***@" not in text
|
||||||
|
|
||||||
|
|||||||
+91
-1
@@ -4,6 +4,7 @@ from dataclasses import dataclass
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
import psycopg.errors
|
||||||
|
|
||||||
from app.core.auth import AuthContext, Permission
|
from app.core.auth import AuthContext, Permission
|
||||||
from app.core.config import Settings
|
from app.core.config import Settings
|
||||||
@@ -96,6 +97,7 @@ class _AuthRepo:
|
|||||||
self.tokens: dict[str, AuthToken] = {}
|
self.tokens: dict[str, AuthToken] = {}
|
||||||
self._daemons: dict[str, object] = {}
|
self._daemons: dict[str, object] = {}
|
||||||
self._seq = 1
|
self._seq = 1
|
||||||
|
self.rename_calls = 0
|
||||||
|
|
||||||
def get_user_by_username(self, username: str):
|
def get_user_by_username(self, username: str):
|
||||||
return self.users.get(username)
|
return self.users.get(username)
|
||||||
@@ -105,10 +107,20 @@ class _AuthRepo:
|
|||||||
|
|
||||||
def get_service_token_by_name(self, name: str) -> AuthToken | None:
|
def get_service_token_by_name(self, name: str) -> AuthToken | None:
|
||||||
for token in self.tokens.values():
|
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 token
|
||||||
return None
|
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(
|
def create_auth_token(
|
||||||
self,
|
self,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
@@ -231,6 +243,84 @@ def test_revoke_service_token_not_found_raises() -> None:
|
|||||||
raise AssertionError("Expected ValueError for missing service token")
|
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)
|
# REQ-018: daemon_id forward 보정 (service layer)
|
||||||
|
|
||||||
def test_issue_service_token_with_daemon_id_stores_it() -> None:
|
def test_issue_service_token_with_daemon_id_stores_it() -> None:
|
||||||
|
|||||||
+168
-2
@@ -4,13 +4,25 @@ import json
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
import app.cli.commands.health as health_commands
|
from click import unstyle
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
import app.cli.commands.health as health_commands
|
||||||
from app.cli.main import create_app
|
from app.cli.main import create_app
|
||||||
from app.core.config import Settings
|
from app.core.config import Settings
|
||||||
from app.core.security import hash_token, token_prefix
|
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:
|
class FakeRepo:
|
||||||
@@ -126,6 +138,15 @@ class FakeRepo:
|
|||||||
None,
|
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(
|
def create_daemon(
|
||||||
self,
|
self,
|
||||||
owner_user_id: str,
|
owner_user_id: str,
|
||||||
@@ -1055,6 +1076,7 @@ def test_cli_help_exposes_bot_token_commands() -> None:
|
|||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "issue" in result.stdout
|
assert "issue" in result.stdout
|
||||||
|
assert "rename" in result.stdout
|
||||||
assert "rebind" in result.stdout
|
assert "rebind" in result.stdout
|
||||||
assert "revoke" 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
|
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:
|
def test_cli_service_token_revoke_force_succeeds() -> None:
|
||||||
repo = FakeRepo(_portfolio())
|
repo = FakeRepo(_portfolio())
|
||||||
user = repo.create_user("alice", permission="read")
|
user = repo.create_user("alice", permission="read")
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user