ForcePilot/backend/server/routers/external_systems/access_rule_router.py
Kris f9f08221fc chore: 整理代码风格与优化细节
本次提交包含多类代码优化:
1. 修复多处单行代码换行格式,统一代码排版
2. 为外部系统模块新增快捷菜单配置
3. 优化前端API请求参数命名一致性
4. 补充媒体下载超限错误码与领域异常类
5. 完善仓储层更新逻辑,支持显式清空字段
6. 优化部分测试用例与工具函数代码结构
7. 为微信插件白名单缓存增加过期时间
2026-07-14 15:13:29 +08:00

419 lines
16 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 typing import Any, Literal
from fastapi import APIRouter, Depends, Path, Query
from pydantic import BaseModel, ConfigDict, Field, field_validator
from yuxi.external_systems.infrastructure.container import UseCases
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.routers.external_systems import get_use_cases, parse_and_naive
from server.utils.auth_middleware import get_admin_user, 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 = Field(default=0, ge=0)
conditions: dict[str, Any] = Field(default_factory=dict)
expires_at: str | 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 = Field(default=None, ge=0)
conditions: dict[str, Any] | None = None
expires_at: str | 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 = Field(ge=0)
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`` 自动去重。
``principal_id_suffix`` 追加到克隆规则 ``principal_id`` 末尾以避免唯一约束冲突,
默认 ``_copy``(对齐 ``batch_clone_test_cases`` 的 ``name_suffix`` 模式)。
"""
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)
principal_id_suffix: str = "_copy"
@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),
keyword: str | None = Query(None, max_length=128, description="关键词搜索主体ID/名称/描述"),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""分页列出工具访问控制规则。"""
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,
keyword=keyword,
)
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,
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""创建工具访问控制规则。``created_by`` 由当前管理员填充。"""
expires_at_dt = parse_and_naive("expires_at", payload.expires_at)
input_dto = CreateAccessRuleInput(
**payload.model_dump(exclude={"expires_at"}),
expires_at=expires_at_dt,
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,
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""评估访问规则(只读查询,不抛异常)。"""
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,
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""模拟规则执行(只读查询,返回每条规则的评估详情)。"""
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,
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""批量启停访问规则。``updated_by`` 由当前管理员填充。"""
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,
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""批量调整规则优先级。``updated_by`` 由当前管理员填充。"""
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,
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""批量克隆访问规则。``created_by`` 由当前管理员填充。"""
input_dto = BatchCloneAccessRulesInput(
source_rule_ids=payload.source_rule_ids,
override_config=payload.override_config,
principal_id_suffix=payload.principal_id_suffix,
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),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""获取访问规则详情。"""
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),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""更新工具访问控制规则。``updated_by`` 由当前管理员填充。仅透传显式设置的字段。"""
update_fields = payload.model_dump(exclude_unset=True)
if "expires_at" in update_fields:
update_fields["expires_at"] = parse_and_naive("expires_at", update_fields["expires_at"])
input_dto = UpdateAccessRuleInput(
id=rule_id,
updated_by=current_user.uid,
**update_fields,
)
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),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""删除工具访问控制规则。``updated_by`` 由当前管理员填充。"""
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),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""查询访问规则的附加条件。"""
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),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""更新访问规则的附加条件。``updated_by`` 由当前管理员填充。"""
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()}