diff --git a/apps/agentstack-cli/src/agentstack_cli/__init__.py b/apps/agentstack-cli/src/agentstack_cli/__init__.py index cf33f1f26..9e114c3f0 100644 --- a/apps/agentstack-cli/src/agentstack_cli/__init__.py +++ b/apps/agentstack-cli/src/agentstack_cli/__init__.py @@ -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 @@ -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 │ @@ -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) diff --git a/apps/agentstack-cli/src/agentstack_cli/commands/user.py b/apps/agentstack-cli/src/agentstack_cli/commands/user.py new file mode 100644 index 000000000..c03144f60 --- /dev/null +++ b/apps/agentstack-cli/src/agentstack_cli/commands/user.py @@ -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") diff --git a/apps/agentstack-sdk-py/src/agentstack_sdk/platform/__init__.py b/apps/agentstack-sdk-py/src/agentstack_sdk/platform/__init__.py index 41900797f..9ee90fd6f 100644 --- a/apps/agentstack-sdk-py/src/agentstack_sdk/platform/__init__.py +++ b/apps/agentstack-sdk-py/src/agentstack_sdk/platform/__init__.py @@ -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 * diff --git a/apps/agentstack-sdk-py/src/agentstack_sdk/platform/user.py b/apps/agentstack-sdk-py/src/agentstack_sdk/platform/user.py index afcc3d222..6f98deb7a 100644 --- a/apps/agentstack-sdk-py/src/agentstack_sdk/platform/user.py +++ b/apps/agentstack-sdk-py/src/agentstack_sdk/platform/user.py @@ -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() + ) diff --git a/apps/agentstack-server/src/agentstack_server/api/auth/auth.py b/apps/agentstack-server/src/agentstack_server/api/auth/auth.py index 961fdc9a3..963b8e7b9 100644 --- a/apps/agentstack-server/src/agentstack_server/api/auth/auth.py +++ b/apps/agentstack-server/src/agentstack_server/api/auth/auth.py @@ -92,6 +92,7 @@ class ParsedToken(BaseModel): context_permissions: Permissions context_id: UUID user_id: UUID + role_version: int raw: dict[str, Any] @@ -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() @@ -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 @@ -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:", "")) @@ -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, ) diff --git a/apps/agentstack-server/src/agentstack_server/api/dependencies.py b/apps/agentstack-server/src/agentstack_server/api/dependencies.py index d1847a2c8..60f114df2 100644 --- a/apps/agentstack-server/src/agentstack_server/api/dependencies.py +++ b/apps/agentstack-server/src/agentstack_server/api/dependencies.py @@ -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, diff --git a/apps/agentstack-server/src/agentstack_server/api/routes/contexts.py b/apps/agentstack-server/src/agentstack_server/api/routes/contexts.py index c4f7a3316..fd107d093 100644 --- a/apps/agentstack-server/src/agentstack_server/api/routes/contexts.py +++ b/apps/agentstack-server/src/agentstack_server/api/routes/contexts.py @@ -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) diff --git a/apps/agentstack-server/src/agentstack_server/api/routes/users.py b/apps/agentstack-server/src/agentstack_server/api/routes/users.py new file mode 100644 index 000000000..664c4e7d3 --- /dev/null +++ b/apps/agentstack-server/src/agentstack_server/api/routes/users.py @@ -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, + ) diff --git a/apps/agentstack-server/src/agentstack_server/api/schema/user.py b/apps/agentstack-server/src/agentstack_server/api/schema/user.py new file mode 100644 index 000000000..69b90c280 --- /dev/null +++ b/apps/agentstack-server/src/agentstack_server/api/schema/user.py @@ -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 diff --git a/apps/agentstack-server/src/agentstack_server/application.py b/apps/agentstack-server/src/agentstack_server/application.py index 142378804..de26a7e00 100644 --- a/apps/agentstack-server/src/agentstack_server/application.py +++ b/apps/agentstack-server/src/agentstack_server/application.py @@ -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 @@ -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"]) diff --git a/apps/agentstack-server/src/agentstack_server/domain/models/user.py b/apps/agentstack-server/src/agentstack_server/domain/models/user.py index 8344c5bda..b934e6c9d 100644 --- a/apps/agentstack-server/src/agentstack_server/domain/models/user.py +++ b/apps/agentstack-server/src/agentstack_server/domain/models/user.py @@ -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) diff --git a/apps/agentstack-server/src/agentstack_server/domain/repositories/user.py b/apps/agentstack-server/src/agentstack_server/domain/repositories/user.py index e481bdaaf..a0e2a592b 100644 --- a/apps/agentstack-server/src/agentstack_server/domain/repositories/user.py +++ b/apps/agentstack-server/src/agentstack_server/domain/repositories/user.py @@ -1,18 +1,24 @@ # Copyright 2025 © BeeAI a Series of LF Projects, LLC # SPDX-License-Identifier: Apache-2.0 -from collections.abc import AsyncIterator from typing import Protocol from uuid import UUID +from agentstack_server.domain.models.common import PaginatedResult from agentstack_server.domain.models.user import User class IUserRepository(Protocol): - async def list(self) -> AsyncIterator[User]: - yield ... + async def list( + self, + *, + limit: int, + page_token: UUID | None = None, + email: str | None = None, + ) -> PaginatedResult[User]: ... async def create(self, *, user: User) -> None: ... async def get(self, *, user_id: UUID) -> User: ... async def get_by_email(self, *, email: str) -> User: ... async def delete(self, *, user_id: UUID) -> int: ... + async def update(self, *, user: User) -> None: ... diff --git a/apps/agentstack-server/src/agentstack_server/infrastructure/persistence/migrations/alembic/versions/4jowyo7q9m66_add_role_versioning.py b/apps/agentstack-server/src/agentstack_server/infrastructure/persistence/migrations/alembic/versions/4jowyo7q9m66_add_role_versioning.py new file mode 100644 index 000000000..ef1ce563f --- /dev/null +++ b/apps/agentstack-server/src/agentstack_server/infrastructure/persistence/migrations/alembic/versions/4jowyo7q9m66_add_role_versioning.py @@ -0,0 +1,32 @@ +# Copyright 2025 © BeeAI a Series of LF Projects, LLC +# SPDX-License-Identifier: Apache-2.0 + +"""add role versioning to users + +Revision ID: 4jowyo7q9m66 +Revises: ef8769062e65 +Create Date: 2025-12-18 14:00:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "4jowyo7q9m66" +down_revision: str | None = "ef8769062e65" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.add_column("users", sa.Column("role_version", sa.Integer(), nullable=False, server_default="1")) + op.add_column("users", sa.Column("role_updated_at", sa.DateTime(timezone=True), nullable=True)) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_column("users", "role_updated_at") + op.drop_column("users", "role_version") diff --git a/apps/agentstack-server/src/agentstack_server/infrastructure/persistence/repositories/user.py b/apps/agentstack-server/src/agentstack_server/infrastructure/persistence/repositories/user.py index ad8bc65a5..06e599314 100644 --- a/apps/agentstack-server/src/agentstack_server/infrastructure/persistence/repositories/user.py +++ b/apps/agentstack-server/src/agentstack_server/infrastructure/persistence/repositories/user.py @@ -5,14 +5,15 @@ from kink import inject from sqlalchemy import UUID as SQL_UUID -from sqlalchemy import Column, DateTime, Row, String, Table +from sqlalchemy import Column, DateTime, Integer, Row, String, Table from sqlalchemy.ext.asyncio import AsyncConnection +from agentstack_server.domain.models.common import PaginatedResult from agentstack_server.domain.models.user import User, UserRole from agentstack_server.domain.repositories.user import IUserRepository from agentstack_server.exceptions import EntityNotFoundError from agentstack_server.infrastructure.persistence.repositories.db_metadata import metadata -from agentstack_server.infrastructure.persistence.repositories.utils import sql_enum +from agentstack_server.infrastructure.persistence.repositories.utils import cursor_paginate, sql_enum users_table = Table( "users", @@ -21,6 +22,8 @@ Column("email", String(256), nullable=False, unique=True), Column("created_at", DateTime(timezone=True), nullable=False), Column("role", sql_enum(UserRole), nullable=False), + Column("role_version", Integer, nullable=False, server_default="1"), + Column("role_updated_at", DateTime(timezone=True), nullable=True), ) @@ -31,10 +34,7 @@ def __init__(self, connection: AsyncConnection): async def create(self, *, user: User) -> None: query = users_table.insert().values( - id=user.id, - email=user.email, - created_at=user.created_at, - role=user.role, + id=user.id, email=user.email, created_at=user.created_at, role=user.role, role_version=user.role_version ) await self.connection.execute(query) @@ -45,6 +45,8 @@ def _to_user(self, row: Row): "email": row.email, "created_at": row.created_at, "role": row.role, + "role_version": row.role_version, + "role_updated_at": row.role_updated_at, } ) @@ -69,7 +71,41 @@ async def delete(self, *, user_id: UUID) -> int: raise EntityNotFoundError(entity="user", id=user_id) return result.rowcount - async def list(self): + async def list( + self, + *, + limit: int, + page_token: UUID | None = None, + email: str | None = None, + ) -> PaginatedResult[User]: query = users_table.select() - async for row in await self.connection.stream(query): - yield self._to_user(row) + + if email is not None: + query = query.where(users_table.c.email.ilike(f"%{email}%")) + + result = await cursor_paginate( + connection=self.connection, + query=query, + order_column=users_table.c.created_at, + id_column=users_table.c.id, + limit=limit, + after_cursor=page_token, + order="desc", + ) + + users = [self._to_user(row) for row in result.items] + return PaginatedResult(items=users, total_count=result.total_count, has_more=result.has_more) + + async def update(self, *, user: User) -> None: + query = ( + users_table.update() + .where(users_table.c.id == user.id) + .values( + role=user.role, + role_version=user.role_version, + role_updated_at=user.role_updated_at, + ) + ) + result = await self.connection.execute(query) + if not result.rowcount: + raise EntityNotFoundError(entity="user", id=user.id) diff --git a/apps/agentstack-server/src/agentstack_server/service_layer/services/users.py b/apps/agentstack-server/src/agentstack_server/service_layer/services/users.py index ed23621c3..af6b12aea 100644 --- a/apps/agentstack-server/src/agentstack_server/service_layer/services/users.py +++ b/apps/agentstack-server/src/agentstack_server/service_layer/services/users.py @@ -7,10 +7,12 @@ from kink import inject from agentstack_server.configuration import Configuration +from agentstack_server.domain.models.common import PaginatedResult from agentstack_server.domain.models.user import User, UserRole from agentstack_server.domain.repositories.env import EnvStoreEntity -from agentstack_server.exceptions import UsageLimitExceededError +from agentstack_server.exceptions import PlatformError, UsageLimitExceededError from agentstack_server.service_layer.unit_of_work import IUnitOfWorkFactory +from agentstack_server.utils.utils import utc_now logger = logging.getLogger(__name__) @@ -58,3 +60,32 @@ async def list_user_env(self, *, user: User) -> dict[str, str]: async with self._uow() as uow: env = await uow.env.get_all(parent_entity=EnvStoreEntity.USER, parent_entity_ids=[user.id]) return env[user.id] + + async def list_users( + self, + *, + limit: int = 40, + page_token: UUID | None = None, + email: str | None = None, + ) -> PaginatedResult[User]: + async with self._uow() as uow: + return await uow.users.list( + limit=limit, + page_token=page_token, + email=email, + ) + + async def change_role(self, user_id: UUID, new_role: UserRole) -> User: + async with self._uow() as uow: + user = await uow.users.get(user_id=user_id) + + if user.role == new_role: + raise PlatformError("User already has this role", status_code=400) + + user.role = new_role + user.role_version += 1 + user.role_updated_at = utc_now() + + await uow.users.update(user=user) + await uow.commit() + return user diff --git a/apps/agentstack-server/tests/integration/persistence/repositories/test_users.py b/apps/agentstack-server/tests/integration/persistence/repositories/test_users.py index 094fc4d1a..5ebd214e8 100644 --- a/apps/agentstack-server/tests/integration/persistence/repositories/test_users.py +++ b/apps/agentstack-server/tests/integration/persistence/repositories/test_users.py @@ -124,7 +124,8 @@ async def test_list_users(db_transaction: AsyncConnection, test_user: User, test await repository.create(user=test_admin) # List users - users = {user.id: user async for user in repository.list()} + result = await repository.list(limit=100) + users = {user.id: user for user in result.items} # Verify users assert len(users) >= 2 # There might be other users in the database diff --git a/docs/development/reference/cli-reference.mdx b/docs/development/reference/cli-reference.mdx index 2c93d145b..1cf786e80 100644 --- a/docs/development/reference/cli-reference.mdx +++ b/docs/development/reference/cli-reference.mdx @@ -332,6 +332,57 @@ List all configured servers: agentstack server list ``` +## User Commands + +### user list + +List platform users (admin only): + +```bash +agentstack user list [OPTIONS] +``` + +**Options:** +- `--email `: Filter by email (case-insensitive partial match) +- `--limit `: Results per page (1-100, default: 40) +- `--after `: Pagination cursor (page_token) + +**Examples:** +```bash +# List all users +agentstack user list + +# Filter by email +agentstack user list --email john@example.com + +# Paginate through results +agentstack user list --limit 20 --after +``` + +### user set-role + +Change user role (admin only): + +```bash +agentstack user set-role --role +``` + +**Arguments:** +- `user-id`: User UUID + +**Options:** +- `-r, --role `: Target role (admin, developer, user) +- `-y, --yes`: Skip confirmation prompts + +**Examples:** +```bash +# Promote user to admin +agentstack user set-role abc123 --role admin + +# Demote user to developer without confirmation +agentstack user set-role abc123 --role developer --yes +``` + ## Environment Commands ### env