fix: harden API key scope boundaries (#9503)

This commit is contained in:
Soulter
2026-08-02 13:19:38 +08:00
committed by GitHub
parent 49095d3ba3
commit cfe6424742
49 changed files with 3550 additions and 508 deletions
+11
View File
@@ -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
+5 -1
View File
@@ -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
+27 -6
View File
@@ -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:
+22 -2
View File
@@ -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",
+2 -3
View File
@@ -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:
+2 -3
View File
@@ -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]:
+2 -3
View File
@@ -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:
+48 -12
View File
@@ -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:
+2 -3
View File
@@ -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]:
+2 -3
View File
@@ -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):
+13 -16
View File
@@ -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),
+2 -3
View File
@@ -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]:
+2 -3
View File
@@ -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]:
+2 -3
View File
@@ -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:
+2 -3
View File
@@ -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:
+60 -26
View File
@@ -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
+2 -3
View File
@@ -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:
+2 -3
View File
@@ -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]:
+2 -3
View File
@@ -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:
+2 -3
View File
@@ -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:
+2 -3
View File
@@ -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]:
+13 -6
View File
@@ -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
+14 -2
View File
@@ -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))
+10 -1
View File
@@ -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("\\", "/"))
+55 -1
View File
@@ -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)
+52 -9
View File
@@ -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": "删除",
+172 -35
View File
@@ -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 {
+8
View File
@@ -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 ScopeEndpoint Reference",
link: "/openapi-scopes",
},
{
text: "AstrBot Configuration File",
link: "/astrbot-config",
+1 -1
View File
@@ -149,7 +149,7 @@
}
.VPSidebarItem.is-link > .item > .link {
margin: 2px 0;
margin: 2px -10px;
border-radius: 8px;
padding: 0 10px;
transition: none;
+324
View File
@@ -0,0 +1,324 @@
---
outline: deep
---
<!-- Generated by docs/scripts/update_openapi_json.py. Do not edit directly. -->
# API ScopeEndpoint 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
View File
@@ -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 ScopeEndpoint 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
View File
File diff suppressed because it is too large Load Diff
+198 -32
View File
@@ -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 ScopeEndpoint 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
+324
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+177
View File
@@ -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(
+37
View File
@@ -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"
+225
View File
@@ -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.
+58 -4
View File
@@ -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
+85
View File
@@ -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