sync: 同步 sql-platform-pro 最新代码 — 权限体系安全修复 v0.8.1

- P0: 实例访问控制全线接入权限组 (829测试)
- P1: permissions字段激活 + 操作级权限检查 (843测试)
- 前端: 删除数据权限页面, 权限组合并为唯一入口
- 新增: CodeMirror编辑器 + 查询模板 + 慢查询分析
- 新增: MongoDB管理页面 + Dashboard增强
- 修复: Pydantic V2迁移 + 前端TS错误修复
This commit is contained in:
logan committed 2026-05-23 14:41:06 +08:00
1 parent 6fcd9465cf
commit 4c9427e546
105 files changed
+23680 -471

No files matched your search

+467
View File
@@ -0,0 +1,467 @@
"""权限组 + 审批流 全链路测试。
覆盖:
1. 查询权限: 用户只能查自己被授权的数据源
2. 工单权限: 用户只能对被授权的数据源提交变更
3. 审批流: 工单创建自动启动审批流,多级审批
"""
import json
import sys
import pytest
from contextlib import asynccontextmanager
from unittest.mock import patch, AsyncMock, MagicMock
from app.core.security import create_access_token, hash_password
from app.models.user import User
from app.models.instance import Instance
from app.models.instance_group import InstanceGroup, user_groups
from app.models.ticket import Ticket
from app.models.workflow import WorkflowDefinition, WorkflowStep, TicketApproval
from tests.conftest import auth_header
def _make_aiomysql_mock():
aiomysql = MagicMock()
aiomysql.connect = AsyncMock()
aiomysql.DictCursor = MagicMock()
return aiomysql
def _make_mock_conn():
mock_cursor = AsyncMock()
mock_cursor.description = [("id",), ("name",)]
mock_cursor.fetchall.return_value = [{"id": 1, "name": "test"}]
mock_cursor.execute = AsyncMock()
@asynccontextmanager
async def mock_cursor_ctx(*args, **kwargs):
yield mock_cursor
mock_conn = AsyncMock()
mock_conn.cursor = mock_cursor_ctx
mock_conn.close = MagicMock()
return mock_conn
# ── Fixtures ─────────────────────────────────────────────────────────
@pytest.fixture
async def grouped_instance(db):
inst = Instance(
name="生产MySQL",
db_type="mysql",
host="10.0.0.1",
port=3306,
username="root",
password="test123",
database="prod_db",
env="production",
status="disconnected",
)
db.add(inst)
await db.commit()
await db.refresh(inst)
return inst
@pytest.fixture
async def public_instance(db):
inst = Instance(
name="测试PG",
db_type="postgres",
host="127.0.0.1",
port=5432,
username="postgres",
password="test123",
database="test_db",
env="development",
status="disconnected",
)
db.add(inst)
await db.commit()
await db.refresh(inst)
return inst
@pytest.fixture
async def dev_group(db, grouped_instance):
group = InstanceGroup(
name="开发组",
description="开发人员可用数据源",
db_type="mysql",
permissions=json.dumps(["query:select", "ticket:create"]),
)
db.add(group)
await db.commit()
await db.refresh(group)
grouped_instance.group_id = group.id
await db.commit()
await db.refresh(grouped_instance)
return group
@pytest.fixture
async def dev_in_group(db, dev_group, developer_user):
await db.execute(
user_groups.insert().values(user_id=developer_user.id, group_id=dev_group.id)
)
await db.commit()
return developer_user
@pytest.fixture
async def dev_in_group_token(dev_in_group):
return create_access_token(data={"sub": str(dev_in_group.id), "role": dev_in_group.role})
@pytest.fixture
async def default_workflow(db):
wf = WorkflowDefinition(
name="标准审批流",
description="DBA审核后Admin确认",
is_default=True,
is_active=True,
)
db.add(wf)
await db.commit()
await db.refresh(wf)
step1 = WorkflowStep(
workflow_id=wf.id,
step_number=1,
name="DBA审核",
step_type="serial",
approver_role="dba",
is_required=True,
timeout_hours=24,
)
step2 = WorkflowStep(
workflow_id=wf.id,
step_number=2,
name="管理员确认",
step_type="serial",
approver_role="admin",
is_required=True,
timeout_hours=24,
)
db.add_all([step1, step2])
await db.commit()
return wf
# ── 1. 查询权限测试 ──────────────────────────────────────────────────
class TestQueryPermission:
async def test_admin_can_query_grouped_instance(self, client, admin_token, grouped_instance, dev_group):
"""admin 可以查询绑定权限组的实例"""
mock_conn = _make_mock_conn()
mock_aiomysql = _make_aiomysql_mock()
mock_aiomysql.connect = AsyncMock(return_value=mock_conn)
with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}):
resp = await client.post(
"/api/v1/query",
json={"instance_id": grouped_instance.id, "database": "prod_db", "sql": "SELECT 1"},
params={"save_history": "false"},
headers=auth_header(admin_token),
)
assert resp.status_code != 403
async def test_dev_in_group_can_query(self, client, dev_in_group_token, grouped_instance, dev_group):
"""developer 在权限组内 → 可以查询"""
mock_conn = _make_mock_conn()
mock_aiomysql = _make_aiomysql_mock()
mock_aiomysql.connect = AsyncMock(return_value=mock_conn)
with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}):
resp = await client.post(
"/api/v1/query",
json={"instance_id": grouped_instance.id, "database": "prod_db", "sql": "SELECT 1"},
params={"save_history": "false"},
headers=auth_header(dev_in_group_token),
)
assert resp.status_code != 403
async def test_dev_not_in_group_blocked(self, client, developer_token, grouped_instance, dev_group):
"""developer 不在权限组 → 403"""
resp = await client.post(
"/api/v1/query",
json={"instance_id": grouped_instance.id, "database": "prod_db", "sql": "SELECT 1"},
params={"save_history": "false"},
headers=auth_header(developer_token),
)
assert resp.status_code == 403
assert "权限" in resp.json()["detail"]
async def test_dev_can_query_public_instance(self, client, developer_token, public_instance):
"""无权限组的公共实例 → 所有认证用户可查"""
resp = await client.post(
"/api/v1/query",
json={"instance_id": public_instance.id, "database": "test_db", "sql": "SELECT 1"},
params={"save_history": "false"},
headers=auth_header(developer_token),
)
assert resp.status_code != 403
async def test_guest_still_blocked(self, client, guest_token, public_instance):
"""guest 被禁止查询"""
resp = await client.post(
"/api/v1/query",
json={"instance_id": public_instance.id, "database": "test_db", "sql": "SELECT 1"},
params={"save_history": "false"},
headers=auth_header(guest_token),
)
assert resp.status_code == 403
async def test_dba_not_in_group_blocked(self, client, dba_token, grouped_instance, dev_group):
"""dba 不在权限组也 → 403"""
resp = await client.post(
"/api/v1/query",
json={"instance_id": grouped_instance.id, "database": "prod_db", "sql": "SELECT 1"},
params={"save_history": "false"},
headers=auth_header(dba_token),
)
assert resp.status_code == 403
# ── 2. 工单权限测试 ──────────────────────────────────────────────────
class TestTicketPermission:
async def test_admin_can_create_ticket_any(self, client, admin_token, grouped_instance, dev_group):
"""admin 可以为任何实例创建工单"""
resp = await client.post(
"/api/v1/tickets",
json={
"title": "管理员修改表结构",
"db_type": "mysql",
"instance_id": grouped_instance.id,
"target_database": "prod_db",
"sql_content": "ALTER TABLE users ADD COLUMN age INT",
"reason": "业务需求,增加年龄字段",
"priority": "medium",
},
headers=auth_header(admin_token),
)
assert resp.status_code != 403
async def test_dev_in_group_can_create_ticket(self, client, dev_in_group_token, grouped_instance, dev_group):
"""developer 在权限组内 → 可以创建工单"""
resp = await client.post(
"/api/v1/tickets",
json={
"title": "开发修正错误数据",
"db_type": "mysql",
"instance_id": grouped_instance.id,
"target_database": "prod_db",
"sql_content": "UPDATE users SET age = 20 WHERE id = 1",
"reason": "修正用户年龄数据错误",
"priority": "low",
},
headers=auth_header(dev_in_group_token),
)
assert resp.status_code != 403
async def test_dev_not_in_group_cannot_create_ticket(self, client, developer_token, grouped_instance, dev_group):
"""developer 不在权限组 → 403"""
resp = await client.post(
"/api/v1/tickets",
json={
"title": "未授权操作工单",
"db_type": "mysql",
"instance_id": grouped_instance.id,
"target_database": "prod_db",
"sql_content": "DELETE FROM users WHERE id = 1",
"reason": "测试未授权操作权限",
"priority": "low",
},
headers=auth_header(developer_token),
)
assert resp.status_code == 403
assert "权限" in resp.json()["detail"]
# ── 3. 审批流对接测试 ──────────────────────────────────────────────────
class TestWorkflowIntegration:
async def test_no_workflow_status_pending(self, client, admin_token, test_instance):
"""无默认审批流 → 状态为 pending"""
resp = await client.post(
"/api/v1/tickets",
json={
"title": "简单测试工单",
"db_type": "mysql",
"instance_id": test_instance.id,
"target_database": "test_db",
"sql_content": "UPDATE users SET name = 'test' WHERE id = 1",
"reason": "测试审批流功能",
"priority": "low",
},
headers=auth_header(admin_token),
)
assert resp.status_code == 201
assert resp.json()["status"] == "pending"
async def test_with_workflow_status_reviewing(self, client, admin_token, test_instance, default_workflow):
"""有默认审批流 → 状态为 reviewing"""
resp = await client.post(
"/api/v1/tickets",
json={
"title": "审批流测试工单",
"db_type": "mysql",
"instance_id": test_instance.id,
"target_database": "test_db",
"sql_content": "UPDATE users SET name = 'test' WHERE id = 1",
"reason": "测试审批流功能",
"priority": "medium",
},
headers=auth_header(admin_token),
)
assert resp.status_code == 201
data = resp.json()
assert data["status"] == "reviewing"
ticket_id = data["id"]
resp2 = await client.get(
f"/api/v1/tickets/{ticket_id}/approvals",
headers=auth_header(admin_token),
)
assert resp2.status_code == 200
approvals = resp2.json()
assert len(approvals) == 2
assert approvals[0]["step_number"] == 1
assert approvals[0]["approver_role"] == "dba"
assert approvals[0]["action"] == "pending"
assert approvals[1]["step_number"] == 2
assert approvals[1]["approver_role"] == "admin"
async def test_multi_step_first_approve_still_reviewing(self, client, admin_token, dba_token, test_instance, default_workflow):
"""多级审批: 第一步通过 → reviewing"""
resp = await client.post(
"/api/v1/tickets",
json={
"title": "多级审批测试工单",
"db_type": "mysql",
"instance_id": test_instance.id,
"target_database": "test_db",
"sql_content": "UPDATE users SET age = 25 WHERE id = 1",
"reason": "测试审批流功能",
"priority": "medium",
},
headers=auth_header(admin_token),
)
ticket_id = resp.json()["id"]
resp2 = await client.post(
f"/api/v1/tickets/{ticket_id}/review",
json={"action": "approve", "comment": "DBA审核通过"},
headers=auth_header(dba_token),
)
assert resp2.status_code == 200
assert resp2.json()["status"] == "reviewing"
async def test_multi_step_all_approve_becomes_approved(self, client, admin_token, dba_token, test_instance, default_workflow):
"""多级审批: 全部通过 → approved"""
resp = await client.post(
"/api/v1/tickets",
json={
"title": "完整审批测试工单",
"db_type": "mysql",
"instance_id": test_instance.id,
"target_database": "test_db",
"sql_content": "UPDATE users SET status = 'active' WHERE id = 1",
"reason": "测试审批流功能",
"priority": "medium",
},
headers=auth_header(admin_token),
)
ticket_id = resp.json()["id"]
await client.post(
f"/api/v1/tickets/{ticket_id}/review",
json={"action": "approve", "comment": "DBA通过"},
headers=auth_header(dba_token),
)
resp3 = await client.post(
f"/api/v1/tickets/{ticket_id}/review",
json={"action": "approve", "comment": "管理员确认通过"},
headers=auth_header(admin_token),
)
assert resp3.status_code == 200
assert resp3.json()["status"] == "approved"
async def test_reject_at_any_step(self, client, admin_token, dba_token, test_instance, default_workflow):
"""多级审批: 拒绝 → rejected"""
resp = await client.post(
"/api/v1/tickets",
json={
"title": "被拒绝的测试工单",
"db_type": "mysql",
"instance_id": test_instance.id,
"target_database": "test_db",
"sql_content": "DELETE FROM logs WHERE id < 1000",
"reason": "清理过期日志数据",
"priority": "high",
},
headers=auth_header(admin_token),
)
ticket_id = resp.json()["id"]
resp2 = await client.post(
f"/api/v1/tickets/{ticket_id}/review",
json={"action": "reject", "comment": "风险太高"},
headers=auth_header(dba_token),
)
assert resp2.status_code == 200
assert resp2.json()["status"] == "rejected"
async def test_no_workflow_single_step_approve(self, client, admin_token, dba_token, test_instance):
"""无审批流: 传统单步审批直接 approved"""
resp = await client.post(
"/api/v1/tickets",
json={
"title": "单步审批测试工单",
"db_type": "mysql",
"instance_id": test_instance.id,
"target_database": "test_db",
"sql_content": "UPDATE users SET name = 'x' WHERE id = 1",
"reason": "测试审批流功能",
"priority": "low",
},
headers=auth_header(admin_token),
)
ticket_id = resp.json()["id"]
assert resp.json()["status"] == "pending"
resp2 = await client.post(
f"/api/v1/tickets/{ticket_id}/review",
json={"action": "approve", "comment": "通过"},
headers=auth_header(dba_token),
)
assert resp2.status_code == 200
assert resp2.json()["status"] == "approved"
async def test_approvals_endpoint_empty(self, client, admin_token, test_instance):
"""无审批流 → approvals 返回空列表"""
resp = await client.post(
"/api/v1/tickets",
json={
"title": "无审批流测试工单",
"db_type": "mysql",
"instance_id": test_instance.id,
"target_database": "test_db",
"sql_content": "SELECT 1",
"reason": "测试审批流功能",
"priority": "low",
},
headers=auth_header(admin_token),
)
ticket_id = resp.json()["id"]
resp2 = await client.get(
f"/api/v1/tickets/{ticket_id}/approvals",
headers=auth_header(admin_token),
)
assert resp2.status_code == 200
assert resp2.json() == []