mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-01 02:46:02 +00:00
fix(gateway): require admin for global skills management endpoints (#3855)
* fix(gateway): require admin for global skills management endpoints The skills router exposed its management endpoints with no authorization check (only Depends(get_config)), while the MCP router guards the equivalent global extensions_config mutations with require_admin_user (added in the #3425 security hardening). Skills storage is global/shared across users (no per-user path), lives in the same extensions_config.json as MCP servers, and custom skill SKILL.md content is injected into every user's agent system prompt. Any authenticated non-admin user could mutate or read global skills that affect all tenants: - POST /api/skills/install: install from an arbitrary thread_id, writing into the global custom skills tree - PUT /api/skills/custom/{skill_name}: rewrite a global skill, injecting instructions into all users' agent prompts (cross-tenant prompt injection) - DELETE /api/skills/custom/{skill_name} and rollback: tamper with global skills - GET /api/skills/custom, GET .../{skill_name}, GET .../history: read raw global custom skill bodies/history - PUT /api/skills/{skill_name} (enable toggle): writes the shared extensions_config.json and refreshes the system prompt for every tenant, so a non-admin could enable/disable any skill globally. There is no per-user skill state, so this is a global mutation, not a preference. Add require_admin_user to every endpoint above, mirroring the MCP router. The shared read path used internally by update/rollback was extracted into a non-auth helper (_read_custom_skill_response) so internal reuse does not double-check auth. Only the read-only GET /api/skills and GET /api/skills/{skill_name} stay open to normal users: they return just name/description/enabled and back the user-facing settings UI. Tests: - New tests/test_skills_router_authz.py: a non-admin user gets 403 on every guarded endpoint (including the enable toggle); basic listing stays open; admins can still toggle. - Update tests/test_skills_custom_router.py to authenticate as admin. - pytest tests/ -k skill -> 350 passed, 1 skipped; ruff clean. Signed-off-by: DengY11 <151997860+DengY11@users.noreply.github.com> * fix(frontend): gate skill enable/install UI behind admin + handle 403 Follow-up to the backend change that made the global skills mutations admin-only. Without a matching UI change, a non-admin user would hit a silent 403 when toggling a skill in Settings -> Skills or clicking "Install skill" on a .skill artifact. - core/skills/api.ts: add SkillRequestError (status + isAdminRequired), throw it from loadSkills/enableSkill on non-ok responses and from installSkill on 403 (other install errors keep the soft-failure contract). - core/skills/hooks.ts: useSkills no longer retries on SkillRequestError. - skill-settings-page.tsx: show an "admin required" message on 403, and disable the enable toggle for non-admins (mirrors the MCP tools page). - artifact-file-detail.tsx / artifact-file-list.tsx: only render the "Install skill" action for admins, and surface an admin-required toast if a 403 still occurs. - i18n: add settings.skills.adminRequired / installAdminRequired (en + zh). Auth/no-auth and static-website modes synthesize an admin user, so these gates do not affect single-user/local deployments. Verified locally: pnpm check (eslint + tsc) passes with no new errors, pnpm build succeeds, and the dev server renders / and /login (200) with no compile/runtime errors. Signed-off-by: DengY11 <151997860+DengY11@users.noreply.github.com> * style(frontend): format skills hook for prettier Keep the admin-guard skills hook aligned with Prettier output so the frontend format check passes in CI. --------- Signed-off-by: DengY11 <151997860+DengY11@users.noreply.github.com>
This commit is contained in:
parent
ddb097a72f
commit
331c949b95
@ -2,10 +2,10 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.gateway.deps import get_config
|
from app.gateway.deps import get_config, require_admin_user
|
||||||
from app.gateway.path_utils import resolve_thread_virtual_path
|
from app.gateway.path_utils import resolve_thread_virtual_path
|
||||||
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
|
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
|
||||||
from deerflow.config.app_config import AppConfig
|
from deerflow.config.app_config import AppConfig
|
||||||
@ -20,6 +20,8 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["skills"])
|
router = APIRouter(prefix="/api", tags=["skills"])
|
||||||
|
|
||||||
|
_ADMIN_REQUIRED_DETAIL = "Admin privileges required to manage skills."
|
||||||
|
|
||||||
|
|
||||||
class SkillResponse(BaseModel):
|
class SkillResponse(BaseModel):
|
||||||
"""Response model for skill information."""
|
"""Response model for skill information."""
|
||||||
@ -106,9 +108,10 @@ async def list_skills(config: AppConfig = Depends(get_config)) -> SkillsListResp
|
|||||||
summary="Install Skill",
|
summary="Install Skill",
|
||||||
description="Install a skill from a .skill file (ZIP archive) located in the thread's user-data directory.",
|
description="Install a skill from a .skill file (ZIP archive) located in the thread's user-data directory.",
|
||||||
)
|
)
|
||||||
async def install_skill(request: SkillInstallRequest, config: AppConfig = Depends(get_config)) -> SkillInstallResponse:
|
async def install_skill(request: Request, body: SkillInstallRequest, config: AppConfig = Depends(get_config)) -> SkillInstallResponse:
|
||||||
|
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
|
||||||
try:
|
try:
|
||||||
skill_file_path = resolve_thread_virtual_path(request.thread_id, request.path)
|
skill_file_path = resolve_thread_virtual_path(body.thread_id, body.path)
|
||||||
result = await get_or_new_skill_storage(app_config=config).ainstall_skill_from_archive(skill_file_path)
|
result = await get_or_new_skill_storage(app_config=config).ainstall_skill_from_archive(skill_file_path)
|
||||||
await refresh_skills_system_prompt_cache_async()
|
await refresh_skills_system_prompt_cache_async()
|
||||||
return SkillInstallResponse(**result)
|
return SkillInstallResponse(**result)
|
||||||
@ -126,7 +129,8 @@ async def install_skill(request: SkillInstallRequest, config: AppConfig = Depend
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/skills/custom", response_model=SkillsListResponse, summary="List Custom Skills")
|
@router.get("/skills/custom", response_model=SkillsListResponse, summary="List Custom Skills")
|
||||||
async def list_custom_skills(config: AppConfig = Depends(get_config)) -> SkillsListResponse:
|
async def list_custom_skills(request: Request, config: AppConfig = Depends(get_config)) -> SkillsListResponse:
|
||||||
|
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
|
||||||
try:
|
try:
|
||||||
skills = [skill for skill in get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) if skill.category == SkillCategory.CUSTOM]
|
skills = [skill for skill in get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) if skill.category == SkillCategory.CUSTOM]
|
||||||
return SkillsListResponse(skills=[_skill_to_response(skill) for skill in skills])
|
return SkillsListResponse(skills=[_skill_to_response(skill) for skill in skills])
|
||||||
@ -136,7 +140,12 @@ async def list_custom_skills(config: AppConfig = Depends(get_config)) -> SkillsL
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/skills/custom/{skill_name}", response_model=CustomSkillContentResponse, summary="Get Custom Skill Content")
|
@router.get("/skills/custom/{skill_name}", response_model=CustomSkillContentResponse, summary="Get Custom Skill Content")
|
||||||
async def get_custom_skill(skill_name: str, config: AppConfig = Depends(get_config)) -> CustomSkillContentResponse:
|
async def get_custom_skill(skill_name: str, request: Request, config: AppConfig = Depends(get_config)) -> CustomSkillContentResponse:
|
||||||
|
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
|
||||||
|
return await _read_custom_skill_response(skill_name, config)
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_custom_skill_response(skill_name: str, config: AppConfig) -> CustomSkillContentResponse:
|
||||||
try:
|
try:
|
||||||
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
||||||
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
|
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
|
||||||
@ -152,17 +161,18 @@ async def get_custom_skill(skill_name: str, config: AppConfig = Depends(get_conf
|
|||||||
|
|
||||||
|
|
||||||
@router.put("/skills/custom/{skill_name}", response_model=CustomSkillContentResponse, summary="Edit Custom Skill")
|
@router.put("/skills/custom/{skill_name}", response_model=CustomSkillContentResponse, summary="Edit Custom Skill")
|
||||||
async def update_custom_skill(skill_name: str, request: CustomSkillUpdateRequest, config: AppConfig = Depends(get_config)) -> CustomSkillContentResponse:
|
async def update_custom_skill(skill_name: str, body: CustomSkillUpdateRequest, request: Request, config: AppConfig = Depends(get_config)) -> CustomSkillContentResponse:
|
||||||
|
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
|
||||||
try:
|
try:
|
||||||
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
||||||
storage = get_or_new_skill_storage(app_config=config)
|
storage = get_or_new_skill_storage(app_config=config)
|
||||||
storage.ensure_custom_skill_is_editable(skill_name)
|
storage.ensure_custom_skill_is_editable(skill_name)
|
||||||
storage.validate_skill_markdown_content(skill_name, request.content)
|
storage.validate_skill_markdown_content(skill_name, body.content)
|
||||||
scan = await scan_skill_content(request.content, executable=False, location=f"{skill_name}/{SKILL_MD_FILE}", app_config=config)
|
scan = await scan_skill_content(body.content, executable=False, location=f"{skill_name}/{SKILL_MD_FILE}", app_config=config)
|
||||||
if scan.decision == "block":
|
if scan.decision == "block":
|
||||||
raise HTTPException(status_code=400, detail=f"Security scan blocked the edit: {scan.reason}")
|
raise HTTPException(status_code=400, detail=f"Security scan blocked the edit: {scan.reason}")
|
||||||
prev_content = storage.read_custom_skill(skill_name)
|
prev_content = storage.read_custom_skill(skill_name)
|
||||||
storage.write_custom_skill(skill_name, SKILL_MD_FILE, request.content)
|
storage.write_custom_skill(skill_name, SKILL_MD_FILE, body.content)
|
||||||
storage.append_history(
|
storage.append_history(
|
||||||
skill_name,
|
skill_name,
|
||||||
{
|
{
|
||||||
@ -171,12 +181,12 @@ async def update_custom_skill(skill_name: str, request: CustomSkillUpdateRequest
|
|||||||
"thread_id": None,
|
"thread_id": None,
|
||||||
"file_path": SKILL_MD_FILE,
|
"file_path": SKILL_MD_FILE,
|
||||||
"prev_content": prev_content,
|
"prev_content": prev_content,
|
||||||
"new_content": request.content,
|
"new_content": body.content,
|
||||||
"scanner": {"decision": scan.decision, "reason": scan.reason},
|
"scanner": {"decision": scan.decision, "reason": scan.reason},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
await refresh_skills_system_prompt_cache_async()
|
await refresh_skills_system_prompt_cache_async()
|
||||||
return await get_custom_skill(skill_name, config)
|
return await _read_custom_skill_response(skill_name, config)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
@ -189,7 +199,8 @@ async def update_custom_skill(skill_name: str, request: CustomSkillUpdateRequest
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/skills/custom/{skill_name}", summary="Delete Custom Skill")
|
@router.delete("/skills/custom/{skill_name}", summary="Delete Custom Skill")
|
||||||
async def delete_custom_skill(skill_name: str, config: AppConfig = Depends(get_config)) -> dict[str, bool]:
|
async def delete_custom_skill(skill_name: str, request: Request, config: AppConfig = Depends(get_config)) -> dict[str, bool]:
|
||||||
|
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
|
||||||
try:
|
try:
|
||||||
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
||||||
storage = get_or_new_skill_storage(app_config=config)
|
storage = get_or_new_skill_storage(app_config=config)
|
||||||
@ -217,7 +228,8 @@ async def delete_custom_skill(skill_name: str, config: AppConfig = Depends(get_c
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/skills/custom/{skill_name}/history", response_model=CustomSkillHistoryResponse, summary="Get Custom Skill History")
|
@router.get("/skills/custom/{skill_name}/history", response_model=CustomSkillHistoryResponse, summary="Get Custom Skill History")
|
||||||
async def get_custom_skill_history(skill_name: str, config: AppConfig = Depends(get_config)) -> CustomSkillHistoryResponse:
|
async def get_custom_skill_history(skill_name: str, request: Request, config: AppConfig = Depends(get_config)) -> CustomSkillHistoryResponse:
|
||||||
|
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
|
||||||
try:
|
try:
|
||||||
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
||||||
storage = get_or_new_skill_storage(app_config=config)
|
storage = get_or_new_skill_storage(app_config=config)
|
||||||
@ -232,7 +244,8 @@ async def get_custom_skill_history(skill_name: str, config: AppConfig = Depends(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/skills/custom/{skill_name}/rollback", response_model=CustomSkillContentResponse, summary="Rollback Custom Skill")
|
@router.post("/skills/custom/{skill_name}/rollback", response_model=CustomSkillContentResponse, summary="Rollback Custom Skill")
|
||||||
async def rollback_custom_skill(skill_name: str, request: SkillRollbackRequest, config: AppConfig = Depends(get_config)) -> CustomSkillContentResponse:
|
async def rollback_custom_skill(skill_name: str, body: SkillRollbackRequest, request: Request, config: AppConfig = Depends(get_config)) -> CustomSkillContentResponse:
|
||||||
|
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
|
||||||
try:
|
try:
|
||||||
storage = get_or_new_skill_storage(app_config=config)
|
storage = get_or_new_skill_storage(app_config=config)
|
||||||
if not storage.custom_skill_exists(skill_name) and not storage.get_skill_history_file(skill_name).exists():
|
if not storage.custom_skill_exists(skill_name) and not storage.get_skill_history_file(skill_name).exists():
|
||||||
@ -240,7 +253,7 @@ async def rollback_custom_skill(skill_name: str, request: SkillRollbackRequest,
|
|||||||
history = storage.read_history(skill_name)
|
history = storage.read_history(skill_name)
|
||||||
if not history:
|
if not history:
|
||||||
raise HTTPException(status_code=400, detail=f"Custom skill '{skill_name}' has no history")
|
raise HTTPException(status_code=400, detail=f"Custom skill '{skill_name}' has no history")
|
||||||
record = history[request.history_index]
|
record = history[body.history_index]
|
||||||
target_content = record.get("prev_content")
|
target_content = record.get("prev_content")
|
||||||
if target_content is None:
|
if target_content is None:
|
||||||
raise HTTPException(status_code=400, detail="Selected history entry has no previous content to roll back to")
|
raise HTTPException(status_code=400, detail="Selected history entry has no previous content to roll back to")
|
||||||
@ -264,7 +277,7 @@ async def rollback_custom_skill(skill_name: str, request: SkillRollbackRequest,
|
|||||||
storage.write_custom_skill(skill_name, SKILL_MD_FILE, target_content)
|
storage.write_custom_skill(skill_name, SKILL_MD_FILE, target_content)
|
||||||
storage.append_history(skill_name, history_entry)
|
storage.append_history(skill_name, history_entry)
|
||||||
await refresh_skills_system_prompt_cache_async()
|
await refresh_skills_system_prompt_cache_async()
|
||||||
return await get_custom_skill(skill_name, config)
|
return await _read_custom_skill_response(skill_name, config)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except IndexError:
|
except IndexError:
|
||||||
@ -307,7 +320,12 @@ async def get_skill(skill_name: str, config: AppConfig = Depends(get_config)) ->
|
|||||||
summary="Update Skill",
|
summary="Update Skill",
|
||||||
description="Update a skill's enabled status by modifying the extensions_config.json file.",
|
description="Update a skill's enabled status by modifying the extensions_config.json file.",
|
||||||
)
|
)
|
||||||
async def update_skill(skill_name: str, request: SkillUpdateRequest, config: AppConfig = Depends(get_config)) -> SkillResponse:
|
async def update_skill(skill_name: str, body: SkillUpdateRequest, request: Request, config: AppConfig = Depends(get_config)) -> SkillResponse:
|
||||||
|
# Enabling/disabling a skill writes the shared extensions_config.json and
|
||||||
|
# refreshes the system prompt for every tenant, so it is a global mutation
|
||||||
|
# (there is no per-user skill state). Guard it as admin-only like the other
|
||||||
|
# global config writes, matching the MCP router.
|
||||||
|
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
|
||||||
try:
|
try:
|
||||||
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
||||||
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
|
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
|
||||||
@ -322,7 +340,7 @@ async def update_skill(skill_name: str, request: SkillUpdateRequest, config: App
|
|||||||
logger.info(f"No existing extensions config found. Creating new config at: {config_path}")
|
logger.info(f"No existing extensions config found. Creating new config at: {config_path}")
|
||||||
|
|
||||||
extensions_config = get_extensions_config()
|
extensions_config = get_extensions_config()
|
||||||
extensions_config.skills[skill_name] = SkillStateConfig(enabled=request.enabled)
|
extensions_config.skills[skill_name] = SkillStateConfig(enabled=body.enabled)
|
||||||
|
|
||||||
config_data = {
|
config_data = {
|
||||||
"mcpServers": {name: server.model_dump() for name, server in extensions_config.mcp_servers.items()},
|
"mcpServers": {name: server.model_dump() for name, server in extensions_config.mcp_servers.items()},
|
||||||
@ -342,7 +360,7 @@ async def update_skill(skill_name: str, request: SkillUpdateRequest, config: App
|
|||||||
if updated_skill is None:
|
if updated_skill is None:
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to reload skill '{skill_name}' after update")
|
raise HTTPException(status_code=500, detail=f"Failed to reload skill '{skill_name}' after update")
|
||||||
|
|
||||||
logger.info(f"Skill '{skill_name}' enabled status updated to {request.enabled}")
|
logger.info(f"Skill '{skill_name}' enabled status updated to {body.enabled}")
|
||||||
return _skill_to_response(updated_skill)
|
return _skill_to_response(updated_skill)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|||||||
@ -10,6 +10,7 @@ from _router_auth_helpers import make_authed_test_app
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.gateway.auth.models import User
|
||||||
from app.gateway.deps import get_config
|
from app.gateway.deps import get_config
|
||||||
from app.gateway.routers import skills as skills_router
|
from app.gateway.routers import skills as skills_router
|
||||||
from app.gateway.routers import uploads as uploads_router
|
from app.gateway.routers import uploads as uploads_router
|
||||||
@ -17,6 +18,12 @@ from deerflow.skills.storage import get_or_new_skill_storage
|
|||||||
from deerflow.skills.types import Skill
|
from deerflow.skills.types import Skill
|
||||||
|
|
||||||
|
|
||||||
|
def _make_admin_user() -> User:
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
return User(email="admin-test@example.com", password_hash="x", system_role="admin", id=uuid4())
|
||||||
|
|
||||||
|
|
||||||
def _skill_content(name: str, description: str = "Demo skill") -> str:
|
def _skill_content(name: str, description: str = "Demo skill") -> str:
|
||||||
return f"---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n"
|
return f"---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n"
|
||||||
|
|
||||||
@ -42,7 +49,7 @@ def _make_skill(name: str, *, enabled: bool) -> Skill:
|
|||||||
|
|
||||||
|
|
||||||
def _make_test_app(config) -> FastAPI:
|
def _make_test_app(config) -> FastAPI:
|
||||||
app = FastAPI()
|
app = make_authed_test_app(user_factory=_make_admin_user)
|
||||||
app.state.config = config # kept for any startup-style reads
|
app.state.config = config # kept for any startup-style reads
|
||||||
app.dependency_overrides[get_config] = lambda: config
|
app.dependency_overrides[get_config] = lambda: config
|
||||||
app.include_router(skills_router.router)
|
app.include_router(skills_router.router)
|
||||||
@ -141,7 +148,7 @@ def test_uploaded_skill_archive_installs_sandbox_readable_tree(monkeypatch, tmp_
|
|||||||
monkeypatch.setattr("deerflow.skills.installer.scan_skill_content", _scan)
|
monkeypatch.setattr("deerflow.skills.installer.scan_skill_content", _scan)
|
||||||
monkeypatch.setattr(skills_router, "refresh_skills_system_prompt_cache_async", _refresh)
|
monkeypatch.setattr(skills_router, "refresh_skills_system_prompt_cache_async", _refresh)
|
||||||
|
|
||||||
app = make_authed_test_app()
|
app = make_authed_test_app(user_factory=_make_admin_user)
|
||||||
app.state.config = config
|
app.state.config = config
|
||||||
app.dependency_overrides[get_config] = lambda: config
|
app.dependency_overrides[get_config] = lambda: config
|
||||||
app.include_router(uploads_router.router)
|
app.include_router(uploads_router.router)
|
||||||
|
|||||||
141
backend/tests/test_skills_router_authz.py
Normal file
141
backend/tests/test_skills_router_authz.py
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
"""Authorization regression tests for the skills router.
|
||||||
|
|
||||||
|
Skills storage is global/shared across all users, and custom skill SKILL.md
|
||||||
|
content is injected into every user's agent system prompt. The mutating skills
|
||||||
|
endpoints (and the endpoints that expose raw custom-skill content/history) must
|
||||||
|
therefore be admin-only, matching the MCP router which guards the equivalent
|
||||||
|
global extensions_config mutations with ``require_admin_user``.
|
||||||
|
|
||||||
|
These tests pin the access-control boundary: a normal authenticated
|
||||||
|
(non-admin) user must receive 403 on every guarded endpoint.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from _router_auth_helpers import make_authed_test_app
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.gateway.auth.models import User
|
||||||
|
from app.gateway.deps import get_config
|
||||||
|
from app.gateway.routers import skills as skills_router
|
||||||
|
|
||||||
|
|
||||||
|
def _make_user(system_role: str) -> User:
|
||||||
|
return User(email=f"{system_role}-test@example.com", password_hash="x", system_role=system_role, id=uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
def _make_app(*, system_role: str) -> FastAPI:
|
||||||
|
config = SimpleNamespace(
|
||||||
|
skills=SimpleNamespace(get_skills_path=lambda: "/tmp/skills", container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage"),
|
||||||
|
skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None),
|
||||||
|
)
|
||||||
|
app = make_authed_test_app(user_factory=lambda: _make_user(system_role))
|
||||||
|
app.state.config = config
|
||||||
|
app.dependency_overrides[get_config] = lambda: config
|
||||||
|
app.include_router(skills_router.router)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
# (method, path, json_body) for every endpoint that must require admin.
|
||||||
|
# Every entry here writes/reads global shared state (the custom skills tree,
|
||||||
|
# the shared extensions_config.json, or raw global skill content), so all are
|
||||||
|
# admin-only. PUT /api/skills/{name} is included: toggling enabled writes the
|
||||||
|
# shared extensions_config.json and changes every tenant's injected skill set.
|
||||||
|
_GUARDED_ENDPOINTS = [
|
||||||
|
("post", "/api/skills/install", {"thread_id": "t1", "path": "mnt/user-data/outputs/x.skill"}),
|
||||||
|
("get", "/api/skills/custom", None),
|
||||||
|
("get", "/api/skills/custom/demo", None),
|
||||||
|
("put", "/api/skills/custom/demo", {"content": "---\nname: demo\ndescription: hijacked\n---\n"}),
|
||||||
|
("delete", "/api/skills/custom/demo", None),
|
||||||
|
("get", "/api/skills/custom/demo/history", None),
|
||||||
|
("post", "/api/skills/custom/demo/rollback", {"history_index": -1}),
|
||||||
|
("put", "/api/skills/demo", {"enabled": False}),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_admin_is_forbidden_on_all_mutating_skills_endpoints():
|
||||||
|
"""A normal (non-admin) authenticated user must get 403, never 200/500.
|
||||||
|
|
||||||
|
403 proves the admin guard fired before any business logic ran. If the
|
||||||
|
guard were missing the request would instead reach the handler and return
|
||||||
|
200 or a 4xx/5xx from the storage layer.
|
||||||
|
"""
|
||||||
|
app = _make_app(system_role="user")
|
||||||
|
with TestClient(app) as client:
|
||||||
|
for method, path, body in _GUARDED_ENDPOINTS:
|
||||||
|
resp = getattr(client, method)(path, json=body) if body is not None else getattr(client, method)(path)
|
||||||
|
assert resp.status_code == 403, f"{method.upper()} {path} expected 403 for non-admin, got {resp.status_code}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_basic_skill_listing_stays_open_to_normal_users(monkeypatch):
|
||||||
|
"""The basic list/detail endpoints expose only name/description and are
|
||||||
|
needed by the normal-user UI, so they must NOT be admin-gated.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _load_skills(*, enabled_only: bool):
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from deerflow.skills.types import Skill
|
||||||
|
|
||||||
|
return [
|
||||||
|
Skill(
|
||||||
|
name="demo",
|
||||||
|
description="d",
|
||||||
|
license="MIT",
|
||||||
|
skill_dir=Path("/tmp/demo"),
|
||||||
|
skill_file=Path("/tmp/demo/SKILL.md"),
|
||||||
|
relative_path=Path("demo"),
|
||||||
|
category="public",
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
app = _make_app(system_role="user")
|
||||||
|
app.dependency_overrides[get_config] = lambda: SimpleNamespace()
|
||||||
|
monkeypatch.setattr(skills_router, "get_or_new_skill_storage", lambda **kw: SimpleNamespace(load_skills=_load_skills))
|
||||||
|
with TestClient(app) as client:
|
||||||
|
assert client.get("/api/skills").status_code == 200
|
||||||
|
assert client.get("/api/skills/demo").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_enable_toggle_allowed_for_admin(monkeypatch, tmp_path):
|
||||||
|
"""`PUT /api/skills/{name}` writes the shared extensions_config.json, so it
|
||||||
|
is admin-only. This confirms the guard does not block a legitimate admin.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from deerflow.skills.types import Skill
|
||||||
|
|
||||||
|
config_path = tmp_path / "extensions_config.json"
|
||||||
|
|
||||||
|
def _load_skills(*, enabled_only: bool):
|
||||||
|
return [
|
||||||
|
Skill(
|
||||||
|
name="demo",
|
||||||
|
description="d",
|
||||||
|
license="MIT",
|
||||||
|
skill_dir=Path("/tmp/demo"),
|
||||||
|
skill_file=Path("/tmp/demo/SKILL.md"),
|
||||||
|
relative_path=Path("demo"),
|
||||||
|
category="public",
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
app = _make_app(system_role="admin")
|
||||||
|
monkeypatch.setattr(skills_router, "get_or_new_skill_storage", lambda **kw: SimpleNamespace(load_skills=_load_skills))
|
||||||
|
monkeypatch.setattr(skills_router, "get_extensions_config", lambda: SimpleNamespace(mcp_servers={}, skills={}))
|
||||||
|
monkeypatch.setattr(skills_router, "reload_extensions_config", lambda: None)
|
||||||
|
monkeypatch.setattr(skills_router.ExtensionsConfig, "resolve_config_path", staticmethod(lambda: config_path))
|
||||||
|
|
||||||
|
async def _refresh():
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(skills_router, "refresh_skills_system_prompt_cache_async", _refresh)
|
||||||
|
with TestClient(app) as client:
|
||||||
|
resp = client.put("/api/skills/demo", json={"enabled": False})
|
||||||
|
assert resp.status_code == 200, f"admin toggle should succeed, got {resp.status_code}"
|
||||||
@ -38,10 +38,11 @@ import {
|
|||||||
HTML_PREVIEW_SCROLL_MESSAGE_SOURCE,
|
HTML_PREVIEW_SCROLL_MESSAGE_SOURCE,
|
||||||
} from "@/core/artifacts/preview";
|
} from "@/core/artifacts/preview";
|
||||||
import { urlOfArtifact } from "@/core/artifacts/utils";
|
import { urlOfArtifact } from "@/core/artifacts/utils";
|
||||||
|
import { useAuth } from "@/core/auth/AuthProvider";
|
||||||
import { writeTextToClipboard } from "@/core/clipboard";
|
import { writeTextToClipboard } from "@/core/clipboard";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { findToolCallResult } from "@/core/messages/utils";
|
import { findToolCallResult } from "@/core/messages/utils";
|
||||||
import { installSkill } from "@/core/skills/api";
|
import { installSkill, SkillRequestError } from "@/core/skills/api";
|
||||||
import { SafeStreamdown } from "@/core/streamdown/components";
|
import { SafeStreamdown } from "@/core/streamdown/components";
|
||||||
import {
|
import {
|
||||||
canBrowserPreviewFile,
|
canBrowserPreviewFile,
|
||||||
@ -72,6 +73,8 @@ export function ArtifactFileDetail({
|
|||||||
threadId: string;
|
threadId: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const isAdmin = user?.system_role === "admin";
|
||||||
const { artifacts, setOpen, select } = useArtifacts();
|
const { artifacts, setOpen, select } = useArtifacts();
|
||||||
const { thread, isMock } = useThread();
|
const { thread, isMock } = useThread();
|
||||||
const isWriteFile = useMemo(() => {
|
const isWriteFile = useMemo(() => {
|
||||||
@ -194,11 +197,15 @@ export function ArtifactFileDetail({
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to install skill:", error);
|
console.error("Failed to install skill:", error);
|
||||||
toast.error("Failed to install skill");
|
if (error instanceof SkillRequestError && error.isAdminRequired) {
|
||||||
|
toast.error(t.settings.skills.installAdminRequired);
|
||||||
|
} else {
|
||||||
|
toast.error("Failed to install skill");
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsInstalling(false);
|
setIsInstalling(false);
|
||||||
}
|
}
|
||||||
}, [threadId, filepath, isInstalling]);
|
}, [threadId, filepath, isInstalling, t]);
|
||||||
return (
|
return (
|
||||||
<Artifact className={cn(className)}>
|
<Artifact className={cn(className)}>
|
||||||
<ArtifactHeader className="px-2">
|
<ArtifactHeader className="px-2">
|
||||||
@ -249,7 +256,7 @@ export function ArtifactFileDetail({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ArtifactActions>
|
<ArtifactActions>
|
||||||
{!isWriteFile && filepath.endsWith(".skill") && (
|
{!isWriteFile && filepath.endsWith(".skill") && isAdmin && (
|
||||||
<Tooltip content={t.toolCalls.skillInstallTooltip}>
|
<Tooltip content={t.toolCalls.skillInstallTooltip}>
|
||||||
<ArtifactAction
|
<ArtifactAction
|
||||||
icon={isInstalling ? LoaderIcon : PackageIcon}
|
icon={isInstalling ? LoaderIcon : PackageIcon}
|
||||||
|
|||||||
@ -11,8 +11,9 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { urlOfArtifact } from "@/core/artifacts/utils";
|
import { urlOfArtifact } from "@/core/artifacts/utils";
|
||||||
|
import { useAuth } from "@/core/auth/AuthProvider";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { installSkill } from "@/core/skills/api";
|
import { installSkill, SkillRequestError } from "@/core/skills/api";
|
||||||
import {
|
import {
|
||||||
getFileExtensionDisplayName,
|
getFileExtensionDisplayName,
|
||||||
getFileIcon,
|
getFileIcon,
|
||||||
@ -32,6 +33,8 @@ export function ArtifactFileList({
|
|||||||
threadId: string;
|
threadId: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const isAdmin = user?.system_role === "admin";
|
||||||
const { select: selectArtifact, setOpen } = useArtifacts();
|
const { select: selectArtifact, setOpen } = useArtifacts();
|
||||||
const [installingFile, setInstallingFile] = useState<string | null>(null);
|
const [installingFile, setInstallingFile] = useState<string | null>(null);
|
||||||
|
|
||||||
@ -63,12 +66,16 @@ export function ArtifactFileList({
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to install skill:", error);
|
console.error("Failed to install skill:", error);
|
||||||
toast.error("Failed to install skill");
|
if (error instanceof SkillRequestError && error.isAdminRequired) {
|
||||||
|
toast.error(t.settings.skills.installAdminRequired);
|
||||||
|
} else {
|
||||||
|
toast.error("Failed to install skill");
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setInstallingFile(null);
|
setInstallingFile(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[threadId, installingFile],
|
[threadId, installingFile, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -90,7 +97,7 @@ export function ArtifactFileList({
|
|||||||
{getFileExtensionDisplayName(file)} file
|
{getFileExtensionDisplayName(file)} file
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
<CardAction className="row-span-1 self-center">
|
<CardAction className="row-span-1 self-center">
|
||||||
{file.endsWith(".skill") && (
|
{file.endsWith(".skill") && isAdmin && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
disabled={installingFile === file}
|
disabled={installingFile === file}
|
||||||
|
|||||||
@ -22,7 +22,9 @@ import {
|
|||||||
} from "@/components/ui/item";
|
} from "@/components/ui/item";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { useAuth } from "@/core/auth/AuthProvider";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
|
import { SkillRequestError } from "@/core/skills/api";
|
||||||
import { useEnableSkill, useSkills } from "@/core/skills/hooks";
|
import { useEnableSkill, useSkills } from "@/core/skills/hooks";
|
||||||
import type { Skill } from "@/core/skills/type";
|
import type { Skill } from "@/core/skills/type";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
@ -32,6 +34,8 @@ import { SettingsSection } from "./settings-section";
|
|||||||
export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
|
export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { skills, isLoading, error } = useSkills();
|
const { skills, isLoading, error } = useSkills();
|
||||||
|
const adminRequired =
|
||||||
|
error instanceof SkillRequestError && error.isAdminRequired;
|
||||||
return (
|
return (
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title={t.settings.skills.title}
|
title={t.settings.skills.title}
|
||||||
@ -39,6 +43,10 @@ export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
|
|||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
|
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
|
||||||
|
) : adminRequired ? (
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
{t.settings.skills.adminRequired}
|
||||||
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div>Error: {error.message}</div>
|
<div>Error: {error.message}</div>
|
||||||
) : (
|
) : (
|
||||||
@ -57,6 +65,8 @@ function SkillSettingsList({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const isAdmin = user?.system_role === "admin";
|
||||||
const [filter, setFilter] = useState<string>("public");
|
const [filter, setFilter] = useState<string>("public");
|
||||||
const { mutate: enableSkill } = useEnableSkill();
|
const { mutate: enableSkill } = useEnableSkill();
|
||||||
const filteredSkills = useMemo(
|
const filteredSkills = useMemo(
|
||||||
@ -102,7 +112,9 @@ function SkillSettingsList({
|
|||||||
<ItemActions>
|
<ItemActions>
|
||||||
<Switch
|
<Switch
|
||||||
checked={skill.enabled}
|
checked={skill.enabled}
|
||||||
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
|
disabled={
|
||||||
|
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" || !isAdmin
|
||||||
|
}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
enableSkill({ skillName: skill.name, enabled: checked })
|
enableSkill({ skillName: skill.name, enabled: checked })
|
||||||
}
|
}
|
||||||
|
|||||||
@ -525,6 +525,9 @@ export const enUS: Translations = {
|
|||||||
emptyDescription:
|
emptyDescription:
|
||||||
"Put your agent skill folders under the `/skills/custom` folder under the root folder of DeerFlow.",
|
"Put your agent skill folders under the `/skills/custom` folder under the root folder of DeerFlow.",
|
||||||
emptyButton: "Create Your First Skill",
|
emptyButton: "Create Your First Skill",
|
||||||
|
adminRequired: "Admin privileges are required to manage agent skills.",
|
||||||
|
installAdminRequired:
|
||||||
|
"Admin privileges are required to install agent skills.",
|
||||||
},
|
},
|
||||||
notification: {
|
notification: {
|
||||||
title: "Notification",
|
title: "Notification",
|
||||||
|
|||||||
@ -431,6 +431,8 @@ export interface Translations {
|
|||||||
emptyTitle: string;
|
emptyTitle: string;
|
||||||
emptyDescription: string;
|
emptyDescription: string;
|
||||||
emptyButton: string;
|
emptyButton: string;
|
||||||
|
adminRequired: string;
|
||||||
|
installAdminRequired: string;
|
||||||
};
|
};
|
||||||
notification: {
|
notification: {
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
@ -503,6 +503,8 @@ export const zhCN: Translations = {
|
|||||||
emptyDescription:
|
emptyDescription:
|
||||||
"将你的 Agent Skill 文件夹放在 DeerFlow 根目录下的 `/skills/custom` 文件夹中。",
|
"将你的 Agent Skill 文件夹放在 DeerFlow 根目录下的 `/skills/custom` 文件夹中。",
|
||||||
emptyButton: "创建你的第一个技能",
|
emptyButton: "创建你的第一个技能",
|
||||||
|
adminRequired: "需要管理员权限才能管理 Agent Skill。",
|
||||||
|
installAdminRequired: "需要管理员权限才能安装 Agent Skill。",
|
||||||
},
|
},
|
||||||
notification: {
|
notification: {
|
||||||
title: "通知",
|
title: "通知",
|
||||||
|
|||||||
@ -3,8 +3,32 @@ import { getBackendBaseURL } from "@/core/config";
|
|||||||
|
|
||||||
import type { Skill } from "./type";
|
import type { Skill } from "./type";
|
||||||
|
|
||||||
|
export class SkillRequestError extends Error {
|
||||||
|
readonly status: number;
|
||||||
|
|
||||||
|
constructor(status: number, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "SkillRequestError";
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isAdminRequired(): boolean {
|
||||||
|
return this.status === 403;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readErrorDetail(response: Response): Promise<string> {
|
||||||
|
const data = (await response.json().catch(() => ({}))) as {
|
||||||
|
detail?: string;
|
||||||
|
};
|
||||||
|
return data.detail ?? `HTTP ${response.status}: ${response.statusText}`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadSkills() {
|
export async function loadSkills() {
|
||||||
const skills = await fetch(`${getBackendBaseURL()}/api/skills`);
|
const skills = await fetch(`${getBackendBaseURL()}/api/skills`);
|
||||||
|
if (!skills.ok) {
|
||||||
|
throw new SkillRequestError(skills.status, await readErrorDetail(skills));
|
||||||
|
}
|
||||||
const json = await skills.json();
|
const json = await skills.json();
|
||||||
return json.skills as Skill[];
|
return json.skills as Skill[];
|
||||||
}
|
}
|
||||||
@ -22,6 +46,12 @@ export async function enableSkill(skillName: string, enabled: boolean) {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new SkillRequestError(
|
||||||
|
response.status,
|
||||||
|
await readErrorDetail(response),
|
||||||
|
);
|
||||||
|
}
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -48,14 +78,17 @@ export async function installSkill(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
// Handle HTTP error responses (4xx, 5xx)
|
const message = await readErrorDetail(response);
|
||||||
const errorData = await response.json().catch(() => ({}));
|
// Surface authorization failures so callers can show an admin-only hint
|
||||||
const errorMessage =
|
// instead of a generic failure.
|
||||||
errorData.detail ?? `HTTP ${response.status}: ${response.statusText}`;
|
if (response.status === 403) {
|
||||||
|
throw new SkillRequestError(response.status, message);
|
||||||
|
}
|
||||||
|
// Other HTTP errors keep the existing soft-failure contract.
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
skill_name: "",
|
skill_name: "",
|
||||||
message: errorMessage,
|
message,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { enableSkill } from "./api";
|
import { enableSkill, SkillRequestError } from "./api";
|
||||||
|
|
||||||
import { loadSkills } from ".";
|
import { loadSkills } from ".";
|
||||||
|
|
||||||
@ -8,6 +8,7 @@ export function useSkills() {
|
|||||||
const { data, isLoading, error } = useQuery({
|
const { data, isLoading, error } = useQuery({
|
||||||
queryKey: ["skills"],
|
queryKey: ["skills"],
|
||||||
queryFn: () => loadSkills(),
|
queryFn: () => loadSkills(),
|
||||||
|
retry: (count, err) => !(err instanceof SkillRequestError) && count < 3,
|
||||||
});
|
});
|
||||||
return { skills: data ?? [], isLoading, error };
|
return { skills: data ?? [], isLoading, error };
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user