forked from xiaohei/taiji-AI-PAD
280 lines
10 KiB
Python
280 lines
10 KiB
Python
"""
|
||
资源绑定(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})
|