feat: add service token rename command (#40)

* docs: preserve original issue 5 body

* feat: add service token rename command

* test: normalize rich option errors
This commit is contained in:
2026-09-19 21:19:39 +09:00
committed by GitHub
parent 996127f215
commit deaaa96c46
20 changed files with 882 additions and 7 deletions
+33 -1
View File
@@ -8,7 +8,13 @@ import typer
from app.cli.core.output import mask_token_text, render_service_token_result
from app.cli.core.utils import render_output
from app.schemas.auth import AuthTokenResponse, ServiceTokenIssueCliResponse, ServiceTokenRebindCliResponse, ServiceTokenRevokeCliResponse
from app.schemas.auth import (
AuthTokenResponse,
ServiceTokenIssueCliResponse,
ServiceTokenRebindCliResponse,
ServiceTokenRenameCliResponse,
ServiceTokenRevokeCliResponse,
)
from app.services.auth_service import AuthService
token_app = typer.Typer(help="Service token lifecycle commands.")
@@ -92,6 +98,32 @@ def issue_service_token(
ctx.obj.console.print("[yellow]⚠ 이 토큰은 다시 표시되지 않습니다. 지금 복사해 안전한 곳에 저장하세요.[/yellow]")
@token_app.command("rename")
def rename_service_token(
ctx: typer.Context,
name: str = typer.Argument(..., help="Active service token name to rename."),
new_name: str = typer.Option(..., "--new-name", help="New unique service token name."),
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation prompt."),
) -> None:
"""Rename an active service token without rotating its bearer token."""
if not force:
confirmed = typer.confirm(f"서비스 토큰 이름을 '{name}'에서 '{new_name}'(으)로 변경하시겠습니까?")
if not confirmed:
raise typer.Exit(code=0)
def _rename(service: AuthService, app_context):
return service.rename_service_token(name, new_name)
renamed = _with_service(ctx, _rename)
response = ServiceTokenRenameCliResponse(
token_id=renamed.auth_token.id,
old_name=name,
new_name=renamed.auth_token.name,
changed=renamed.changed,
)
render_service_token_result(ctx.obj.console, ctx.obj.output_format, response)
@token_app.command("rotate")
def rotate_service_token(
ctx: typer.Context,
+2
View File
@@ -87,6 +87,8 @@ class PortfolioRepository(Protocol):
def get_service_token_by_name(self, name: str) -> AuthToken | None: ...
def rename_service_token(self, name: str, new_name: str) -> AuthToken: ...
def get_token_principal(self, token_hash: str) -> TokenPrincipal | None: ...
def rotate_service_token(self, name: str) -> tuple[str, AuthToken]: ...
+31
View File
@@ -1128,6 +1128,37 @@ class PostgresPortfolioRepository:
return None
return self._row_to_auth_token(row)
def rename_service_token(self, name: str, new_name: str) -> AuthToken:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
UPDATE auth_tokens
SET name = %s
WHERE name = %s
AND token_type = 'service'
AND revoked_at IS NULL
RETURNING
id,
user_id,
name,
token_prefix,
permission,
token_type,
expires_at,
last_used_at,
revoked_at,
created_at,
runtime_id,
daemon_id
""",
(new_name, name),
)
row = cur.fetchone()
if row is None:
raise ValueError(f"서비스 토큰을 찾을 수 없습니다: {name}")
return self._row_to_auth_token(row)
def get_token_principal(self, token_hash: str) -> TokenPrincipal | None:
self.ensure_schema()
with self._connect().cursor() as cur:
+7
View File
@@ -90,6 +90,13 @@ class ServiceTokenRebindCliResponse(BaseModel):
previous_daemon_id: str | None = None
class ServiceTokenRenameCliResponse(BaseModel):
token_id: str
old_name: str
new_name: str
changed: bool
class ServiceTokenRevokeCliResponse(BaseModel):
token_id: str
name: str
+19
View File
@@ -35,6 +35,12 @@ class IssuedServiceToken:
auth_token: AuthToken
@dataclass
class RenamedServiceToken:
auth_token: AuthToken
changed: bool
def get_auth_service_repository(settings: Settings = Depends(get_settings)) -> PortfolioRepository:
return get_portfolio_repository_from_settings(settings)
@@ -130,6 +136,19 @@ class AuthService:
raw_token, auth_token = self._repository.rotate_service_token(name)
return IssuedServiceToken(token=raw_token, auth_token=auth_token)
def rename_service_token(self, name: str, new_name: str) -> RenamedServiceToken:
if name == new_name:
existing = self._repository.get_service_token_by_name(name)
if existing is None:
raise ValueError(f"서비스 토큰을 찾을 수 없습니다: {name}")
return RenamedServiceToken(auth_token=existing, changed=False)
try:
renamed = self._repository.rename_service_token(name, new_name)
except psycopg.errors.UniqueViolation as exc:
raise ValueError(f"이미 존재하는 서비스 토큰 이름입니다: {new_name}") from exc
return RenamedServiceToken(auth_token=renamed, changed=True)
def rebind_service_token(self, name: str, daemon_id: str) -> tuple[str | None, AuthToken]:
existing = self._repository.get_service_token_by_name(name)
if existing is None: