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

251 lines
8.6 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/resource-grants 创建授权
GET /api/resource-grants 列表
GET /api/resource-grants/{id} 详情
DELETE /api/resource-grants/{id} 撤销(status=revoked)
依据:heicode.md §五 / plan.md §P1。
约束:
- 必须基于已存在且属于同一用户的 ResourceBinding 创建。
- allowed_actions 必须是 binding.permission_scope 的子集。
- constraints 不得放宽 binding.constraints。
"""
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, ResourceGrant
from app.auth import require_auth
from app.schemas import SuccessResponse
from app.routes.resources import reject_sensitive_keys
router = APIRouter(prefix="/api/resource-grants", tags=["资源授权"])
ALLOWED_GRANT_STATUS = {"active", "suspended", "revoked", "expired"}
# ==================== Pydantic schemas ====================
class ResourceGrantCreate(BaseModel):
resource_id: str
binding_scope: str
role: Optional[str] = None
agent_id: Optional[str] = None
allowed_actions: List[str] = Field(default_factory=list)
constraints: Dict[str, Any] = Field(default_factory=dict)
expires_at: Optional[datetime] = None
status: str = "active"
@field_validator("status")
@classmethod
def _check_status(cls, v: str) -> str:
if v not in ALLOWED_GRANT_STATUS:
raise ValueError(
f"status must be one of {sorted(ALLOWED_GRANT_STATUS)}, got '{v}'"
)
return v
def _to_dict(g: ResourceGrant) -> Dict[str, Any]:
return {
"id": str(g.id),
"user_id": str(g.user_id),
"binding_scope": g.binding_scope,
"resource_id": str(g.resource_id),
"role": g.role,
"agent_id": str(g.agent_id) if g.agent_id else None,
"allowed_actions": g.allowed_actions or [],
"constraints": g.constraints or {},
"status": g.status,
"expires_at": g.expires_at.isoformat() if g.expires_at else None,
"created_by": str(g.created_by) if g.created_by else None,
"revoked_by": str(g.revoked_by) if g.revoked_by else None,
"created_at": g.created_at.isoformat() if g.created_at else None,
"revoked_at": g.revoked_at.isoformat() if g.revoked_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")
def _validate_subset_actions(allowed: List[str], parent_scope: List[str]) -> None:
"""allowed_actions 必须是 parent permission_scope 的子集。"""
extra = set(allowed) - set(parent_scope or [])
if extra:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": "RESOURCE_GRANT_INVALID",
"message": f"allowed_actions 超出对应 binding.permission_scope: {sorted(extra)}",
},
)
# ==================== 路由 ====================
@router.post("", response_model=SuccessResponse)
async def create_resource_grant(
payload: ResourceGrantCreate,
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
user_id = _current_user_id(principal)
# 校验:拒绝明文凭据
body = payload.model_dump()
reject_sensitive_keys(body.get("constraints"), "constraints.")
reject_sensitive_keys(body.get("allowed_actions"), "allowed_actions.")
# 解析 resource_id
try:
rid = uuid.UUID(payload.resource_id)
except Exception:
raise HTTPException(status_code=400, detail="resource_id 格式无效")
binding = await db.get(ResourceBinding, rid)
if binding is None:
raise HTTPException(
status_code=404,
detail={"code": "NOT_FOUND", "message": "对应 ResourceBinding 不存在"},
)
if binding.user_id != user_id:
raise HTTPException(
status_code=403,
detail={"code": "FORBIDDEN_SCOPE", "message": "不能基于他人的资源创建授权"},
)
if binding.status not in ("active", "pending"):
raise HTTPException(
status_code=400,
detail={
"code": "RESOURCE_GRANT_INVALID",
"message": f"binding 状态为 {binding.status},不允许新建授权",
},
)
# allowed_actions 子集校验
_validate_subset_actions(payload.allowed_actions, binding.permission_scope or [])
agent_uid: Optional[uuid.UUID] = None
if payload.agent_id:
try:
agent_uid = uuid.UUID(payload.agent_id)
except Exception:
raise HTTPException(status_code=400, detail="agent_id 格式无效")
grant = ResourceGrant(
user_id=user_id,
binding_scope=payload.binding_scope,
resource_id=rid,
role=payload.role,
agent_id=agent_uid,
allowed_actions=payload.allowed_actions,
constraints=payload.constraints,
status=payload.status,
expires_at=payload.expires_at,
created_by=user_id,
)
db.add(grant)
await db.commit()
await db.refresh(grant)
return SuccessResponse(data=_to_dict(grant))
@router.get("", response_model=SuccessResponse)
async def list_resource_grants(
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db),
resource_id: Optional[str] = Query(None),
role: Optional[str] = Query(None),
binding_scope: Optional[str] = Query(None),
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(ResourceGrant).where(ResourceGrant.user_id == user_id)
if resource_id:
try:
rid = uuid.UUID(resource_id)
except Exception:
raise HTTPException(status_code=400, detail="resource_id 格式无效")
stmt = stmt.where(ResourceGrant.resource_id == rid)
if role:
stmt = stmt.where(ResourceGrant.role == role)
if binding_scope:
stmt = stmt.where(ResourceGrant.binding_scope == binding_scope)
if status_filter:
stmt = stmt.where(ResourceGrant.status == status_filter)
stmt = stmt.order_by(ResourceGrant.created_at.desc()).offset(offset).limit(limit)
result = await db.execute(stmt)
items = [_to_dict(g) for g in result.scalars().all()]
return SuccessResponse(data={"items": items, "total": len(items), "offset": offset, "limit": limit})
async def _load_owned_grant(db: AsyncSession, grant_id: str, user_id: uuid.UUID) -> ResourceGrant:
try:
gid = uuid.UUID(grant_id)
except Exception:
raise HTTPException(status_code=400, detail="grant_id 格式无效")
grant = await db.get(ResourceGrant, gid)
if grant is None:
raise HTTPException(
status_code=404,
detail={"code": "NOT_FOUND", "message": "授权不存在"},
)
if grant.user_id != user_id:
raise HTTPException(
status_code=403,
detail={"code": "FORBIDDEN_SCOPE", "message": "无权访问该授权"},
)
return grant
@router.get("/{grant_id}", response_model=SuccessResponse)
async def get_resource_grant(
grant_id: str,
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
user_id = _current_user_id(principal)
grant = await _load_owned_grant(db, grant_id, user_id)
return SuccessResponse(data=_to_dict(grant))
@router.delete("/{grant_id}", response_model=SuccessResponse)
async def revoke_resource_grant(
grant_id: str,
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
"""撤销授权(软删除:status=revoked + revoked_at)。"""
user_id = _current_user_id(principal)
grant = await _load_owned_grant(db, grant_id, user_id)
if grant.status == "revoked":
# 幂等
return SuccessResponse(data=_to_dict(grant))
grant.status = "revoked"
grant.revoked_by = user_id
grant.revoked_at = datetime.utcnow()
await db.commit()
await db.refresh(grant)
return SuccessResponse(data=_to_dict(grant))