1. 清理测试文件中未使用的导入与冗余代码 2. 修复审计日志与批量操作的空值约束,统一填充"global"作为默认渠道 3. 调整批量消息撤回的响应语义,对齐其他端点的部分成功契约 4. 修复访问规则批量克隆的唯一约束问题,新增后缀自动处理逻辑 5. 替换anyio为asyncio并行调用,修正时间UTC导入路径 6. 优化前端外部系统概览页的刷新状态提示与缓存逻辑 7. 修复测试用例中的断言与请求方式问题,适配httpx删除请求特性 8. 重构后端路由的依赖注入,移除冗余的数据库会话依赖 9. 调整测试用例的权限校验逻辑,修正强制登出的权限判断 10. 修复语义分块测试的numpy依赖问题,清理冗余导入
415 lines
15 KiB
Python
415 lines
15 KiB
Python
"""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 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
|
||
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: 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 = Field(default=None, ge=0)
|
||
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 = 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`` 由当前管理员填充。"""
|
||
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,
|
||
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`` 由当前管理员填充。仅透传显式设置的字段。"""
|
||
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),
|
||
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()}
|