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..959c1ba7c --- /dev/null +++ b/apps/agentstack-cli/src/agentstack_cli/commands/user.py @@ -0,0 +1,92 @@ +# 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.Argument(help="Target role (admin, developer, user)")], + 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]") + + +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..75948b766 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,68 @@ 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 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..2c7af26a3 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 + iat: int raw: dict[str, Any] @@ -142,6 +143,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"]), + iat=payload["iat"], 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..8908fde36 100644 --- a/apps/agentstack-server/src/agentstack_server/api/dependencies.py +++ b/apps/agentstack-server/src/agentstack_server/api/dependencies.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 import logging +from datetime import UTC, datetime from typing import Annotated, Final from uuid import UUID @@ -123,11 +124,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) + + iat_dt = datetime.fromtimestamp(parsed_token.iat, tz=UTC) + if user.role_updated_at and iat_dt < user.role_updated_at: + 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/users.py b/apps/agentstack-server/src/agentstack_server/api/routes/users.py new file mode 100644 index 000000000..9795033ba --- /dev/null +++ b/apps/agentstack-server/src/agentstack_server/api/routes/users.py @@ -0,0 +1,61 @@ +# 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, + ) 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..6d53ff943 --- /dev/null +++ b/apps/agentstack-server/src/agentstack_server/api/schema/user.py @@ -0,0 +1,31 @@ +# 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 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..11f2e27fe 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,5 @@ class User(BaseModel): id: UUID = Field(default_factory=uuid4) role: UserRole = UserRole.USER email: EmailStr + 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..87b0ab378 --- /dev/null +++ b/apps/agentstack-server/src/agentstack_server/infrastructure/persistence/migrations/alembic/versions/4jowyo7q9m66_add_role_versioning.py @@ -0,0 +1,30 @@ +# Copyright 2025 © BeeAI a Series of LF Projects, LLC +# SPDX-License-Identifier: Apache-2.0 + +"""add role_updated_at 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_updated_at", sa.DateTime(timezone=True), nullable=True)) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_column("users", "role_updated_at") 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..c0c409da8 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 @@ -8,11 +8,12 @@ from sqlalchemy import Column, DateTime, 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,7 @@ 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_updated_at", DateTime(timezone=True), nullable=True), ) @@ -45,6 +47,7 @@ def _to_user(self, row: Row): "email": row.email, "created_at": row.created_at, "role": row.role, + "role_updated_at": row.role_updated_at, } ) @@ -69,7 +72,40 @@ 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_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..02290f155 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,31 @@ 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_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 b8d722e0b..41762fb9e 100644 --- a/docs/development/reference/cli-reference.mdx +++ b/docs/development/reference/cli-reference.mdx @@ -31,6 +31,7 @@ $ agentstack [OPTIONS] COMMAND [ARGS]... * `build`: Build agent from a GitHub repository in... * `server`: Manage Agent Stack servers and... * `self`: Manage Agent Stack installation. +* `user`: Manage users. * `add`: Add a docker image or GitHub repository... * `update`: Upgrade agent to a newer docker image or... * `remove | uninstall | rm | delete`: Remove agent @@ -959,6 +960,62 @@ $ agentstack self uninstall [OPTIONS] * `-v, --verbose`: Show verbose output * `--help`: Show this message and exit. +## `agentstack user` + +Manage users. + +**Usage**: + +```console +$ agentstack user [OPTIONS] COMMAND [ARGS]... +``` + +**Options**: + +* `--help`: Show this message and exit. + +**Commands**: + +* `list`: List platform users (admin only). +* `set-role`: Change user role (admin only). + +### `agentstack user list` + +List platform users (admin only). + +**Usage**: + +```console +$ agentstack user list [OPTIONS] +``` + +**Options**: + +* `--email TEXT`: Filter by email (case-insensitive partial match) +* `--limit INTEGER`: Results per page (1-100) [default: 40] +* `--after TEXT`: Pagination cursor (page_token) +* `--help`: Show this message and exit. + +### `agentstack user set-role` + +Change user role (admin only). + +**Usage**: + +```console +$ agentstack user set-role [OPTIONS] USER_ID ROLE:{admin|developer|user} +``` + +**Arguments**: + +* `USER_ID`: User UUID [required] +* `ROLE:{admin|developer|user}`: Target role (admin, developer, user) [required] + +**Options**: + +* `-y, --yes`: Skip confirmation prompts. +* `--help`: Show this message and exit. + ## `agentstack add` Add a docker image or GitHub repository