ForcePilot/backend/server/routers/external_systems/access_rule_router.py
Kris 6dd16ad3f8 refactor(external-systems-routers): 统一分页参数格式并完善各路由文档与校验
本次提交对多个外部系统路由进行了多维度优化:
1.  统一分页参数:将所有路由的`page = offset//limit +1`、`page_size=limit`替换为标准的`limit`+`offset`分页格式
2.  完善接口文档:补充多个端点的功能说明、参数含义与返回字段解释
3.  增强参数校验:新增字段长度限制、正则校验、枚举类型约束与业务逻辑校验
4.  优化代码复用:提取重复逻辑为辅助函数,减少样板代码
5.  修复接口问题:修正工具健康检查端点路径参数类型,优化导出接口响应格式
6.  补充异常处理:为批量操作添加异常捕获与日志记录,避免流程中断
2026-07-11 06:57:24 +08:00

419 lines
15 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.

"""AccessRule 子域 Router。
外部系统限界上下文的工具访问控制规则管理 API覆盖访问规则 CRUD。
所有端点通过 ``create_use_cases_from_db`` 装配 use_cases
``access_rule_service`` 端口调用用例。
Request Schema 与 Input DTO 不共享类Router 内显式构造 DTO操作人字段
``created_by`` / ``updated_by``)由 ``current_user.uid`` 填充。
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from fastapi import APIRouter, Depends, Path, Query
from pydantic import BaseModel, ConfigDict, Field, field_validator
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.access_rule import (
BatchCloneAccessRulesInput,
BatchToggleAccessRulesInput,
CreateAccessRuleInput,
DeleteAccessRuleInput,
EvaluateAccessInput,
GetAccessRuleInput,
GetConditionsInput,
ListAccessRulesInput,
ReorderAccessRulesInput,
SimulateAccessInput,
UpdateAccessRuleInput,
UpdateConditionsInput,
)
from yuxi.external_systems.use_cases.dto.access_rule import (
ReorderItem as DtoReorderItem,
)
from yuxi.storage.postgres.models_business import User
from server.utils.auth_middleware import get_admin_user, get_db, get_required_user
access_rule_router = APIRouter(prefix="/access-rules", tags=["external-systems-access-rule"])
# 主体类型枚举:对齐 ExternalToolAccessRule ORM 注释user/role/department/agent/service_account
PrincipalType = Literal["user", "role", "department", "agent", "service_account"]
# tool_slug 格式约束:对齐其他 external_systems router 的 slug pattern
TOOL_SLUG_PATTERN = r"^[a-zA-Z_][a-zA-Z0-9_-]{0,127}$"
def _dedup_int_list(value: list[int]) -> list[int]:
"""整型列表去重,保持原顺序。批量操作接口的 IDs 不允许重复。"""
seen: set[int] = set()
result: list[int] = []
for item in value:
if item not in seen:
seen.add(item)
result.append(item)
return result
# ---------------- Request Schemas ----------------
class CreateAccessRuleRequest(BaseModel):
"""创建访问规则请求体。字段对齐 ``CreateAccessRuleInput``(不含 created_by
``created_by`` 由 Router 从 ``current_user.uid`` 注入。字段长度约束对齐
``ExternalToolAccessRule`` ORM 列定义,在边界层拦截非法输入。
"""
model_config = ConfigDict(frozen=True)
principal_type: PrincipalType
principal_id: str = Field(..., min_length=1, max_length=64)
tool_slug: str | None = Field(default=None, max_length=128, pattern=TOOL_SLUG_PATTERN)
system_id: int | None = Field(default=None, ge=1)
principal_name: str | None = Field(default=None, max_length=128)
env_key: str | None = Field(default=None, max_length=32)
effect: Literal["allow", "deny"] = "allow"
priority: int = 0
conditions: dict[str, Any] = Field(default_factory=dict)
expires_at: datetime | None = None
enabled: bool = True
description: str | None = None
class UpdateAccessRuleRequest(BaseModel):
"""更新访问规则请求体。字段对齐 ``UpdateAccessRuleInput``(不含 id 与 updated_by
``id`` 由路径参数提供,``updated_by`` 由 Router 从 ``current_user.uid`` 注入。
所有字段均为可选,仅透传客户端显式设置的字段。字符串字段长度约束对齐
``ExternalToolAccessRule`` ORM 列定义。
"""
model_config = ConfigDict(frozen=True)
principal_name: str | None = Field(default=None, max_length=128)
effect: Literal["allow", "deny"] | None = None
priority: int | None = None
conditions: dict[str, Any] | None = None
expires_at: datetime | None = None
enabled: bool | None = None
description: str | None = None
# ---------------- 扩展 Request Schemas ----------------
class EvaluateAccessRequest(BaseModel):
"""评估访问规则请求体。字段对齐 EvaluateAccessInput不含操作人字段"""
model_config = ConfigDict(frozen=True)
principal_type: PrincipalType
principal_id: str = Field(..., min_length=1, max_length=64)
tool_slug: str | None = Field(default=None, max_length=128, pattern=TOOL_SLUG_PATTERN)
system_id: int | None = Field(default=None, ge=1)
env_key: str | None = Field(default=None, max_length=32)
operation_type: Literal["read", "write"] = "write"
class SimulateAccessRequest(BaseModel):
"""模拟规则执行请求体。字段对齐 SimulateAccessInput不含操作人字段"""
model_config = ConfigDict(frozen=True)
principal_type: PrincipalType
principal_id: str = Field(..., min_length=1, max_length=64)
tool_slug: str | None = Field(default=None, max_length=128, pattern=TOOL_SLUG_PATTERN)
system_id: int | None = Field(default=None, ge=1)
env_key: str | None = Field(default=None, max_length=32)
operation_type: Literal["read", "write"] = "write"
class BatchToggleAccessRulesRequest(BaseModel):
"""批量启停访问规则请求体。``rule_ids`` 自动去重。"""
model_config = ConfigDict(frozen=True)
rule_ids: list[int] = Field(..., min_length=1, max_length=100)
enabled: bool
@field_validator("rule_ids")
@classmethod
def _dedup_rule_ids(cls, value: list[int]) -> list[int]:
return _dedup_int_list(value)
class ReorderItem(BaseModel):
"""单条规则优先级调整项。"""
model_config = ConfigDict(frozen=True)
rule_id: int
priority: int
class ReorderAccessRulesRequest(BaseModel):
"""批量调整规则优先级请求体。``orders`` 按 ``rule_id`` 去重。"""
model_config = ConfigDict(frozen=True)
orders: list[ReorderItem] = Field(..., min_length=1, max_length=100)
@field_validator("orders")
@classmethod
def _dedup_orders(cls, value: list[ReorderItem]) -> list[ReorderItem]:
seen: set[int] = set()
result: list[ReorderItem] = []
for item in value:
if item.rule_id in seen:
continue
seen.add(item.rule_id)
result.append(item)
return result
class UpdateConditionsRequest(BaseModel):
"""更新规则条件请求体。"""
model_config = ConfigDict(frozen=True)
conditions: dict[str, Any]
class BatchCloneAccessRulesRequest(BaseModel):
"""批量克隆访问规则请求体。``source_rule_ids`` 自动去重。"""
model_config = ConfigDict(frozen=True)
source_rule_ids: list[int] = Field(..., min_length=1, max_length=100)
override_config: dict[str, Any] = Field(default_factory=dict)
@field_validator("source_rule_ids")
@classmethod
def _dedup_source_rule_ids(cls, value: list[int]) -> list[int]:
return _dedup_int_list(value)
# ---------------- Endpoints ----------------
@access_rule_router.get("", response_model=dict)
async def list_access_rules(
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
system_id: int | None = Query(None, ge=1),
tool_slug: str | None = Query(None, max_length=128, pattern=TOOL_SLUG_PATTERN),
principal_type: PrincipalType | None = Query(None),
principal_id: str | None = Query(None, max_length=64),
env_key: str | None = Query(None, max_length=32),
effect: Literal["allow", "deny"] | None = Query(None, description="规则效果allow/deny"),
enabled: bool | None = Query(None),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""分页列出工具访问控制规则。"""
use_cases = create_use_cases_from_db(db)
input_dto = ListAccessRulesInput(
limit=limit,
offset=offset,
system_id=system_id,
tool_slug=tool_slug,
principal_type=principal_type,
principal_id=principal_id,
env_key=env_key,
effect=effect,
enabled=enabled,
)
output = await use_cases.access_rule_service.list_access_rules(input_dto)
return {"success": True, "data": output.model_dump()}
@access_rule_router.post("", response_model=dict)
async def create_access_rule(
payload: CreateAccessRuleRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""创建工具访问控制规则。``created_by`` 由当前管理员填充。"""
use_cases = create_use_cases_from_db(db)
input_dto = CreateAccessRuleInput(
**payload.model_dump(),
created_by=current_user.uid,
)
output = await use_cases.access_rule_service.create_access_rule(input_dto)
return {"success": True, "data": output.model_dump()}
# ---------------- 静态路径扩展端点(必须在 /{rule_id} 之前声明)----------------
@access_rule_router.post("/evaluate", response_model=dict)
async def evaluate_access(
payload: EvaluateAccessRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""评估访问规则(只读查询,不抛异常)。"""
use_cases = create_use_cases_from_db(db)
input_dto = EvaluateAccessInput(
principal_type=payload.principal_type,
principal_id=payload.principal_id,
tool_slug=payload.tool_slug,
system_id=payload.system_id,
env_key=payload.env_key,
operation_type=payload.operation_type,
)
output = await use_cases.access_rule_service.evaluate_access(input_dto)
return {"success": True, "data": output.model_dump()}
@access_rule_router.post("/simulate", response_model=dict)
async def simulate_access(
payload: SimulateAccessRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""模拟规则执行(只读查询,返回每条规则的评估详情)。"""
use_cases = create_use_cases_from_db(db)
input_dto = SimulateAccessInput(
principal_type=payload.principal_type,
principal_id=payload.principal_id,
tool_slug=payload.tool_slug,
system_id=payload.system_id,
env_key=payload.env_key,
operation_type=payload.operation_type,
)
output = await use_cases.access_rule_service.simulate_access(input_dto)
return {"success": True, "data": output.model_dump()}
@access_rule_router.post("/batch-toggle", response_model=dict)
async def batch_toggle_access_rules(
payload: BatchToggleAccessRulesRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""批量启停访问规则。``updated_by`` 由当前管理员填充。"""
use_cases = create_use_cases_from_db(db)
input_dto = BatchToggleAccessRulesInput(
rule_ids=payload.rule_ids,
enabled=payload.enabled,
updated_by=current_user.uid,
)
output = await use_cases.access_rule_service.batch_toggle_access_rules(input_dto)
return {"success": True, "data": output.model_dump()}
@access_rule_router.put("/reorder", response_model=dict)
async def reorder_access_rules(
payload: ReorderAccessRulesRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""批量调整规则优先级。``updated_by`` 由当前管理员填充。"""
use_cases = create_use_cases_from_db(db)
input_dto = ReorderAccessRulesInput(
orders=[DtoReorderItem(rule_id=item.rule_id, priority=item.priority) for item in payload.orders],
updated_by=current_user.uid,
)
output = await use_cases.access_rule_service.reorder_access_rules(input_dto)
return {"success": True, "data": output.model_dump()}
@access_rule_router.post("/batch-clone", response_model=dict)
async def batch_clone_access_rules(
payload: BatchCloneAccessRulesRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""批量克隆访问规则。``created_by`` 由当前管理员填充。"""
use_cases = create_use_cases_from_db(db)
input_dto = BatchCloneAccessRulesInput(
source_rule_ids=payload.source_rule_ids,
override_config=payload.override_config,
created_by=current_user.uid,
)
output = await use_cases.access_rule_service.batch_clone_access_rules(input_dto)
return {"success": True, "data": output.model_dump()}
@access_rule_router.get("/{rule_id}", response_model=dict)
async def get_access_rule(
rule_id: int = Path(ge=1),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""获取访问规则详情。"""
use_cases = create_use_cases_from_db(db)
input_dto = GetAccessRuleInput(id=rule_id)
output = await use_cases.access_rule_service.get_access_rule(input_dto)
return {"success": True, "data": output.model_dump()}
@access_rule_router.put("/{rule_id}", response_model=dict)
async def update_access_rule(
payload: UpdateAccessRuleRequest,
rule_id: int = Path(ge=1),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""更新工具访问控制规则。``updated_by`` 由当前管理员填充。仅透传显式设置的字段。"""
use_cases = create_use_cases_from_db(db)
input_dto = UpdateAccessRuleInput(
id=rule_id,
updated_by=current_user.uid,
**payload.model_dump(exclude_unset=True),
)
output = await use_cases.access_rule_service.update_access_rule(input_dto)
return {"success": True, "data": output.model_dump()}
@access_rule_router.delete("/{rule_id}", response_model=dict)
async def delete_access_rule(
rule_id: int = Path(ge=1),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""删除工具访问控制规则。``updated_by`` 由当前管理员填充。"""
use_cases = create_use_cases_from_db(db)
input_dto = DeleteAccessRuleInput(id=rule_id, updated_by=current_user.uid)
output = await use_cases.access_rule_service.delete_access_rule(input_dto)
return {"success": True, "data": output.model_dump()}
# ---------------- 动态路径扩展端点(/{rule_id}/conditions----------------
@access_rule_router.get("/{rule_id}/conditions", response_model=dict)
async def get_conditions(
rule_id: int = Path(ge=1),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""查询访问规则的附加条件。"""
use_cases = create_use_cases_from_db(db)
input_dto = GetConditionsInput(rule_id=rule_id)
output = await use_cases.access_rule_service.get_conditions(input_dto)
return {"success": True, "data": output.model_dump()}
@access_rule_router.put("/{rule_id}/conditions", response_model=dict)
async def update_conditions(
payload: UpdateConditionsRequest,
rule_id: int = Path(ge=1),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""更新访问规则的附加条件。``updated_by`` 由当前管理员填充。"""
use_cases = create_use_cases_from_db(db)
input_dto = UpdateConditionsInput(
rule_id=rule_id,
conditions=payload.conditions,
updated_by=current_user.uid,
)
output = await use_cases.access_rule_service.update_conditions(input_dto)
return {"success": True, "data": output.model_dump()}