Files
taiji-AI-PAD/services/mcp-server/app/routes/resources.py
T
2026-05-05 14:13:59 +08:00

280 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
资源绑定(Heicode P1)路由
POST /api/resources 创建资源绑定
GET /api/resources 列表(按当前登录用户过滤)
GET /api/resources/{id} 详情
PUT /api/resources/{id} 更新
DELETE /api/resources/{id} 软删除(status=revoked)
依据:heicode.md §五 资源绑定与密钥托管 / plan.md §P1。
安全红线:写入前拒绝任何 metadata/constraints/permission_scope 字段名包含
password/token/secret/private_key/access_key/credential 的请求。
"""
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db
from models import ResourceBinding
from app.auth import require_auth
from app.schemas import SuccessResponse
router = APIRouter(prefix="/api/resources", tags=["资源绑定"])
# ==================== 共享:敏感字段拒绝 ====================
SENSITIVE_KEY_PATTERNS = (
"password", "token", "secret", "private_key", "access_key", "credential"
)
ALLOWED_RESOURCE_TYPES = {"git", "sk", "project_doc", "cloud_account", "cloud_resource"}
ALLOWED_BINDING_STATUS = {"pending", "active", "disabled", "revoked"}
def reject_sensitive_keys(data: Any, path: str = "") -> None:
"""递归检查 dict/list 中的 key,命中敏感词就 422 拒绝。
注意:白名单 key `secret_ref` 是引用而非密钥,单独跳过。
"""
if isinstance(data, dict):
for k, v in data.items():
kl = str(k).lower()
if kl == "secret_ref": # 引用允许通过
continue
for pat in SENSITIVE_KEY_PATTERNS:
if pat in kl:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={
"code": "RESOURCE_GRANT_SECRET_REJECTED",
"message": (
f"Field '{path}{k}' contains sensitive keyword '{pat}'. "
f"Use secret_ref to reference secrets; never pass plaintext."
),
},
)
reject_sensitive_keys(v, f"{path}{k}.")
elif isinstance(data, list):
for i, item in enumerate(data):
reject_sensitive_keys(item, f"{path}[{i}].")
# ==================== Pydantic schemas ====================
class ResourceBindingCreate(BaseModel):
type: str
name: str
external_ref: Optional[str] = None
metadata: Dict[str, Any] = Field(default_factory=dict)
permission_scope: List[str] = Field(default_factory=list)
constraints: Dict[str, Any] = Field(default_factory=dict)
secret_ref: Optional[str] = None
status: str = "pending"
@field_validator("type")
@classmethod
def _check_type(cls, v: str) -> str:
if v not in ALLOWED_RESOURCE_TYPES:
raise ValueError(
f"type must be one of {sorted(ALLOWED_RESOURCE_TYPES)}, got '{v}'"
)
return v
@field_validator("status")
@classmethod
def _check_status(cls, v: str) -> str:
if v not in ALLOWED_BINDING_STATUS:
raise ValueError(
f"status must be one of {sorted(ALLOWED_BINDING_STATUS)}, got '{v}'"
)
return v
class ResourceBindingUpdate(BaseModel):
name: Optional[str] = None
external_ref: Optional[str] = None
metadata: Optional[Dict[str, Any]] = None
permission_scope: Optional[List[str]] = None
constraints: Optional[Dict[str, Any]] = None
secret_ref: Optional[str] = None
status: Optional[str] = None
@field_validator("status")
@classmethod
def _check_status(cls, v: Optional[str]) -> Optional[str]:
if v is not None and v not in ALLOWED_BINDING_STATUS:
raise ValueError(
f"status must be one of {sorted(ALLOWED_BINDING_STATUS)}"
)
return v
def _to_dict(b: ResourceBinding) -> Dict[str, Any]:
return {
"id": str(b.id),
"user_id": str(b.user_id),
"type": b.type,
"name": b.name,
"external_ref": b.external_ref,
"metadata": b.binding_metadata or {},
"permission_scope": b.permission_scope or [],
"constraints": b.constraints or {},
"secret_ref": b.secret_ref,
"status": b.status,
"created_by": str(b.created_by) if b.created_by else None,
"updated_by": str(b.updated_by) if b.updated_by else None,
"created_at": b.created_at.isoformat() if b.created_at else None,
"updated_at": b.updated_at.isoformat() if b.updated_at else None,
}
def _current_user_id(principal: dict) -> uuid.UUID:
uid = principal.get("user_id") or (principal.get("claims") or {}).get("sub")
if not uid:
raise HTTPException(status_code=401, detail="未登录")
try:
return uuid.UUID(str(uid))
except Exception:
raise HTTPException(status_code=400, detail="user_id 不是有效 UUID")
# ==================== 路由 ====================
@router.post("", response_model=SuccessResponse)
async def create_resource_binding(
payload: ResourceBindingCreate,
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
"""创建资源绑定。
安全:metadata / permission_scope / constraints 中不得出现敏感关键字(明文凭据)。
"""
user_id = _current_user_id(principal)
body = payload.model_dump()
# 校验:拒绝明文凭据
reject_sensitive_keys(body.get("metadata"), "metadata.")
reject_sensitive_keys(body.get("constraints"), "constraints.")
reject_sensitive_keys(body.get("permission_scope"), "permission_scope.")
binding = ResourceBinding(
user_id=user_id,
type=payload.type,
name=payload.name,
external_ref=payload.external_ref,
binding_metadata=payload.metadata,
permission_scope=payload.permission_scope,
constraints=payload.constraints,
secret_ref=payload.secret_ref,
status=payload.status,
created_by=user_id,
updated_by=user_id,
)
db.add(binding)
await db.commit()
await db.refresh(binding)
return SuccessResponse(data=_to_dict(binding))
@router.get("", response_model=SuccessResponse)
async def list_resource_bindings(
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db),
type: Optional[str] = Query(None, description="按类型过滤"),
status_filter: Optional[str] = Query(None, alias="status"),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
):
"""列出当前登录用户的资源绑定。"""
user_id = _current_user_id(principal)
stmt = select(ResourceBinding).where(ResourceBinding.user_id == user_id)
if type is not None:
stmt = stmt.where(ResourceBinding.type == type)
if status_filter is not None:
stmt = stmt.where(ResourceBinding.status == status_filter)
stmt = stmt.order_by(ResourceBinding.created_at.desc()).offset(offset).limit(limit)
result = await db.execute(stmt)
items = [_to_dict(b) for b in result.scalars().all()]
return SuccessResponse(data={"items": items, "total": len(items), "offset": offset, "limit": limit})
async def _load_owned(db: AsyncSession, binding_id: str, user_id: uuid.UUID) -> ResourceBinding:
try:
bid = uuid.UUID(binding_id)
except Exception:
raise HTTPException(status_code=400, detail="binding_id 格式无效")
binding = await db.get(ResourceBinding, bid)
if binding is None:
raise HTTPException(status_code=404, detail="资源不存在")
if binding.user_id != user_id:
raise HTTPException(status_code=403, detail="无权访问该资源")
return binding
@router.get("/{binding_id}", response_model=SuccessResponse)
async def get_resource_binding(
binding_id: str,
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
user_id = _current_user_id(principal)
binding = await _load_owned(db, binding_id, user_id)
return SuccessResponse(data=_to_dict(binding))
@router.put("/{binding_id}", response_model=SuccessResponse)
async def update_resource_binding(
binding_id: str,
payload: ResourceBindingUpdate,
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
user_id = _current_user_id(principal)
binding = await _load_owned(db, binding_id, user_id)
body = payload.model_dump(exclude_unset=True)
# 校验更新中的敏感字段
if "metadata" in body:
reject_sensitive_keys(body["metadata"], "metadata.")
if "constraints" in body:
reject_sensitive_keys(body["constraints"], "constraints.")
if "permission_scope" in body:
reject_sensitive_keys(body["permission_scope"], "permission_scope.")
for k, v in body.items():
if k == "metadata":
binding.binding_metadata = v
else:
setattr(binding, k, v)
binding.updated_by = user_id
binding.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(binding)
return SuccessResponse(data=_to_dict(binding))
@router.delete("/{binding_id}", response_model=SuccessResponse)
async def delete_resource_binding(
binding_id: str,
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
"""软删除:status=revoked。所有关联 grants 也会因 ondelete=CASCADE 被清理(如果使用硬删除);
本接口默认软删除,保留历史审计。"""
user_id = _current_user_id(principal)
binding = await _load_owned(db, binding_id, user_id)
binding.status = "revoked"
binding.updated_by = user_id
binding.updated_at = datetime.utcnow()
await db.commit()
return SuccessResponse(data={"id": str(binding.id), "status": binding.status})