feat(auth): owner-bound bot tokens and self-service bot management
This commit is contained in:
@@ -119,7 +119,7 @@ class FakeRepo:
|
||||
def get_bot_principal(self, bot_principal_id: str):
|
||||
raise ValueError("봇을 찾을 수 없습니다")
|
||||
|
||||
def list_bot_principals(self, *, is_active=None):
|
||||
def list_bot_principals(self, *, is_active=None, owner_user_id=None):
|
||||
return []
|
||||
|
||||
def deactivate_bot_principal(self, *args, **kwargs):
|
||||
|
||||
@@ -108,6 +108,25 @@ def test_non_owner_gets_403_on_db_auth(monkeypatch) -> None:
|
||||
assert response.json()["detail"] == "Portfolio ownership mismatch"
|
||||
|
||||
|
||||
def test_owner_bound_bot_token_can_read_owned_portfolio(monkeypatch) -> None:
|
||||
token = "owner-bot-token"
|
||||
principal = TokenPrincipal(
|
||||
token_id="t-bot-1",
|
||||
user_id="owner-1",
|
||||
permission="read",
|
||||
token_type="bot",
|
||||
bot_principal_id="bot-1",
|
||||
)
|
||||
repo = FakeRepo(portfolio=_portfolio("owner-1"), principals={hash_token(token): principal})
|
||||
client = _configure_client(monkeypatch, repo)
|
||||
|
||||
response = client.get("/portfolios/p1", headers={"Authorization": f"Bearer {token}"})
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
assert response.status_code == 200
|
||||
assert response.json()["portfolio"]["owner_user_id"] == "owner-1"
|
||||
|
||||
|
||||
def test_db_enabled_env_token_without_user_context_is_rejected(monkeypatch) -> None:
|
||||
repo = FakeRepo(portfolio=_portfolio("owner-1"), principals={})
|
||||
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
|
||||
|
||||
+85
-4
@@ -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"] == "봇 접근 권한이 없습니다"
|
||||
Reference in New Issue
Block a user