ForcePilot/backend/server/routers/external_systems/token_router.py

285 lines
11 KiB
Python
Raw Normal View History

"""Token 子域 Router。
外部系统限界上下文的 Token 管理 API覆盖 Token 查询 / 详情 / 统计 /
历史 / 使用记录 / 失效单条 + 环境级/ 即将过期查询 / 轮换预览
所有端点通过 ``create_use_cases_from_db`` 装配 use_cases ``token_service`` 端口调用用例
路径顺序约束静态路径``/stats````/history````/invalidate-environment````/expiring``
必须在 ``/{token_id}`` 前声明避免被路径参数捕获
注意``get_token_history`` 实际按 ``system_id`` 查询审计日志 token_id
故采用静态路径 ``/history`` + 必填 Query ``system_id``路径语义与查询语义对齐
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.ext.asyncio import AsyncSession
from yuxi.external_systems.infrastructure.container import create_use_cases_from_db
from yuxi.external_systems.use_cases.dto.token import (
GetTokenDetailInput,
GetTokenHistoryInput,
GetTokenRotationPreviewInput,
GetTokenStatsInput,
InvalidateEnvironmentTokensInput,
InvalidateTokenInput,
ListExpiringTokensInput,
ListTokensInput,
ListTokenUsageInput,
)
from yuxi.storage.postgres.models_business import User
from server.utils.auth_middleware import get_admin_user, get_db, get_required_user
token_router = APIRouter(prefix="/tokens", tags=["external-systems-token"])
# ---------------- Request Schemas ----------------
class InvalidateEnvironmentTokensRequest(BaseModel):
"""失效环境级 Token 请求体。字段对齐 ``InvalidateEnvironmentTokensInput``(不含 invalidated_by
``invalidated_by`` Router ``current_user.uid`` 注入
``env_key`` 长度约束对齐 ``ExternalSystemEnvironment`` ORM 列定义String(32)
在边界层拦截非法输入
"""
model_config = ConfigDict(frozen=True)
system_id: int
env_key: str = Field(..., min_length=1, max_length=32)
# ---------------- 静态路径端点(必须在 /{token_id} 之前声明) ----------------
@token_router.get("/stats", response_model=dict)
async def get_token_stats(
system_id: int | None = Query(None),
env_key: str | None = Query(None, max_length=32),
expiring_days: int = Query(7, ge=1, le=90, description="expiring_soon 阈值天数"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""获取 Token 统计信息。
返回字段``total`` / ``active`` / ``invalidated`` / ``expired`` / ``expiring_soon``
``active = total - invalidated - expired````expiring_days`` 控制 ``expiring_soon``
的阈值天数默认 7 ``/expiring`` 端点保持一致
"""
use_cases = create_use_cases_from_db(db)
input_dto = GetTokenStatsInput(
system_id=system_id,
env_key=env_key,
expiring_days=expiring_days,
)
output = await use_cases.token_service.get_token_stats(input_dto)
return {"success": True, "data": output.model_dump()}
@token_router.get("/history", response_model=dict)
async def get_token_history(
system_id: int = Query(..., description="按系统 ID 查询 Token 变更历史"),
env_key: str | None = Query(None, max_length=32),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""获取 Token 变更历史(基于审计日志)。
``system_id`` 查询 Token 子域相关的审计事件失效 / 环境失效 / 刷新成功 / 刷新失败
从审计日志的 ``snapshot_after`` 构造 ``TokenHistoryItem``Token 字段 + 审计元数据
"""
use_cases = create_use_cases_from_db(db)
input_dto = GetTokenHistoryInput(
system_id=system_id,
env_key=env_key,
limit=limit,
offset=offset,
)
output = await use_cases.token_service.get_token_history(input_dto)
return {"success": True, "data": output.model_dump()}
@token_router.post("/invalidate-environment", response_model=dict)
async def invalidate_environment_tokens(
body: InvalidateEnvironmentTokensRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""失效指定系统 + 环境下的 Token幂等
``(system_id, env_key)`` 唯一对应一条 Token``invalidated_by`` 由当前管理员填充
写入审计日志以追溯操作人已失效的 Token ``ConflictError``409
"""
use_cases = create_use_cases_from_db(db)
input_dto = InvalidateEnvironmentTokensInput(
system_id=body.system_id,
env_key=body.env_key,
invalidated_by=current_user.uid,
)
output = await use_cases.token_service.invalidate_environment_tokens(input_dto)
return {"success": True, "data": output.model_dump()}
@token_router.get("/expiring", response_model=dict)
async def list_expiring_tokens(
days: int = Query(7, ge=1, le=90),
system_id: int | None = Query(None),
env_key: str | None = Query(None, max_length=32),
token_type: str | None = Query(None),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""查询即将过期的 Token默认 7 天内,未失效)。
所有过滤条件``system_id`` / ``env_key`` / ``token_type``均下推至 DB
``offset`` 支持分页 ``/`` 列表端点契约对齐
"""
use_cases = create_use_cases_from_db(db)
input_dto = ListExpiringTokensInput(
days=days,
system_id=system_id,
env_key=env_key,
token_type=token_type,
limit=limit,
offset=offset,
)
output = await use_cases.token_service.list_expiring_tokens(input_dto)
return {"success": True, "data": output.model_dump()}
# ---------------- 根路径端点 ----------------
@token_router.get("", response_model=dict)
async def list_tokens(
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
system_id: int | None = Query(None),
env_key: str | None = Query(None, max_length=32),
token_type: str | None = Query(None),
is_invalidated: bool | None = Query(None),
sort_by: str | None = Query(None, description="排序字段updated_at/expires_at/created_at"),
sort_order: str = Query("desc", pattern="^(asc|desc)$", description="排序方向"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""分页列出 Token。
所有过滤条件``system_id`` / ``env_key`` / ``token_type`` / ``is_invalidated``
均下推至 DB 保证分页 ``total`` ``items`` 的一致性
``sort_by`` / ``sort_order`` 控制排序由仓储白名单校验防注入
"""
use_cases = create_use_cases_from_db(db)
input_dto = ListTokensInput(
limit=limit,
offset=offset,
system_id=system_id,
env_key=env_key,
token_type=token_type,
is_invalidated=is_invalidated,
sort_by=sort_by,
sort_order=sort_order,
)
output = await use_cases.token_service.list_tokens(input_dto)
return {"success": True, "data": output.model_dump()}
# ---------------- 动态路径端点 /{token_id} ----------------
@token_router.get("/{token_id}", response_model=dict)
async def get_token_detail(
token_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""获取 Token 详情。
返回 Token 字段 + 关联系统上下文``system_name`` / ``system_slug``
``access_token`` / ``refresh_token`` 脱敏为 ``***``
"""
use_cases = create_use_cases_from_db(db)
input_dto = GetTokenDetailInput(id=token_id)
output = await use_cases.token_service.get_token_detail(input_dto)
return {"success": True, "data": output.model_dump()}
@token_router.post("/{token_id}/invalidate", response_model=dict)
async def invalidate_token(
token_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""失效 Token幂等。``invalidated_by`` 由当前管理员填充,写入审计日志以追溯操作人。
已失效的 Token ``ConflictError``409避免重复审计日志与事件发布
"""
use_cases = create_use_cases_from_db(db)
input_dto = InvalidateTokenInput(
id=token_id,
invalidated_by=current_user.uid,
)
output = await use_cases.token_service.invalidate_token(input_dto)
return {"success": True, "data": output.model_dump()}
@token_router.get("/{token_id}/usage", response_model=dict)
async def list_token_usage(
token_id: int,
limit: int = Query(20, ge=1, le=500),
offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""查询 Token 使用记录。
基于 ``ExternalToolExecution`` 表按 ``(system_id, env_key)`` 关联查询
返回使用该 Token 的工具执行记录摘要id / execution_id / tool_slug /
status / started_at / ended_at / duration_ms / operation / caller
"""
use_cases = create_use_cases_from_db(db)
input_dto = ListTokenUsageInput(
token_id=token_id,
limit=limit,
offset=offset,
)
output = await use_cases.token_service.list_token_usage(input_dto)
return {"success": True, "data": output.model_dump()}
@token_router.get("/{token_id}/rotation-preview", response_model=dict)
async def get_token_rotation_preview(
token_id: int,
expiring_days: int = Query(7, ge=1, le=90, description="expiring_soon 阈值天数"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""预览 Token 轮换的影响范围(关联系统、环境、工具、密钥轮换策略)。
只读查询不调用外部适配器对齐 ``secret_rotation_router.precheck_rotation``
的权限梯度使用 ``get_required_user``
返回字段``token_status``active/expiring_soon/expired/invalidated+
``affected_system`` + ``affected_environment`` + ``affected_tools`` +
``affected_policies``关联的密钥轮换策略列表
``expiring_days`` 控制 ``token_status`` ``expiring_soon`` 的阈值天数
``/stats````/expiring`` 端点保持一致
"""
use_cases = create_use_cases_from_db(db)
input_dto = GetTokenRotationPreviewInput(
token_id=token_id,
expiring_days=expiring_days,
)
output = await use_cases.token_service.get_token_rotation_preview(input_dto)
return {"success": True, "data": output.model_dump()}