Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
94 changes: 94 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,94 @@
# 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()

ROLE_DISPLAY = {
"admin": "[red]admin[/red]",
"developer": "[cyan]developer[/cyan]",
"user": "user",
}


@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 = ROLE_DISPLAY.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 = ROLE_DISPLAY.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["role_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