Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

224 lines
7.8 KiB
Python

"""Admin APIs for the optional multi-user layer."""
from __future__ import annotations
import asyncio
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from deeptutor.api.routers.auth import require_admin
from deeptutor.knowledge.manager import KnowledgeBaseManager
from deeptutor.services.config.model_catalog import ModelCatalogService
from deeptutor.services.skill.service import SkillService
from .audit import log_admin_action
from .grants import load_grant, save_grant
from .identity import get_user_by_id, list_user_info
from .knowledge_access import admin_kb_base_dir
from .paths import get_admin_path_service
router = APIRouter()
class GrantPayload(BaseModel):
grant: dict[str, Any]
class SkillInstallPayload(BaseModel):
ref: str
name: str | None = None
force: bool = False
allow_unverified: bool = False
def _admin_catalog_summary() -> dict[str, list[dict[str, Any]]]:
catalog = ModelCatalogService(
path=get_admin_path_service().get_settings_file("model_catalog")
).load()
out: dict[str, list[dict[str, Any]]] = {"llm": []}
for service, state in (catalog.get("services") or {}).items():
if service not in out:
continue
for profile in state.get("profiles", []) or []:
profile_id = str(profile.get("id") or "")
models = []
for model in profile.get("models", []) or []:
models.append(
{
"model_id": model.get("id", ""),
"name": model.get("name") or model.get("model") or model.get("id"),
"model": model.get("model", ""),
}
)
out[service].append(
{
"profile_id": profile_id,
"name": profile.get("name") or profile_id,
"models": models,
}
)
return out
def _admin_kb_summary() -> list[dict[str, Any]]:
manager = KnowledgeBaseManager(base_dir=str(admin_kb_base_dir()))
return [
{
"resource_id": f"admin:kb:{name}",
"name": name,
"source": "admin",
}
for name in manager.list_knowledge_bases()
]
def _admin_skill_summary() -> list[dict[str, Any]]:
root = get_admin_path_service().get_workspace_dir() / "skills"
service = SkillService(root=root)
return [item.to_dict() for item in service.list_skills()]
def _admin_partner_summary() -> list[dict[str, Any]]:
"""The partners an admin can assign. Partners are process-wide resources
anchored at the admin workspace, so this lists them all (identity only — no
channel wiring or model selection leaks into the assignable summary)."""
from deeptutor.services.partners import get_partner_manager
return [
{
"partner_id": str(item.get("partner_id") or ""),
"name": item.get("name") or item.get("partner_id") or "",
"description": item.get("description") or "",
"emoji": item.get("emoji") or "",
}
for item in get_partner_manager().list_partners()
]
def _require_assignable_user(user_id: str) -> tuple[str, dict[str, Any]]:
user_record = get_user_by_id(user_id)
if user_record is None:
raise HTTPException(status_code=404, detail="User not found")
username, record = user_record
if str(record.get("role") or "user") == "admin":
raise HTTPException(
status_code=403,
detail="Admin users use the main workspace and cannot receive assignments.",
)
return username, record
@router.get("/admin/resources")
async def admin_resources(_: object = Depends(require_admin)) -> dict[str, Any]:
"""Everything an admin can assign to a user: models, KBs, skills, and
the tool surface (system tools + MCP tools, same pool partners use)."""
from deeptutor.api.utils.tool_options import build_tool_options
tool_options = await build_tool_options()
return {
"models": _admin_catalog_summary(),
"knowledge_bases": _admin_kb_summary(),
"skills": _admin_skill_summary(),
"partners": _admin_partner_summary(),
"tools": tool_options["tools"],
"mcp_tools": tool_options["mcp_tools"],
}
@router.get("/users/{user_id}/grants")
async def get_user_grants(user_id: str, _: object = Depends(require_admin)) -> dict[str, Any]:
_require_assignable_user(user_id)
return {"grant": load_grant(user_id)}
@router.put("/users/{user_id}/grants")
async def put_user_grants(
user_id: str,
payload: GrantPayload,
_: object = Depends(require_admin),
) -> dict[str, Any]:
_require_assignable_user(user_id)
try:
grant = save_grant(user_id, payload.grant)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
log_admin_action(
"grant_set",
target_user_id=user_id,
summary={
"model_count": len(grant.get("models", {}).get("llm", []) or []),
"kb_count": len(grant.get("knowledge_bases", []) or []),
"skill_count": len(grant.get("skills", []) or []),
"partner_count": len(grant.get("partners", []) or []),
"enabled_tools": grant.get("enabled_tools"),
"mcp_tool_count": (
None if grant.get("mcp_tools") is None else len(grant.get("mcp_tools") or [])
),
"exec_enabled": grant.get("exec_enabled"),
},
)
return {"grant": grant}
@router.post("/admin/skills/install")
async def admin_install_skill(
payload: SkillInstallPayload,
_: object = Depends(require_admin),
) -> dict[str, Any]:
"""Install a hub skill into the admin catalog (``<hub>:<slug>[@version]``).
The skill lands in the admin workspace — the same pool ``/admin/resources``
lists — so it stays invisible to non-admin users until a grant assigns it.
The install pipeline (verdict gate, safe extraction, ``always`` stripping)
lives in :func:`deeptutor.services.skill.hub.install_from_hub`; this
endpoint only chooses the target root and audits the action.
"""
from deeptutor.services.skill.hub import HubError, install_from_hub
from deeptutor.services.skill.service import (
InvalidSkillNameError,
SkillExistsError,
SkillImportError,
)
service = SkillService(root=get_admin_path_service().get_workspace_dir() / "skills")
try:
outcome = await asyncio.to_thread(
install_from_hub,
payload.ref,
service=service,
rename_to=payload.name,
force=payload.force,
allow_unverified=payload.allow_unverified,
)
except SkillExistsError as exc:
raise HTTPException(status_code=409, detail=f"Skill already exists: {exc}") from exc
except (SkillImportError, InvalidSkillNameError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except HubError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
log_admin_action(
"skill_hub_install",
summary={
"ref": payload.ref,
"installed_as": outcome.result.info.name,
"version": outcome.ref.version,
"verdict": outcome.verdict.status,
"forced": payload.force,
"allow_unverified": payload.allow_unverified,
},
)
return {
"skill": outcome.result.info.to_dict(),
"verdict": {"status": outcome.verdict.status, "detail": outcome.verdict.detail},
"version": outcome.ref.version,
"skipped": [{"path": rel, "reason": reason} for rel, reason in outcome.result.skipped],
}
@router.get("/users")
async def multi_user_list_users(_: object = Depends(require_admin)) -> dict[str, Any]:
return {"users": list_user_info()}