Skip to content

Commit 2e2a0bb

Browse files
committed
feat: CLI for permission elevation
Signed-off-by: Tomas Weiss <[email protected]>
1 parent 2964ff2 commit 2e2a0bb

File tree

5 files changed

+155
-4
lines changed

5 files changed

+155
-4
lines changed

apps/agentstack-cli/src/agentstack_cli/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import agentstack_cli.commands.platform
1616
import agentstack_cli.commands.self
1717
import agentstack_cli.commands.server
18+
import agentstack_cli.commands.user
1819
from agentstack_cli.async_typer import AliasGroup, AsyncTyper
1920
from agentstack_cli.configuration import Configuration
2021

@@ -48,6 +49,7 @@ def get_help(self, ctx):
4849
│ model Configure 15+ LLM providers │
4950
│ platform Start, stop, or delete local platform │
5051
│ server Connect to remote Agent Stack servers │
52+
│ user Manage users and roles │
5153
│ self version Show Agent Stack CLI and Platform version │
5254
│ self upgrade Upgrade Agent Stack CLI and Platform │
5355
│ self uninstall Uninstall Agent Stack CLI and Platform │
@@ -84,6 +86,12 @@ def get_help(self, ctx):
8486
help="Manage Agent Stack installation.",
8587
hidden=True,
8688
)
89+
app.add_typer(
90+
agentstack_cli.commands.user.app,
91+
name="user",
92+
no_args_is_help=True,
93+
help="Manage users.",
94+
)
8795

8896

8997
agent_alias = deepcopy(agentstack_cli.commands.agent.app)
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# Copyright 2025 © BeeAI a Series of LF Projects, LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
import typing
5+
from datetime import datetime
6+
7+
import typer
8+
from agentstack_sdk.platform import User
9+
from agentstack_sdk.platform.user import UserRole
10+
from rich.table import Column
11+
12+
from agentstack_cli.async_typer import AsyncTyper, console, create_table
13+
from agentstack_cli.configuration import Configuration
14+
from agentstack_cli.utils import announce_server_action, confirm_server_action
15+
16+
app = AsyncTyper()
17+
configuration = Configuration()
18+
19+
20+
@app.command("list")
21+
async def list_users(
22+
email: typing.Annotated[str | None, typer.Option(help="Filter by email (case-insensitive partial match)")] = None,
23+
limit: typing.Annotated[int, typer.Option(help="Results per page (1-100)")] = 40,
24+
after: typing.Annotated[str | None, typer.Option(help="Pagination cursor (page_token)")] = None,
25+
):
26+
"""List platform users (admin only)."""
27+
announce_server_action("Listing users on")
28+
29+
async with configuration.use_platform_client():
30+
result = await User.list(email=email, limit=limit, page_token=after)
31+
32+
items = result.items
33+
has_more = result.has_more
34+
next_page_token = result.next_page_token
35+
36+
with create_table(
37+
Column("ID", style="yellow"),
38+
Column("Email"),
39+
Column("Role"),
40+
Column("Created"),
41+
Column("Role Updated"),
42+
no_wrap=True,
43+
) as table:
44+
for user in items:
45+
role_display = {
46+
"admin": "[red]admin[/red]",
47+
"developer": "[cyan]developer[/cyan]",
48+
"user": "user",
49+
}.get(user.role, user.role)
50+
51+
created_at = _format_date(user.created_at)
52+
role_updated_at = _format_date(user.role_updated_at) if user.role_updated_at else "-"
53+
54+
table.add_row(
55+
user.id,
56+
user.email,
57+
role_display,
58+
created_at,
59+
role_updated_at,
60+
)
61+
62+
console.print()
63+
console.print(table)
64+
65+
if has_more and next_page_token:
66+
console.print(f"\n[dim]Use --after {next_page_token} to see more[/dim]")
67+
68+
69+
@app.command("set-role")
70+
async def set_role(
71+
user_id: typing.Annotated[str, typer.Argument(help="User UUID")],
72+
role: typing.Annotated[UserRole, typer.Option("--role", "-r", help="Target role")],
73+
yes: typing.Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompts.")] = False,
74+
):
75+
"""Change user role (admin only)."""
76+
url = announce_server_action(f"Changing user {user_id} to role '{role}' on")
77+
await confirm_server_action("Proceed with role change on", url=url, yes=yes)
78+
79+
async with configuration.use_platform_client():
80+
result = await User.set_role(user_id, UserRole(role))
81+
82+
role_display = {
83+
"admin": "[red]admin[/red]",
84+
"developer": "[cyan]developer[/cyan]",
85+
"user": "user",
86+
}.get(result.new_role, result.new_role)
87+
88+
console.success(
89+
f"User role updated to [cyan]{role_display}[/cyan] (version [yellow]{result.role_version}[/yellow])"
90+
)
91+
92+
93+
def _format_date(dt: datetime | None) -> str:
94+
if not dt:
95+
return "-"
96+
return dt.strftime("%Y-%m-%d %H:%M")

apps/agentstack-sdk-py/src/agentstack_sdk/platform/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,6 @@
77
from .model_provider import *
88
from .provider import *
99
from .provider_build import *
10+
from .user import *
1011
from .user_feedback import *
1112
from .vector_store import *

apps/agentstack-sdk-py/src/agentstack_sdk/platform/user.py

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,69 @@
33

44
from __future__ import annotations
55

6-
from typing import Literal
6+
from enum import StrEnum
77

88
import pydantic
99

1010
from agentstack_sdk.platform.client import PlatformClient, get_platform_client
11+
from agentstack_sdk.platform.common import PaginatedResult
12+
13+
14+
class UserRole(StrEnum):
15+
ADMIN = "admin"
16+
DEVELOPER = "developer"
17+
USER = "user"
18+
19+
20+
class ChangeRoleResponse(pydantic.BaseModel):
21+
user_id: str
22+
new_role: UserRole
23+
role_version: int
1124

1225

1326
class User(pydantic.BaseModel):
1427
id: str
15-
role: Literal["admin", "developer", "user"]
28+
role: UserRole
1629
email: str
1730
created_at: pydantic.AwareDatetime
31+
role_updated_at: pydantic.AwareDatetime | None = None
1832

1933
@staticmethod
2034
async def get(*, client: PlatformClient | None = None) -> User:
21-
"""Get the current user information."""
2235
async with client or get_platform_client() as client:
2336
return pydantic.TypeAdapter(User).validate_python(
2437
(await client.get(url="/api/v1/user")).raise_for_status().json()
2538
)
39+
40+
@staticmethod
41+
async def list(
42+
*,
43+
email: str | None = None,
44+
limit: int = 40,
45+
page_token: str | None = None,
46+
client: PlatformClient | None = None,
47+
) -> PaginatedResult[User]:
48+
async with client or get_platform_client() as client:
49+
params: dict[str, int | str] = {"limit": limit}
50+
if email:
51+
params["email"] = email
52+
if page_token:
53+
params["page_token"] = page_token
54+
55+
return pydantic.TypeAdapter(PaginatedResult[User]).validate_python(
56+
(await client.get(url="/api/v1/users", params=params)).raise_for_status().json()
57+
)
58+
59+
@staticmethod
60+
async def set_role(
61+
user_id: str,
62+
new_role: UserRole,
63+
*,
64+
client: PlatformClient | None = None,
65+
) -> ChangeRoleResponse:
66+
async with client or get_platform_client() as client:
67+
return pydantic.TypeAdapter(ChangeRoleResponse).validate_python(
68+
(await client.put(url=f"/api/v1/users/{user_id}/role", json={"new_role": new_role}))
69+
.raise_for_status()
70+
.json()
71+
)

apps/agentstack-server/src/agentstack_server/api/routes/users.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ async def list_users(
4040
)
4141

4242

43-
@router.put("/users/{user_id}/role", response_model=ChangeRoleResponse)
43+
@router.put("/{user_id}/role", response_model=ChangeRoleResponse)
4444
async def change_user_role(
4545
user_id: UUID,
4646
request: ChangeRoleRequest,

0 commit comments

Comments
 (0)