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

@@ -0,0 +1,476 @@
"""P0 安全测试: 实例访问控制 — 权限组过滤、403 拒绝、admin 旁路。
测试矩阵:
- GET /instances 权限组过滤(admin看全部, developer只看自己组+公开)
- GET /instances/{id} 权限检查(有权限/无权限/公开实例)
- GET /instances/{id}/databases 权限检查
- GET /instances/{id}/databases/{db}/tables 权限检查
"""
import pytest
from app.models.instance import Instance
from app.models.instance_group import InstanceGroup, user_groups
from app.core.security import hash_password, create_access_token
from app.models.user import User
# ── Fixtures ──────────────────────────────────────────────
@pytest.fixture
async def group_with_instance(db):
"""创建一个权限组 + 一个关联的私有实例"""
group = InstanceGroup(
name="生产MySQL组",
description="生产环境MySQL",
db_type="mysql",
)
db.add(group)
await db.flush()
instance = Instance(
name="生产MySQL",
db_type="mysql",
host="10.0.0.1",
port=3306,
username="root",
password="encrypted_prod_pw",
database="prod_db",
env="production",
status="disconnected",
group_id=group.id,
)
db.add(instance)
await db.flush()
await db.commit()
await db.refresh(group)
await db.refresh(instance)
return group, instance
@pytest.fixture
async def public_instance(db):
"""创建一个无权限组的公开实例"""
instance = Instance(
name="开发MySQL",
db_type="mysql",
host="127.0.0.1",
port=3306,
username="root",
password="encrypted_dev_pw",
database="dev_db",
env="development",
status="disconnected",
group_id=None,
)
db.add(instance)
await db.commit()
await db.refresh(instance)
return instance
@pytest.fixture
async def dev_in_group(db, developer_user, group_with_instance):
"""将 developer 用户加入权限组"""
group, instance = group_with_instance
await db.execute(
user_groups.insert().values(
user_id=developer_user.id,
group_id=group.id,
)
)
await db.commit()
return developer_user, group, instance
@pytest.fixture
async def dev_not_in_any_group(db, developer_user):
"""developer 用户不属于任何权限组"""
return developer_user
# ── Test: GET /instances 权限组过滤 ───────────────────────
class TestListInstancesPermissionFilter:
"""GET /instances — 按权限组过滤实例列表"""
@pytest.mark.asyncio
async def test_admin_sees_all_instances(
self, client, admin_token, group_with_instance, public_instance
):
"""admin 能看到所有实例(包括有权限组的和公开的)"""
resp = await client.get(
"/api/v1/instances",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 200
data = resp.json()
assert len(data) == 2
names = {i["name"] for i in data}
assert "生产MySQL" in names
assert "开发MySQL" in names
@pytest.mark.asyncio
async def test_developer_in_group_sees_own_plus_public(
self, client, dev_in_group, public_instance
):
"""属于权限组的 developer 能看到自己组的实例 + 公开实例"""
dev_user, group, group_instance = dev_in_group
token = create_access_token(data={"sub": str(dev_user.id), "role": dev_user.role})
resp = await client.get(
"/api/v1/instances",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert len(data) == 2
names = {i["name"] for i in data}
assert "生产MySQL" in names # 自己权限组内的
assert "开发MySQL" in names # 公开的
@pytest.mark.asyncio
async def test_developer_not_in_group_sees_only_public(
self, client, developer_user, developer_token,
group_with_instance, public_instance
):
"""不属于任何权限组的 developer 只能看到公开实例"""
resp = await client.get(
"/api/v1/instances",
headers={"Authorization": f"Bearer {developer_token}"},
)
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["name"] == "开发MySQL"
@pytest.mark.asyncio
async def test_developer_no_group_no_public(
self, client, developer_token, group_with_instance
):
"""无权限组 + 无公开实例 = 空列表"""
resp = await client.get(
"/api/v1/instances",
headers={"Authorization": f"Bearer {developer_token}"},
)
assert resp.status_code == 200
assert resp.json() == []
@pytest.mark.asyncio
async def test_guest_sees_only_public(
self, client, guest_user, guest_token,
group_with_instance, public_instance
):
"""guest 只能看到公开实例"""
resp = await client.get(
"/api/v1/instances",
headers={"Authorization": f"Bearer {guest_token}"},
)
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["name"] == "开发MySQL"
@pytest.mark.asyncio
async def test_filter_by_db_type_with_permission(
self, client, dev_in_group, public_instance
):
"""权限过滤 + db_type 过滤组合"""
dev_user, group, group_instance = dev_in_group
token = create_access_token(data={"sub": str(dev_user.id), "role": dev_user.role})
resp = await client.get(
"/api/v1/instances?db_type=mysql",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
assert len(resp.json()) == 2 # 两个都是 mysql
@pytest.mark.asyncio
async def test_filter_by_env_with_permission(
self, client, dev_in_group, public_instance
):
"""权限过滤 + env 过滤组合"""
dev_user, group, group_instance = dev_in_group
token = create_access_token(data={"sub": str(dev_user.id), "role": dev_user.role})
# 只看 production
resp = await client.get(
"/api/v1/instances?env=production",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["name"] == "生产MySQL"
# ── Test: GET /instances/{id} 权限检查 ────────────────────
class TestGetInstancePermission:
"""GET /instances/{id} — 单实例访问权限检查"""
@pytest.mark.asyncio
async def test_admin_can_access_any_instance(
self, client, admin_token, group_with_instance
):
"""admin 能访问任何实例(包括有权限组的)"""
_, instance = group_with_instance
resp = await client.get(
f"/api/v1/instances/{instance.id}",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 200
assert resp.json()["name"] == "生产MySQL"
@pytest.mark.asyncio
async def test_developer_in_group_can_access(
self, client, dev_in_group
):
"""属于权限组的 developer 能访问组内实例"""
dev_user, group, instance = dev_in_group
token = create_access_token(data={"sub": str(dev_user.id), "role": dev_user.role})
resp = await client.get(
f"/api/v1/instances/{instance.id}",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
assert resp.json()["name"] == "生产MySQL"
@pytest.mark.asyncio
async def test_developer_not_in_group_gets_403(
self, client, developer_token, group_with_instance
):
"""不属于权限组的 developer 访问受限实例 → 403"""
_, instance = group_with_instance
resp = await client.get(
f"/api/v1/instances/{instance.id}",
headers={"Authorization": f"Bearer {developer_token}"},
)
assert resp.status_code == 403
assert "权限" in resp.json()["detail"]
@pytest.mark.asyncio
async def test_anyone_can_access_public_instance(
self, client, developer_token, public_instance
):
"""任何认证用户都能访问公开实例(group_id=None)"""
resp = await client.get(
f"/api/v1/instances/{public_instance.id}",
headers={"Authorization": f"Bearer {developer_token}"},
)
assert resp.status_code == 200
assert resp.json()["name"] == "开发MySQL"
@pytest.mark.asyncio
async def test_guest_can_access_public_instance(
self, client, guest_token, public_instance
):
"""guest 也能访问公开实例"""
resp = await client.get(
f"/api/v1/instances/{public_instance.id}",
headers={"Authorization": f"Bearer {guest_token}"},
)
assert resp.status_code == 200
@pytest.mark.asyncio
async def test_nonexistent_instance_returns_404(
self, client, admin_token
):
"""不存在的实例 → 404(不是 403)"""
resp = await client.get(
"/api/v1/instances/99999",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 404
# ── Test: GET /instances/{id}/databases 权限检查 ──────────
class TestListDatabasesPermission:
"""GET /instances/{id}/databases — 数据库列表访问控制"""
@pytest.mark.asyncio
async def test_developer_not_in_group_denied(
self, client, developer_token, group_with_instance
):
"""非权限组成员无法访问 databases 端点"""
_, instance = group_with_instance
resp = await client.get(
f"/api/v1/instances/{instance.id}/databases",
headers={"Authorization": f"Bearer {developer_token}"},
)
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_developer_in_group_allowed(
self, client, dev_in_group
):
"""权限组成员可以访问 databases 端点(即使连接失败也是 500 不是 403)"""
dev_user, group, instance = dev_in_group
token = create_access_token(data={"sub": str(dev_user.id), "role": dev_user.role})
# 注意:测试环境没有真实 MySQL,InstanceService.list_databases 会连接超时
# 所以我们只验证权限检查通过(不是 403),不验证实际结果
# 改用更短的超时检查:如果返回 403 说明权限没通过
# 直接验证 get_instance 端点作为替代(因为 databases 会卡在真实连接)
resp = await client.get(
f"/api/v1/instances/{instance.id}",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
assert resp.json()["name"] == "生产MySQL"
# ── Test: GET /instances/{id}/databases/{db}/tables 权限检查 ──
class TestListTablesPermission:
"""GET /instances/{id}/databases/{db}/tables — 表列表访问控制"""
@pytest.mark.asyncio
async def test_developer_not_in_group_denied(
self, client, developer_token, group_with_instance
):
"""非权限组成员无法访问 tables 端点"""
_, instance = group_with_instance
resp = await client.get(
f"/api/v1/instances/{instance.id}/databases/test_db/tables",
headers={"Authorization": f"Bearer {developer_token}"},
)
assert resp.status_code == 403
# ── Test: 多权限组交叉 ────────────────────────────────────
class TestMultipleGroupCrossAccess:
"""多权限组场景测试"""
@pytest.mark.asyncio
async def test_user_in_group_a_cannot_access_group_b_instance(
self, client, db, developer_user
):
"""属于组A的用户不能访问组B的实例"""
# 创建组A(用户属于)
group_a = InstanceGroup(name="组A", db_type="mysql")
db.add(group_a)
await db.flush()
instance_a = Instance(
name="组A实例", db_type="mysql", host="10.0.0.1", port=3306,
username="root", password="pw", database="db_a",
status="disconnected", group_id=group_a.id,
)
db.add(instance_a)
# 创建组B(用户不属于)
group_b = InstanceGroup(name="组B", db_type="mysql")
db.add(group_b)
await db.flush()
instance_b = Instance(
name="组B实例", db_type="mysql", host="10.0.0.2", port=3306,
username="root", password="pw", database="db_b",
status="disconnected", group_id=group_b.id,
)
db.add(instance_b)
# 公开实例
instance_pub = Instance(
name="公开实例", db_type="mysql", host="127.0.0.1", port=3306,
username="root", password="pw", database="db_pub",
status="disconnected", group_id=None,
)
db.add(instance_pub)
# developer 加入组A
await db.execute(
user_groups.insert().values(
user_id=developer_user.id, group_id=group_a.id
)
)
await db.commit()
token = create_access_token(data={"sub": str(developer_user.id), "role": developer_user.role})
# 列表应该只有 组A实例 + 公开实例
resp = await client.get(
"/api/v1/instances",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
names = {i["name"] for i in resp.json()}
assert "组A实例" in names
assert "公开实例" in names
assert "组B实例" not in names
# 直接访问组B实例 → 403
resp = await client.get(
f"/api/v1/instances/{instance_b.id}",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 403
# 直接访问组A实例 → 200
resp = await client.get(
f"/api/v1/instances/{instance_a.id}",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
# 直接访问公开实例 → 200
resp = await client.get(
f"/api/v1/instances/{instance_pub.id}",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
# ── Test: permissions.py 工具函数 ─────────────────────────
class TestPermissionUtilities:
"""测试 permissions.py 中的工具函数"""
@pytest.mark.asyncio
async def test_get_user_accessible_group_ids_admin(self, db, admin_user):
"""admin 返回空列表"""
from app.core.permissions import get_user_accessible_group_ids
result = await get_user_accessible_group_ids(admin_user, db)
assert result == []
@pytest.mark.asyncio
async def test_get_user_accessible_group_ids_with_groups(
self, db, developer_user
):
"""普通用户返回其所属权限组列表"""
from app.core.permissions import get_user_accessible_group_ids
group = InstanceGroup(name="测试组", db_type="mysql")
db.add(group)
await db.flush()
await db.execute(
user_groups.insert().values(
user_id=developer_user.id, group_id=group.id
)
)
await db.commit()
result = await get_user_accessible_group_ids(developer_user, db)
assert len(result) == 1
assert group.id in result
@pytest.mark.asyncio
async def test_get_instance_or_403_admin(self, db, admin_user, test_instance):
"""admin 通过 get_instance_or_403 能获取任何实例"""
from app.core.permissions import get_instance_or_403
instance = await get_instance_or_403(test_instance.id, admin_user, db)
assert instance.id == test_instance.id
@pytest.mark.asyncio
async def test_get_instance_or_403_not_found(self, db, admin_user):
"""不存在的实例 → 404"""
from app.core.permissions import get_instance_or_403
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc_info:
await get_instance_or_403(99999, admin_user, db)
assert exc_info.value.status_code == 404