forked from xiaohei/taiji-AI-PAD
251 lines
8.6 KiB
Python
251 lines
8.6 KiB
Python
"""
|
||
资源授权(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))
|