mirror of
https://github.com/AstrBotDevs/AstrBot.git
synced 2026-08-31 01:40:25 +08:00
fix: harden API key scope boundaries (#9503)
This commit is contained in:
@@ -178,6 +178,17 @@ class SQLiteDatabase(BaseDatabase):
|
||||
await conn.execute(
|
||||
text("ALTER TABLE chatui_projects ADD COLUMN workspace_path VARCHAR")
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"UPDATE chatui_projects SET "
|
||||
"workspace_type = CASE "
|
||||
"WHEN LOWER(workspace_type) = 'custom' THEN 'project' "
|
||||
"ELSE workspace_type END, "
|
||||
"workspace_path = NULL "
|
||||
"WHERE SUBSTR(creator, 1, 8) = 'api_key:' "
|
||||
"AND (LOWER(workspace_type) = 'custom' OR workspace_path IS NOT NULL)"
|
||||
)
|
||||
)
|
||||
|
||||
# ====
|
||||
# Platform Statistics
|
||||
|
||||
@@ -94,8 +94,12 @@ class WakingCheckStage(Stage):
|
||||
|
||||
# 设置 sender 身份
|
||||
event.message_str = event.message_str.strip()
|
||||
api_key_allow_admin_role = event.get_extra("_api_key_allow_admin_role")
|
||||
for admin_id in self.ctx.astrbot_config["admins_id"]:
|
||||
if str(event.get_sender_id()) == admin_id:
|
||||
if (
|
||||
api_key_allow_admin_role is not False
|
||||
and str(event.get_sender_id()) == admin_id
|
||||
):
|
||||
event.role = "admin"
|
||||
break
|
||||
|
||||
|
||||
@@ -271,6 +271,12 @@ class WebChatAdapter(Platform):
|
||||
message_event.set_extra(
|
||||
"thread_selected_text", payload.get("thread_selected_text")
|
||||
)
|
||||
api_key_allow_admin_role = payload.get("_api_key_allow_admin_role")
|
||||
if isinstance(api_key_allow_admin_role, bool):
|
||||
message_event.set_extra(
|
||||
"_api_key_allow_admin_role",
|
||||
api_key_allow_admin_role,
|
||||
)
|
||||
|
||||
return message_event
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from astrbot.core.utils.astrbot_path import get_astrbot_workspaces_path
|
||||
WORKSPACE_TYPE_SESSION = "session"
|
||||
WORKSPACE_TYPE_PROJECT = "project"
|
||||
WORKSPACE_TYPE_CUSTOM = "custom"
|
||||
API_KEY_USERNAME_PREFIX = "api_key:"
|
||||
WORKSPACE_TYPES = {
|
||||
WORKSPACE_TYPE_SESSION,
|
||||
WORKSPACE_TYPE_PROJECT,
|
||||
@@ -126,22 +127,42 @@ def resolve_project_workspace_root(project: Any, *, fallback_umo: str) -> Path:
|
||||
|
||||
Returns:
|
||||
Workspace root used as cwd.
|
||||
|
||||
Raises:
|
||||
ValueError: If an API key project resolves outside AstrBot workspaces.
|
||||
"""
|
||||
workspaces_root = Path(get_astrbot_workspaces_path()).resolve(strict=False)
|
||||
fallback = default_workspace_root(fallback_umo)
|
||||
workspace_type = normalize_project_workspace_type(
|
||||
getattr(project, "workspace_type", WORKSPACE_TYPE_SESSION)
|
||||
)
|
||||
creator = str(getattr(project, "creator", ""))
|
||||
if workspace_type == WORKSPACE_TYPE_SESSION:
|
||||
return fallback
|
||||
if workspace_type == WORKSPACE_TYPE_PROJECT:
|
||||
return project_workspace_root(str(project.project_id))
|
||||
if workspace_type == WORKSPACE_TYPE_CUSTOM:
|
||||
resolved = fallback
|
||||
elif workspace_type == WORKSPACE_TYPE_PROJECT:
|
||||
resolved = project_workspace_root(str(project.project_id))
|
||||
elif workspace_type == WORKSPACE_TYPE_CUSTOM and creator.startswith(
|
||||
API_KEY_USERNAME_PREFIX
|
||||
):
|
||||
resolved = project_workspace_root(str(project.project_id))
|
||||
elif workspace_type == WORKSPACE_TYPE_CUSTOM:
|
||||
workspace_path = normalize_workspace_path(
|
||||
getattr(project, "workspace_path", None)
|
||||
)
|
||||
if workspace_path:
|
||||
return workspace_path_to_root(workspace_path)
|
||||
return fallback
|
||||
resolved = workspace_path_to_root(workspace_path)
|
||||
else:
|
||||
resolved = fallback
|
||||
else:
|
||||
resolved = fallback
|
||||
|
||||
if creator.startswith(API_KEY_USERNAME_PREFIX) and (
|
||||
resolved == workspaces_root or not resolved.is_relative_to(workspaces_root)
|
||||
):
|
||||
raise ValueError(
|
||||
"API key project workspace must stay within AstrBot workspaces"
|
||||
)
|
||||
return resolved
|
||||
|
||||
|
||||
def parse_webchat_umo(umo: str) -> tuple[str, str] | None:
|
||||
|
||||
@@ -6,6 +6,7 @@ import jwt
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from astrbot.core.workspace import API_KEY_USERNAME_PREFIX
|
||||
from astrbot.dashboard.responses import ApiError
|
||||
from astrbot.dashboard.schemas import (
|
||||
AccountUpdateRequest,
|
||||
@@ -18,6 +19,7 @@ from astrbot.dashboard.services.auth_service import (
|
||||
ALL_OPEN_API_SCOPES,
|
||||
DASHBOARD_JWT_COOKIE_MAX_AGE,
|
||||
DASHBOARD_JWT_COOKIE_NAME,
|
||||
DEFAULT_OPEN_API_SCOPES,
|
||||
OPEN_API_SCOPE_INCLUDES,
|
||||
TOTP_TRUSTED_DEVICE_COOKIE_NAME,
|
||||
TOTP_TRUSTED_DEVICE_MAX_AGE,
|
||||
@@ -41,6 +43,24 @@ class AuthContext:
|
||||
via: str = "jwt"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScopeDependency:
|
||||
"""Authenticate a request and expose its API key scope to OpenAPI."""
|
||||
|
||||
scope: str
|
||||
|
||||
async def __call__(self, request: Request) -> AuthContext:
|
||||
"""Require the configured scope for a request.
|
||||
|
||||
Args:
|
||||
request: Current FastAPI request.
|
||||
|
||||
Returns:
|
||||
Authentication context for the authorized caller.
|
||||
"""
|
||||
return await require_scope(request, self.scope)
|
||||
|
||||
|
||||
def _extract_raw_api_key(request: Request) -> str | None:
|
||||
auth_header = request.headers.get("Authorization", "").strip()
|
||||
if auth_header.startswith("Bearer "):
|
||||
@@ -122,7 +142,7 @@ async def _require_api_key_scope(
|
||||
scopes = (
|
||||
[str(scope) for scope in api_key.scopes]
|
||||
if isinstance(api_key.scopes, list)
|
||||
else [str(scope) for scope in ALL_OPEN_API_SCOPES]
|
||||
else [str(scope) for scope in DEFAULT_OPEN_API_SCOPES]
|
||||
)
|
||||
if (
|
||||
"*" not in scopes
|
||||
@@ -135,7 +155,7 @@ async def _require_api_key_scope(
|
||||
raise ApiError("Insufficient API key scope", status_code=403)
|
||||
await request.app.state.db.touch_api_key(api_key.key_id)
|
||||
return AuthContext(
|
||||
username=f"api_key:{api_key.key_id}",
|
||||
username=f"{API_KEY_USERNAME_PREFIX}{api_key.key_id}",
|
||||
scopes=scopes,
|
||||
api_key_id=api_key.key_id,
|
||||
via="api_key",
|
||||
|
||||
@@ -6,7 +6,7 @@ from astrbot.dashboard.responses import error, ok
|
||||
from astrbot.dashboard.schemas import BotConfigRequest, EnabledPatch
|
||||
from astrbot.dashboard.services.config_service import BotConfigService
|
||||
|
||||
from .auth import AuthContext, require_scope
|
||||
from .auth import AuthContext, ScopeDependency
|
||||
|
||||
router = APIRouter(tags=["Bots"])
|
||||
legacy_router = APIRouter(
|
||||
@@ -16,8 +16,7 @@ legacy_router = APIRouter(
|
||||
)
|
||||
|
||||
|
||||
async def require_bot_scope(request: Request) -> AuthContext:
|
||||
return await require_scope(request, "bot")
|
||||
require_bot_scope = ScopeDependency("bot")
|
||||
|
||||
|
||||
def get_service(request: Request) -> BotConfigService:
|
||||
|
||||
@@ -20,7 +20,7 @@ from astrbot.dashboard.services.chat_service import (
|
||||
ChatServiceError,
|
||||
)
|
||||
|
||||
from .auth import AuthContext, require_dashboard_user, require_scope
|
||||
from .auth import AuthContext, ScopeDependency, require_dashboard_user
|
||||
from .multipart import single_upload
|
||||
|
||||
router = APIRouter(tags=["Chat"])
|
||||
@@ -35,8 +35,7 @@ def get_service(request: Request) -> ChatService:
|
||||
return request.app.state.services.chat
|
||||
|
||||
|
||||
async def require_chat_scope(request: Request) -> AuthContext:
|
||||
return await require_scope(request, "chat")
|
||||
require_chat_scope = ScopeDependency("chat")
|
||||
|
||||
|
||||
async def _json_or_empty(request: Request) -> dict[str, Any]:
|
||||
|
||||
@@ -13,7 +13,7 @@ from astrbot.dashboard.services.chatui_project_service import (
|
||||
ChatUIProjectServiceError,
|
||||
)
|
||||
|
||||
from .auth import AuthContext, require_dashboard_user, require_scope
|
||||
from .auth import AuthContext, ScopeDependency, require_dashboard_user
|
||||
|
||||
router = APIRouter(tags=["Chat Projects"])
|
||||
legacy_router = APIRouter(
|
||||
@@ -27,8 +27,7 @@ def get_service(request: Request) -> ChatUIProjectService:
|
||||
return request.app.state.services.chat_projects
|
||||
|
||||
|
||||
async def require_chat_scope(request: Request) -> AuthContext:
|
||||
return await require_scope(request, "chat")
|
||||
require_chat_scope = ScopeDependency("chat")
|
||||
|
||||
|
||||
async def _json_or_empty(request: Request) -> dict:
|
||||
|
||||
@@ -12,6 +12,7 @@ from astrbot.dashboard.schemas import (
|
||||
ConfigRouteUpsertRequest,
|
||||
RenameRequest,
|
||||
)
|
||||
from astrbot.dashboard.services.auth_service import CONFIG_EDIT_ADMIN_SCOPE
|
||||
from astrbot.dashboard.services.config_service import (
|
||||
ConfigDisplayService,
|
||||
ConfigFileService,
|
||||
@@ -19,7 +20,7 @@ from astrbot.dashboard.services.config_service import (
|
||||
ConfigRoutingService,
|
||||
)
|
||||
|
||||
from .auth import AuthContext, require_scope
|
||||
from .auth import AuthContext, ScopeDependency
|
||||
from .multipart import multipart_parts
|
||||
|
||||
router = APIRouter(tags=["Config Profiles"])
|
||||
@@ -30,8 +31,7 @@ legacy_router = APIRouter(
|
||||
)
|
||||
|
||||
|
||||
async def require_config_scope(request: Request) -> AuthContext:
|
||||
return await require_scope(request, "config")
|
||||
require_config_scope = ScopeDependency("config")
|
||||
|
||||
|
||||
def get_service(request: Request) -> ConfigProfileService:
|
||||
@@ -66,6 +66,22 @@ def _model_dict(payload) -> dict[str, Any]:
|
||||
return payload.model_dump(exclude_none=True)
|
||||
|
||||
|
||||
def _can_edit_admin_ids(auth: AuthContext) -> bool:
|
||||
"""Return whether an authenticated caller may change administrator IDs.
|
||||
|
||||
Args:
|
||||
auth: Authentication context for the current request.
|
||||
|
||||
Returns:
|
||||
True for dashboard users or API keys with the dedicated subscope.
|
||||
"""
|
||||
return (
|
||||
auth.via != "api_key"
|
||||
or "*" in auth.scopes
|
||||
or CONFIG_EDIT_ADMIN_SCOPE in auth.scopes
|
||||
)
|
||||
|
||||
|
||||
@router.get("/config-profiles/schema")
|
||||
async def get_config_profile_schema(
|
||||
_auth: AuthContext = Depends(require_config_scope),
|
||||
@@ -82,13 +98,23 @@ async def list_config_profiles(
|
||||
return ok(service.list_profiles())
|
||||
|
||||
|
||||
@router.post("/config-profiles")
|
||||
@router.post(
|
||||
"/config-profiles",
|
||||
openapi_extra={"x-astrbot-sensitive-scopes": [CONFIG_EDIT_ADMIN_SCOPE]},
|
||||
)
|
||||
async def create_config_profile(
|
||||
payload: ConfigProfileCreateRequest,
|
||||
_auth: AuthContext = Depends(require_config_scope),
|
||||
auth: AuthContext = Depends(require_config_scope),
|
||||
service: ConfigProfileService = Depends(get_service),
|
||||
):
|
||||
return ok(await service.create_profile(payload.name, payload.config), "创建成功")
|
||||
return ok(
|
||||
await service.create_profile(
|
||||
payload.name,
|
||||
payload.config,
|
||||
allow_admin_id_change=_can_edit_admin_ids(auth),
|
||||
),
|
||||
"创建成功",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/config-profiles/{config_id}")
|
||||
@@ -100,18 +126,22 @@ async def get_config_profile(
|
||||
return ok(service.get_profile(config_id))
|
||||
|
||||
|
||||
@router.put("/config-profiles/{config_id}")
|
||||
@router.put(
|
||||
"/config-profiles/{config_id}",
|
||||
openapi_extra={"x-astrbot-sensitive-scopes": [CONFIG_EDIT_ADMIN_SCOPE]},
|
||||
)
|
||||
async def update_config_profile(
|
||||
config_id: str,
|
||||
payload: ConfigContentRequest,
|
||||
request: Request,
|
||||
_auth: AuthContext = Depends(require_config_scope),
|
||||
auth: AuthContext = Depends(require_config_scope),
|
||||
service: ConfigProfileService = Depends(get_service),
|
||||
):
|
||||
message = await service.update_profile(
|
||||
config_id,
|
||||
_model_dict(payload),
|
||||
two_factor_code=request.headers.get("X-2FA-Code"),
|
||||
allow_admin_id_change=_can_edit_admin_ids(auth),
|
||||
)
|
||||
return ok(message=message or "保存成功")
|
||||
|
||||
@@ -161,17 +191,21 @@ async def get_system_config_runtime(
|
||||
return ok(await service.get_configs())
|
||||
|
||||
|
||||
@router.put("/system-config")
|
||||
@router.put(
|
||||
"/system-config",
|
||||
openapi_extra={"x-astrbot-sensitive-scopes": [CONFIG_EDIT_ADMIN_SCOPE]},
|
||||
)
|
||||
async def update_system_config(
|
||||
payload: ConfigContentRequest,
|
||||
request: Request,
|
||||
_auth: AuthContext = Depends(require_config_scope),
|
||||
auth: AuthContext = Depends(require_config_scope),
|
||||
service: ConfigProfileService = Depends(get_service),
|
||||
):
|
||||
message = await service.update_profile(
|
||||
"default",
|
||||
_model_dict(payload),
|
||||
two_factor_code=request.headers.get("X-2FA-Code"),
|
||||
allow_admin_id_change=_can_edit_admin_ids(auth),
|
||||
)
|
||||
return ok(message=message or "保存成功")
|
||||
|
||||
@@ -234,7 +268,7 @@ async def list_dashboard_alias_config_profiles(
|
||||
@legacy_router.post("/abconf/new")
|
||||
async def create_dashboard_alias_config_profile(
|
||||
request: Request,
|
||||
_auth: AuthContext = Depends(require_config_scope),
|
||||
auth: AuthContext = Depends(require_config_scope),
|
||||
service: ConfigProfileService = Depends(get_service),
|
||||
):
|
||||
body = await _json_or_empty(request)
|
||||
@@ -243,6 +277,7 @@ async def create_dashboard_alias_config_profile(
|
||||
await service.create_profile(
|
||||
body.get("name"),
|
||||
body.get("config"),
|
||||
allow_admin_id_change=_can_edit_admin_ids(auth),
|
||||
),
|
||||
"创建成功",
|
||||
)
|
||||
@@ -304,7 +339,7 @@ async def rename_dashboard_alias_config_profile(
|
||||
@legacy_router.post("/astrbot/update")
|
||||
async def update_dashboard_alias_astrbot_config(
|
||||
request: Request,
|
||||
_auth: AuthContext = Depends(require_config_scope),
|
||||
auth: AuthContext = Depends(require_config_scope),
|
||||
service: ConfigProfileService = Depends(get_service),
|
||||
):
|
||||
body = await _json_or_empty(request)
|
||||
@@ -319,6 +354,7 @@ async def update_dashboard_alias_astrbot_config(
|
||||
str(config_id),
|
||||
config,
|
||||
two_factor_code=request.headers.get("X-2FA-Code"),
|
||||
allow_admin_id_change=_can_edit_admin_ids(auth),
|
||||
)
|
||||
return ok(message=message or "保存成功~")
|
||||
except ValueError as exc:
|
||||
|
||||
@@ -19,7 +19,7 @@ from astrbot.dashboard.services.conversation_service import (
|
||||
ConversationServiceError,
|
||||
)
|
||||
|
||||
from .auth import AuthContext, require_dashboard_user, require_scope
|
||||
from .auth import AuthContext, ScopeDependency, require_dashboard_user
|
||||
|
||||
router = APIRouter(tags=["Conversations"])
|
||||
legacy_router = APIRouter(
|
||||
@@ -33,8 +33,7 @@ def get_service(request: Request) -> ConversationService:
|
||||
return request.app.state.services.conversations
|
||||
|
||||
|
||||
async def require_data_scope(request: Request) -> AuthContext:
|
||||
return await require_scope(request, "data")
|
||||
require_data_scope = ScopeDependency("data")
|
||||
|
||||
|
||||
async def _json_or_empty(request: Request) -> dict[str, Any]:
|
||||
|
||||
@@ -8,7 +8,7 @@ from astrbot.dashboard.responses import error, ok
|
||||
from astrbot.dashboard.services.chat_service import ChatService, ChatServiceError
|
||||
from astrbot.dashboard.services.file_service import FileService, FileServiceError
|
||||
|
||||
from .auth import AuthContext, require_scope
|
||||
from .auth import AuthContext, ScopeDependency
|
||||
from .multipart import UploadFileAdapter
|
||||
|
||||
router = APIRouter(tags=["Files"])
|
||||
@@ -23,8 +23,7 @@ def get_chat_service(request: Request) -> ChatService:
|
||||
return request.app.state.services.chat
|
||||
|
||||
|
||||
async def require_file_scope(request: Request) -> AuthContext:
|
||||
return await require_scope(request, "file")
|
||||
require_file_scope = ScopeDependency("file")
|
||||
|
||||
|
||||
async def _serve_token_file(file_token: str, service: FileService):
|
||||
|
||||
@@ -7,6 +7,7 @@ from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
||||
|
||||
from astrbot.dashboard.responses import ApiError, error, ok
|
||||
from astrbot.dashboard.schemas import ImMessageRequest, OpenApiChatRequest
|
||||
from astrbot.dashboard.services.auth_service import CHAT_ADMIN_SCOPE
|
||||
from astrbot.dashboard.services.chat_service import (
|
||||
ChatService,
|
||||
ChatServiceError,
|
||||
@@ -18,26 +19,16 @@ from astrbot.dashboard.services.open_api_service import (
|
||||
OpenApiWebSocketChatBridge,
|
||||
)
|
||||
|
||||
from .auth import AuthContext, require_scope
|
||||
from .auth import AuthContext, ScopeDependency
|
||||
from .multipart import UploadFileAdapter
|
||||
|
||||
router = APIRouter(tags=["Open API"])
|
||||
|
||||
|
||||
async def require_im_scope(request: Request) -> AuthContext:
|
||||
return await require_scope(request, "im")
|
||||
|
||||
|
||||
async def require_chat_scope(request: Request) -> AuthContext:
|
||||
return await require_scope(request, "chat")
|
||||
|
||||
|
||||
async def require_config_scope(request: Request) -> AuthContext:
|
||||
return await require_scope(request, "config")
|
||||
|
||||
|
||||
async def require_file_scope(request: Request) -> AuthContext:
|
||||
return await require_scope(request, "file")
|
||||
require_im_scope = ScopeDependency("im")
|
||||
require_chat_scope = ScopeDependency("chat")
|
||||
require_config_scope = ScopeDependency("config")
|
||||
require_file_scope = ScopeDependency("file")
|
||||
|
||||
|
||||
def get_service(request: Request) -> OpenApiService:
|
||||
@@ -98,6 +89,7 @@ async def _open_api_chat_response(
|
||||
post_data,
|
||||
)
|
||||
|
||||
allow_admin_username = "*" in auth.scopes or CHAT_ADMIN_SCOPE in auth.scopes
|
||||
try:
|
||||
(
|
||||
effective_username,
|
||||
@@ -106,6 +98,7 @@ async def _open_api_chat_response(
|
||||
) = await open_api_service.prepare_chat_send(
|
||||
post_data,
|
||||
_get_chat_config_list(open_api_service),
|
||||
allow_admin_username=allow_admin_username,
|
||||
)
|
||||
except OpenApiServiceError as exc:
|
||||
return _open_api_error(str(exc))
|
||||
@@ -118,6 +111,7 @@ async def _open_api_chat_response(
|
||||
if config_err:
|
||||
return _open_api_error(config_err)
|
||||
|
||||
post_data["_api_key_allow_admin_role"] = allow_admin_username
|
||||
return await _build_streaming_chat_response(
|
||||
chat_service,
|
||||
effective_username,
|
||||
@@ -176,7 +170,10 @@ def _extract_ws_api_key(websocket: WebSocket) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/chat")
|
||||
@router.post(
|
||||
"/chat",
|
||||
openapi_extra={"x-astrbot-sensitive-scopes": [CHAT_ADMIN_SCOPE]},
|
||||
)
|
||||
async def chat(
|
||||
payload: OpenApiChatRequest,
|
||||
auth: AuthContext = Depends(require_chat_scope),
|
||||
|
||||
@@ -18,7 +18,7 @@ from astrbot.dashboard.services.persona_service import (
|
||||
PersonaServiceError,
|
||||
)
|
||||
|
||||
from .auth import AuthContext, require_dashboard_user, require_scope
|
||||
from .auth import AuthContext, ScopeDependency, require_dashboard_user
|
||||
|
||||
router = APIRouter(tags=["Personas"])
|
||||
legacy_router = APIRouter(
|
||||
@@ -32,8 +32,7 @@ def get_service(request: Request) -> PersonaService:
|
||||
return request.app.state.services.personas
|
||||
|
||||
|
||||
async def require_persona_scope(request: Request) -> AuthContext:
|
||||
return await require_scope(request, "persona")
|
||||
require_persona_scope = ScopeDependency("persona")
|
||||
|
||||
|
||||
async def _json_or_empty(request: Request) -> dict[str, Any]:
|
||||
|
||||
@@ -15,7 +15,7 @@ from astrbot.dashboard.services.platform_service import (
|
||||
PlatformServiceError,
|
||||
)
|
||||
|
||||
from .auth import AuthContext, require_dashboard_user, require_scope
|
||||
from .auth import AuthContext, ScopeDependency, require_dashboard_user
|
||||
|
||||
router = APIRouter(tags=["Platforms"])
|
||||
legacy_router = APIRouter(
|
||||
@@ -29,8 +29,7 @@ def get_service(request: Request) -> PlatformService:
|
||||
return request.app.state.services.platforms
|
||||
|
||||
|
||||
async def require_config_scope(request: Request) -> AuthContext:
|
||||
return await require_scope(request, "config")
|
||||
require_config_scope = ScopeDependency("config")
|
||||
|
||||
|
||||
async def _json_or_empty(request: Request) -> dict[str, Any]:
|
||||
|
||||
@@ -49,15 +49,14 @@ from astrbot.dashboard.services.plugin_service import (
|
||||
PluginServiceWarning,
|
||||
)
|
||||
|
||||
from .auth import AuthContext, require_dashboard_user, require_scope
|
||||
from .auth import AuthContext, ScopeDependency, require_dashboard_user
|
||||
from .multipart import multipart_parts
|
||||
|
||||
router = APIRouter(tags=["Plugins"])
|
||||
legacy_router = APIRouter(tags=["Dashboard Plugins"], include_in_schema=False)
|
||||
|
||||
|
||||
async def require_plugin_scope(request: Request) -> AuthContext:
|
||||
return await require_scope(request, "plugin")
|
||||
require_plugin_scope = ScopeDependency("plugin")
|
||||
|
||||
|
||||
def get_service(request: Request) -> PluginService:
|
||||
|
||||
@@ -10,7 +10,7 @@ from astrbot.dashboard.schemas import (
|
||||
)
|
||||
from astrbot.dashboard.services.config_service import ProviderConfigService
|
||||
|
||||
from .auth import AuthContext, require_scope
|
||||
from .auth import AuthContext, ScopeDependency
|
||||
|
||||
router = APIRouter(tags=["Providers"])
|
||||
legacy_router = APIRouter(
|
||||
@@ -20,8 +20,7 @@ legacy_router = APIRouter(
|
||||
)
|
||||
|
||||
|
||||
async def require_provider_scope(request: Request) -> AuthContext:
|
||||
return await require_scope(request, "provider")
|
||||
require_provider_scope = ScopeDependency("provider")
|
||||
|
||||
|
||||
def get_service(request: Request) -> ProviderConfigService:
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""FastAPI HTTP API surface for the AstrBot dashboard."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.routing import APIRoute
|
||||
|
||||
from .api_keys import router as api_keys_router
|
||||
from .auth import ScopeDependency
|
||||
from .auth import router as auth_router
|
||||
from .backups import router as backups_router
|
||||
from .bots import router as bots_router
|
||||
@@ -34,30 +36,62 @@ API_V1_PREFIX = "/api/v1"
|
||||
|
||||
def build_api_router() -> APIRouter:
|
||||
router = APIRouter(prefix=API_V1_PREFIX)
|
||||
router.include_router(auth_router)
|
||||
router.include_router(backups_router)
|
||||
router.include_router(config_profiles_router)
|
||||
router.include_router(api_keys_router)
|
||||
router.include_router(bots_router)
|
||||
router.include_router(providers_router)
|
||||
router.include_router(plugins_router)
|
||||
router.include_router(chat_router)
|
||||
router.include_router(chat_projects_router)
|
||||
router.include_router(conversations_router)
|
||||
router.include_router(cron_router)
|
||||
router.include_router(files_router)
|
||||
router.include_router(knowledge_bases_router)
|
||||
router.include_router(extensions_router)
|
||||
router.include_router(skills_router)
|
||||
router.include_router(sessions_router)
|
||||
router.include_router(subagents_router)
|
||||
router.include_router(logs_router)
|
||||
router.include_router(stats_router)
|
||||
router.include_router(tools_router)
|
||||
router.include_router(platform_router)
|
||||
router.include_router(t2i_router)
|
||||
router.include_router(personas_router)
|
||||
router.include_router(updates_router)
|
||||
router.include_router(open_api_router)
|
||||
router.include_router(live_chat_router)
|
||||
child_routers = (
|
||||
auth_router,
|
||||
backups_router,
|
||||
config_profiles_router,
|
||||
api_keys_router,
|
||||
bots_router,
|
||||
providers_router,
|
||||
plugins_router,
|
||||
chat_router,
|
||||
chat_projects_router,
|
||||
conversations_router,
|
||||
cron_router,
|
||||
files_router,
|
||||
knowledge_bases_router,
|
||||
extensions_router,
|
||||
skills_router,
|
||||
sessions_router,
|
||||
subagents_router,
|
||||
logs_router,
|
||||
stats_router,
|
||||
tools_router,
|
||||
platform_router,
|
||||
t2i_router,
|
||||
personas_router,
|
||||
updates_router,
|
||||
open_api_router,
|
||||
live_chat_router,
|
||||
)
|
||||
for child_router in child_routers:
|
||||
for route in child_router.routes:
|
||||
if not isinstance(route, APIRoute) or not route.include_in_schema:
|
||||
continue
|
||||
required_scopes = {
|
||||
dependency.call.scope
|
||||
for dependency in route.dependant.dependencies
|
||||
if isinstance(dependency.call, ScopeDependency)
|
||||
}
|
||||
if len(required_scopes) != 1:
|
||||
continue
|
||||
required_scope = required_scopes.pop()
|
||||
route.openapi_extra = {
|
||||
**(route.openapi_extra or {}),
|
||||
"x-astrbot-scope": required_scope,
|
||||
}
|
||||
scope_description = f"**Required scope:** `{required_scope}`"
|
||||
sensitive_scopes = route.openapi_extra.get("x-astrbot-sensitive-scopes", [])
|
||||
if sensitive_scopes:
|
||||
formatted_scopes = ", ".join(f"`{scope}`" for scope in sensitive_scopes)
|
||||
scope_description += (
|
||||
"\n\n**Conditional sensitive scope:** " + formatted_scopes
|
||||
)
|
||||
if scope_description not in route.description:
|
||||
route.description = "\n\n".join(
|
||||
part
|
||||
for part in (route.description.strip(), scope_description)
|
||||
if part
|
||||
)
|
||||
router.include_router(child_router)
|
||||
return router
|
||||
|
||||
@@ -17,7 +17,7 @@ from astrbot.dashboard.services.session_management_service import (
|
||||
SessionManagementServiceError,
|
||||
)
|
||||
|
||||
from .auth import AuthContext, require_dashboard_user, require_scope
|
||||
from .auth import AuthContext, ScopeDependency, require_dashboard_user
|
||||
|
||||
router = APIRouter(tags=["Sessions"])
|
||||
legacy_router = APIRouter(
|
||||
@@ -31,8 +31,7 @@ def get_service(request: Request) -> SessionManagementService:
|
||||
return request.app.state.services.sessions
|
||||
|
||||
|
||||
async def require_data_scope(request: Request) -> AuthContext:
|
||||
return await require_scope(request, "data")
|
||||
require_data_scope = ScopeDependency("data")
|
||||
|
||||
|
||||
async def _json_or_empty(request: Request) -> dict:
|
||||
|
||||
@@ -21,7 +21,7 @@ from astrbot.dashboard.services.skills_service import (
|
||||
SkillsServiceError,
|
||||
)
|
||||
|
||||
from .auth import AuthContext, require_dashboard_user, require_scope
|
||||
from .auth import AuthContext, ScopeDependency, require_dashboard_user
|
||||
from .multipart import multipart_parts, single_upload
|
||||
|
||||
router = APIRouter(tags=["Skills"])
|
||||
@@ -36,8 +36,7 @@ def get_service(request: Request) -> SkillsService:
|
||||
return request.app.state.services.skills
|
||||
|
||||
|
||||
async def require_skill_scope(request: Request) -> AuthContext:
|
||||
return await require_scope(request, "skill")
|
||||
require_skill_scope = ScopeDependency("skill")
|
||||
|
||||
|
||||
async def _json_or_empty(request: Request) -> dict[str, Any]:
|
||||
|
||||
@@ -9,7 +9,7 @@ from astrbot.dashboard.services.subagent_service import (
|
||||
SubAgentServiceError,
|
||||
)
|
||||
|
||||
from .auth import AuthContext, require_dashboard_user, require_scope
|
||||
from .auth import AuthContext, ScopeDependency, require_dashboard_user
|
||||
|
||||
router = APIRouter(tags=["Subagents"])
|
||||
legacy_router = APIRouter(
|
||||
@@ -19,8 +19,7 @@ legacy_router = APIRouter(
|
||||
)
|
||||
|
||||
|
||||
async def require_config_scope(request: Request) -> AuthContext:
|
||||
return await require_scope(request, "config")
|
||||
require_config_scope = ScopeDependency("config")
|
||||
|
||||
|
||||
def get_service(request: Request) -> SubAgentService:
|
||||
|
||||
@@ -8,7 +8,7 @@ from astrbot.dashboard.responses import ApiError, ok
|
||||
from astrbot.dashboard.schemas import T2iActiveTemplateRequest, T2iTemplateRequest
|
||||
from astrbot.dashboard.services.t2i_service import T2iService, T2iServiceError
|
||||
|
||||
from .auth import AuthContext, require_dashboard_user, require_scope
|
||||
from .auth import AuthContext, ScopeDependency, require_dashboard_user
|
||||
|
||||
router = APIRouter(tags=["Text To Image"])
|
||||
legacy_router = APIRouter(
|
||||
@@ -22,8 +22,7 @@ def get_service(request: Request) -> T2iService:
|
||||
return request.app.state.services.t2i
|
||||
|
||||
|
||||
async def require_config_scope(request: Request) -> AuthContext:
|
||||
return await require_scope(request, "config")
|
||||
require_config_scope = ScopeDependency("config")
|
||||
|
||||
|
||||
async def _json_or_empty(request: Request) -> dict:
|
||||
|
||||
@@ -15,7 +15,7 @@ from astrbot.dashboard.schemas import (
|
||||
)
|
||||
from astrbot.dashboard.services.tools_service import ToolsService, ToolsServiceError
|
||||
|
||||
from .auth import AuthContext, require_dashboard_user, require_scope
|
||||
from .auth import AuthContext, ScopeDependency, require_dashboard_user, require_scope
|
||||
|
||||
router = APIRouter(tags=["Extension Components"])
|
||||
legacy_router = APIRouter(
|
||||
@@ -33,8 +33,7 @@ async def require_tool_scope(request: Request) -> AuthContext:
|
||||
return await require_scope(request, "tool")
|
||||
|
||||
|
||||
async def require_mcp_scope(request: Request) -> AuthContext:
|
||||
return await require_scope(request, "mcp")
|
||||
require_mcp_scope = ScopeDependency("mcp")
|
||||
|
||||
|
||||
async def _json_or_empty(request: Request) -> dict[str, Any]:
|
||||
|
||||
@@ -95,8 +95,17 @@ class ChatProjectRequest(OpenModel):
|
||||
title: str | None = None
|
||||
emoji: str | None = None
|
||||
description: str | None = None
|
||||
workspace_type: str | None = None
|
||||
workspace_path: str | None = None
|
||||
workspace_type: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Workspace mode. API key callers may use only session or project; "
|
||||
"project is the default."
|
||||
),
|
||||
)
|
||||
workspace_path: str | None = Field(
|
||||
default=None,
|
||||
description="Dashboard-only custom workspace path.",
|
||||
)
|
||||
|
||||
|
||||
class ChatProjectSessionRequest(OpenModel):
|
||||
@@ -196,10 +205,8 @@ class OpenApiChatRequest(OpenModel):
|
||||
username: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Caller-declared WebChat sender/session owner. This value is used "
|
||||
"as the message sender identity and may participate in "
|
||||
"sender-ID-based permission checks; trusted integrations should "
|
||||
"validate or map it before accepting end-user input."
|
||||
"Caller-declared WebChat sender/session owner. Configured AstrBot "
|
||||
"administrator IDs require the chat:admin API key subscope."
|
||||
),
|
||||
)
|
||||
config_id: str | None = None
|
||||
|
||||
@@ -8,7 +8,13 @@ from typing import Any
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from astrbot.core.utils.datetime_utils import normalize_datetime_utc
|
||||
|
||||
from .auth_service import ALL_OPEN_API_SCOPES, OPEN_API_SCOPE_INCLUDES
|
||||
from .auth_service import (
|
||||
ALL_OPEN_API_SCOPES,
|
||||
CHAT_ADMIN_SCOPE,
|
||||
CONFIG_EDIT_ADMIN_SCOPE,
|
||||
DEFAULT_OPEN_API_SCOPES,
|
||||
OPEN_API_SCOPE_INCLUDES,
|
||||
)
|
||||
|
||||
|
||||
class ApiKeyServiceError(Exception):
|
||||
@@ -60,7 +66,7 @@ class ApiKeyService:
|
||||
@staticmethod
|
||||
def _normalize_scopes(raw_scopes: Any) -> list[str]:
|
||||
if raw_scopes is None:
|
||||
return list(ALL_OPEN_API_SCOPES)
|
||||
return list(DEFAULT_OPEN_API_SCOPES)
|
||||
if not isinstance(raw_scopes, list):
|
||||
raise ApiKeyServiceError("Invalid scopes")
|
||||
|
||||
@@ -73,6 +79,12 @@ class ApiKeyService:
|
||||
invalid_scopes.append(str(scope))
|
||||
if invalid_scopes:
|
||||
raise ApiKeyServiceError(f"Invalid scopes: {', '.join(invalid_scopes)}")
|
||||
if CONFIG_EDIT_ADMIN_SCOPE in scopes and "config" not in scopes:
|
||||
raise ApiKeyServiceError(
|
||||
f"{CONFIG_EDIT_ADMIN_SCOPE} requires the config scope"
|
||||
)
|
||||
if CHAT_ADMIN_SCOPE in scopes and "chat" not in scopes:
|
||||
raise ApiKeyServiceError(f"{CHAT_ADMIN_SCOPE} requires the chat scope")
|
||||
for scope in tuple(scopes):
|
||||
scopes.extend(OPEN_API_SCOPE_INCLUDES.get(scope, ()))
|
||||
normalized_scopes = list(dict.fromkeys(scopes))
|
||||
|
||||
@@ -47,7 +47,10 @@ from astrbot.dashboard.password_state import (
|
||||
set_password_storage_upgraded,
|
||||
)
|
||||
|
||||
ALL_OPEN_API_SCOPES = (
|
||||
CHAT_ADMIN_SCOPE = "chat:admin"
|
||||
CONFIG_EDIT_ADMIN_SCOPE = "config:edit_admin"
|
||||
|
||||
DEFAULT_OPEN_API_SCOPES = (
|
||||
"bot",
|
||||
"provider",
|
||||
"persona",
|
||||
@@ -61,6 +64,12 @@ ALL_OPEN_API_SCOPES = (
|
||||
"skill",
|
||||
)
|
||||
|
||||
ALL_OPEN_API_SCOPES = (
|
||||
*DEFAULT_OPEN_API_SCOPES,
|
||||
CHAT_ADMIN_SCOPE,
|
||||
CONFIG_EDIT_ADMIN_SCOPE,
|
||||
)
|
||||
|
||||
OPEN_API_SCOPE_INCLUDES = {
|
||||
"config": ("bot", "provider"),
|
||||
}
|
||||
|
||||
@@ -1168,6 +1168,9 @@ class ChatService:
|
||||
"message_id": message_id,
|
||||
"llm_checkpoint_id": llm_checkpoint_id,
|
||||
"thread_selected_text": thread_selected_text,
|
||||
"_api_key_allow_admin_role": post_data.get(
|
||||
"_api_key_allow_admin_role"
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -6,7 +6,9 @@ from pathlib import Path
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from astrbot.core.utils.datetime_utils import to_utc_isoformat
|
||||
from astrbot.core.workspace import (
|
||||
API_KEY_USERNAME_PREFIX,
|
||||
WORKSPACE_TYPE_CUSTOM,
|
||||
WORKSPACE_TYPE_PROJECT,
|
||||
WORKSPACE_TYPE_SESSION,
|
||||
normalize_project_workspace_type,
|
||||
normalize_workspace_path,
|
||||
@@ -27,6 +29,18 @@ class ChatUIProjectService:
|
||||
|
||||
async def create_project(self, username: str, data: object) -> dict:
|
||||
payload = self._as_payload(data)
|
||||
if username.startswith(API_KEY_USERNAME_PREFIX):
|
||||
requested_workspace_type = normalize_project_workspace_type(
|
||||
payload.get("workspace_type", WORKSPACE_TYPE_PROJECT)
|
||||
)
|
||||
if (
|
||||
requested_workspace_type == WORKSPACE_TYPE_CUSTOM
|
||||
or "workspace_path" in payload
|
||||
):
|
||||
raise ChatUIProjectServiceError(
|
||||
"API key projects cannot use custom workspaces"
|
||||
)
|
||||
payload = {**payload, "workspace_type": requested_workspace_type}
|
||||
title = payload.get("title")
|
||||
emoji = payload.get("emoji", "📁")
|
||||
description = payload.get("description")
|
||||
@@ -72,6 +86,21 @@ class ChatUIProjectService:
|
||||
project = await self._get_owned_project(username, project_id)
|
||||
workspace_type = None
|
||||
workspace_path = None
|
||||
if username.startswith(API_KEY_USERNAME_PREFIX):
|
||||
requested_workspace_type = normalize_project_workspace_type(
|
||||
payload.get("workspace_type", project.workspace_type)
|
||||
)
|
||||
if (
|
||||
"workspace_type" in payload
|
||||
and requested_workspace_type == WORKSPACE_TYPE_CUSTOM
|
||||
) or "workspace_path" in payload:
|
||||
raise ChatUIProjectServiceError(
|
||||
"API key projects cannot use custom workspaces"
|
||||
)
|
||||
if normalize_project_workspace_type(project.workspace_type) == (
|
||||
WORKSPACE_TYPE_CUSTOM
|
||||
):
|
||||
payload = {**payload, "workspace_type": WORKSPACE_TYPE_PROJECT}
|
||||
if "workspace_type" in payload or "workspace_path" in payload:
|
||||
workspace_type, workspace_path = self._normalize_workspace_config(
|
||||
payload,
|
||||
@@ -165,13 +194,15 @@ class ChatUIProjectService:
|
||||
"""
|
||||
project = await self._get_owned_project(username, project_id)
|
||||
fallback_umo = f"webchat:FriendMessage:webchat!{project.creator}!default"
|
||||
workspace_root_path = os.path.normcase(
|
||||
os.path.realpath(
|
||||
resolve_project_workspace_root(
|
||||
project,
|
||||
fallback_umo=fallback_umo,
|
||||
)
|
||||
try:
|
||||
resolved_workspace_root = resolve_project_workspace_root(
|
||||
project,
|
||||
fallback_umo=fallback_umo,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise ChatUIProjectServiceError(str(exc)) from exc
|
||||
workspace_root_path = os.path.normcase(
|
||||
os.path.realpath(resolved_workspace_root)
|
||||
)
|
||||
workspace_root = Path(workspace_root_path)
|
||||
raw_path = str(relative_path or "").strip()
|
||||
@@ -299,13 +330,15 @@ class ChatUIProjectService:
|
||||
"""
|
||||
project = await self._get_owned_project(username, project_id)
|
||||
fallback_umo = f"webchat:FriendMessage:webchat!{project.creator}!default"
|
||||
workspace_root_path = os.path.normcase(
|
||||
os.path.realpath(
|
||||
resolve_project_workspace_root(
|
||||
project,
|
||||
fallback_umo=fallback_umo,
|
||||
)
|
||||
try:
|
||||
resolved_workspace_root = resolve_project_workspace_root(
|
||||
project,
|
||||
fallback_umo=fallback_umo,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise ChatUIProjectServiceError(str(exc)) from exc
|
||||
workspace_root_path = os.path.normcase(
|
||||
os.path.realpath(resolved_workspace_root)
|
||||
)
|
||||
raw_path = str(relative_path or "").strip()
|
||||
normalized_path = Path(raw_path.replace("\\", "/"))
|
||||
|
||||
@@ -479,7 +479,36 @@ class ConfigProfileService:
|
||||
def list_profiles(self) -> dict:
|
||||
return {"info_list": self.acm.get_conf_list()}
|
||||
|
||||
async def create_profile(self, name: str | None, config: dict | None) -> dict:
|
||||
async def create_profile(
|
||||
self,
|
||||
name: str | None,
|
||||
config: dict | None,
|
||||
*,
|
||||
allow_admin_id_change: bool = True,
|
||||
) -> dict:
|
||||
"""Create a config profile with explicit admin-ID permission.
|
||||
|
||||
Args:
|
||||
name: Display name for the new profile.
|
||||
config: Optional initial config content.
|
||||
allow_admin_id_change: Whether caller may define non-default admin IDs.
|
||||
|
||||
Returns:
|
||||
Identifier of the created config profile.
|
||||
|
||||
Raises:
|
||||
ApiError: If caller attempts to define administrator IDs without scope.
|
||||
"""
|
||||
if (
|
||||
not allow_admin_id_change
|
||||
and isinstance(config, dict)
|
||||
and config.get("admins_id", DEFAULT_CONFIG.get("admins_id"))
|
||||
!= DEFAULT_CONFIG.get("admins_id")
|
||||
):
|
||||
raise ApiError(
|
||||
"config:edit_admin scope is required to change admins_id",
|
||||
status_code=403,
|
||||
)
|
||||
conf_id = self.acm.create_conf(name=name, config=config or DEFAULT_CONFIG)
|
||||
await self.core_lifecycle.reload_pipeline_scheduler(conf_id)
|
||||
return {"conf_id": conf_id}
|
||||
@@ -528,7 +557,23 @@ class ConfigProfileService:
|
||||
config: dict,
|
||||
*,
|
||||
two_factor_code: str | None = None,
|
||||
allow_admin_id_change: bool = True,
|
||||
) -> str | None:
|
||||
"""Update a config profile with explicit admin-ID permission.
|
||||
|
||||
Args:
|
||||
config_id: Identifier of the profile to update.
|
||||
config: Complete replacement config content.
|
||||
two_factor_code: Optional TOTP code for protected dashboard changes.
|
||||
allow_admin_id_change: Whether caller may change administrator IDs.
|
||||
|
||||
Returns:
|
||||
Success message, optionally including a connectivity warning.
|
||||
|
||||
Raises:
|
||||
ApiError: If admin IDs change without permission or TOTP is invalid.
|
||||
ValueError: If the requested config profile does not exist.
|
||||
"""
|
||||
if config_id not in self.acm.confs:
|
||||
raise ValueError(f"Config file {config_id} does not exist")
|
||||
config = copy.deepcopy(config)
|
||||
@@ -538,6 +583,15 @@ class ConfigProfileService:
|
||||
config[key] = default_conf.get(key, [])
|
||||
|
||||
current_config = self.acm.confs[config_id]
|
||||
if (
|
||||
not allow_admin_id_change
|
||||
and "admins_id" in config
|
||||
and config.get("admins_id") != current_config.get("admins_id")
|
||||
):
|
||||
raise ApiError(
|
||||
"config:edit_admin scope is required to change admins_id",
|
||||
status_code=403,
|
||||
)
|
||||
protected_2fa_changed = _protected_2fa_config_changed(current_config, config)
|
||||
if (
|
||||
is_totp_enabled(current_config)
|
||||
|
||||
@@ -22,7 +22,10 @@ from astrbot.core.platform.sources.webchat.request_flags import (
|
||||
from astrbot.core.platform.sources.webchat.webchat_queue_mgr import webchat_queue_mgr
|
||||
from astrbot.core.utils.datetime_utils import to_utc_isoformat
|
||||
from astrbot.dashboard.services.api_key_service import ApiKeyService
|
||||
from astrbot.dashboard.services.auth_service import ALL_OPEN_API_SCOPES
|
||||
from astrbot.dashboard.services.auth_service import (
|
||||
CHAT_ADMIN_SCOPE,
|
||||
DEFAULT_OPEN_API_SCOPES,
|
||||
)
|
||||
from astrbot.dashboard.services.chat_service import (
|
||||
BotMessageAccumulator,
|
||||
collect_plain_text_from_message_parts,
|
||||
@@ -128,7 +131,22 @@ class OpenApiService:
|
||||
self,
|
||||
post_data: dict,
|
||||
conf_list: list[dict],
|
||||
*,
|
||||
allow_admin_username: bool = False,
|
||||
) -> tuple[str, str, str | None]:
|
||||
"""Validate and prepare an API chat request.
|
||||
|
||||
Args:
|
||||
post_data: Mutable chat request payload.
|
||||
conf_list: Available chat configuration descriptors.
|
||||
allow_admin_username: Whether the API key has the chat-admin subscope.
|
||||
|
||||
Returns:
|
||||
Effective username, session ID, and optional config ID.
|
||||
|
||||
Raises:
|
||||
OpenApiServiceError: If identity, session, or config validation fails.
|
||||
"""
|
||||
effective_username, username_err = self.resolve_open_username(
|
||||
post_data.get("username")
|
||||
)
|
||||
@@ -136,6 +154,18 @@ class OpenApiService:
|
||||
raise OpenApiServiceError(username_err)
|
||||
if not effective_username:
|
||||
raise OpenApiServiceError("Invalid username")
|
||||
for config in self.core_lifecycle.astrbot_config_mgr.confs.values():
|
||||
if not isinstance(config, dict):
|
||||
continue
|
||||
admin_ids = config.get("admins_id", [])
|
||||
if (
|
||||
not allow_admin_username
|
||||
and isinstance(admin_ids, list)
|
||||
and any(str(admin_id) == effective_username for admin_id in admin_ids)
|
||||
):
|
||||
raise OpenApiServiceError(
|
||||
"username is reserved for an AstrBot administrator"
|
||||
)
|
||||
|
||||
raw_session_id = post_data.get("session_id", post_data.get("conversation_id"))
|
||||
session_id = str(raw_session_id).strip() if raw_session_id is not None else ""
|
||||
@@ -185,25 +215,34 @@ class OpenApiService:
|
||||
|
||||
async def authenticate_api_key(
|
||||
self, raw_key: str | None
|
||||
) -> tuple[bool, str | None]:
|
||||
) -> tuple[list[str] | None, str | None]:
|
||||
"""Authenticate a WebSocket API key and return its effective scopes.
|
||||
|
||||
Args:
|
||||
raw_key: API key supplied by the WebSocket client.
|
||||
|
||||
Returns:
|
||||
Effective scopes and no error on success, otherwise no scopes and
|
||||
a public authentication error.
|
||||
"""
|
||||
if not raw_key:
|
||||
return False, "Missing API key"
|
||||
return None, "Missing API key"
|
||||
|
||||
key_hash = ApiKeyService.hash_key(raw_key)
|
||||
api_key = await self.db.get_active_api_key_by_hash(key_hash)
|
||||
if not api_key:
|
||||
return False, "Invalid API key"
|
||||
return None, "Invalid API key"
|
||||
|
||||
if isinstance(api_key.scopes, list):
|
||||
scopes = api_key.scopes
|
||||
else:
|
||||
scopes = list(ALL_OPEN_API_SCOPES)
|
||||
scopes = list(DEFAULT_OPEN_API_SCOPES)
|
||||
|
||||
if "*" not in scopes and "chat" not in scopes:
|
||||
return False, "Insufficient API key scope"
|
||||
return None, "Insufficient API key scope"
|
||||
|
||||
await self.db.touch_api_key(api_key.key_id)
|
||||
return True, None
|
||||
return [str(scope) for scope in scopes], None
|
||||
|
||||
@staticmethod
|
||||
async def send_chat_ws_error(
|
||||
@@ -229,8 +268,8 @@ class OpenApiService:
|
||||
conf_list: list[dict],
|
||||
chat_bridge: OpenApiWebSocketChatBridge,
|
||||
) -> None:
|
||||
authed, auth_err = await self.authenticate_api_key(raw_api_key)
|
||||
if not authed:
|
||||
scopes, auth_err = await self.authenticate_api_key(raw_api_key)
|
||||
if scopes is None:
|
||||
message = auth_err or "Unauthorized"
|
||||
await self.send_chat_ws_error(send_json, message, "UNAUTHORIZED")
|
||||
await close(1008, message)
|
||||
@@ -266,6 +305,7 @@ class OpenApiService:
|
||||
chat_bridge=chat_bridge,
|
||||
send_json=send_json,
|
||||
send_error=send_error,
|
||||
allow_admin_username=("*" in scopes or CHAT_ADMIN_SCOPE in scopes),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Open API WS connection closed: %s", exc)
|
||||
@@ -334,6 +374,7 @@ class OpenApiService:
|
||||
chat_bridge: OpenApiWebSocketChatBridge,
|
||||
send_json: SendJson,
|
||||
send_error: Callable[[str, str], Awaitable[None]],
|
||||
allow_admin_username: bool = False,
|
||||
) -> None:
|
||||
message = post_data.get("message")
|
||||
if message is None:
|
||||
@@ -348,6 +389,7 @@ class OpenApiService:
|
||||
) = await self.prepare_chat_send(
|
||||
post_data,
|
||||
conf_list,
|
||||
allow_admin_username=allow_admin_username,
|
||||
)
|
||||
except OpenApiServiceError as exc:
|
||||
message = str(exc)
|
||||
@@ -389,6 +431,7 @@ class OpenApiService:
|
||||
"selected_model": selected_model,
|
||||
"flags": flags,
|
||||
"message_id": message_id,
|
||||
"_api_key_allow_admin_role": allow_admin_username,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
@@ -106,15 +106,24 @@ export type ChatProjectRequest = {
|
||||
title?: string;
|
||||
emoji?: string;
|
||||
description?: string;
|
||||
/**
|
||||
* Workspace mode. API key callers may use only session or project; project is the default.
|
||||
*/
|
||||
workspace_type?: 'session' | 'project' | 'custom';
|
||||
/**
|
||||
* Dashboard-only custom workspace path. API key callers cannot set this field.
|
||||
*/
|
||||
workspace_path?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Workspace mode. API key callers may use only session or project; project is the default.
|
||||
*/
|
||||
export type workspace_type = 'session' | 'project' | 'custom';
|
||||
|
||||
export type ChatRequest = {
|
||||
/**
|
||||
* Caller-declared WebChat sender/session owner. This value is used as the message sender identity and may participate in sender-ID-based command permission checks. Treat chat-scoped API keys as trusted backend credentials and map or validate usernames before accepting end-user input.
|
||||
* Caller-declared WebChat sender/session owner. Configured AstrBot administrator IDs require the chat:admin API key sub-scope.
|
||||
*/
|
||||
username?: string;
|
||||
session_id?: string;
|
||||
@@ -231,7 +240,7 @@ export type ConversationRef = {
|
||||
|
||||
export type CreateApiKeyRequest = {
|
||||
name: string;
|
||||
scopes?: Array<('bot' | 'provider' | 'persona' | 'im' | 'config' | 'chat' | 'data' | 'file' | 'plugin' | 'mcp' | 'skill')>;
|
||||
scopes?: Array<('bot' | 'provider' | 'persona' | 'im' | 'config' | 'config:edit_admin' | 'chat' | 'chat:admin' | 'data' | 'file' | 'plugin' | 'mcp' | 'skill')>;
|
||||
expires_at?: string;
|
||||
expires_in_days?: number;
|
||||
};
|
||||
|
||||
@@ -249,6 +249,25 @@
|
||||
},
|
||||
"permanentWarning": "Permanent API keys are high risk. Store them securely and use only when necessary.",
|
||||
"scopes": "Scopes",
|
||||
"sensitiveSubscope": "Sensitive sub-scope",
|
||||
"chatAdminWarning": "chat:admin lets this API key use configured administrator IDs and perform administrator actions. Grant it only to fully trusted callers.",
|
||||
"createConfirm": "Create an API key with the following scopes?\n\n{scopes}",
|
||||
"sensitiveCreateConfirm": "You are about to create an API key with these high-risk scopes:\n\n{scopes}\n\nThe holder may gain administrator capabilities. Continue?",
|
||||
"scopeDescriptions": {
|
||||
"bot": "Read and manage bot and platform configurations.",
|
||||
"provider": "Read and manage model providers.",
|
||||
"persona": "Read and manage personas.",
|
||||
"im": "Access instant messaging APIs.",
|
||||
"config": "Read and manage system and profile configuration; includes bot and provider.",
|
||||
"editAdmin": "Allow admins_id changes, which may grant administrator privileges.",
|
||||
"chat": "Create sessions, send messages, and manage ChatUI projects.",
|
||||
"chatAdmin": "Allow username to use configured administrator IDs and receive their administrator privileges.",
|
||||
"data": "Access statistics and runtime data.",
|
||||
"file": "Upload and access API files.",
|
||||
"plugin": "Read and manage plugins.",
|
||||
"mcp": "Read and manage MCP servers.",
|
||||
"skill": "Read and manage skills."
|
||||
},
|
||||
"create": "Create API Key",
|
||||
"revoke": "Revoke",
|
||||
"delete": "Delete",
|
||||
|
||||
@@ -249,6 +249,25 @@
|
||||
},
|
||||
"permanentWarning": "Бессрочные ключи менее безопасны. Пожалуйста, храните их в надежном месте.",
|
||||
"scopes": "Область доступа (Scopes)",
|
||||
"sensitiveSubscope": "Чувствительное подправо",
|
||||
"chatAdminWarning": "chat:admin позволяет API-ключу использовать настроенные ID администраторов и выполнять административные действия. Выдавайте его только полностью доверенным клиентам.",
|
||||
"createConfirm": "Создать API-ключ со следующими областями доступа?\n\n{scopes}",
|
||||
"sensitiveCreateConfirm": "Будет создан API-ключ со следующими высокорисковыми правами:\n\n{scopes}\n\nВладелец может получить административные возможности. Продолжить?",
|
||||
"scopeDescriptions": {
|
||||
"bot": "Чтение и управление конфигурациями бота и платформ.",
|
||||
"provider": "Чтение и управление провайдерами моделей.",
|
||||
"persona": "Чтение и управление персонами.",
|
||||
"im": "Доступ к API мгновенных сообщений.",
|
||||
"config": "Чтение и управление конфигурацией; включает права bot и provider.",
|
||||
"editAdmin": "Разрешает изменение admins_id, что может предоставить права администратора.",
|
||||
"chat": "Создание сессий, отправка сообщений и управление проектами ChatUI.",
|
||||
"chatAdmin": "Разрешает username использовать настроенные ID администраторов и получать их права.",
|
||||
"data": "Доступ к статистике и рабочим данным.",
|
||||
"file": "Загрузка и доступ к файлам API.",
|
||||
"plugin": "Чтение и управление плагинами.",
|
||||
"mcp": "Чтение и управление MCP-серверами.",
|
||||
"skill": "Чтение и управление навыками."
|
||||
},
|
||||
"create": "Создать API Key",
|
||||
"revoke": "Отозвать",
|
||||
"delete": "Удалить",
|
||||
|
||||
@@ -249,6 +249,25 @@
|
||||
},
|
||||
"permanentWarning": "永久有效的 API Key 风险较高,请妥善保存并建议仅在必要场景使用。",
|
||||
"scopes": "权限范围",
|
||||
"sensitiveSubscope": "敏感子权限",
|
||||
"chatAdminWarning": "chat:admin 允许 API Key 使用已配置的管理员 ID,并执行管理员操作。请仅授予完全可信的调用方。",
|
||||
"createConfirm": "确认创建具有以下权限的 API Key 吗?\n\n{scopes}",
|
||||
"sensitiveCreateConfirm": "即将创建包含以下高风险权限的 API Key:\n\n{scopes}\n\n持有者可能获得管理员能力。确认继续吗?",
|
||||
"scopeDescriptions": {
|
||||
"bot": "读取和管理机器人及平台配置。",
|
||||
"provider": "读取和管理模型服务提供商。",
|
||||
"persona": "读取和管理人格设定。",
|
||||
"im": "访问即时通讯相关接口。",
|
||||
"config": "读取和管理系统及配置文件;会同时包含 bot 和 provider 权限。",
|
||||
"editAdmin": "允许修改 admins_id,这可能授予用户管理员权限。",
|
||||
"chat": "创建会话、发送消息和管理 ChatUI 项目。",
|
||||
"chatAdmin": "允许 username 使用配置中的管理员 ID,并获得对应的管理员权限。",
|
||||
"data": "访问统计信息和运行数据。",
|
||||
"file": "上传和访问 API 文件。",
|
||||
"plugin": "读取和管理插件。",
|
||||
"mcp": "读取和管理 MCP 服务。",
|
||||
"skill": "读取和管理技能。"
|
||||
},
|
||||
"create": "创建 API Key",
|
||||
"revoke": "吊销",
|
||||
"delete": "删除",
|
||||
|
||||
@@ -309,17 +309,62 @@
|
||||
</v-alert>
|
||||
|
||||
<div class="text-caption text-medium-emphasis mb-1">{{ tm('apiKey.scopes') }}</div>
|
||||
<v-chip-group v-model="newApiKeyScopes" multiple class="mb-3">
|
||||
<v-chip
|
||||
<div class="api-key-scope-list mb-3">
|
||||
<div
|
||||
v-for="scope in availableScopes"
|
||||
:key="scope.value"
|
||||
:value="scope.value"
|
||||
:color="newApiKeyScopes.includes(scope.value) ? 'primary' : undefined"
|
||||
:variant="newApiKeyScopes.includes(scope.value) ? 'flat' : 'tonal'"
|
||||
class="api-key-scope-item"
|
||||
>
|
||||
{{ scope.label }}
|
||||
</v-chip>
|
||||
</v-chip-group>
|
||||
<v-checkbox
|
||||
v-model="newApiKeyScopes"
|
||||
:value="scope.value"
|
||||
density="compact"
|
||||
hide-details
|
||||
color="primary"
|
||||
>
|
||||
<template #label>
|
||||
<div class="api-key-scope-label">
|
||||
<code>{{ scope.label }}</code>
|
||||
<span>{{ tm(scope.descriptionKey) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</v-checkbox>
|
||||
<div
|
||||
v-if="scope.children?.length && newApiKeyScopes.includes(scope.value)"
|
||||
class="api-key-subscope-list"
|
||||
>
|
||||
<div class="api-key-subscope-heading">
|
||||
{{ tm('apiKey.sensitiveSubscope') }}
|
||||
</div>
|
||||
<v-checkbox
|
||||
v-for="child in scope.children"
|
||||
:key="child.value"
|
||||
v-model="newApiKeyScopes"
|
||||
:value="child.value"
|
||||
density="compact"
|
||||
hide-details
|
||||
color="warning"
|
||||
>
|
||||
<template #label>
|
||||
<div class="api-key-scope-label">
|
||||
<code>{{ child.label }}</code>
|
||||
<span>{{ tm(child.descriptionKey) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</v-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-alert
|
||||
v-if="newApiKeyScopes.includes('chat:admin')"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
>
|
||||
{{ tm('apiKey.chatAdminWarning') }}
|
||||
</v-alert>
|
||||
|
||||
<v-alert v-if="createdApiKeyPlaintext" type="warning" variant="tonal" class="mb-4">
|
||||
<div class="d-flex align-center justify-space-between flex-wrap">
|
||||
@@ -514,7 +559,7 @@ const apiKeys = ref([]);
|
||||
const apiKeyCreating = ref(false);
|
||||
const newApiKeyName = ref('');
|
||||
const newApiKeyExpiresInDays = ref(30);
|
||||
const newApiKeyScopes = ref(['bot', 'provider', 'im', 'config', 'chat', 'file']);
|
||||
const newApiKeyScopes = ref(['bot', 'provider', 'im', 'chat', 'file']);
|
||||
const createdApiKeyPlaintext = ref('');
|
||||
const systemConfigData = ref({});
|
||||
const systemConfigMetadata = ref({});
|
||||
@@ -540,18 +585,44 @@ const apiKeyExpiryOptions = computed(() => [
|
||||
]);
|
||||
|
||||
const availableScopes = [
|
||||
{ value: 'bot', label: 'bot' },
|
||||
{ value: 'provider', label: 'provider' },
|
||||
{ value: 'persona', label: 'persona' },
|
||||
{ value: 'im', label: 'im' },
|
||||
{ value: 'config', label: 'config' },
|
||||
{ value: 'chat', label: 'chat' },
|
||||
{ value: 'data', label: 'data' },
|
||||
{ value: 'file', label: 'file' },
|
||||
{ value: 'plugin', label: 'plugin' },
|
||||
{ value: 'mcp', label: 'mcp' },
|
||||
{ value: 'skill', label: 'skill' }
|
||||
{ value: 'bot', label: 'bot', descriptionKey: 'apiKey.scopeDescriptions.bot' },
|
||||
{ value: 'provider', label: 'provider', descriptionKey: 'apiKey.scopeDescriptions.provider' },
|
||||
{ value: 'persona', label: 'persona', descriptionKey: 'apiKey.scopeDescriptions.persona' },
|
||||
{ value: 'im', label: 'im', descriptionKey: 'apiKey.scopeDescriptions.im' },
|
||||
{
|
||||
value: 'config',
|
||||
label: 'config',
|
||||
descriptionKey: 'apiKey.scopeDescriptions.config',
|
||||
children: [
|
||||
{
|
||||
value: 'config:edit_admin',
|
||||
label: 'edit_admin',
|
||||
descriptionKey: 'apiKey.scopeDescriptions.editAdmin'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
value: 'chat',
|
||||
label: 'chat',
|
||||
descriptionKey: 'apiKey.scopeDescriptions.chat',
|
||||
children: [
|
||||
{
|
||||
value: 'chat:admin',
|
||||
label: 'admin',
|
||||
descriptionKey: 'apiKey.scopeDescriptions.chatAdmin'
|
||||
}
|
||||
]
|
||||
},
|
||||
{ value: 'data', label: 'data', descriptionKey: 'apiKey.scopeDescriptions.data' },
|
||||
{ value: 'file', label: 'file', descriptionKey: 'apiKey.scopeDescriptions.file' },
|
||||
{ value: 'plugin', label: 'plugin', descriptionKey: 'apiKey.scopeDescriptions.plugin' },
|
||||
{ value: 'mcp', label: 'mcp', descriptionKey: 'apiKey.scopeDescriptions.mcp' },
|
||||
{ value: 'skill', label: 'skill', descriptionKey: 'apiKey.scopeDescriptions.skill' }
|
||||
];
|
||||
const apiKeyScopeOrder = availableScopes.flatMap((scope) => [
|
||||
scope.value,
|
||||
...(scope.children || []).map((child) => child.value)
|
||||
]);
|
||||
|
||||
const settingsNavItems = computed(() => [
|
||||
{ id: 'general', label: tm('sections.general.title'), icon: 'mdi mdi-tune-variant' },
|
||||
@@ -606,6 +677,7 @@ const resourceItems = computed(() => [
|
||||
]);
|
||||
|
||||
const configIncludedScopes = ['bot', 'provider'];
|
||||
const sensitiveApiKeyScopes = ['config:edit_admin', 'chat:admin'];
|
||||
const previousApiKeyScopes = ref([...newApiKeyScopes.value]);
|
||||
|
||||
const systemConfigHasChanges = computed(() => (
|
||||
@@ -702,17 +774,22 @@ watch(
|
||||
selectedScopes.add(scope);
|
||||
}
|
||||
}
|
||||
|
||||
nextScopes = availableScopes
|
||||
.map((scopeOption) => scopeOption.value)
|
||||
.filter((scope) => selectedScopes.has(scope));
|
||||
if (
|
||||
nextScopes.length !== scopes.length
|
||||
|| nextScopes.some((scope, index) => scope !== scopes[index])
|
||||
) {
|
||||
newApiKeyScopes.value = nextScopes;
|
||||
}
|
||||
for (const scopeOption of availableScopes) {
|
||||
if (!selectedScopes.has(scopeOption.value)) {
|
||||
for (const child of scopeOption.children || []) {
|
||||
selectedScopes.delete(child.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nextScopes = apiKeyScopeOrder.filter((scope) => selectedScopes.has(scope));
|
||||
if (
|
||||
nextScopes.length !== scopes.length
|
||||
|| nextScopes.some((scope, index) => scope !== scopes[index])
|
||||
) {
|
||||
newApiKeyScopes.value = nextScopes;
|
||||
}
|
||||
previousApiKeyScopes.value = [...nextScopes];
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
@@ -892,14 +969,25 @@ const createApiKey = async () => {
|
||||
selectedScopeSet.add(scope);
|
||||
}
|
||||
}
|
||||
const selectedScopes = availableScopes
|
||||
.map((scope) => scope.value)
|
||||
.filter((scope) => selectedScopeSet.has(scope));
|
||||
const selectedScopes = apiKeyScopeOrder.filter((scope) => selectedScopeSet.has(scope));
|
||||
|
||||
if (selectedScopes.length === 0) {
|
||||
showToast(tm('apiKey.messages.scopeRequired'), 'warning');
|
||||
return;
|
||||
}
|
||||
const selectedSensitiveScopes = selectedScopes.filter((scope) => (
|
||||
sensitiveApiKeyScopes.includes(scope)
|
||||
));
|
||||
const confirmationMessage = selectedSensitiveScopes.length > 0
|
||||
? tm('apiKey.sensitiveCreateConfirm', {
|
||||
scopes: selectedSensitiveScopes.join(', ')
|
||||
})
|
||||
: tm('apiKey.createConfirm', {
|
||||
scopes: selectedScopes.join(', ')
|
||||
});
|
||||
if (!(await askForConfirmation(confirmationMessage, confirmDialog))) {
|
||||
return;
|
||||
}
|
||||
apiKeyCreating.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
@@ -1408,9 +1496,58 @@ onUnmounted(() => {
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.api-key-panel :deep(.v-chip) {
|
||||
height: 30px;
|
||||
font-size: 0.8rem;
|
||||
.api-key-scope-list {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--settings-border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.api-key-scope-item {
|
||||
padding: 8px 14px;
|
||||
border-bottom: 1px solid var(--settings-divider);
|
||||
}
|
||||
|
||||
.api-key-scope-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.api-key-scope-item :deep(.v-selection-control) {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.api-key-scope-label {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
padding: 3px 0;
|
||||
}
|
||||
|
||||
.api-key-scope-label code {
|
||||
width: fit-content;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.api-key-scope-label span {
|
||||
color: rgba(var(--v-theme-on-surface), 0.64);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.api-key-subscope-list {
|
||||
margin: 4px 0 4px 34px;
|
||||
padding: 10px 12px;
|
||||
border-left: 3px solid rgb(var(--v-theme-warning));
|
||||
border-radius: 0 8px 8px 0;
|
||||
background: rgba(var(--v-theme-warning), 0.07);
|
||||
}
|
||||
|
||||
.api-key-subscope-heading {
|
||||
margin-bottom: 2px;
|
||||
color: rgb(var(--v-theme-warning));
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.api-key-plain {
|
||||
|
||||
@@ -220,6 +220,10 @@ export default defineConfig({
|
||||
text: "AstrBot HTTP API",
|
||||
link: "/openapi",
|
||||
},
|
||||
{
|
||||
text: "API Scope 与接口对照",
|
||||
link: "/openapi-scopes",
|
||||
},
|
||||
{
|
||||
text: "AstrBot 配置文件",
|
||||
link: "/astrbot-config",
|
||||
@@ -474,6 +478,10 @@ export default defineConfig({
|
||||
text: "AstrBot HTTP API",
|
||||
link: "/openapi",
|
||||
},
|
||||
{
|
||||
text: "API Scope–Endpoint Reference",
|
||||
link: "/openapi-scopes",
|
||||
},
|
||||
{
|
||||
text: "AstrBot Configuration File",
|
||||
link: "/astrbot-config",
|
||||
|
||||
@@ -149,7 +149,7 @@
|
||||
}
|
||||
|
||||
.VPSidebarItem.is-link > .item > .link {
|
||||
margin: 2px 0;
|
||||
margin: 2px -10px;
|
||||
border-radius: 8px;
|
||||
padding: 0 10px;
|
||||
transition: none;
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
---
|
||||
outline: deep
|
||||
---
|
||||
|
||||
<!-- Generated by docs/scripts/update_openapi_json.py. Do not edit directly. -->
|
||||
|
||||
# API Scope–Endpoint Reference
|
||||
|
||||
This page is generated from `openspec/openapi-v1.yaml`. Each endpoint's base permission comes from `x-astrbot-scope`; sensitive operations also list the sub-scope that must be granted explicitly.
|
||||
|
||||
## `bot`
|
||||
|
||||
Manage bot and platform configurations.
|
||||
|
||||
| Method | Endpoint | Conditional sensitive sub-scope |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/v1/bot-types` | — |
|
||||
| `GET` | `/api/v1/bots` | — |
|
||||
| `POST` | `/api/v1/bots` | — |
|
||||
| `GET` | `/api/v1/bots/by-id` | — |
|
||||
| `PUT` | `/api/v1/bots/by-id` | — |
|
||||
| `DELETE` | `/api/v1/bots/by-id` | — |
|
||||
| `PATCH` | `/api/v1/bots/enabled` | — |
|
||||
| `GET` | `/api/v1/bots/stats` | — |
|
||||
| `POST` | `/api/v1/bots/test` | — |
|
||||
| `GET` | `/api/v1/bots/{bot_id}` | — |
|
||||
| `PUT` | `/api/v1/bots/{bot_id}` | — |
|
||||
| `DELETE` | `/api/v1/bots/{bot_id}` | — |
|
||||
| `PATCH` | `/api/v1/bots/{bot_id}/enabled` | — |
|
||||
| `POST` | `/api/v1/bots/{bot_id}/test` | — |
|
||||
|
||||
## `provider`
|
||||
|
||||
Manage model providers and provider sources.
|
||||
|
||||
| Method | Endpoint | Conditional sensitive sub-scope |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/v1/provider-sources` | — |
|
||||
| `POST` | `/api/v1/provider-sources` | — |
|
||||
| `GET` | `/api/v1/provider-sources/by-id` | — |
|
||||
| `PUT` | `/api/v1/provider-sources/by-id` | — |
|
||||
| `DELETE` | `/api/v1/provider-sources/by-id` | — |
|
||||
| `GET` | `/api/v1/provider-sources/models` | — |
|
||||
| `GET` | `/api/v1/provider-sources/providers` | — |
|
||||
| `POST` | `/api/v1/provider-sources/providers` | — |
|
||||
| `GET` | `/api/v1/provider-sources/{source_id}` | — |
|
||||
| `PUT` | `/api/v1/provider-sources/{source_id}` | — |
|
||||
| `DELETE` | `/api/v1/provider-sources/{source_id}` | — |
|
||||
| `GET` | `/api/v1/provider-sources/{source_id}/models` | — |
|
||||
| `GET` | `/api/v1/provider-sources/{source_id}/providers` | — |
|
||||
| `POST` | `/api/v1/provider-sources/{source_id}/providers` | — |
|
||||
| `GET` | `/api/v1/providers` | — |
|
||||
| `POST` | `/api/v1/providers` | — |
|
||||
| `GET` | `/api/v1/providers/by-id` | — |
|
||||
| `PUT` | `/api/v1/providers/by-id` | — |
|
||||
| `DELETE` | `/api/v1/providers/by-id` | — |
|
||||
| `POST` | `/api/v1/providers/embedding-dimension` | — |
|
||||
| `PATCH` | `/api/v1/providers/enabled` | — |
|
||||
| `GET` | `/api/v1/providers/schema` | — |
|
||||
| `POST` | `/api/v1/providers/test` | — |
|
||||
| `GET` | `/api/v1/providers/{provider_id}` | — |
|
||||
| `PUT` | `/api/v1/providers/{provider_id}` | — |
|
||||
| `DELETE` | `/api/v1/providers/{provider_id}` | — |
|
||||
| `POST` | `/api/v1/providers/{provider_id}/embedding-dimension` | — |
|
||||
| `PATCH` | `/api/v1/providers/{provider_id}/enabled` | — |
|
||||
| `POST` | `/api/v1/providers/{provider_id}/test` | — |
|
||||
|
||||
## `persona`
|
||||
|
||||
Manage personas and persona folders.
|
||||
|
||||
| Method | Endpoint | Conditional sensitive sub-scope |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/v1/persona-folders` | — |
|
||||
| `POST` | `/api/v1/persona-folders` | — |
|
||||
| `PUT` | `/api/v1/persona-folders/{folder_id}` | — |
|
||||
| `DELETE` | `/api/v1/persona-folders/{folder_id}` | — |
|
||||
| `GET` | `/api/v1/personas` | — |
|
||||
| `POST` | `/api/v1/personas` | — |
|
||||
| `GET` | `/api/v1/personas/by-id` | — |
|
||||
| `PUT` | `/api/v1/personas/by-id` | — |
|
||||
| `DELETE` | `/api/v1/personas/by-id` | — |
|
||||
| `POST` | `/api/v1/personas/move` | — |
|
||||
| `POST` | `/api/v1/personas/reorder` | — |
|
||||
| `GET` | `/api/v1/personas/tree` | — |
|
||||
| `GET` | `/api/v1/personas/{persona_id}` | — |
|
||||
| `PUT` | `/api/v1/personas/{persona_id}` | — |
|
||||
| `DELETE` | `/api/v1/personas/{persona_id}` | — |
|
||||
|
||||
## `im`
|
||||
|
||||
Send proactive IM messages and query bot or platform identifiers.
|
||||
|
||||
| Method | Endpoint | Conditional sensitive sub-scope |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/v1/im/bots` | — |
|
||||
| `POST` | `/api/v1/im/messages` | — |
|
||||
|
||||
## `config`
|
||||
|
||||
Manage configuration profiles, system configuration, and shared configuration, excluding changes to `admins_id`.
|
||||
|
||||
- **Includes:** `bot`, `provider`
|
||||
|
||||
- **Sensitive sub-scope `config:edit_admin`:** Allow a `config`-scoped key to change `admins_id`. This sub-scope must be granted explicitly.
|
||||
|
||||
| Method | Endpoint | Conditional sensitive sub-scope |
|
||||
| --- | --- | --- |
|
||||
| `POST` | `/api/v1/bot-types/{bot_type}/registration` | — |
|
||||
| `GET` | `/api/v1/config-profiles` | — |
|
||||
| `POST` | `/api/v1/config-profiles` | `config:edit_admin` |
|
||||
| `GET` | `/api/v1/config-profiles/schema` | — |
|
||||
| `GET` | `/api/v1/config-profiles/{config_id}` | — |
|
||||
| `PUT` | `/api/v1/config-profiles/{config_id}` | `config:edit_admin` |
|
||||
| `PATCH` | `/api/v1/config-profiles/{config_id}` | — |
|
||||
| `DELETE` | `/api/v1/config-profiles/{config_id}` | — |
|
||||
| `GET` | `/api/v1/config-routes` | — |
|
||||
| `PUT` | `/api/v1/config-routes` | — |
|
||||
| `PUT` | `/api/v1/config-routes/{umo}` | — |
|
||||
| `DELETE` | `/api/v1/config-routes/{umo}` | — |
|
||||
| `GET` | `/api/v1/subagents/available-tools` | — |
|
||||
| `GET` | `/api/v1/subagents/config` | — |
|
||||
| `PUT` | `/api/v1/subagents/config` | — |
|
||||
| `GET` | `/api/v1/system-config` | — |
|
||||
| `PUT` | `/api/v1/system-config` | `config:edit_admin` |
|
||||
| `GET` | `/api/v1/system-config/runtime` | — |
|
||||
| `GET` | `/api/v1/system-config/schema` | — |
|
||||
| `GET` | `/api/v1/t2i/templates` | — |
|
||||
| `POST` | `/api/v1/t2i/templates` | — |
|
||||
| `GET` | `/api/v1/t2i/templates/active` | — |
|
||||
| `PUT` | `/api/v1/t2i/templates/active` | — |
|
||||
| `POST` | `/api/v1/t2i/templates/default/reset` | — |
|
||||
| `GET` | `/api/v1/t2i/templates/{name}` | — |
|
||||
| `PUT` | `/api/v1/t2i/templates/{name}` | — |
|
||||
| `DELETE` | `/api/v1/t2i/templates/{name}` | — |
|
||||
|
||||
## `chat`
|
||||
|
||||
Use chat capabilities and manage ChatUI sessions and projects.
|
||||
|
||||
- **Sensitive sub-scope `chat:admin`:** Allow `username` to use a configured AstrBot administrator ID. This sub-scope must be granted explicitly.
|
||||
|
||||
| Method | Endpoint | Conditional sensitive sub-scope |
|
||||
| --- | --- | --- |
|
||||
| `POST` | `/api/v1/chat` | `chat:admin` |
|
||||
| `GET` | `/api/v1/chat/configs` | — |
|
||||
| `GET` | `/api/v1/chat/projects` | — |
|
||||
| `POST` | `/api/v1/chat/projects` | — |
|
||||
| `DELETE` | `/api/v1/chat/projects/sessions/{session_id}` | — |
|
||||
| `GET` | `/api/v1/chat/projects/{project_id}` | — |
|
||||
| `PATCH` | `/api/v1/chat/projects/{project_id}` | — |
|
||||
| `DELETE` | `/api/v1/chat/projects/{project_id}` | — |
|
||||
| `GET` | `/api/v1/chat/projects/{project_id}/sessions` | — |
|
||||
| `POST` | `/api/v1/chat/projects/{project_id}/sessions/{session_id}` | — |
|
||||
| `GET` | `/api/v1/chat/projects/{project_id}/workspace/file` | — |
|
||||
| `GET` | `/api/v1/chat/projects/{project_id}/workspace/file/download` | — |
|
||||
| `GET` | `/api/v1/chat/projects/{project_id}/workspace/files` | — |
|
||||
| `GET` | `/api/v1/chat/runs/{run_id}/stream` | — |
|
||||
| `GET` | `/api/v1/chat/sessions` | — |
|
||||
| `POST` | `/api/v1/chat/sessions/batch-delete` | — |
|
||||
| `GET` | `/api/v1/chat/sessions/new` | — |
|
||||
| `GET` | `/api/v1/chat/sessions/{session_id}` | — |
|
||||
| `PATCH` | `/api/v1/chat/sessions/{session_id}` | — |
|
||||
| `DELETE` | `/api/v1/chat/sessions/{session_id}` | — |
|
||||
| `PATCH` | `/api/v1/chat/sessions/{session_id}/messages/{message_id}` | — |
|
||||
| `POST` | `/api/v1/chat/sessions/{session_id}/messages/{message_id}/regenerate` | — |
|
||||
| `POST` | `/api/v1/chat/sessions/{session_id}/stop` | — |
|
||||
| `POST` | `/api/v1/chat/threads` | — |
|
||||
| `GET` | `/api/v1/chat/threads/{thread_id}` | — |
|
||||
| `DELETE` | `/api/v1/chat/threads/{thread_id}` | — |
|
||||
| `POST` | `/api/v1/chat/threads/{thread_id}/messages` | — |
|
||||
| `GET` | `/api/v1/chat/ws` | `chat:admin` |
|
||||
|
||||
## `data`
|
||||
|
||||
Manage conversations and platform-session data.
|
||||
|
||||
| Method | Endpoint | Conditional sensitive sub-scope |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/v1/conversations` | — |
|
||||
| `POST` | `/api/v1/conversations/batch-delete` | — |
|
||||
| `POST` | `/api/v1/conversations/export` | — |
|
||||
| `GET` | `/api/v1/conversations/{conversation_id}` | — |
|
||||
| `PATCH` | `/api/v1/conversations/{conversation_id}` | — |
|
||||
| `DELETE` | `/api/v1/conversations/{conversation_id}` | — |
|
||||
| `PUT` | `/api/v1/conversations/{conversation_id}/messages` | — |
|
||||
| `GET` | `/api/v1/session-groups` | — |
|
||||
| `POST` | `/api/v1/session-groups` | — |
|
||||
| `PUT` | `/api/v1/session-groups/{group_id}` | — |
|
||||
| `DELETE` | `/api/v1/session-groups/{group_id}` | — |
|
||||
| `GET` | `/api/v1/sessions` | — |
|
||||
| `GET` | `/api/v1/sessions/active-umos` | — |
|
||||
| `PATCH` | `/api/v1/sessions/provider` | — |
|
||||
| `GET` | `/api/v1/sessions/rules` | — |
|
||||
| `POST` | `/api/v1/sessions/rules` | — |
|
||||
| `POST` | `/api/v1/sessions/rules/delete` | — |
|
||||
| `PATCH` | `/api/v1/sessions/service` | — |
|
||||
|
||||
## `file`
|
||||
|
||||
Upload and download chat attachments.
|
||||
|
||||
| Method | Endpoint | Conditional sensitive sub-scope |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/v1/file` | — |
|
||||
| `POST` | `/api/v1/file` | — |
|
||||
| `POST` | `/api/v1/files` | — |
|
||||
| `GET` | `/api/v1/files/content` | — |
|
||||
| `GET` | `/api/v1/files/{attachment_id}` | — |
|
||||
| `DELETE` | `/api/v1/files/{attachment_id}` | — |
|
||||
| `GET` | `/api/v1/files/{attachment_id}/content` | — |
|
||||
|
||||
## `plugin`
|
||||
|
||||
Manage plugins, plugin configuration, plugin sources, and marketplace data.
|
||||
|
||||
| Method | Endpoint | Conditional sensitive sub-scope |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/v1/plugin-sources` | — |
|
||||
| `POST` | `/api/v1/plugin-sources` | — |
|
||||
| `PUT` | `/api/v1/plugin-sources` | — |
|
||||
| `DELETE` | `/api/v1/plugin-sources/by-id` | — |
|
||||
| `DELETE` | `/api/v1/plugin-sources/{source_id}` | — |
|
||||
| `GET` | `/api/v1/plugins` | — |
|
||||
| `GET` | `/api/v1/plugins/by-id` | — |
|
||||
| `DELETE` | `/api/v1/plugins/by-id` | — |
|
||||
| `GET` | `/api/v1/plugins/changelog` | — |
|
||||
| `GET` | `/api/v1/plugins/config` | — |
|
||||
| `PUT` | `/api/v1/plugins/config` | — |
|
||||
| `GET` | `/api/v1/plugins/config-files` | — |
|
||||
| `POST` | `/api/v1/plugins/config-files` | — |
|
||||
| `DELETE` | `/api/v1/plugins/config-files` | — |
|
||||
| `GET` | `/api/v1/plugins/config/schema` | — |
|
||||
| `PATCH` | `/api/v1/plugins/enabled` | — |
|
||||
| `GET` | `/api/v1/plugins/extensions/{plugin_path}` | — |
|
||||
| `POST` | `/api/v1/plugins/extensions/{plugin_path}` | — |
|
||||
| `PUT` | `/api/v1/plugins/extensions/{plugin_path}` | — |
|
||||
| `PATCH` | `/api/v1/plugins/extensions/{plugin_path}` | — |
|
||||
| `DELETE` | `/api/v1/plugins/extensions/{plugin_path}` | — |
|
||||
| `GET` | `/api/v1/plugins/failed` | — |
|
||||
| `DELETE` | `/api/v1/plugins/failed/{plugin_id}` | — |
|
||||
| `POST` | `/api/v1/plugins/failed/{plugin_id}/reload` | — |
|
||||
| `POST` | `/api/v1/plugins/install/git` | — |
|
||||
| `POST` | `/api/v1/plugins/install/github` | — |
|
||||
| `POST` | `/api/v1/plugins/install/upload` | — |
|
||||
| `POST` | `/api/v1/plugins/install/url` | — |
|
||||
| `GET` | `/api/v1/plugins/market` | — |
|
||||
| `GET` | `/api/v1/plugins/market/categories` | — |
|
||||
| `GET` | `/api/v1/plugins/page` | — |
|
||||
| `GET` | `/api/v1/plugins/page-bridge-sdk.js` | — |
|
||||
| `GET` | `/api/v1/plugins/page/assets` | — |
|
||||
| `GET` | `/api/v1/plugins/pages` | — |
|
||||
| `GET` | `/api/v1/plugins/readme` | — |
|
||||
| `POST` | `/api/v1/plugins/reload` | — |
|
||||
| `POST` | `/api/v1/plugins/update` | — |
|
||||
| `POST` | `/api/v1/plugins/validate/repo` | — |
|
||||
| `POST` | `/api/v1/plugins/version-support/check` | — |
|
||||
| `GET` | `/api/v1/plugins/{plugin_id}` | — |
|
||||
| `DELETE` | `/api/v1/plugins/{plugin_id}` | — |
|
||||
| `GET` | `/api/v1/plugins/{plugin_id}/changelog` | — |
|
||||
| `GET` | `/api/v1/plugins/{plugin_id}/config` | — |
|
||||
| `PUT` | `/api/v1/plugins/{plugin_id}/config` | — |
|
||||
| `DELETE` | `/api/v1/plugins/{plugin_id}/config-files` | — |
|
||||
| `GET` | `/api/v1/plugins/{plugin_id}/config-files/{config_key}` | — |
|
||||
| `POST` | `/api/v1/plugins/{plugin_id}/config-files/{config_key}` | — |
|
||||
| `GET` | `/api/v1/plugins/{plugin_id}/config/schema` | — |
|
||||
| `PATCH` | `/api/v1/plugins/{plugin_id}/enabled` | — |
|
||||
| `PUT` | `/api/v1/plugins/{plugin_id}/log-level` | — |
|
||||
| `GET` | `/api/v1/plugins/{plugin_id}/pages` | — |
|
||||
| `GET` | `/api/v1/plugins/{plugin_id}/pages/{page_name}` | — |
|
||||
| `GET` | `/api/v1/plugins/{plugin_id}/pages/{page_name}/assets/{asset_path}` | — |
|
||||
| `GET` | `/api/v1/plugins/{plugin_id}/readme` | — |
|
||||
| `POST` | `/api/v1/plugins/{plugin_id}/reload` | — |
|
||||
| `POST` | `/api/v1/plugins/{plugin_id}/source` | — |
|
||||
| `POST` | `/api/v1/plugins/{plugin_id}/update` | — |
|
||||
|
||||
## `mcp`
|
||||
|
||||
Manage MCP server configuration and provider synchronization.
|
||||
|
||||
| Method | Endpoint | Conditional sensitive sub-scope |
|
||||
| --- | --- | --- |
|
||||
| `POST` | `/api/v1/mcp/providers/modelscope/sync` | — |
|
||||
| `GET` | `/api/v1/mcp/servers` | — |
|
||||
| `POST` | `/api/v1/mcp/servers` | — |
|
||||
| `PUT` | `/api/v1/mcp/servers/by-name` | — |
|
||||
| `DELETE` | `/api/v1/mcp/servers/by-name` | — |
|
||||
| `PATCH` | `/api/v1/mcp/servers/enabled` | — |
|
||||
| `POST` | `/api/v1/mcp/servers/test` | — |
|
||||
| `PUT` | `/api/v1/mcp/servers/{server_name}` | — |
|
||||
| `DELETE` | `/api/v1/mcp/servers/{server_name}` | — |
|
||||
| `PATCH` | `/api/v1/mcp/servers/{server_name}/enabled` | — |
|
||||
| `POST` | `/api/v1/mcp/servers/{server_name}/test` | — |
|
||||
|
||||
## `skill`
|
||||
|
||||
Manage Skills, archives, files, and Shipyard Neo Skill workflows.
|
||||
|
||||
| Method | Endpoint | Conditional sensitive sub-scope |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/v1/skills` | — |
|
||||
| `POST` | `/api/v1/skills` | — |
|
||||
| `GET` | `/api/v1/skills/archive` | — |
|
||||
| `POST` | `/api/v1/skills/batch` | — |
|
||||
| `PATCH` | `/api/v1/skills/by-name` | — |
|
||||
| `DELETE` | `/api/v1/skills/by-name` | — |
|
||||
| `GET` | `/api/v1/skills/file` | — |
|
||||
| `PUT` | `/api/v1/skills/file` | — |
|
||||
| `GET` | `/api/v1/skills/files` | — |
|
||||
| `GET` | `/api/v1/skills/neo/candidates` | — |
|
||||
| `POST` | `/api/v1/skills/neo/candidates/delete` | — |
|
||||
| `POST` | `/api/v1/skills/neo/evaluate` | — |
|
||||
| `GET` | `/api/v1/skills/neo/payload` | — |
|
||||
| `POST` | `/api/v1/skills/neo/promote` | — |
|
||||
| `GET` | `/api/v1/skills/neo/releases` | — |
|
||||
| `POST` | `/api/v1/skills/neo/releases/delete` | — |
|
||||
| `POST` | `/api/v1/skills/neo/rollback` | — |
|
||||
| `POST` | `/api/v1/skills/neo/sync` | — |
|
||||
| `PATCH` | `/api/v1/skills/{skill_name}` | — |
|
||||
| `DELETE` | `/api/v1/skills/{skill_name}` | — |
|
||||
| `GET` | `/api/v1/skills/{skill_name}/archive` | — |
|
||||
| `GET` | `/api/v1/skills/{skill_name}/files` | — |
|
||||
| `GET` | `/api/v1/skills/{skill_name}/files/{file_path}` | — |
|
||||
| `PUT` | `/api/v1/skills/{skill_name}/files/{file_path}` | — |
|
||||
+8
-17
@@ -30,26 +30,17 @@ The local OpenAPI schema is available at `http://localhost:6185/api/v1/openapi.j
|
||||
|
||||
## Scope Permissions
|
||||
|
||||
When creating an API Key, you can configure `scopes`. Each scope controls the range of accessible endpoints:
|
||||
|
||||
| Scope | Purpose | Accessible Endpoints |
|
||||
| --- | --- | --- |
|
||||
| `bot` | Manage bot/platform configurations | `GET /api/v1/bot-types`, `GET/POST /api/v1/bots`, `PATCH /api/v1/bots/enabled` |
|
||||
| `provider` | Manage model providers and provider sources | `GET/POST /api/v1/providers`, `GET/PUT/DELETE /api/v1/provider-sources/by-id` |
|
||||
| `persona` | Manage personas and persona folders | `GET/POST /api/v1/personas`, `GET/POST /api/v1/persona-folders` |
|
||||
| `im` | Send proactive IM messages and query bot/platform list | `POST /api/v1/im/message`, `GET /api/v1/im/bots` |
|
||||
| `config` | Manage config profiles, system config, and shared configuration. This scope also includes `bot` and `provider` access. | `GET /api/v1/configs`, `GET/PUT /api/v1/system-config`, `GET/POST /api/v1/config-profiles` |
|
||||
| `chat` | Access chat capabilities and query sessions | `POST /api/v1/chat`, `GET /api/v1/chat/sessions` |
|
||||
| `file` | Upload and download chat attachments | `POST /api/v1/file`, `GET /api/v1/file`, `POST /api/v1/files` |
|
||||
| `plugin` | Manage plugins, plugin config, plugin sources, and marketplace entries | `GET /api/v1/plugins`, `GET/PUT /api/v1/plugins/config`, `POST /api/v1/plugins/install/url` |
|
||||
| `mcp` | Manage MCP server configurations and provider sync | `GET/POST /api/v1/mcp/servers`, `PATCH /api/v1/mcp/servers/{server_name}/enabled`, `POST /api/v1/mcp/providers/modelscope/sync` |
|
||||
| `skill` | Manage skills, skill archives, skill files, and Shipyard Neo skill workflows | `GET/POST /api/v1/skills`, `PUT /api/v1/skills/{skill_name}/files/{file_path}`, `POST /api/v1/skills/neo/sync` |
|
||||
API Keys can be configured with `scopes`. See the [API Scope–Endpoint Reference](./openapi-scopes.md) for each scope's purpose, inheritance rules, and complete endpoint list.
|
||||
|
||||
If the API Key does not include the required scope for the target endpoint, the request will return `403 Insufficient API key scope`.
|
||||
|
||||
`config` is a broad management scope. When an API key is created with `config`, AstrBot grants the key `config`, `bot`, and `provider` access together. The WebUI mirrors this dependency: selecting `config` selects `bot` and `provider`; deselecting `bot` or `provider` removes `config`.
|
||||
- `config` is not selected by default in the WebUI and automatically includes `bot` and `provider`.
|
||||
- `config:edit_admin` and `chat:admin` must be granted explicitly and are never inherited from their parent scopes.
|
||||
- Deselecting `bot` or `provider` in the WebUI also removes the dependent `config` scope.
|
||||
|
||||
Developer API keys currently support only the 10 scopes listed above. `tool`, `skills`, `kb`, `data`, and `system` are not valid developer API key scopes. Use the singular `skill` scope for `/api/v1/skills/*` endpoints. The public OpenAPI reference only includes endpoints covered by supported developer API key scopes.
|
||||
Developer API keys currently support 11 top-level scopes and two sensitive sub-scopes. `tool`, `skills`, `kb`, and `system` are not valid developer API key scopes. Use the singular `skill` scope for `/api/v1/skills/*` endpoints.
|
||||
|
||||
Every operation in the interactive reference also displays `Required scope: ...`; operations involving administrator capabilities additionally display `Conditional sensitive scope: ...`.
|
||||
|
||||
## Common Endpoints
|
||||
|
||||
@@ -130,7 +121,7 @@ Notes:
|
||||
|
||||
`POST /api/v1/chat` additionally requires `username`, with optional `session_id` (a UUID is auto-generated if omitted).
|
||||
|
||||
`username` is a caller-declared WebChat identity. It is used as the message sender and session owner in the message pipeline, including sender-ID-based command permission checks. Treat API keys with the `chat` scope as trusted backend credentials. If you expose chat access to end users, proxy requests through your own service and map each external user to an allowed `username`; do not let clients submit administrator IDs or other reserved sender IDs directly.
|
||||
`username` is a caller-declared WebChat identity used as the message sender and session owner. A key with only `chat` is rejected when the value matches any configured administrator ID and is prevented from receiving an administrator role inside the message pipeline. The sensitive `chat:admin` sub-scope explicitly permits configured administrator IDs; it does not make arbitrary usernames administrators. Integrations should still map external users to stable, application-controlled usernames.
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
+1309
-253
File diff suppressed because it is too large
Load Diff
@@ -11,26 +11,22 @@ import yaml
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
DEFAULT_SPEC = REPO_ROOT / "openspec" / "openapi-v1.yaml"
|
||||
DEFAULT_OUTPUT = REPO_ROOT / "docs" / "public" / "openapi.json"
|
||||
PUBLIC_OPEN_API_TAGS = {
|
||||
"Open API",
|
||||
"System Config",
|
||||
"Config Profiles",
|
||||
"Bot Config Routes",
|
||||
"Bots",
|
||||
"Provider Sources",
|
||||
"Providers",
|
||||
"Chat",
|
||||
"IM",
|
||||
"Files",
|
||||
"Plugins",
|
||||
"Plugin Sources",
|
||||
"Plugin Pages",
|
||||
"MCP",
|
||||
"Skills",
|
||||
"Personas",
|
||||
"T2I",
|
||||
"Subagents",
|
||||
}
|
||||
DEFAULT_ZH_SCOPE_OUTPUT = REPO_ROOT / "docs" / "zh" / "dev" / "openapi-scopes.md"
|
||||
DEFAULT_EN_SCOPE_OUTPUT = REPO_ROOT / "docs" / "en" / "dev" / "openapi-scopes.md"
|
||||
HTTP_METHODS = ("get", "post", "put", "patch", "delete", "options", "head", "trace")
|
||||
PUBLIC_OPEN_API_SCOPES = (
|
||||
"bot",
|
||||
"provider",
|
||||
"persona",
|
||||
"im",
|
||||
"config",
|
||||
"chat",
|
||||
"data",
|
||||
"file",
|
||||
"plugin",
|
||||
"mcp",
|
||||
"skill",
|
||||
)
|
||||
PUBLIC_OPEN_API_EXCLUDED_PATHS = {
|
||||
"/api/v1/live-chat/ws",
|
||||
"/api/v1/unified-chat/ws",
|
||||
@@ -54,6 +50,18 @@ def parse_args() -> argparse.Namespace:
|
||||
default=DEFAULT_OUTPUT,
|
||||
help=f"OpenAPI JSON output path. Default: {DEFAULT_OUTPUT}",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--zh-scope-output",
|
||||
type=Path,
|
||||
default=DEFAULT_ZH_SCOPE_OUTPUT,
|
||||
help=f"Chinese scope reference path. Default: {DEFAULT_ZH_SCOPE_OUTPUT}",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--en-scope-output",
|
||||
type=Path,
|
||||
default=DEFAULT_EN_SCOPE_OUTPUT,
|
||||
help=f"English scope reference path. Default: {DEFAULT_EN_SCOPE_OUTPUT}",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
@@ -112,25 +120,49 @@ def filter_public_openapi(spec: dict[str, Any]) -> dict[str, Any]:
|
||||
A filtered OpenAPI spec for the public docs site.
|
||||
"""
|
||||
output = dict(spec)
|
||||
output["tags"] = [
|
||||
tag
|
||||
for tag in spec.get("tags", [])
|
||||
if isinstance(tag, dict) and tag.get("name") in PUBLIC_OPEN_API_TAGS
|
||||
]
|
||||
|
||||
paths = {}
|
||||
for path, methods in spec.get("paths", {}).items():
|
||||
if path in PUBLIC_OPEN_API_EXCLUDED_PATHS:
|
||||
continue
|
||||
kept_methods = {
|
||||
method: operation
|
||||
for method, operation in methods.items()
|
||||
if any(tag in PUBLIC_OPEN_API_TAGS for tag in operation.get("tags", []))
|
||||
}
|
||||
kept_methods = {}
|
||||
for method, operation in methods.items():
|
||||
if (
|
||||
method not in HTTP_METHODS
|
||||
or not isinstance(operation, dict)
|
||||
or operation.get("x-astrbot-scope") not in PUBLIC_OPEN_API_SCOPES
|
||||
):
|
||||
continue
|
||||
operation = dict(operation)
|
||||
required_scope = operation["x-astrbot-scope"]
|
||||
scope_description = f"**Required scope:** `{required_scope}`"
|
||||
sensitive_scopes = operation.get("x-astrbot-sensitive-scopes", [])
|
||||
if sensitive_scopes:
|
||||
formatted_scopes = ", ".join(f"`{scope}`" for scope in sensitive_scopes)
|
||||
scope_description += (
|
||||
"\n\n**Conditional sensitive scope:** " + formatted_scopes
|
||||
)
|
||||
description = str(operation.get("description", "")).strip()
|
||||
if scope_description not in description:
|
||||
operation["description"] = "\n\n".join(
|
||||
part for part in (description, scope_description) if part
|
||||
)
|
||||
kept_methods[method] = operation
|
||||
if kept_methods:
|
||||
paths[path] = kept_methods
|
||||
output["paths"] = paths
|
||||
|
||||
used_tags = {
|
||||
tag
|
||||
for methods in paths.values()
|
||||
for operation in methods.values()
|
||||
for tag in operation.get("tags", [])
|
||||
}
|
||||
output["tags"] = [
|
||||
tag
|
||||
for tag in spec.get("tags", [])
|
||||
if isinstance(tag, dict) and tag.get("name") in used_tags
|
||||
]
|
||||
|
||||
used_refs: dict[str, set[str]] = {}
|
||||
pending = list(iter_refs(paths))
|
||||
components = output.get("components", {})
|
||||
@@ -162,10 +194,131 @@ def filter_public_openapi(spec: dict[str, Any]) -> dict[str, Any]:
|
||||
return output
|
||||
|
||||
|
||||
def render_scope_reference(spec: dict[str, Any], *, language: str) -> str:
|
||||
"""Render the complete API key scope-to-endpoint reference.
|
||||
|
||||
Args:
|
||||
spec: Filtered public OpenAPI specification.
|
||||
language: Documentation language, either ``zh`` or ``en``.
|
||||
|
||||
Returns:
|
||||
Generated Markdown document.
|
||||
|
||||
Raises:
|
||||
ValueError: If the requested language is unsupported.
|
||||
"""
|
||||
if language == "zh":
|
||||
title = "API Scope 与接口对照"
|
||||
intro = (
|
||||
"本页由 `openspec/openapi-v1.yaml` 自动生成。"
|
||||
"每个接口的基础权限来自 `x-astrbot-scope`;敏感操作还会列出需要显式授予的子权限。"
|
||||
)
|
||||
method_header = "方法"
|
||||
endpoint_header = "接口"
|
||||
sensitive_header = "条件性敏感子权限"
|
||||
includes_label = "包含权限"
|
||||
sensitive_scope_label = "敏感子权限"
|
||||
description_key = "description_zh"
|
||||
scope_separator = "、"
|
||||
elif language == "en":
|
||||
title = "API Scope–Endpoint Reference"
|
||||
intro = (
|
||||
"This page is generated from `openspec/openapi-v1.yaml`. "
|
||||
"Each endpoint's base permission comes from `x-astrbot-scope`; "
|
||||
"sensitive operations also list the sub-scope that must be granted explicitly."
|
||||
)
|
||||
method_header = "Method"
|
||||
endpoint_header = "Endpoint"
|
||||
sensitive_header = "Conditional sensitive sub-scope"
|
||||
includes_label = "Includes"
|
||||
sensitive_scope_label = "Sensitive sub-scope"
|
||||
description_key = "description"
|
||||
scope_separator = ", "
|
||||
else:
|
||||
raise ValueError(f"Unsupported documentation language: {language}")
|
||||
|
||||
operations = []
|
||||
scope_definitions = spec.get("x-astrbot-scope-definitions", {})
|
||||
for path, methods in spec.get("paths", {}).items():
|
||||
for method in HTTP_METHODS:
|
||||
operation = methods.get(method)
|
||||
if not isinstance(operation, dict):
|
||||
continue
|
||||
scope = operation.get("x-astrbot-scope")
|
||||
if scope not in PUBLIC_OPEN_API_SCOPES:
|
||||
continue
|
||||
operations.append((scope, path, method, operation))
|
||||
|
||||
lines = [
|
||||
"---",
|
||||
"outline: deep",
|
||||
"---",
|
||||
"",
|
||||
"<!-- Generated by docs/scripts/update_openapi_json.py. Do not edit directly. -->",
|
||||
"",
|
||||
f"# {title}",
|
||||
"",
|
||||
intro,
|
||||
"",
|
||||
]
|
||||
for scope in PUBLIC_OPEN_API_SCOPES:
|
||||
scoped_operations = sorted(
|
||||
(item for item in operations if item[0] == scope),
|
||||
key=lambda item: (item[1], HTTP_METHODS.index(item[2])),
|
||||
)
|
||||
if not scoped_operations:
|
||||
continue
|
||||
definition = scope_definitions.get(scope, {})
|
||||
lines.extend(
|
||||
[
|
||||
f"## `{scope}`",
|
||||
"",
|
||||
]
|
||||
)
|
||||
description = definition.get(description_key)
|
||||
if description:
|
||||
lines.extend([description, ""])
|
||||
included_scopes = definition.get("includes", [])
|
||||
if included_scopes:
|
||||
included_display = scope_separator.join(
|
||||
f"`{value}`" for value in included_scopes
|
||||
)
|
||||
lines.extend([f"- **{includes_label}:** {included_display}", ""])
|
||||
sensitive_children = [
|
||||
(name, child_definition)
|
||||
for name, child_definition in scope_definitions.items()
|
||||
if child_definition.get("parent") == scope
|
||||
and child_definition.get("sensitive") is True
|
||||
]
|
||||
for name, child_definition in sensitive_children:
|
||||
child_description = child_definition.get(description_key, "")
|
||||
lines.append(f"- **{sensitive_scope_label} `{name}`:** {child_description}")
|
||||
if sensitive_children:
|
||||
lines.append("")
|
||||
lines.extend(
|
||||
[
|
||||
f"| {method_header} | {endpoint_header} | {sensitive_header} |",
|
||||
"| --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for _, path, method, operation in scoped_operations:
|
||||
sensitive_scopes = operation.get("x-astrbot-sensitive-scopes", [])
|
||||
sensitive_display = (
|
||||
", ".join(f"`{value}`" for value in sensitive_scopes)
|
||||
if sensitive_scopes
|
||||
else "—"
|
||||
)
|
||||
lines.append(f"| `{method.upper()}` | `{path}` | {sensitive_display} |")
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
spec_path = args.spec.resolve()
|
||||
output_path = args.output.resolve()
|
||||
zh_scope_output_path = args.zh_scope_output.resolve()
|
||||
en_scope_output_path = args.en_scope_output.resolve()
|
||||
|
||||
spec = load_yaml(spec_path)
|
||||
spec = filter_public_openapi(spec)
|
||||
@@ -174,8 +327,21 @@ def main() -> int:
|
||||
json.dumps(spec, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
zh_scope_output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
zh_scope_output_path.write_text(
|
||||
render_scope_reference(spec, language="zh"),
|
||||
encoding="utf-8",
|
||||
)
|
||||
en_scope_output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
en_scope_output_path.write_text(
|
||||
render_scope_reference(spec, language="en"),
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(
|
||||
f"Updated {output_path.relative_to(REPO_ROOT)} from {spec_path.relative_to(REPO_ROOT)}"
|
||||
f"Updated {output_path.relative_to(REPO_ROOT)}, "
|
||||
f"{zh_scope_output_path.relative_to(REPO_ROOT)}, and "
|
||||
f"{en_scope_output_path.relative_to(REPO_ROOT)} from "
|
||||
f"{spec_path.relative_to(REPO_ROOT)}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
---
|
||||
outline: deep
|
||||
---
|
||||
|
||||
<!-- Generated by docs/scripts/update_openapi_json.py. Do not edit directly. -->
|
||||
|
||||
# API Scope 与接口对照
|
||||
|
||||
本页由 `openspec/openapi-v1.yaml` 自动生成。每个接口的基础权限来自 `x-astrbot-scope`;敏感操作还会列出需要显式授予的子权限。
|
||||
|
||||
## `bot`
|
||||
|
||||
管理机器人及平台配置。
|
||||
|
||||
| 方法 | 接口 | 条件性敏感子权限 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/v1/bot-types` | — |
|
||||
| `GET` | `/api/v1/bots` | — |
|
||||
| `POST` | `/api/v1/bots` | — |
|
||||
| `GET` | `/api/v1/bots/by-id` | — |
|
||||
| `PUT` | `/api/v1/bots/by-id` | — |
|
||||
| `DELETE` | `/api/v1/bots/by-id` | — |
|
||||
| `PATCH` | `/api/v1/bots/enabled` | — |
|
||||
| `GET` | `/api/v1/bots/stats` | — |
|
||||
| `POST` | `/api/v1/bots/test` | — |
|
||||
| `GET` | `/api/v1/bots/{bot_id}` | — |
|
||||
| `PUT` | `/api/v1/bots/{bot_id}` | — |
|
||||
| `DELETE` | `/api/v1/bots/{bot_id}` | — |
|
||||
| `PATCH` | `/api/v1/bots/{bot_id}/enabled` | — |
|
||||
| `POST` | `/api/v1/bots/{bot_id}/test` | — |
|
||||
|
||||
## `provider`
|
||||
|
||||
管理模型服务提供商和提供商源。
|
||||
|
||||
| 方法 | 接口 | 条件性敏感子权限 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/v1/provider-sources` | — |
|
||||
| `POST` | `/api/v1/provider-sources` | — |
|
||||
| `GET` | `/api/v1/provider-sources/by-id` | — |
|
||||
| `PUT` | `/api/v1/provider-sources/by-id` | — |
|
||||
| `DELETE` | `/api/v1/provider-sources/by-id` | — |
|
||||
| `GET` | `/api/v1/provider-sources/models` | — |
|
||||
| `GET` | `/api/v1/provider-sources/providers` | — |
|
||||
| `POST` | `/api/v1/provider-sources/providers` | — |
|
||||
| `GET` | `/api/v1/provider-sources/{source_id}` | — |
|
||||
| `PUT` | `/api/v1/provider-sources/{source_id}` | — |
|
||||
| `DELETE` | `/api/v1/provider-sources/{source_id}` | — |
|
||||
| `GET` | `/api/v1/provider-sources/{source_id}/models` | — |
|
||||
| `GET` | `/api/v1/provider-sources/{source_id}/providers` | — |
|
||||
| `POST` | `/api/v1/provider-sources/{source_id}/providers` | — |
|
||||
| `GET` | `/api/v1/providers` | — |
|
||||
| `POST` | `/api/v1/providers` | — |
|
||||
| `GET` | `/api/v1/providers/by-id` | — |
|
||||
| `PUT` | `/api/v1/providers/by-id` | — |
|
||||
| `DELETE` | `/api/v1/providers/by-id` | — |
|
||||
| `POST` | `/api/v1/providers/embedding-dimension` | — |
|
||||
| `PATCH` | `/api/v1/providers/enabled` | — |
|
||||
| `GET` | `/api/v1/providers/schema` | — |
|
||||
| `POST` | `/api/v1/providers/test` | — |
|
||||
| `GET` | `/api/v1/providers/{provider_id}` | — |
|
||||
| `PUT` | `/api/v1/providers/{provider_id}` | — |
|
||||
| `DELETE` | `/api/v1/providers/{provider_id}` | — |
|
||||
| `POST` | `/api/v1/providers/{provider_id}/embedding-dimension` | — |
|
||||
| `PATCH` | `/api/v1/providers/{provider_id}/enabled` | — |
|
||||
| `POST` | `/api/v1/providers/{provider_id}/test` | — |
|
||||
|
||||
## `persona`
|
||||
|
||||
管理人格设定和人格文件夹。
|
||||
|
||||
| 方法 | 接口 | 条件性敏感子权限 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/v1/persona-folders` | — |
|
||||
| `POST` | `/api/v1/persona-folders` | — |
|
||||
| `PUT` | `/api/v1/persona-folders/{folder_id}` | — |
|
||||
| `DELETE` | `/api/v1/persona-folders/{folder_id}` | — |
|
||||
| `GET` | `/api/v1/personas` | — |
|
||||
| `POST` | `/api/v1/personas` | — |
|
||||
| `GET` | `/api/v1/personas/by-id` | — |
|
||||
| `PUT` | `/api/v1/personas/by-id` | — |
|
||||
| `DELETE` | `/api/v1/personas/by-id` | — |
|
||||
| `POST` | `/api/v1/personas/move` | — |
|
||||
| `POST` | `/api/v1/personas/reorder` | — |
|
||||
| `GET` | `/api/v1/personas/tree` | — |
|
||||
| `GET` | `/api/v1/personas/{persona_id}` | — |
|
||||
| `PUT` | `/api/v1/personas/{persona_id}` | — |
|
||||
| `DELETE` | `/api/v1/personas/{persona_id}` | — |
|
||||
|
||||
## `im`
|
||||
|
||||
主动发送 IM 消息,并查询机器人或平台标识。
|
||||
|
||||
| 方法 | 接口 | 条件性敏感子权限 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/v1/im/bots` | — |
|
||||
| `POST` | `/api/v1/im/messages` | — |
|
||||
|
||||
## `config`
|
||||
|
||||
管理配置文件、系统配置和通用配置,但不包含修改 `admins_id` 的能力。
|
||||
|
||||
- **包含权限:** `bot`、`provider`
|
||||
|
||||
- **敏感子权限 `config:edit_admin`:** 允许具有 `config` 权限的 Key 修改 `admins_id`。该子权限必须显式授予。
|
||||
|
||||
| 方法 | 接口 | 条件性敏感子权限 |
|
||||
| --- | --- | --- |
|
||||
| `POST` | `/api/v1/bot-types/{bot_type}/registration` | — |
|
||||
| `GET` | `/api/v1/config-profiles` | — |
|
||||
| `POST` | `/api/v1/config-profiles` | `config:edit_admin` |
|
||||
| `GET` | `/api/v1/config-profiles/schema` | — |
|
||||
| `GET` | `/api/v1/config-profiles/{config_id}` | — |
|
||||
| `PUT` | `/api/v1/config-profiles/{config_id}` | `config:edit_admin` |
|
||||
| `PATCH` | `/api/v1/config-profiles/{config_id}` | — |
|
||||
| `DELETE` | `/api/v1/config-profiles/{config_id}` | — |
|
||||
| `GET` | `/api/v1/config-routes` | — |
|
||||
| `PUT` | `/api/v1/config-routes` | — |
|
||||
| `PUT` | `/api/v1/config-routes/{umo}` | — |
|
||||
| `DELETE` | `/api/v1/config-routes/{umo}` | — |
|
||||
| `GET` | `/api/v1/subagents/available-tools` | — |
|
||||
| `GET` | `/api/v1/subagents/config` | — |
|
||||
| `PUT` | `/api/v1/subagents/config` | — |
|
||||
| `GET` | `/api/v1/system-config` | — |
|
||||
| `PUT` | `/api/v1/system-config` | `config:edit_admin` |
|
||||
| `GET` | `/api/v1/system-config/runtime` | — |
|
||||
| `GET` | `/api/v1/system-config/schema` | — |
|
||||
| `GET` | `/api/v1/t2i/templates` | — |
|
||||
| `POST` | `/api/v1/t2i/templates` | — |
|
||||
| `GET` | `/api/v1/t2i/templates/active` | — |
|
||||
| `PUT` | `/api/v1/t2i/templates/active` | — |
|
||||
| `POST` | `/api/v1/t2i/templates/default/reset` | — |
|
||||
| `GET` | `/api/v1/t2i/templates/{name}` | — |
|
||||
| `PUT` | `/api/v1/t2i/templates/{name}` | — |
|
||||
| `DELETE` | `/api/v1/t2i/templates/{name}` | — |
|
||||
|
||||
## `chat`
|
||||
|
||||
调用对话能力,并管理 ChatUI 会话和项目。
|
||||
|
||||
- **敏感子权限 `chat:admin`:** 允许 `username` 使用 AstrBot 中已配置的管理员 ID。该子权限必须显式授予。
|
||||
|
||||
| 方法 | 接口 | 条件性敏感子权限 |
|
||||
| --- | --- | --- |
|
||||
| `POST` | `/api/v1/chat` | `chat:admin` |
|
||||
| `GET` | `/api/v1/chat/configs` | — |
|
||||
| `GET` | `/api/v1/chat/projects` | — |
|
||||
| `POST` | `/api/v1/chat/projects` | — |
|
||||
| `DELETE` | `/api/v1/chat/projects/sessions/{session_id}` | — |
|
||||
| `GET` | `/api/v1/chat/projects/{project_id}` | — |
|
||||
| `PATCH` | `/api/v1/chat/projects/{project_id}` | — |
|
||||
| `DELETE` | `/api/v1/chat/projects/{project_id}` | — |
|
||||
| `GET` | `/api/v1/chat/projects/{project_id}/sessions` | — |
|
||||
| `POST` | `/api/v1/chat/projects/{project_id}/sessions/{session_id}` | — |
|
||||
| `GET` | `/api/v1/chat/projects/{project_id}/workspace/file` | — |
|
||||
| `GET` | `/api/v1/chat/projects/{project_id}/workspace/file/download` | — |
|
||||
| `GET` | `/api/v1/chat/projects/{project_id}/workspace/files` | — |
|
||||
| `GET` | `/api/v1/chat/runs/{run_id}/stream` | — |
|
||||
| `GET` | `/api/v1/chat/sessions` | — |
|
||||
| `POST` | `/api/v1/chat/sessions/batch-delete` | — |
|
||||
| `GET` | `/api/v1/chat/sessions/new` | — |
|
||||
| `GET` | `/api/v1/chat/sessions/{session_id}` | — |
|
||||
| `PATCH` | `/api/v1/chat/sessions/{session_id}` | — |
|
||||
| `DELETE` | `/api/v1/chat/sessions/{session_id}` | — |
|
||||
| `PATCH` | `/api/v1/chat/sessions/{session_id}/messages/{message_id}` | — |
|
||||
| `POST` | `/api/v1/chat/sessions/{session_id}/messages/{message_id}/regenerate` | — |
|
||||
| `POST` | `/api/v1/chat/sessions/{session_id}/stop` | — |
|
||||
| `POST` | `/api/v1/chat/threads` | — |
|
||||
| `GET` | `/api/v1/chat/threads/{thread_id}` | — |
|
||||
| `DELETE` | `/api/v1/chat/threads/{thread_id}` | — |
|
||||
| `POST` | `/api/v1/chat/threads/{thread_id}/messages` | — |
|
||||
| `GET` | `/api/v1/chat/ws` | `chat:admin` |
|
||||
|
||||
## `data`
|
||||
|
||||
管理对话记录和平台会话数据。
|
||||
|
||||
| 方法 | 接口 | 条件性敏感子权限 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/v1/conversations` | — |
|
||||
| `POST` | `/api/v1/conversations/batch-delete` | — |
|
||||
| `POST` | `/api/v1/conversations/export` | — |
|
||||
| `GET` | `/api/v1/conversations/{conversation_id}` | — |
|
||||
| `PATCH` | `/api/v1/conversations/{conversation_id}` | — |
|
||||
| `DELETE` | `/api/v1/conversations/{conversation_id}` | — |
|
||||
| `PUT` | `/api/v1/conversations/{conversation_id}/messages` | — |
|
||||
| `GET` | `/api/v1/session-groups` | — |
|
||||
| `POST` | `/api/v1/session-groups` | — |
|
||||
| `PUT` | `/api/v1/session-groups/{group_id}` | — |
|
||||
| `DELETE` | `/api/v1/session-groups/{group_id}` | — |
|
||||
| `GET` | `/api/v1/sessions` | — |
|
||||
| `GET` | `/api/v1/sessions/active-umos` | — |
|
||||
| `PATCH` | `/api/v1/sessions/provider` | — |
|
||||
| `GET` | `/api/v1/sessions/rules` | — |
|
||||
| `POST` | `/api/v1/sessions/rules` | — |
|
||||
| `POST` | `/api/v1/sessions/rules/delete` | — |
|
||||
| `PATCH` | `/api/v1/sessions/service` | — |
|
||||
|
||||
## `file`
|
||||
|
||||
上传和下载对话附件。
|
||||
|
||||
| 方法 | 接口 | 条件性敏感子权限 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/v1/file` | — |
|
||||
| `POST` | `/api/v1/file` | — |
|
||||
| `POST` | `/api/v1/files` | — |
|
||||
| `GET` | `/api/v1/files/content` | — |
|
||||
| `GET` | `/api/v1/files/{attachment_id}` | — |
|
||||
| `DELETE` | `/api/v1/files/{attachment_id}` | — |
|
||||
| `GET` | `/api/v1/files/{attachment_id}/content` | — |
|
||||
|
||||
## `plugin`
|
||||
|
||||
管理插件、插件配置、插件源和插件市场数据。
|
||||
|
||||
| 方法 | 接口 | 条件性敏感子权限 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/v1/plugin-sources` | — |
|
||||
| `POST` | `/api/v1/plugin-sources` | — |
|
||||
| `PUT` | `/api/v1/plugin-sources` | — |
|
||||
| `DELETE` | `/api/v1/plugin-sources/by-id` | — |
|
||||
| `DELETE` | `/api/v1/plugin-sources/{source_id}` | — |
|
||||
| `GET` | `/api/v1/plugins` | — |
|
||||
| `GET` | `/api/v1/plugins/by-id` | — |
|
||||
| `DELETE` | `/api/v1/plugins/by-id` | — |
|
||||
| `GET` | `/api/v1/plugins/changelog` | — |
|
||||
| `GET` | `/api/v1/plugins/config` | — |
|
||||
| `PUT` | `/api/v1/plugins/config` | — |
|
||||
| `GET` | `/api/v1/plugins/config-files` | — |
|
||||
| `POST` | `/api/v1/plugins/config-files` | — |
|
||||
| `DELETE` | `/api/v1/plugins/config-files` | — |
|
||||
| `GET` | `/api/v1/plugins/config/schema` | — |
|
||||
| `PATCH` | `/api/v1/plugins/enabled` | — |
|
||||
| `GET` | `/api/v1/plugins/extensions/{plugin_path}` | — |
|
||||
| `POST` | `/api/v1/plugins/extensions/{plugin_path}` | — |
|
||||
| `PUT` | `/api/v1/plugins/extensions/{plugin_path}` | — |
|
||||
| `PATCH` | `/api/v1/plugins/extensions/{plugin_path}` | — |
|
||||
| `DELETE` | `/api/v1/plugins/extensions/{plugin_path}` | — |
|
||||
| `GET` | `/api/v1/plugins/failed` | — |
|
||||
| `DELETE` | `/api/v1/plugins/failed/{plugin_id}` | — |
|
||||
| `POST` | `/api/v1/plugins/failed/{plugin_id}/reload` | — |
|
||||
| `POST` | `/api/v1/plugins/install/git` | — |
|
||||
| `POST` | `/api/v1/plugins/install/github` | — |
|
||||
| `POST` | `/api/v1/plugins/install/upload` | — |
|
||||
| `POST` | `/api/v1/plugins/install/url` | — |
|
||||
| `GET` | `/api/v1/plugins/market` | — |
|
||||
| `GET` | `/api/v1/plugins/market/categories` | — |
|
||||
| `GET` | `/api/v1/plugins/page` | — |
|
||||
| `GET` | `/api/v1/plugins/page-bridge-sdk.js` | — |
|
||||
| `GET` | `/api/v1/plugins/page/assets` | — |
|
||||
| `GET` | `/api/v1/plugins/pages` | — |
|
||||
| `GET` | `/api/v1/plugins/readme` | — |
|
||||
| `POST` | `/api/v1/plugins/reload` | — |
|
||||
| `POST` | `/api/v1/plugins/update` | — |
|
||||
| `POST` | `/api/v1/plugins/validate/repo` | — |
|
||||
| `POST` | `/api/v1/plugins/version-support/check` | — |
|
||||
| `GET` | `/api/v1/plugins/{plugin_id}` | — |
|
||||
| `DELETE` | `/api/v1/plugins/{plugin_id}` | — |
|
||||
| `GET` | `/api/v1/plugins/{plugin_id}/changelog` | — |
|
||||
| `GET` | `/api/v1/plugins/{plugin_id}/config` | — |
|
||||
| `PUT` | `/api/v1/plugins/{plugin_id}/config` | — |
|
||||
| `DELETE` | `/api/v1/plugins/{plugin_id}/config-files` | — |
|
||||
| `GET` | `/api/v1/plugins/{plugin_id}/config-files/{config_key}` | — |
|
||||
| `POST` | `/api/v1/plugins/{plugin_id}/config-files/{config_key}` | — |
|
||||
| `GET` | `/api/v1/plugins/{plugin_id}/config/schema` | — |
|
||||
| `PATCH` | `/api/v1/plugins/{plugin_id}/enabled` | — |
|
||||
| `PUT` | `/api/v1/plugins/{plugin_id}/log-level` | — |
|
||||
| `GET` | `/api/v1/plugins/{plugin_id}/pages` | — |
|
||||
| `GET` | `/api/v1/plugins/{plugin_id}/pages/{page_name}` | — |
|
||||
| `GET` | `/api/v1/plugins/{plugin_id}/pages/{page_name}/assets/{asset_path}` | — |
|
||||
| `GET` | `/api/v1/plugins/{plugin_id}/readme` | — |
|
||||
| `POST` | `/api/v1/plugins/{plugin_id}/reload` | — |
|
||||
| `POST` | `/api/v1/plugins/{plugin_id}/source` | — |
|
||||
| `POST` | `/api/v1/plugins/{plugin_id}/update` | — |
|
||||
|
||||
## `mcp`
|
||||
|
||||
管理 MCP 服务器配置和服务端同步。
|
||||
|
||||
| 方法 | 接口 | 条件性敏感子权限 |
|
||||
| --- | --- | --- |
|
||||
| `POST` | `/api/v1/mcp/providers/modelscope/sync` | — |
|
||||
| `GET` | `/api/v1/mcp/servers` | — |
|
||||
| `POST` | `/api/v1/mcp/servers` | — |
|
||||
| `PUT` | `/api/v1/mcp/servers/by-name` | — |
|
||||
| `DELETE` | `/api/v1/mcp/servers/by-name` | — |
|
||||
| `PATCH` | `/api/v1/mcp/servers/enabled` | — |
|
||||
| `POST` | `/api/v1/mcp/servers/test` | — |
|
||||
| `PUT` | `/api/v1/mcp/servers/{server_name}` | — |
|
||||
| `DELETE` | `/api/v1/mcp/servers/{server_name}` | — |
|
||||
| `PATCH` | `/api/v1/mcp/servers/{server_name}/enabled` | — |
|
||||
| `POST` | `/api/v1/mcp/servers/{server_name}/test` | — |
|
||||
|
||||
## `skill`
|
||||
|
||||
管理 Skills、Skill 压缩包、Skill 文件和 Shipyard Neo Skill 流程。
|
||||
|
||||
| 方法 | 接口 | 条件性敏感子权限 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/v1/skills` | — |
|
||||
| `POST` | `/api/v1/skills` | — |
|
||||
| `GET` | `/api/v1/skills/archive` | — |
|
||||
| `POST` | `/api/v1/skills/batch` | — |
|
||||
| `PATCH` | `/api/v1/skills/by-name` | — |
|
||||
| `DELETE` | `/api/v1/skills/by-name` | — |
|
||||
| `GET` | `/api/v1/skills/file` | — |
|
||||
| `PUT` | `/api/v1/skills/file` | — |
|
||||
| `GET` | `/api/v1/skills/files` | — |
|
||||
| `GET` | `/api/v1/skills/neo/candidates` | — |
|
||||
| `POST` | `/api/v1/skills/neo/candidates/delete` | — |
|
||||
| `POST` | `/api/v1/skills/neo/evaluate` | — |
|
||||
| `GET` | `/api/v1/skills/neo/payload` | — |
|
||||
| `POST` | `/api/v1/skills/neo/promote` | — |
|
||||
| `GET` | `/api/v1/skills/neo/releases` | — |
|
||||
| `POST` | `/api/v1/skills/neo/releases/delete` | — |
|
||||
| `POST` | `/api/v1/skills/neo/rollback` | — |
|
||||
| `POST` | `/api/v1/skills/neo/sync` | — |
|
||||
| `PATCH` | `/api/v1/skills/{skill_name}` | — |
|
||||
| `DELETE` | `/api/v1/skills/{skill_name}` | — |
|
||||
| `GET` | `/api/v1/skills/{skill_name}/archive` | — |
|
||||
| `GET` | `/api/v1/skills/{skill_name}/files` | — |
|
||||
| `GET` | `/api/v1/skills/{skill_name}/files/{file_path}` | — |
|
||||
| `PUT` | `/api/v1/skills/{skill_name}/files/{file_path}` | — |
|
||||
+8
-17
@@ -30,26 +30,17 @@ X-API-Key: abk_xxx
|
||||
|
||||
## Scope 权限说明
|
||||
|
||||
创建 API Key 时可配置 `scopes`。每个 scope 控制可访问的接口范围:
|
||||
|
||||
| Scope | 作用 | 可访问接口 |
|
||||
| --- | --- | --- |
|
||||
| `bot` | 管理机器人/平台配置 | `GET /api/v1/bot-types`、`GET/POST /api/v1/bots`、`PATCH /api/v1/bots/enabled` |
|
||||
| `provider` | 管理模型提供商和提供商源 | `GET/POST /api/v1/providers`、`GET/PUT/DELETE /api/v1/provider-sources/by-id` |
|
||||
| `persona` | 管理人格和人格文件夹 | `GET/POST /api/v1/personas`、`GET/POST /api/v1/persona-folders` |
|
||||
| `im` | 主动发 IM 消息、查询 bot/platform 列表 | `POST /api/v1/im/message`、`GET /api/v1/im/bots` |
|
||||
| `config` | 管理配置文件、系统配置和通用配置。该 scope 同时包含 `bot` 和 `provider` 访问权限。 | `GET /api/v1/configs`、`GET/PUT /api/v1/system-config`、`GET/POST /api/v1/config-profiles` |
|
||||
| `chat` | 调用对话能力、查询对话会话 | `POST /api/v1/chat`、`GET /api/v1/chat/sessions` |
|
||||
| `file` | 上传和下载对话附件 | `POST /api/v1/file`、`GET /api/v1/file`、`POST /api/v1/files` |
|
||||
| `plugin` | 管理插件、插件配置、插件源和插件市场 | `GET /api/v1/plugins`、`GET/PUT /api/v1/plugins/config`、`POST /api/v1/plugins/install/url` |
|
||||
| `mcp` | 管理 MCP 服务器配置和服务端同步 | `GET/POST /api/v1/mcp/servers`、`PATCH /api/v1/mcp/servers/{server_name}/enabled`、`POST /api/v1/mcp/providers/modelscope/sync` |
|
||||
| `skill` | 管理 Skills、Skill 压缩包、Skill 文件和 Shipyard Neo Skill 流程 | `GET/POST /api/v1/skills`、`PUT /api/v1/skills/{skill_name}/files/{file_path}`、`POST /api/v1/skills/neo/sync` |
|
||||
创建 API Key 时可配置 `scopes`。每个 scope 的作用、继承关系及完整接口清单见 [API Scope 与接口对照](./openapi-scopes.md)。
|
||||
|
||||
如果 API Key 未包含目标接口所需 scope,请求会返回 `403 Insufficient API key scope`。
|
||||
|
||||
`config` 是较大的管理 scope。创建 API Key 时如果包含 `config`,AstrBot 会同时授予该 Key `config`、`bot` 和 `provider` 访问权限。WebUI 的勾选逻辑也会体现这个依赖关系:选中 `config` 会同时选中 `bot` 和 `provider`;取消选中 `bot` 或 `provider` 时,会同步取消 `config`。
|
||||
- `config` 在 WebUI 中默认不选中,并自动包含 `bot` 和 `provider`。
|
||||
- `config:edit_admin` 和 `chat:admin` 必须显式授予,不会随父 scope 隐式获得。
|
||||
- WebUI 中取消 `bot` 或 `provider` 时,会同步取消依赖它们的 `config`。
|
||||
|
||||
当前开发者 API Key 仅开放以上 10 个 scope。`tool`、`skills`、`kb`、`data`、`system` 暂不支持作为开发者 API Key scope。`/api/v1/skills/*` 接口使用单数 `skill` scope,不使用复数 `skills`。公开 OpenAPI 文档只包含这些开发者 API Key scope 覆盖的接口。
|
||||
当前开发者 API Key 开放 11 个顶级 scope 和 2 个敏感子权限。`tool`、`skills`、`kb`、`system` 暂不支持作为开发者 API Key scope。`/api/v1/skills/*` 接口使用单数 `skill` scope,不使用复数 `skills`。
|
||||
|
||||
交互式文档中的每个接口也会显示英文标签 `Required scope: ...`;涉及管理员能力时,还会显示 `Conditional sensitive scope: ...`。
|
||||
|
||||
## 常用接口
|
||||
|
||||
@@ -131,7 +122,7 @@ X-API-Key: abk_xxx
|
||||
|
||||
`POST /api/v1/chat` 额外需要 `username`,可选 `session_id`(不传会自动创建 UUID)。
|
||||
|
||||
`username` 是调用方声明的 WebChat 用户标识,会作为本次消息的 sender 和会话 owner 进入消息管道,并参与基于 sender ID 的指令权限判断。因此,带有 `chat` scope 的 API Key 应仅发放给可信后端服务。如果需要面向终端用户开放,请在自己的服务端将外部用户映射到受控的 `username`,不要允许客户端直接传入管理员 ID 或其他保留 sender ID。
|
||||
`username` 是调用方声明的 WebChat 用户标识,会作为本次消息的 sender 和会话 owner。只有 `chat` 的 Key 如果使用任一已配置管理员 ID,会被拒绝,并且消息管道也不会为其授予管理员角色。敏感子权限 `chat:admin` 会显式允许使用已配置的管理员 ID,但不会把任意用户名变成管理员。集成方仍应将外部用户映射为稳定、由应用控制的用户名。
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
+64
-11
@@ -8,8 +8,54 @@ info:
|
||||
JSON objects because their schemas are provided at runtime by template
|
||||
endpoints.
|
||||
Developer API keys currently support these scopes only: bot, provider,
|
||||
persona, im, config, chat, data, file, plugin, mcp, skill. The config scope also
|
||||
grants bot and provider access.
|
||||
persona, im, config, chat, data, file, plugin, mcp, skill, plus the sensitive
|
||||
config:edit_admin and chat:admin sub-scopes. The config scope also grants bot
|
||||
and provider access, but no parent scope grants a sensitive sub-scope.
|
||||
x-astrbot-scope-definitions:
|
||||
bot:
|
||||
description: Manage bot and platform configurations.
|
||||
description_zh: 管理机器人及平台配置。
|
||||
provider:
|
||||
description: Manage model providers and provider sources.
|
||||
description_zh: 管理模型服务提供商和提供商源。
|
||||
persona:
|
||||
description: Manage personas and persona folders.
|
||||
description_zh: 管理人格设定和人格文件夹。
|
||||
im:
|
||||
description: Send proactive IM messages and query bot or platform identifiers.
|
||||
description_zh: 主动发送 IM 消息,并查询机器人或平台标识。
|
||||
config:
|
||||
description: Manage configuration profiles, system configuration, and shared configuration, excluding changes to `admins_id`.
|
||||
description_zh: 管理配置文件、系统配置和通用配置,但不包含修改 `admins_id` 的能力。
|
||||
includes: [bot, provider]
|
||||
"config:edit_admin":
|
||||
description: Allow a `config`-scoped key to change `admins_id`. This sub-scope must be granted explicitly.
|
||||
description_zh: 允许具有 `config` 权限的 Key 修改 `admins_id`。该子权限必须显式授予。
|
||||
parent: config
|
||||
sensitive: true
|
||||
chat:
|
||||
description: Use chat capabilities and manage ChatUI sessions and projects.
|
||||
description_zh: 调用对话能力,并管理 ChatUI 会话和项目。
|
||||
"chat:admin":
|
||||
description: Allow `username` to use a configured AstrBot administrator ID. This sub-scope must be granted explicitly.
|
||||
description_zh: 允许 `username` 使用 AstrBot 中已配置的管理员 ID。该子权限必须显式授予。
|
||||
parent: chat
|
||||
sensitive: true
|
||||
data:
|
||||
description: Manage conversations and platform-session data.
|
||||
description_zh: 管理对话记录和平台会话数据。
|
||||
file:
|
||||
description: Upload and download chat attachments.
|
||||
description_zh: 上传和下载对话附件。
|
||||
plugin:
|
||||
description: Manage plugins, plugin configuration, plugin sources, and marketplace data.
|
||||
description_zh: 管理插件、插件配置、插件源和插件市场数据。
|
||||
mcp:
|
||||
description: Manage MCP server configuration and provider synchronization.
|
||||
description_zh: 管理 MCP 服务器配置和服务端同步。
|
||||
skill:
|
||||
description: Manage Skills, archives, files, and Shipyard Neo Skill workflows.
|
||||
description_zh: 管理 Skills、Skill 压缩包、Skill 文件和 Shipyard Neo Skill 流程。
|
||||
servers:
|
||||
- url: http://localhost:6185
|
||||
description: Local AstrBot server
|
||||
@@ -211,6 +257,7 @@ paths:
|
||||
summary: Replace the system configuration
|
||||
operationId: updateSystemConfig
|
||||
x-astrbot-scope: config
|
||||
x-astrbot-sensitive-scopes: ["config:edit_admin"]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -255,6 +302,7 @@ paths:
|
||||
summary: Create a configuration profile
|
||||
operationId: createConfigProfile
|
||||
x-astrbot-scope: config
|
||||
x-astrbot-sensitive-scopes: ["config:edit_admin"]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -281,6 +329,7 @@ paths:
|
||||
summary: Replace a configuration profile
|
||||
operationId: updateConfigProfileContent
|
||||
x-astrbot-scope: config
|
||||
x-astrbot-sensitive-scopes: ["config:edit_admin"]
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/ConfigId"
|
||||
requestBody:
|
||||
@@ -386,7 +435,7 @@ paths:
|
||||
tags: [Bots]
|
||||
summary: Run a bot type registration flow action
|
||||
operationId: registerBotType
|
||||
x-astrbot-scope: bot
|
||||
x-astrbot-scope: config
|
||||
parameters:
|
||||
- name: bot_type
|
||||
in: path
|
||||
@@ -1104,6 +1153,7 @@ paths:
|
||||
summary: Send a webchat message
|
||||
operationId: sendChatMessage
|
||||
x-astrbot-scope: chat
|
||||
x-astrbot-sensitive-scopes: ["chat:admin"]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -1121,6 +1171,7 @@ paths:
|
||||
operationId: openChatWebSocket
|
||||
x-websocket: true
|
||||
x-astrbot-scope: chat
|
||||
x-astrbot-sensitive-scopes: ["chat:admin"]
|
||||
parameters:
|
||||
- name: api_key
|
||||
in: query
|
||||
@@ -1334,7 +1385,7 @@ paths:
|
||||
tags: [Chat]
|
||||
summary: List chat-selectable configuration profiles
|
||||
operationId: listChatConfigs
|
||||
x-astrbot-scope: config
|
||||
x-astrbot-scope: chat
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/Ok"
|
||||
@@ -1694,7 +1745,7 @@ paths:
|
||||
tags: [Files]
|
||||
summary: Get a tokenized public file
|
||||
operationId: getTokenFile
|
||||
x-astrbot-scope: file
|
||||
security: []
|
||||
parameters:
|
||||
- name: file_token
|
||||
in: path
|
||||
@@ -2770,7 +2821,7 @@ paths:
|
||||
tags: [Commands]
|
||||
summary: List plugin commands
|
||||
operationId: listCommands
|
||||
x-astrbot-scope: plugin
|
||||
x-astrbot-scope: tool
|
||||
parameters:
|
||||
- name: config_id
|
||||
in: query
|
||||
@@ -2785,7 +2836,7 @@ paths:
|
||||
tags: [Commands]
|
||||
summary: Update command enabled state, alias, or permission group
|
||||
operationId: updateCommand
|
||||
x-astrbot-scope: plugin
|
||||
x-astrbot-scope: tool
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/CommandId"
|
||||
requestBody:
|
||||
@@ -2803,7 +2854,7 @@ paths:
|
||||
tags: [Commands]
|
||||
summary: List command conflicts
|
||||
operationId: listCommandConflicts
|
||||
x-astrbot-scope: plugin
|
||||
x-astrbot-scope: tool
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/Ok"
|
||||
@@ -5309,8 +5360,8 @@ components:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum: [bot, provider, persona, im, config, chat, data, file, plugin, mcp, skill]
|
||||
example: [bot, provider, persona, im, config, chat, data, file, plugin, mcp, skill]
|
||||
enum: [bot, provider, persona, im, config, "config:edit_admin", chat, "chat:admin", data, file, plugin, mcp, skill]
|
||||
example: [bot, provider, persona, im, config, "config:edit_admin", chat, "chat:admin", data, file, plugin, mcp, skill]
|
||||
expires_at:
|
||||
type: string
|
||||
format: date-time
|
||||
@@ -5466,7 +5517,7 @@ components:
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
description: Caller-declared WebChat sender/session owner. This value is used as the message sender identity and may participate in sender-ID-based command permission checks. Treat chat-scoped API keys as trusted backend credentials and map or validate usernames before accepting end-user input.
|
||||
description: Caller-declared WebChat sender/session owner. Configured AstrBot administrator IDs require the chat:admin API key sub-scope.
|
||||
session_id:
|
||||
type: string
|
||||
conversation_id:
|
||||
@@ -5598,8 +5649,10 @@ components:
|
||||
workspace_type:
|
||||
type: string
|
||||
enum: [session, project, custom]
|
||||
description: Workspace mode. API key callers may use only session or project; project is the default.
|
||||
workspace_path:
|
||||
type: string
|
||||
description: Dashboard-only custom workspace path. API key callers cannot set this field.
|
||||
additionalProperties: false
|
||||
|
||||
MessagePart:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import copy
|
||||
import io
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock
|
||||
@@ -220,6 +221,7 @@ async def test_open_chat_send_auto_session_id_and_username(
|
||||
{
|
||||
"session_id": post_data.get("session_id"),
|
||||
"creator": username,
|
||||
"allow_admin_role": post_data.get("_api_key_allow_admin_role"),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -249,6 +251,7 @@ async def test_open_chat_send_auto_session_id_and_username(
|
||||
assert isinstance(created_session_id, str)
|
||||
uuid.UUID(created_session_id)
|
||||
assert send_data["data"]["creator"] == "alice_auto_session"
|
||||
assert send_data["data"]["allow_admin_role"] is False
|
||||
created_session = await core_lifecycle_td.db.get_platform_session_by_id(
|
||||
created_session_id
|
||||
)
|
||||
@@ -287,6 +290,71 @@ async def test_open_chat_send_auto_session_id_and_username(
|
||||
assert missing_username_data["status"] == "error"
|
||||
assert missing_username_data["message"] == "Missing key: username"
|
||||
|
||||
admin_username = str(core_lifecycle_td.astrbot_config["admins_id"][0])
|
||||
reserved_admin_res = await test_client.post(
|
||||
"/api/v1/chat",
|
||||
json={
|
||||
"message": "hello",
|
||||
"username": admin_username,
|
||||
"enable_streaming": False,
|
||||
},
|
||||
headers={"X-API-Key": raw_key},
|
||||
)
|
||||
reserved_admin_data = await reserved_admin_res.get_json()
|
||||
assert reserved_admin_data["status"] == "error"
|
||||
assert reserved_admin_data["message"] == (
|
||||
"username is reserved for an AstrBot administrator"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_admin_subscope_allows_configured_admin_username(
|
||||
app: FastAPIAppAdapter,
|
||||
authenticated_header: dict,
|
||||
core_lifecycle_td: AstrBotCoreLifecycle,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
"""A chat-admin key may use an administrator ID from configuration."""
|
||||
test_client = app.test_client()
|
||||
raw_key, _ = await _create_api_key(
|
||||
app,
|
||||
authenticated_header,
|
||||
scopes=["chat", "chat:admin"],
|
||||
name_prefix="chat-admin-key",
|
||||
)
|
||||
|
||||
async def fake_chat_response(_chat_service, username: str, post_data: dict):
|
||||
return ok(
|
||||
{
|
||||
"session_id": post_data.get("session_id"),
|
||||
"creator": username,
|
||||
"allow_admin_role": post_data.get("_api_key_allow_admin_role"),
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
open_api_routes,
|
||||
"_build_streaming_chat_response",
|
||||
fake_chat_response,
|
||||
)
|
||||
admin_username = str(core_lifecycle_td.astrbot_config["admins_id"][0])
|
||||
|
||||
response = await test_client.post(
|
||||
"/api/v1/chat",
|
||||
json={
|
||||
"message": "hello",
|
||||
"username": admin_username,
|
||||
"enable_streaming": False,
|
||||
},
|
||||
headers={"X-API-Key": raw_key},
|
||||
)
|
||||
data = await response.get_json()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert data["status"] == "ok"
|
||||
assert data["data"]["creator"] == admin_username
|
||||
assert data["data"]["allow_admin_role"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_chat_sessions_pagination(
|
||||
@@ -793,6 +861,115 @@ async def test_open_api_key_scope_normalization(
|
||||
assert extra_scope_data["status"] == "ok"
|
||||
assert set(extra_scope_data["data"]["scopes"]) == {"mcp", "skill"}
|
||||
|
||||
edit_admin_res = await test_client.post(
|
||||
"/api/apikey/create",
|
||||
json={
|
||||
"name": "config-edit-admin-key",
|
||||
"scopes": ["config", "config:edit_admin"],
|
||||
},
|
||||
headers=authenticated_header,
|
||||
)
|
||||
edit_admin_data = await edit_admin_res.get_json()
|
||||
assert edit_admin_res.status_code == 200
|
||||
assert edit_admin_data["status"] == "ok"
|
||||
assert set(edit_admin_data["data"]["scopes"]) == {
|
||||
"config",
|
||||
"config:edit_admin",
|
||||
"bot",
|
||||
"provider",
|
||||
}
|
||||
|
||||
orphan_subscope_res = await test_client.post(
|
||||
"/api/apikey/create",
|
||||
json={"name": "orphan-edit-admin-key", "scopes": ["config:edit_admin"]},
|
||||
headers=authenticated_header,
|
||||
)
|
||||
orphan_subscope_data = await orphan_subscope_res.get_json()
|
||||
assert orphan_subscope_data["status"] == "error"
|
||||
assert orphan_subscope_data["message"] == (
|
||||
"config:edit_admin requires the config scope"
|
||||
)
|
||||
|
||||
chat_admin_res = await test_client.post(
|
||||
"/api/apikey/create",
|
||||
json={"name": "chat-admin-key", "scopes": ["chat", "chat:admin"]},
|
||||
headers=authenticated_header,
|
||||
)
|
||||
chat_admin_data = await chat_admin_res.get_json()
|
||||
assert chat_admin_res.status_code == 200
|
||||
assert chat_admin_data["status"] == "ok"
|
||||
assert set(chat_admin_data["data"]["scopes"]) == {"chat", "chat:admin"}
|
||||
|
||||
orphan_chat_admin_res = await test_client.post(
|
||||
"/api/apikey/create",
|
||||
json={"name": "orphan-chat-admin-key", "scopes": ["chat:admin"]},
|
||||
headers=authenticated_header,
|
||||
)
|
||||
orphan_chat_admin_data = await orphan_chat_admin_res.get_json()
|
||||
assert orphan_chat_admin_data["status"] == "error"
|
||||
assert orphan_chat_admin_data["message"] == (
|
||||
"chat:admin requires the chat scope"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_edit_admin_subscope_controls_admin_id_changes(
|
||||
app: FastAPIAppAdapter,
|
||||
authenticated_header: dict,
|
||||
core_lifecycle_td: AstrBotCoreLifecycle,
|
||||
):
|
||||
"""Only the config edit-admin subscope may change administrator IDs."""
|
||||
test_client = app.test_client()
|
||||
config_key, _ = await _create_api_key(
|
||||
app,
|
||||
authenticated_header,
|
||||
scopes=["config"],
|
||||
name_prefix="config-without-edit-admin",
|
||||
)
|
||||
edit_admin_key, _ = await _create_api_key(
|
||||
app,
|
||||
authenticated_header,
|
||||
scopes=["config", "config:edit_admin"],
|
||||
name_prefix="config-with-edit-admin",
|
||||
)
|
||||
config_res = await test_client.get(
|
||||
"/api/v1/system-config",
|
||||
headers={"X-API-Key": config_key},
|
||||
)
|
||||
config_data = await config_res.get_json()
|
||||
original_config = copy.deepcopy(config_data["data"]["config"])
|
||||
changed_config = copy.deepcopy(original_config)
|
||||
changed_config["admins_id"] = [*original_config["admins_id"], "api-admin-test"]
|
||||
|
||||
denied_res = await test_client.put(
|
||||
"/api/v1/system-config",
|
||||
json=changed_config,
|
||||
headers={"X-API-Key": config_key},
|
||||
)
|
||||
denied_data = await denied_res.get_json()
|
||||
assert denied_res.status_code == 403
|
||||
assert denied_data["message"] == (
|
||||
"config:edit_admin scope is required to change admins_id"
|
||||
)
|
||||
|
||||
try:
|
||||
allowed_res = await test_client.put(
|
||||
"/api/v1/system-config",
|
||||
json=changed_config,
|
||||
headers={"X-API-Key": edit_admin_key},
|
||||
)
|
||||
allowed_data = await allowed_res.get_json()
|
||||
assert allowed_res.status_code == 200
|
||||
assert allowed_data["status"] == "ok"
|
||||
assert "api-admin-test" in core_lifecycle_td.astrbot_config["admins_id"]
|
||||
finally:
|
||||
restore_res = await test_client.put(
|
||||
"/api/v1/system-config",
|
||||
json=original_config,
|
||||
headers=authenticated_header,
|
||||
)
|
||||
assert restore_res.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_scope_is_available_for_developer_api_key(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import copy
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
@@ -1076,6 +1077,42 @@ async def test_v1_openapi_is_served_by_fastapi(asgi_client: httpx.AsyncClient):
|
||||
assert "/api/v1/skills" in spec["paths"]
|
||||
assert "/api/v1/file" in spec["paths"]
|
||||
|
||||
bot_list = spec["paths"]["/api/v1/bots"]["get"]
|
||||
assert bot_list["x-astrbot-scope"] == "bot"
|
||||
assert "**Required scope:** `bot`" in bot_list["description"]
|
||||
|
||||
conversation_list = spec["paths"]["/api/v1/conversations"]["get"]
|
||||
assert conversation_list["x-astrbot-scope"] == "data"
|
||||
assert "**Required scope:** `data`" in conversation_list["description"]
|
||||
|
||||
chat_send = spec["paths"]["/api/v1/chat"]["post"]
|
||||
assert chat_send["x-astrbot-scope"] == "chat"
|
||||
assert chat_send["x-astrbot-sensitive-scopes"] == ["chat:admin"]
|
||||
assert "**Required scope:** `chat`" in chat_send["description"]
|
||||
assert (
|
||||
"**Conditional sensitive scope:** `chat:admin`" in chat_send["description"]
|
||||
)
|
||||
|
||||
public_spec_path = (
|
||||
Path(__file__).resolve().parents[1] / "docs" / "public" / "openapi.json"
|
||||
)
|
||||
public_spec = json.loads(public_spec_path.read_text(encoding="utf-8"))
|
||||
runtime_scope_map = {
|
||||
(method, path): operation["x-astrbot-scope"]
|
||||
for path, methods in spec["paths"].items()
|
||||
for method, operation in methods.items()
|
||||
if isinstance(operation, dict) and "x-astrbot-scope" in operation
|
||||
}
|
||||
public_scope_map = {
|
||||
(method, path): operation["x-astrbot-scope"]
|
||||
for path, methods in public_spec["paths"].items()
|
||||
for method, operation in methods.items()
|
||||
if isinstance(operation, dict)
|
||||
and "x-astrbot-scope" in operation
|
||||
and not operation.get("x-websocket")
|
||||
}
|
||||
assert public_scope_map == runtime_scope_map
|
||||
|
||||
|
||||
def test_static_openapi_v1_paths_include_api_version():
|
||||
spec_path = Path(__file__).resolve().parents[1] / "openspec" / "openapi-v1.yaml"
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from astrbot.core.db.sqlite import SQLiteDatabase
|
||||
from astrbot.core.workspace import resolve_project_workspace_root
|
||||
from astrbot.dashboard.services.chatui_project_service import (
|
||||
ChatUIProjectService,
|
||||
ChatUIProjectServiceError,
|
||||
@@ -143,6 +146,228 @@ def test_custom_workspace_accepts_absolute_path_outside_workspaces(
|
||||
assert workspace_path == str(outside_workspace)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_key_project_rejects_custom_workspace(tmp_path):
|
||||
"""API key projects must not accept caller-selected workspace roots."""
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
db = SimpleNamespace(create_chatui_project=AsyncMock())
|
||||
service = ChatUIProjectService(db)
|
||||
|
||||
with pytest.raises(
|
||||
ChatUIProjectServiceError,
|
||||
match="API key projects cannot use custom workspaces",
|
||||
):
|
||||
await service.create_project(
|
||||
"api_key:key-id",
|
||||
{
|
||||
"title": "Unsafe project",
|
||||
"workspace_type": "custom",
|
||||
"workspace_path": str(workspace),
|
||||
},
|
||||
)
|
||||
|
||||
db.create_chatui_project.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_key_project_rejects_workspace_path_without_custom_type():
|
||||
"""API key projects must reject workspace paths for every workspace type."""
|
||||
db = SimpleNamespace(create_chatui_project=AsyncMock())
|
||||
service = ChatUIProjectService(db)
|
||||
|
||||
with pytest.raises(
|
||||
ChatUIProjectServiceError,
|
||||
match="API key projects cannot use custom workspaces",
|
||||
):
|
||||
await service.create_project(
|
||||
"api_key:key-id",
|
||||
{
|
||||
"title": "Unsafe project",
|
||||
"workspace_type": "project",
|
||||
"workspace_path": "/etc",
|
||||
},
|
||||
)
|
||||
|
||||
db.create_chatui_project.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_key_project_defaults_to_managed_project_workspace():
|
||||
"""API key projects should default to a managed per-project workspace."""
|
||||
now = datetime.now(timezone.utc)
|
||||
project = SimpleNamespace(
|
||||
project_id="project-1",
|
||||
title="Managed project",
|
||||
emoji="📁",
|
||||
description=None,
|
||||
creator="api_key:key-id",
|
||||
workspace_type="project",
|
||||
workspace_path=None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db = SimpleNamespace(create_chatui_project=AsyncMock(return_value=project))
|
||||
service = ChatUIProjectService(db)
|
||||
|
||||
result = await service.create_project(
|
||||
"api_key:key-id",
|
||||
{"title": "Managed project"},
|
||||
)
|
||||
|
||||
assert result["workspace_type"] == "project"
|
||||
db.create_chatui_project.assert_awaited_once_with(
|
||||
creator="api_key:key-id",
|
||||
title="Managed project",
|
||||
emoji="📁",
|
||||
description=None,
|
||||
workspace_type="project",
|
||||
workspace_path=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_key_project_update_rejects_custom_workspace():
|
||||
"""API key project updates must not accept a custom workspace path."""
|
||||
project = SimpleNamespace(
|
||||
project_id="project-1",
|
||||
creator="api_key:key-id",
|
||||
workspace_type="project",
|
||||
workspace_path=None,
|
||||
)
|
||||
db = SimpleNamespace(
|
||||
get_chatui_project_by_id=AsyncMock(return_value=project),
|
||||
update_chatui_project=AsyncMock(),
|
||||
)
|
||||
service = ChatUIProjectService(db)
|
||||
|
||||
with pytest.raises(
|
||||
ChatUIProjectServiceError,
|
||||
match="API key projects cannot use custom workspaces",
|
||||
):
|
||||
await service.update_project(
|
||||
"api_key:key-id",
|
||||
{
|
||||
"project_id": "project-1",
|
||||
"workspace_type": "custom",
|
||||
"workspace_path": "/etc",
|
||||
},
|
||||
)
|
||||
|
||||
db.update_chatui_project.assert_not_awaited()
|
||||
|
||||
|
||||
def test_dashboard_project_resolves_absolute_custom_workspace(tmp_path, monkeypatch):
|
||||
"""Dashboard projects should preserve administrator-selected workspaces."""
|
||||
workspaces_root = tmp_path / "workspaces"
|
||||
custom_root = tmp_path / "custom"
|
||||
workspaces_root.mkdir()
|
||||
custom_root.mkdir()
|
||||
monkeypatch.setattr(
|
||||
"astrbot.core.workspace.get_astrbot_workspaces_path",
|
||||
lambda: str(workspaces_root),
|
||||
)
|
||||
project = SimpleNamespace(
|
||||
project_id="project-1",
|
||||
creator="alice",
|
||||
workspace_type="custom",
|
||||
workspace_path=str(custom_root),
|
||||
)
|
||||
|
||||
resolved = resolve_project_workspace_root(
|
||||
project,
|
||||
fallback_umo="webchat:FriendMessage:webchat!alice!default",
|
||||
)
|
||||
|
||||
assert resolved == custom_root
|
||||
|
||||
|
||||
def test_api_key_project_runtime_rejects_root_outside_workspaces(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
"""Runtime resolution must keep every API key project under workspaces."""
|
||||
workspaces_root = tmp_path / "workspaces"
|
||||
external_root = tmp_path / "external"
|
||||
workspaces_root.mkdir()
|
||||
external_root.mkdir()
|
||||
monkeypatch.setattr(
|
||||
"astrbot.core.workspace.get_astrbot_workspaces_path",
|
||||
lambda: str(workspaces_root),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"astrbot.core.workspace.project_workspace_root",
|
||||
lambda _project_id: external_root,
|
||||
)
|
||||
project = SimpleNamespace(
|
||||
project_id="project-1",
|
||||
creator="api_key:key-id",
|
||||
workspace_type="project",
|
||||
workspace_path=None,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="must stay within AstrBot workspaces"):
|
||||
resolve_project_workspace_root(
|
||||
project,
|
||||
fallback_umo="webchat:FriendMessage:webchat!api-key!default",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_key_custom_workspace_cannot_expose_external_files(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
"""Legacy API key projects must resolve to managed project workspaces."""
|
||||
workspaces_root = tmp_path / "workspaces"
|
||||
external_root = tmp_path / "external"
|
||||
workspaces_root.mkdir()
|
||||
external_root.mkdir()
|
||||
(external_root / "secret.txt").write_text("secret", encoding="utf-8")
|
||||
monkeypatch.setattr(
|
||||
"astrbot.core.workspace.get_astrbot_workspaces_path",
|
||||
lambda: str(workspaces_root),
|
||||
)
|
||||
project = SimpleNamespace(
|
||||
project_id="project-1",
|
||||
creator="api_key:key-id",
|
||||
workspace_type="custom",
|
||||
workspace_path=str(external_root),
|
||||
)
|
||||
db = SimpleNamespace(get_chatui_project_by_id=AsyncMock(return_value=project))
|
||||
service = ChatUIProjectService(db)
|
||||
|
||||
result = await service.list_workspace_files(
|
||||
"api_key:key-id",
|
||||
"project-1",
|
||||
)
|
||||
|
||||
assert result == {"path": "", "entries": []}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_database_migrates_api_key_custom_workspaces(tmp_path):
|
||||
"""Database startup should downgrade existing API key custom workspaces."""
|
||||
db = SQLiteDatabase(str(tmp_path / "workspace-migration.db"))
|
||||
try:
|
||||
await db.initialize()
|
||||
project = await db.create_chatui_project(
|
||||
creator="api_key:key-id",
|
||||
title="Legacy API project",
|
||||
workspace_type="custom",
|
||||
workspace_path="/external/workspace",
|
||||
)
|
||||
|
||||
await db.initialize()
|
||||
migrated = await db.get_chatui_project_by_id(project.project_id)
|
||||
|
||||
assert migrated is not None
|
||||
assert migrated.workspace_type == "project"
|
||||
assert migrated.workspace_path is None
|
||||
finally:
|
||||
await db.engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace_service(tmp_path, monkeypatch):
|
||||
"""Create a project service backed by a temporary workspace.
|
||||
|
||||
@@ -4,6 +4,7 @@ import pytest
|
||||
|
||||
from astrbot.dashboard.services.open_api_service import (
|
||||
OpenApiService,
|
||||
OpenApiServiceError,
|
||||
OpenApiWebSocketChatBridge,
|
||||
)
|
||||
|
||||
@@ -12,6 +13,9 @@ def _service() -> OpenApiService:
|
||||
core_lifecycle = SimpleNamespace(
|
||||
platform_manager=SimpleNamespace(platform_insts=[]),
|
||||
platform_message_history_manager=None,
|
||||
astrbot_config_mgr=SimpleNamespace(
|
||||
confs={"default": {"admins_id": ["admin-user"]}}
|
||||
),
|
||||
)
|
||||
return OpenApiService(SimpleNamespace(), core_lifecycle)
|
||||
|
||||
@@ -45,7 +49,7 @@ async def test_run_chat_websocket_closes_when_api_key_is_invalid(monkeypatch):
|
||||
closed: list[tuple[int, str]] = []
|
||||
|
||||
async def authenticate_api_key(_raw_key):
|
||||
return False, "Invalid API key"
|
||||
return None, "Invalid API key"
|
||||
|
||||
monkeypatch.setattr(service, "authenticate_api_key", authenticate_api_key)
|
||||
|
||||
@@ -88,10 +92,15 @@ async def test_run_chat_websocket_handles_control_messages(monkeypatch):
|
||||
handled: list[dict] = []
|
||||
|
||||
async def authenticate_api_key(_raw_key):
|
||||
return True, None
|
||||
return ["chat", "chat:admin"], None
|
||||
|
||||
async def handle_chat_ws_send(**kwargs):
|
||||
handled.append(kwargs["post_data"])
|
||||
handled.append(
|
||||
{
|
||||
"post_data": kwargs["post_data"],
|
||||
"allow_admin_username": kwargs["allow_admin_username"],
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(service, "authenticate_api_key", authenticate_api_key)
|
||||
monkeypatch.setattr(service, "handle_chat_ws_send", handle_chat_ws_send)
|
||||
@@ -130,4 +139,49 @@ async def test_run_chat_websocket_handles_control_messages(monkeypatch):
|
||||
"data": "Unsupported message type: unknown",
|
||||
},
|
||||
]
|
||||
assert handled == [{"t": "send", "message": "hello"}]
|
||||
assert handled == [
|
||||
{
|
||||
"post_data": {"t": "send", "message": "hello"},
|
||||
"allow_admin_username": True,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_chat_send_rejects_configured_admin_username():
|
||||
"""The shared HTTP/WS boundary must reject administrator impersonation."""
|
||||
service = _service()
|
||||
|
||||
with pytest.raises(
|
||||
OpenApiServiceError,
|
||||
match="username is reserved for an AstrBot administrator",
|
||||
):
|
||||
await service.prepare_chat_send(
|
||||
{"username": "admin-user", "message": "hello"},
|
||||
[],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_chat_send_allows_admin_username_with_subscope(monkeypatch):
|
||||
"""The explicit chat-admin subscope should preserve legitimate admin calls."""
|
||||
service = _service()
|
||||
|
||||
async def ensure_chat_session(_username, _session_id):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(service, "ensure_chat_session", ensure_chat_session)
|
||||
|
||||
username, session_id, config_id = await service.prepare_chat_send(
|
||||
{
|
||||
"username": "admin-user",
|
||||
"session_id": "admin-session",
|
||||
"message": "hello",
|
||||
},
|
||||
[],
|
||||
allow_admin_username=True,
|
||||
)
|
||||
|
||||
assert username == "admin-user"
|
||||
assert session_id == "admin-session"
|
||||
assert config_id is None
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from docs.scripts.update_openapi_json import (
|
||||
PUBLIC_OPEN_API_SCOPES,
|
||||
filter_public_openapi,
|
||||
load_yaml,
|
||||
render_scope_reference,
|
||||
)
|
||||
|
||||
|
||||
SPEC_PATH = Path(__file__).resolve().parents[2] / "openspec" / "openapi-v1.yaml"
|
||||
PUBLIC_SPEC_PATH = Path(__file__).resolve().parents[2] / "docs" / "public" / "openapi.json"
|
||||
ZH_REFERENCE_PATH = (
|
||||
Path(__file__).resolve().parents[2] / "docs" / "zh" / "dev" / "openapi-scopes.md"
|
||||
)
|
||||
EN_REFERENCE_PATH = (
|
||||
Path(__file__).resolve().parents[2] / "docs" / "en" / "dev" / "openapi-scopes.md"
|
||||
)
|
||||
|
||||
|
||||
def test_public_openapi_is_filtered_by_supported_scope() -> None:
|
||||
spec = filter_public_openapi(load_yaml(SPEC_PATH))
|
||||
|
||||
assert "/api/v1/conversations" in spec["paths"]
|
||||
assert spec["paths"]["/api/v1/conversations"]["get"][
|
||||
"x-astrbot-scope"
|
||||
] == "data"
|
||||
assert "/api/v1/commands" not in spec["paths"]
|
||||
assert "/api/v1/files/tokens/{file_token}" not in spec["paths"]
|
||||
assert "/api/v1/stats/versions" not in spec["paths"]
|
||||
|
||||
for methods in spec["paths"].values():
|
||||
for operation in methods.values():
|
||||
assert operation["x-astrbot-scope"] in PUBLIC_OPEN_API_SCOPES
|
||||
assert "**Required scope:**" in operation["description"]
|
||||
|
||||
|
||||
def test_public_openapi_documents_sensitive_subscopes() -> None:
|
||||
spec = filter_public_openapi(load_yaml(SPEC_PATH))
|
||||
|
||||
chat_send = spec["paths"]["/api/v1/chat"]["post"]
|
||||
assert chat_send["x-astrbot-sensitive-scopes"] == ["chat:admin"]
|
||||
assert chat_send["description"] == (
|
||||
"**Required scope:** `chat`\n\n"
|
||||
"**Conditional sensitive scope:** `chat:admin`"
|
||||
)
|
||||
|
||||
system_config_update = spec["paths"]["/api/v1/system-config"]["put"]
|
||||
assert system_config_update["x-astrbot-sensitive-scopes"] == [
|
||||
"config:edit_admin"
|
||||
]
|
||||
assert "`config:edit_admin`" in system_config_update["description"]
|
||||
|
||||
|
||||
def test_scope_reference_lists_every_supported_scope() -> None:
|
||||
spec = filter_public_openapi(load_yaml(SPEC_PATH))
|
||||
|
||||
zh_reference = render_scope_reference(spec, language="zh")
|
||||
en_reference = render_scope_reference(spec, language="en")
|
||||
|
||||
for scope in PUBLIC_OPEN_API_SCOPES:
|
||||
assert f"## `{scope}`" in zh_reference
|
||||
assert f"## `{scope}`" in en_reference
|
||||
definition = spec["x-astrbot-scope-definitions"][scope]
|
||||
assert definition["description_zh"] in zh_reference
|
||||
assert definition["description"] in en_reference
|
||||
assert "| `GET` | `/api/v1/conversations` | — |" in zh_reference
|
||||
assert "| `POST` | `/api/v1/chat` | `chat:admin` |" in en_reference
|
||||
assert "**包含权限:** `bot`、`provider`" in zh_reference
|
||||
assert "**Sensitive sub-scope `chat:admin`:**" in en_reference
|
||||
|
||||
|
||||
def test_generated_openapi_scope_artifacts_are_current() -> None:
|
||||
spec = filter_public_openapi(load_yaml(SPEC_PATH))
|
||||
|
||||
assert json.loads(PUBLIC_SPEC_PATH.read_text(encoding="utf-8")) == spec
|
||||
assert ZH_REFERENCE_PATH.read_text(encoding="utf-8") == render_scope_reference(
|
||||
spec,
|
||||
language="zh",
|
||||
)
|
||||
assert EN_REFERENCE_PATH.read_text(encoding="utf-8") == render_scope_reference(
|
||||
spec,
|
||||
language="en",
|
||||
)
|
||||
@@ -0,0 +1,72 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from astrbot.core.pipeline.waking_check.stage import (
|
||||
WakingCheckStage,
|
||||
star_handlers_registry,
|
||||
)
|
||||
from astrbot.core.star.session_plugin_manager import SessionPluginManager
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("api_key_allow_admin_role", "expected_role"),
|
||||
[
|
||||
(False, "member"),
|
||||
(True, "admin"),
|
||||
(None, "admin"),
|
||||
],
|
||||
)
|
||||
async def test_waking_check_enforces_api_key_admin_authorization(
|
||||
api_key_allow_admin_role,
|
||||
expected_role,
|
||||
monkeypatch,
|
||||
):
|
||||
"""Only explicitly authorized API requests may assume a configured admin ID."""
|
||||
stage = WakingCheckStage()
|
||||
stage.ctx = SimpleNamespace(
|
||||
astrbot_config={
|
||||
"admins_id": ["admin-user"],
|
||||
"wake_prefix": [],
|
||||
"plugin_set": ["*"],
|
||||
}
|
||||
)
|
||||
stage.unique_session = False
|
||||
stage.ignore_bot_self_message = False
|
||||
stage.friend_message_needs_wake_prefix = False
|
||||
stage.ignore_at_all = False
|
||||
stage.disable_builtin_commands = False
|
||||
stage.no_permission_reply = True
|
||||
|
||||
event = MagicMock()
|
||||
event.message_str = "hello"
|
||||
event.role = "member"
|
||||
event.get_sender_id.return_value = "admin-user"
|
||||
event.get_messages.return_value = []
|
||||
event.is_private_chat.return_value = True
|
||||
event.get_platform_name.return_value = "webchat"
|
||||
event.get_extra.side_effect = lambda key=None, default=None: (
|
||||
api_key_allow_admin_role
|
||||
if key == "_api_key_allow_admin_role"
|
||||
else default
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
star_handlers_registry,
|
||||
"get_handlers_by_event_type",
|
||||
lambda *_args, **_kwargs: [],
|
||||
)
|
||||
|
||||
async def return_handlers(_event, handlers):
|
||||
return handlers
|
||||
|
||||
monkeypatch.setattr(
|
||||
SessionPluginManager,
|
||||
"filter_handlers_by_session",
|
||||
return_handlers,
|
||||
)
|
||||
|
||||
await stage.process(event)
|
||||
|
||||
assert event.role == expected_role
|
||||
Reference in New Issue
Block a user