feat(auth): owner-bound bot tokens and self-service bot management

This commit is contained in:
2026-04-14 00:34:44 +09:00
parent 9787cf3159
commit 2dab17cbb1
16 changed files with 576 additions and 35 deletions
+85 -4
View File
@@ -163,21 +163,33 @@ class FakeUserRepo:
if token.expires_at is not None and token.expires_at <= datetime.now(timezone.utc):
return None
token.last_used_at = datetime.now(timezone.utc)
if token.bot_principal_id is not None:
bot = self.get_bot_principal(token.bot_principal_id)
return TokenPrincipal(
token_id=principal.token_id,
user_id=bot.owner_user_id,
permission=principal.permission,
token_type=principal.token_type,
bot_principal_id=principal.bot_principal_id,
)
return principal
def create_bot_principal(
self,
*,
name: str,
owner_user_id: str,
permission: str,
description: str | None = None,
) -> BotPrincipal:
if any(item.name == name for item in self.bots.values()):
raise ValueError(f"이미 존재하는 bot name입니다: {name}")
self.get_user(owner_user_id)
now = datetime.now(timezone.utc)
bot = BotPrincipal(
id=f"bot-{self.next_bot_id}",
name=name,
owner_user_id=owner_user_id,
permission=permission,
description=description,
is_active=True,
@@ -194,10 +206,17 @@ class FakeUserRepo:
raise ValueError("봇을 찾을 수 없습니다")
return bot
def list_bot_principals(self, *, is_active: bool | None = None) -> list[BotPrincipal]:
def list_bot_principals(
self,
*,
is_active: bool | None = None,
owner_user_id: str | None = None,
) -> list[BotPrincipal]:
items = list(self.bots.values())
if is_active is not None:
items = [item for item in items if item.is_active == is_active]
if owner_user_id is not None:
items = [item for item in items if item.owner_user_id == owner_user_id]
return sorted(items, key=lambda item: item.name)
def deactivate_bot_principal(self, bot_principal_id: str) -> BotPrincipal:
@@ -340,7 +359,12 @@ def test_admin_can_create_bot_and_issue_token(monkeypatch) -> None:
bot_response = client.post(
"/auth/bots",
headers={"Authorization": f"Bearer {admin_token}"},
json={"name": "market-bot", "permission": "trade-write", "description": "external execution bot"},
json={
"name": "market-bot",
"owner_user_id": admin.id,
"permission": "trade-write",
"description": "external execution bot",
},
)
assert bot_response.status_code == 201
bot_id = bot_response.json()["id"]
@@ -373,7 +397,7 @@ def test_deactivated_bot_token_is_rejected(monkeypatch) -> None:
bot_response = client.post(
"/auth/bots",
headers={"Authorization": f"Bearer {admin_token}"},
json={"name": "ops-bot", "permission": "read"},
json={"name": "ops-bot", "owner_user_id": admin.id, "permission": "read"},
)
assert bot_response.status_code == 201
bot_id = bot_response.json()["id"]
@@ -396,4 +420,61 @@ def test_deactivated_bot_token_is_rejected(monkeypatch) -> None:
app.dependency_overrides.clear()
assert denied_response.status_code == 401
assert denied_response.json()["detail"] == "Invalid token"
assert denied_response.json()["detail"] == "Invalid token"
def test_user_can_create_and_issue_owned_bot_token(monkeypatch) -> None:
repo = FakeUserRepo()
user = repo.create_user("alice", password_hash=hash_password("alice123!"), permission="trade-write")
client = _client_with_repo(monkeypatch, repo)
login_response = client.post("/auth/login", json={"username": "alice", "password": "alice123!"})
assert login_response.status_code == 200
user_token = login_response.json()["access_token"]
bot_response = client.post(
"/auth/me/bots",
headers={"Authorization": f"Bearer {user_token}"},
json={"name": "alice-bot", "permission": "read", "description": "personal bot"},
)
assert bot_response.status_code == 201
bot_id = bot_response.json()["id"]
assert bot_response.json()["owner_user_id"] == user.id
list_response = client.get("/auth/me/bots", headers={"Authorization": f"Bearer {user_token}"})
assert list_response.status_code == 200
assert [item["id"] for item in list_response.json()] == [bot_id]
issue_response = client.post(
f"/auth/me/bots/{bot_id}/tokens",
headers={"Authorization": f"Bearer {user_token}"},
json={"name": "alice-key", "permission": "read"},
)
app.dependency_overrides.clear()
assert issue_response.status_code == 201
payload = issue_response.json()
assert payload["auth_token"]["token_type"] == "bot"
assert payload["auth_token"]["bot_principal_id"] == bot_id
def test_user_cannot_issue_token_for_other_users_bot(monkeypatch) -> None:
repo = FakeUserRepo()
owner = repo.create_user("owner", password_hash=hash_password("owner123!"), permission="trade-write")
other = repo.create_user("other", password_hash=hash_password("other123!"), permission="trade-write")
bot = repo.create_bot_principal(name="owner-bot", owner_user_id=owner.id, permission="read")
client = _client_with_repo(monkeypatch, repo)
login_response = client.post("/auth/login", json={"username": "other", "password": "other123!"})
assert login_response.status_code == 200
other_token = login_response.json()["access_token"]
issue_response = client.post(
f"/auth/me/bots/{bot.id}/tokens",
headers={"Authorization": f"Bearer {other_token}"},
json={"name": "invalid-key", "permission": "read"},
)
app.dependency_overrides.clear()
assert issue_response.status_code == 403
assert issue_response.json()["detail"] == "봇 접근 권한이 없습니다"