1. 为渠道账户ID查询添加最小长度校验,统一分析模块常量引用 2. 新增扫码登录向导端点,完善文档说明 3. 优化配对统计接口,移除无效参数 4. 为出站箱接口添加批量上限与202状态码 5. 新增测试用例、访问规则、配额等模块的查询与校验参数 6. 新增适配器健康批量查询、健康检查触发接口 7. 统一告警、审计日志的错误处理方式 8. 新增插件配置账户ID支持,优化批量操作响应 9. 新增环境健康批量查询、Webhook限流与参数校验 10. 完善会话管理、审计日志的参数与文档说明 11. 修复导入模块的校验错误处理逻辑
421 lines
16 KiB
Python
421 lines
16 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 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 = 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`` 自动去重。"""
|
||
|
||
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),
|
||
keyword: str | None = Query(None, max_length=128, description="关键词搜索:主体ID/名称/描述"),
|
||
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,
|
||
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,
|
||
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()}
|