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
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
"next_line_number": 10,
|
||||
"next_ac_number": 42
|
||||
"next_line_number": 11,
|
||||
"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 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`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]: ...
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 "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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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