Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/agentstack-cli/src/agentstack_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import agentstack_cli.commands.platform
import agentstack_cli.commands.self
import agentstack_cli.commands.server
import agentstack_cli.commands.user
from agentstack_cli.async_typer import AliasGroup, AsyncTyper
from agentstack_cli.configuration import Configuration

Expand Down Expand Up @@ -48,6 +49,7 @@ def get_help(self, ctx):
│ model Configure 15+ LLM providers │
│ platform Start, stop, or delete local platform │
│ server Connect to remote Agent Stack servers │
│ user Manage users and roles │
│ self version Show Agent Stack CLI and Platform version │
│ self upgrade Upgrade Agent Stack CLI and Platform │
│ self uninstall Uninstall Agent Stack CLI and Platform │
Expand Down Expand Up @@ -84,6 +86,12 @@ def get_help(self, ctx):
help="Manage Agent Stack installation.",
hidden=True,
)
app.add_typer(
agentstack_cli.commands.user.app,
name="user",
no_args_is_help=True,
help="Manage users.",
)


agent_alias = deepcopy(agentstack_cli.commands.agent.app)
Expand Down
96 changes: 96 additions & 0 deletions apps/agentstack-cli/src/agentstack_cli/commands/user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Copyright 2025 © BeeAI a Series of LF Projects, LLC
# SPDX-License-Identifier: Apache-2.0

import typing
from datetime import datetime

import typer
from agentstack_sdk.platform import User
from agentstack_sdk.platform.user import UserRole
from rich.table import Column

from agentstack_cli.async_typer import AsyncTyper, console, create_table
from agentstack_cli.configuration import Configuration
from agentstack_cli.utils import announce_server_action, confirm_server_action

app = AsyncTyper()
configuration = Configuration()


@app.command("list")
async def list_users(
email: typing.Annotated[str | None, typer.Option(help="Filter by email (case-insensitive partial match)")] = None,
limit: typing.Annotated[int, typer.Option(help="Results per page (1-100)")] = 40,
after: typing.Annotated[str | None, typer.Option(help="Pagination cursor (page_token)")] = None,
):
"""List platform users (admin only)."""
announce_server_action("Listing users on")

async with configuration.use_platform_client():
result = await User.list(email=email, limit=limit, page_token=after)

items = result.items
has_more = result.has_more
next_page_token = result.next_page_token

with create_table(
Column("ID", style="yellow"),
Column("Email"),
Column("Role"),
Column("Created"),
Column("Role Updated"),
no_wrap=True,
) as table:
for user in items:
role_display = {
"admin": "[red]admin[/red]",
"developer": "[cyan]developer[/cyan]",
"user": "user",
}.get(user.role, user.role)

created_at = _format_date(user.created_at)
role_updated_at = _format_date(user.role_updated_at) if user.role_updated_at else "-"

table.add_row(
user.id,
user.email,
role_display,
created_at,
role_updated_at,
)

console.print()
console.print(table)

if has_more and next_page_token:
console.print(f"\n[dim]Use --after {next_page_token} to see more[/dim]")


@app.command("set-role")
async def set_role(
user_id: typing.Annotated[str, typer.Argument(help="User UUID")],
role: typing.Annotated[UserRole, typer.Option("--role", "-r", help="Target role")],
yes: typing.Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompts.")] = False,
):
"""Change user role (admin only)."""
url = announce_server_action(f"Changing user {user_id} to role '{role}' on")
await confirm_server_action("Proceed with role change on", url=url, yes=yes)

async with configuration.use_platform_client():
result = await User.set_role(user_id, UserRole(role))

role_display = {
"admin": "[red]admin[/red]",
"developer": "[cyan]developer[/cyan]",
"user": "user",
}.get(result.new_role, result.new_role)

console.success(
f"User role updated to [cyan]{role_display}[/cyan] (version [yellow]{result.role_version}[/yellow])"
)


def _format_date(dt: datetime | None) -> str:
if not dt:
return "-"
return dt.strftime("%Y-%m-%d %H:%M")
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@
from .model_provider import *
from .provider import *
from .provider_build import *
from .user import *
from .user_feedback import *
from .vector_store import *
52 changes: 49 additions & 3 deletions apps/agentstack-sdk-py/src/agentstack_sdk/platform/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,69 @@

from __future__ import annotations

from typing import Literal
from enum import StrEnum

import pydantic

from agentstack_sdk.platform.client import PlatformClient, get_platform_client
from agentstack_sdk.platform.common import PaginatedResult


class UserRole(StrEnum):
ADMIN = "admin"
DEVELOPER = "developer"
USER = "user"


class ChangeRoleResponse(pydantic.BaseModel):
user_id: str
new_role: UserRole
role_version: int


class User(pydantic.BaseModel):
id: str
role: Literal["admin", "developer", "user"]
role: UserRole
email: str
created_at: pydantic.AwareDatetime
role_updated_at: pydantic.AwareDatetime | None = None

@staticmethod
async def get(*, client: PlatformClient | None = None) -> User:
"""Get the current user information."""
async with client or get_platform_client() as client:
return pydantic.TypeAdapter(User).validate_python(
(await client.get(url="/api/v1/user")).raise_for_status().json()
)

@staticmethod
async def list(
*,
email: str | None = None,
limit: int = 40,
page_token: str | None = None,
client: PlatformClient | None = None,
) -> PaginatedResult[User]:
async with client or get_platform_client() as client:
params: dict[str, int | str] = {"limit": limit}
if email:
params["email"] = email
if page_token:
params["page_token"] = page_token

return pydantic.TypeAdapter(PaginatedResult[User]).validate_python(
(await client.get(url="/api/v1/users", params=params)).raise_for_status().json()
)

@staticmethod
async def set_role(
user_id: str,
new_role: UserRole,
*,
client: PlatformClient | None = None,
) -> ChangeRoleResponse:
async with client or get_platform_client() as client:
return pydantic.TypeAdapter(ChangeRoleResponse).validate_python(
(await client.put(url=f"/api/v1/users/{user_id}/role", json={"new_role": new_role}))
.raise_for_status()
.json()
)
5 changes: 5 additions & 0 deletions apps/agentstack-server/src/agentstack_server/api/auth/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ class ParsedToken(BaseModel):
context_permissions: Permissions
context_id: UUID
user_id: UUID
role_version: int
raw: dict[str, Any]


Expand All @@ -101,6 +102,7 @@ def issue_internal_jwt(
global_permissions: Permissions,
context_permissions: Permissions,
configuration: Configuration,
role_version: int,
) -> tuple[str, AwareDatetime]:
assert configuration.auth.jwt_secret_key
secret_key = configuration.auth.jwt_secret_key.get_secret_value()
Expand All @@ -119,6 +121,7 @@ def issue_internal_jwt(
"global": global_permissions.model_dump(mode="json"),
"context": context_permissions.model_dump(mode="json"),
},
"role_version": role_version,
}
return jwt.encode(header, payload, key=secret_key), expires_at

Expand All @@ -134,6 +137,7 @@ def verify_internal_jwt(token: str, configuration: Configuration) -> ParsedToken
"exp": {"essential": True},
"iss": {"essential": True, "value": "agentstack-server"},
"aud": {"essential": True, "value": "agentstack-server"},
"role_version": {"essential": True},
},
)
context_id = UUID(payload["resource"][0].replace("context:", ""))
Expand All @@ -142,6 +146,7 @@ def verify_internal_jwt(token: str, configuration: Configuration) -> ParsedToken
context_permissions=Permissions.model_validate(payload["scope"]["context"]),
context_id=context_id,
user_id=UUID(payload["sub"]),
role_version=int(payload["token_version"]),
raw=payload,
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,11 +123,19 @@ async def authorized_user(
request: Request,
) -> AuthorizedUser:
if bearer_auth:
# Check Bearer token first - locally this allows for "checking permissions" for development purposes
# Check Context token first - locally this allows for "checking permissions" for development purposes
# even if auth is disabled (requests that would pass with no header may not pass with context token header)
try:
parsed_token = verify_internal_jwt(bearer_auth.credentials, configuration=configuration)
user = await user_service.get_user(parsed_token.user_id)

token_role_version = parsed_token.role_version
if token_role_version < user.role_version:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token invalidated due to role change",
)

token = AuthorizedUser(
user=user,
global_permissions=parsed_token.global_permissions,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ async def generate_context_token(
global_permissions=global_grant,
context_permissions=context_grant,
configuration=configuration,
role_version=user.user.role_version,
)
return ContextTokenResponse(token=token, expires_at=expires_at)

Expand Down
62 changes: 62 additions & 0 deletions apps/agentstack-server/src/agentstack_server/api/routes/users.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Copyright 2025 © BeeAI a Series of LF Projects, LLC
# SPDX-License-Identifier: Apache-2.0

import logging
from typing import Annotated
from uuid import UUID

from fastapi import APIRouter, Depends, HTTPException, Query, status

from agentstack_server.api.dependencies import UserServiceDependency, authorized_user
from agentstack_server.api.schema.user import ChangeRoleRequest, ChangeRoleResponse, UserListQuery, UserResponse
from agentstack_server.domain.models.common import PaginatedResult
from agentstack_server.domain.models.permissions import AuthorizedUser
from agentstack_server.domain.models.user import UserRole

logger = logging.getLogger(__name__)

router = APIRouter(tags=["users"])


@router.get("", response_model=PaginatedResult[UserResponse])
async def list_users(
query: Annotated[UserListQuery, Query()],
user: Annotated[AuthorizedUser, Depends(authorized_user)],
user_service: UserServiceDependency,
) -> PaginatedResult[UserResponse]:
if not user.user.role == UserRole.ADMIN:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin permission required")

result = await user_service.list_users(
limit=query.limit,
page_token=query.page_token,
email=query.email,
)

return PaginatedResult(
items=[UserResponse(**u.model_dump()) for u in result.items],
total_count=result.total_count,
has_more=result.has_more,
)


@router.put("/{user_id}/role", response_model=ChangeRoleResponse)
async def change_user_role(
user_id: UUID,
request: ChangeRoleRequest,
user: Annotated[AuthorizedUser, Depends(authorized_user)],
user_service: UserServiceDependency,
) -> ChangeRoleResponse:
if not user.user.role == UserRole.ADMIN:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin permission required")

if user_id == user.user.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot change own role")

updated_user = await user_service.change_role(user_id=user_id, new_role=request.new_role)

return ChangeRoleResponse(
user_id=updated_user.id,
new_role=updated_user.role,
role_version=updated_user.role_version,
)
32 changes: 32 additions & 0 deletions apps/agentstack-server/src/agentstack_server/api/schema/user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Copyright 2025 © BeeAI a Series of LF Projects, LLC
# SPDX-License-Identifier: Apache-2.0

from uuid import UUID

from pydantic import AwareDatetime, BaseModel, EmailStr, Field

from agentstack_server.domain.models.user import UserRole


class UserListQuery(BaseModel):
limit: int = Field(default=40, ge=1, le=100)
page_token: UUID | None = None
email: str | None = Field(default=None, description="Filter by email (case-insensitive partial match)")


class UserResponse(BaseModel):
id: UUID
email: EmailStr
role: UserRole
created_at: AwareDatetime
role_updated_at: AwareDatetime | None


class ChangeRoleRequest(BaseModel):
new_role: UserRole


class ChangeRoleResponse(BaseModel):
user_id: UUID
new_role: UserRole
role_version: int
2 changes: 2 additions & 0 deletions apps/agentstack-server/src/agentstack_server/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from agentstack_server.api.routes.providers import router as provider_router
from agentstack_server.api.routes.user import router as user_router
from agentstack_server.api.routes.user_feedback import router as user_feedback_router
from agentstack_server.api.routes.users import router as users_router
from agentstack_server.api.routes.variables import router as variables_router
from agentstack_server.api.routes.vector_stores import router as vector_stores_router
from agentstack_server.api.utils import format_openai_error
Expand Down Expand Up @@ -118,6 +119,7 @@ async def custom_http_exception_handler(request: Request, exc: Exception):
def mount_routes(app: FastAPI):
server_router = APIRouter()
server_router.include_router(user_router, prefix="/user")
server_router.include_router(users_router, prefix="/users")
server_router.include_router(a2a_router, prefix="/a2a")
server_router.include_router(mcp_router, prefix="/mcp")
server_router.include_router(provider_router, prefix="/providers", tags=["providers"])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,6 @@ class User(BaseModel):
id: UUID = Field(default_factory=uuid4)
role: UserRole = UserRole.USER
email: EmailStr
role_version: int = 1
role_updated_at: AwareDatetime | None = None
created_at: AwareDatetime = Field(default_factory=utc_now)
Loading
Loading