feat(REQ-012): implement service token rotation CLI

- Add service token rotation CLI command with force flag support
- Implement token rotation in auth service with expiration handling
- Add portfolio repository helper for transaction/batch operations
- Add cascade delete support in postgres repository
- Add comprehensive CLI tests for service token operations
- Add REQ-012 specification for service token rotation
- Create BAT-002 delivery batch for token rotation implementation
- Update SRS and batch indexes
This commit is contained in:
2026-05-02 12:43:43 +00:00
parent 8370d998d2
commit b4d964253b
12 changed files with 492 additions and 3 deletions
+122
View File
@@ -145,6 +145,20 @@ class FakeRepo:
raise ValueError("토큰을 찾을 수 없습니다")
token.revoked_at = datetime.now(timezone.utc)
def rotate_service_token(
self,
token_id: str,
token_hash: str,
token_prefix: str,
runtime_id: str,
) -> AuthToken:
token = self._auth_tokens.get(token_id)
if token is None or token.revoked_at is not None:
raise ValueError("토큰을 찾을 수 없습니다")
token.token_prefix = token_prefix
token.runtime_id = runtime_id
return token
def list_service_tokens(
self,
user_id: str | None = None,
@@ -1044,3 +1058,111 @@ def test_cli_service_token_list_empty_message() -> None:
assert result.exit_code == 0
assert "No service tokens found." in result.stdout
# ---------------------------------------------------------------------------
# service-token rotate
# ---------------------------------------------------------------------------
def test_cli_service_token_rotate_no_expiry_succeeds() -> None:
repo = FakeRepo(_portfolio())
user = repo.create_user("alice", permission="read")
# no-expiry 토큰 발급 (expires_at=None)
repo.create_auth_token(
user.id,
name="permanent",
token_hash="oldhash",
token_prefix="oldpref",
permission="read",
token_type="service",
expires_at=None,
runtime_id="old-runtime-id",
)
runner = build_runner(repo)
result = runner.invoke(runner.app, ["service-token", "rotate", "permanent", "--force"])
assert result.exit_code == 0
assert "token:" in result.stdout
# token_prefix가 새 값으로 교체되었는지 확인
rotated = repo.get_service_token_by_name("permanent")
assert rotated is not None
assert rotated.token_prefix != "oldpref"
assert rotated.runtime_id != "old-runtime-id"
def test_cli_service_token_rotate_unknown_name_exits_error() -> None:
repo = FakeRepo(_portfolio())
runner = build_runner(repo)
result = runner.invoke(runner.app, ["service-token", "rotate", "nonexistent", "--force"])
assert result.exit_code == 1
assert "nonexistent" in result.stdout
def test_cli_service_token_rotate_revoked_token_exits_error() -> None:
repo = FakeRepo(_portfolio())
user = repo.create_user("alice", permission="read")
token = repo.create_auth_token(
user.id,
name="old-token",
token_hash="hash",
token_prefix="pref",
permission="read",
token_type="service",
expires_at=None,
runtime_id="rid",
)
repo.revoke_auth_token(user.id, token.id)
runner = build_runner(repo)
result = runner.invoke(runner.app, ["service-token", "rotate", "old-token", "--force"])
assert result.exit_code == 1
assert "old-token" in result.stdout
def test_cli_service_token_rotate_with_expiry_exits_error() -> None:
repo = FakeRepo(_portfolio())
user = repo.create_user("alice", permission="read")
repo.create_auth_token(
user.id,
name="expiring",
token_hash="hash",
token_prefix="pref",
permission="read",
token_type="service",
expires_at=datetime(2027, 1, 1, tzinfo=timezone.utc),
runtime_id="rid",
)
runner = build_runner(repo)
result = runner.invoke(runner.app, ["service-token", "rotate", "expiring", "--force"])
assert result.exit_code == 1
assert "expiring" in result.stdout
def test_cli_service_token_rotate_can_abort() -> None:
repo = FakeRepo(_portfolio())
user = repo.create_user("alice", permission="read")
repo.create_auth_token(
user.id,
name="permanent",
token_hash="hash",
token_prefix="pref",
permission="read",
token_type="service",
expires_at=None,
runtime_id="rid",
)
runner = build_runner(repo)
result = runner.invoke(runner.app, ["service-token", "rotate", "permanent"], input="n\n")
assert result.exit_code == 0
# token_prefix 변경 없음
token = repo.get_service_token_by_name("permanent")
assert token is not None
assert token.token_prefix == "pref"