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
224 lines
7.8 KiB
Python
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()}
|