feat: 实例权限组系统 — 完整CRUD + 实例/用户关联 + 前端管理页面

后端:
- InstanceGroup 模型 (name/db_type/permissions JSON/is_active)
- user_groups 关联表 (用户-权限组 M2M + 权限覆盖)
- Instance 模型增加 group_id FK
- User 模型增加 groups 关系
- API: CRUD + 实例关联 + 用户分配 + 权限组过滤
- 15 个新测试 (CRUD/关联/权限检查/参数校验) — 217/217 passing

前端:
- InstanceGroupPage: 左右布局(列表+详情)
- 权限配置: 5类17项权限勾选 (查询/DDL/工单/数据操作/管理)
- 实例关联/用户关联弹框
- 侧边栏增加权限组菜单 (数据源分组下)
- 路由 + 权限注册
This commit is contained in:
logan committed 2026-05-23 00:09:34 +08:00
1 parent 836ea97ee5
commit 765d27f1e3
15 files changed
+1283 -1

No files matched your search

@@ -0,0 +1,330 @@
import json
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, func as sa_func
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_user, require_role
from app.models.user import User
from app.models.instance import Instance
from app.models.instance_group import InstanceGroup, user_groups
from app.schemas.instance_group import (
InstanceGroupCreate, InstanceGroupUpdate,
InstanceGroupResponse, InstanceGroupDetailResponse,
InstanceAssignRequest, UserAssignRequest, UserGroupUpdateRequest,
)
router = APIRouter(prefix="/instance-groups", tags=["权限组管理"])
def _serialize_group(group: InstanceGroup, instance_count: int = 0, user_count: int = 0) -> dict:
return {
"id": group.id,
"name": group.name,
"description": group.description,
"db_type": group.db_type,
"permissions": group.get_permissions(),
"is_active": group.is_active,
"created_at": group.created_at,
"updated_at": group.updated_at,
"instance_count": instance_count,
"user_count": user_count,
}
def _serialize_group_detail(group: InstanceGroup) -> dict:
d = _serialize_group(
group,
instance_count=len(group.instances) if group.instances else 0,
user_count=len(group.users) if group.users else 0,
)
d["instances"] = [
{
"id": inst.id,
"name": inst.name,
"db_type": inst.db_type,
"host": inst.host,
"port": inst.port,
"env": inst.env,
"status": inst.status,
}
for inst in (group.instances or [])
]
d["users"] = [
{
"id": u.id,
"username": u.username,
"display_name": u.display_name,
"role": u.role,
}
for u in (group.users or [])
]
return d
@router.get("", response_model=List[InstanceGroupResponse])
async def list_instance_groups(
db_type: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
stmt = select(InstanceGroup).order_by(InstanceGroup.id)
if db_type:
stmt = stmt.where(InstanceGroup.db_type == db_type)
result = await db.execute(stmt)
groups = result.scalars().all()
return [_serialize_group_detail(g) for g in groups]
@router.post("", response_model=InstanceGroupResponse, status_code=201)
async def create_instance_group(
data: InstanceGroupCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
# Check name uniqueness
existing = await db.execute(
select(InstanceGroup).where(InstanceGroup.name == data.name)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="权限组名称已存在")
group = InstanceGroup(
name=data.name,
description=data.description,
db_type=data.db_type,
permissions=json.dumps(data.permissions, ensure_ascii=False) if data.permissions else None,
is_active=data.is_active,
)
db.add(group)
await db.commit()
await db.refresh(group)
return _serialize_group(group)
@router.get("/{group_id}", response_model=InstanceGroupDetailResponse)
async def get_instance_group(
group_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(InstanceGroup).where(InstanceGroup.id == group_id)
)
group = result.scalar_one_or_none()
if not group:
raise HTTPException(status_code=404, detail="权限组不存在")
return _serialize_group_detail(group)
@router.put("/{group_id}", response_model=InstanceGroupResponse)
async def update_instance_group(
group_id: int,
data: InstanceGroupUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
result = await db.execute(
select(InstanceGroup).where(InstanceGroup.id == group_id)
)
group = result.scalar_one_or_none()
if not group:
raise HTTPException(status_code=404, detail="权限组不存在")
update_data = data.model_dump(exclude_unset=True)
if "name" in update_data:
# Check uniqueness
existing = await db.execute(
select(InstanceGroup).where(
InstanceGroup.name == update_data["name"],
InstanceGroup.id != group_id,
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="权限组名称已存在")
if "permissions" in update_data:
update_data["permissions"] = (
json.dumps(update_data["permissions"], ensure_ascii=False)
if update_data["permissions"]
else None
)
for key, value in update_data.items():
setattr(group, key, value)
await db.commit()
await db.refresh(group)
return _serialize_group(group)
@router.delete("/{group_id}")
async def delete_instance_group(
group_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
result = await db.execute(
select(InstanceGroup).where(InstanceGroup.id == group_id)
)
group = result.scalar_one_or_none()
if not group:
raise HTTPException(status_code=404, detail="权限组不存在")
# Detach instances
inst_result = await db.execute(
select(Instance).where(Instance.group_id == group_id)
)
for inst in inst_result.scalars().all():
inst.group_id = None
await db.delete(group)
await db.commit()
return {"message": "权限组已删除"}
@router.post("/{group_id}/instances")
async def assign_instances(
group_id: int,
data: InstanceAssignRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
result = await db.execute(
select(InstanceGroup).where(InstanceGroup.id == group_id)
)
group = result.scalar_one_or_none()
if not group:
raise HTTPException(status_code=404, detail="权限组不存在")
for inst_id in data.instance_ids:
inst_result = await db.execute(
select(Instance).where(Instance.id == inst_id)
)
inst = inst_result.scalar_one_or_none()
if inst:
inst.group_id = group_id
await db.commit()
return {"message": "实例已关联"}
@router.delete("/{group_id}/instances/{instance_id}")
async def remove_instance(
group_id: int,
instance_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
inst_result = await db.execute(
select(Instance).where(Instance.id == instance_id, Instance.group_id == group_id)
)
inst = inst_result.scalar_one_or_none()
if not inst:
raise HTTPException(status_code=404, detail="实例未关联该权限组")
inst.group_id = None
await db.commit()
return {"message": "实例已取消关联"}
@router.get("/{group_id}/users")
async def list_group_users(
group_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(InstanceGroup).where(InstanceGroup.id == group_id)
)
group = result.scalar_one_or_none()
if not group:
raise HTTPException(status_code=404, detail="权限组不存在")
# Query user_groups for permissions override
ug_result = await db.execute(
select(user_groups).where(user_groups.c.group_id == group_id)
)
ug_rows = ug_result.all()
perm_map = {}
for row in ug_rows:
perms = json.loads(row.permissions) if row.permissions else None
perm_map[row.user_id] = perms
users_data = []
for u in (group.users or []):
users_data.append({
"id": u.id,
"username": u.username,
"display_name": u.display_name,
"role": u.role,
"permissions": perm_map.get(u.id),
})
return users_data
@router.post("/{group_id}/users")
async def assign_users(
group_id: int,
data: UserAssignRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
result = await db.execute(
select(InstanceGroup).where(InstanceGroup.id == group_id)
)
group = result.scalar_one_or_none()
if not group:
raise HTTPException(status_code=404, detail="权限组不存在")
perm_json = json.dumps(data.permissions, ensure_ascii=False) if data.permissions else None
for user_id in data.user_ids:
# Check if already assigned
check = await db.execute(
select(user_groups).where(
user_groups.c.user_id == user_id,
user_groups.c.group_id == group_id,
)
)
if not check.first():
await db.execute(
user_groups.insert().values(
user_id=user_id,
group_id=group_id,
permissions=perm_json,
)
)
await db.commit()
return {"message": "用户已分配"}
@router.put("/users/{user_id}/groups")
async def update_user_groups(
user_id: int,
data: UserGroupUpdateRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
user_result = await db.execute(
select(User).where(User.id == user_id)
)
user = user_result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
# Remove existing group associations
await db.execute(
user_groups.delete().where(user_groups.c.user_id == user_id)
)
# Add new ones
for gid in data.group_ids:
group_result = await db.execute(
select(InstanceGroup).where(InstanceGroup.id == gid)
)
if group_result.scalar_one_or_none():
await db.execute(
user_groups.insert().values(user_id=user_id, group_id=gid)
)
await db.commit()
return {"message": "用户权限组已更新"}
+2
View File
@@ -4,6 +4,7 @@ from app.api.v1.endpoints import (
auth, user, instance, ticket, query, dashboard, audit, audit_rule,
announcement, notification, ldap_config,
instance_detail, backup, redis, dictionary,
instance_group,
)
api_router = APIRouter(prefix="/api/v1")
@@ -23,3 +24,4 @@ api_router.include_router(instance_detail.router)
api_router.include_router(backup.router)
api_router.include_router(redis.router)
api_router.include_router(dictionary.router, prefix="/dictionary", tags=["dictionary"])
api_router.include_router(instance_group.router)
+1
View File
@@ -53,6 +53,7 @@ async def lifespan(app: FastAPI):
import app.models.notification # noqa
import app.models.ldap_config # noqa
import app.models.backup # noqa
import app.models.instance_group # noqa
await init_db()
await seed_data()
+1
View File
@@ -0,0 +1 @@
from app.models.instance_group import InstanceGroup, user_groups # noqa: F401
+8 -1
View File
@@ -1,4 +1,5 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, func
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, func
from sqlalchemy.orm import relationship
from app.core.database import Base
@@ -25,5 +26,11 @@ class Instance(Base):
excluded_databases = Column(Text, nullable=True) # JSON: ["sys", "information_schema", ...]
mask_fields = Column(Text, nullable=True) # JSON: [{table, field, rule}]
# 权限组关联
group_id = Column(Integer, ForeignKey("instance_groups.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
# 关系
group = relationship("InstanceGroup", back_populates="instances")
+42
View File
@@ -0,0 +1,42 @@
import json
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, Table, func
from sqlalchemy.orm import relationship
from app.core.database import Base
# 用户-权限组关联表
user_groups = Table(
"user_groups",
Base.metadata,
Column("user_id", Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
Column("group_id", Integer, ForeignKey("instance_groups.id", ondelete="CASCADE"), primary_key=True),
Column("permissions", Text, nullable=True), # JSON: 覆盖组默认权限
)
class InstanceGroup(Base):
__tablename__ = "instance_groups"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(100), nullable=False, unique=True)
description = Column(Text, nullable=True)
db_type = Column(String(20), nullable=False, default="all") # mysql/redis/pg/mongo/all
permissions = Column(Text, nullable=True) # JSON: ["query:select", ...]
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
# 关系
instances = relationship("Instance", back_populates="group", lazy="selectin")
users = relationship("User", secondary=user_groups, back_populates="groups", lazy="selectin")
def get_permissions(self) -> list:
if self.permissions:
try:
return json.loads(self.permissions)
except (json.JSONDecodeError, TypeError):
return []
return []
def set_permissions(self, perms: list):
self.permissions = json.dumps(perms, ensure_ascii=False) if perms else None
+4
View File
@@ -1,4 +1,5 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, func
from sqlalchemy.orm import relationship
from app.core.database import Base
@@ -17,3 +18,6 @@ class User(Base):
last_login = Column(DateTime, nullable=True)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
# Many-to-Many relationship with InstanceGroup
groups = relationship("InstanceGroup", secondary="user_groups", back_populates="users", lazy="selectin")
+53
View File
@@ -0,0 +1,53 @@
from datetime import datetime
from typing import Optional, List
from pydantic import BaseModel, Field
class InstanceGroupCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
description: Optional[str] = None
db_type: str = Field(default="all", pattern=r"^(mysql|redis|pg|mongo|all)$")
permissions: Optional[List[str]] = None
is_active: bool = True
class InstanceGroupUpdate(BaseModel):
name: Optional[str] = Field(default=None, min_length=1, max_length=100)
description: Optional[str] = None
db_type: Optional[str] = Field(default=None, pattern=r"^(mysql|redis|pg|mongo|all)$")
permissions: Optional[List[str]] = None
is_active: Optional[bool] = None
class InstanceGroupResponse(BaseModel):
id: int
name: str
description: Optional[str] = None
db_type: str
permissions: Optional[List[str]] = None
is_active: bool
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
instance_count: int = 0
user_count: int = 0
class Config:
from_attributes = True
class InstanceGroupDetailResponse(InstanceGroupResponse):
instances: List[dict] = []
users: List[dict] = []
class InstanceAssignRequest(BaseModel):
instance_ids: List[int] = Field(..., min_length=1)
class UserAssignRequest(BaseModel):
user_ids: List[int] = Field(..., min_length=1)
permissions: Optional[List[str]] = None
class UserGroupUpdateRequest(BaseModel):
group_ids: List[int] = Field(default_factory=list)
+1
View File
@@ -9,6 +9,7 @@ from app.core.database import Base, get_db
from app.core.security import hash_password, create_access_token
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.audit_log import AuditLog
from app.models.audit_rule import AuditRule
+291
View File
@@ -0,0 +1,291 @@
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from app.core.security import hash_password, create_access_token
from app.models.user import User
from app.models.instance import Instance
from app.models.instance_group import InstanceGroup
from tests.conftest import auth_header
@pytest.mark.asyncio
async def test_create_instance_group(client: AsyncClient, admin_token: str):
resp = await client.post(
"/api/v1/instance-groups",
json={
"name": "生产MySQL组",
"description": "生产环境MySQL实例",
"db_type": "mysql",
"permissions": ["query:select", "query:insert", "ddl:create_table"],
"is_active": True,
},
headers=auth_header(admin_token),
)
assert resp.status_code == 201
data = resp.json()
assert data["name"] == "生产MySQL组"
assert data["db_type"] == "mysql"
assert "query:select" in data["permissions"]
@pytest.mark.asyncio
async def test_create_instance_group_duplicate_name(client: AsyncClient, admin_token: str):
payload = {"name": "重复组", "db_type": "mysql"}
resp1 = await client.post("/api/v1/instance-groups", json=payload, headers=auth_header(admin_token))
assert resp1.status_code == 201
resp2 = await client.post("/api/v1/instance-groups", json=payload, headers=auth_header(admin_token))
assert resp2.status_code == 400
@pytest.mark.asyncio
async def test_list_instance_groups(client: AsyncClient, admin_token: str):
# Create two groups
await client.post(
"/api/v1/instance-groups",
json={"name": "组A", "db_type": "mysql"},
headers=auth_header(admin_token),
)
await client.post(
"/api/v1/instance-groups",
json={"name": "组B", "db_type": "redis"},
headers=auth_header(admin_token),
)
resp = await client.get("/api/v1/instance-groups", headers=auth_header(admin_token))
assert resp.status_code == 200
data = resp.json()
assert len(data) >= 2
@pytest.mark.asyncio
async def test_list_instance_groups_filter_db_type(client: AsyncClient, admin_token: str):
await client.post(
"/api/v1/instance-groups",
json={"name": "过滤MySQL", "db_type": "mysql"},
headers=auth_header(admin_token),
)
resp = await client.get(
"/api/v1/instance-groups",
params={"db_type": "mysql"},
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert all(g["db_type"] == "mysql" for g in data)
@pytest.mark.asyncio
async def test_get_instance_group(client: AsyncClient, admin_token: str):
create_resp = await client.post(
"/api/v1/instance-groups",
json={"name": "详情组", "db_type": "pg", "permissions": ["query:select"]},
headers=auth_header(admin_token),
)
group_id = create_resp.json()["id"]
resp = await client.get(
f"/api/v1/instance-groups/{group_id}",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "详情组"
assert data["permissions"] == ["query:select"]
@pytest.mark.asyncio
async def test_get_instance_group_not_found(client: AsyncClient, admin_token: str):
resp = await client.get("/api/v1/instance-groups/9999", headers=auth_header(admin_token))
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_update_instance_group(client: AsyncClient, admin_token: str):
create_resp = await client.post(
"/api/v1/instance-groups",
json={"name": "更新前", "db_type": "mysql"},
headers=auth_header(admin_token),
)
group_id = create_resp.json()["id"]
resp = await client.put(
f"/api/v1/instance-groups/{group_id}",
json={"name": "更新后", "description": "已更新"},
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "更新后"
assert data["description"] == "已更新"
@pytest.mark.asyncio
async def test_delete_instance_group(client: AsyncClient, admin_token: str):
create_resp = await client.post(
"/api/v1/instance-groups",
json={"name": "删除组", "db_type": "redis"},
headers=auth_header(admin_token),
)
group_id = create_resp.json()["id"]
resp = await client.delete(
f"/api/v1/instance-groups/{group_id}",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
# Verify deleted
get_resp = await client.get(
f"/api/v1/instance-groups/{group_id}",
headers=auth_header(admin_token),
)
assert get_resp.status_code == 404
@pytest.mark.asyncio
async def test_assign_instances_to_group(client: AsyncClient, admin_token: str, db):
# Create group
create_resp = await client.post(
"/api/v1/instance-groups",
json={"name": "实例关联组", "db_type": "mysql"},
headers=auth_header(admin_token),
)
group_id = create_resp.json()["id"]
# Create instance directly
inst = Instance(
name="测试实例1", db_type="mysql", host="127.0.0.1",
port=3306, username="root", password="test", database="test",
)
db.add(inst)
await db.commit()
await db.refresh(inst)
resp = await client.post(
f"/api/v1/instance-groups/{group_id}/instances",
json={"instance_ids": [inst.id]},
headers=auth_header(admin_token),
)
assert resp.status_code == 200
# Verify via group detail
detail_resp = await client.get(
f"/api/v1/instance-groups/{group_id}",
headers=auth_header(admin_token),
)
data = detail_resp.json()
assert data["instance_count"] >= 1
assert any(i["id"] == inst.id for i in data["instances"])
@pytest.mark.asyncio
async def test_remove_instance_from_group(client: AsyncClient, admin_token: str, db):
# Create group + instance
create_resp = await client.post(
"/api/v1/instance-groups",
json={"name": "移除实例组", "db_type": "mysql"},
headers=auth_header(admin_token),
)
group_id = create_resp.json()["id"]
inst = Instance(
name="移除实例", db_type="mysql", host="127.0.0.1",
port=3306, username="root", password="test", database="test",
)
db.add(inst)
await db.commit()
await db.refresh(inst)
# Assign first
await client.post(
f"/api/v1/instance-groups/{group_id}/instances",
json={"instance_ids": [inst.id]},
headers=auth_header(admin_token),
)
# Remove
resp = await client.delete(
f"/api/v1/instance-groups/{group_id}/instances/{inst.id}",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
@pytest.mark.asyncio
async def test_assign_users_to_group(client: AsyncClient, admin_token: str, admin_user):
create_resp = await client.post(
"/api/v1/instance-groups",
json={"name": "用户关联组", "db_type": "all"},
headers=auth_header(admin_token),
)
group_id = create_resp.json()["id"]
resp = await client.post(
f"/api/v1/instance-groups/{group_id}/users",
json={"user_ids": [admin_user.id], "permissions": ["query:select"]},
headers=auth_header(admin_token),
)
assert resp.status_code == 200
# Verify via group users
users_resp = await client.get(
f"/api/v1/instance-groups/{group_id}/users",
headers=auth_header(admin_token),
)
assert users_resp.status_code == 200
users_data = users_resp.json()
assert any(u["id"] == admin_user.id for u in users_data)
@pytest.mark.asyncio
async def test_update_user_groups(client: AsyncClient, admin_token: str, admin_user):
# Create two groups
g1 = await client.post(
"/api/v1/instance-groups",
json={"name": "用户组1", "db_type": "mysql"},
headers=auth_header(admin_token),
)
g2 = await client.post(
"/api/v1/instance-groups",
json={"name": "用户组2", "db_type": "redis"},
headers=auth_header(admin_token),
)
g1_id = g1.json()["id"]
g2_id = g2.json()["id"]
resp = await client.put(
f"/api/v1/instance-groups/users/{admin_user.id}/groups",
json={"group_ids": [g1_id, g2_id]},
headers=auth_header(admin_token),
)
assert resp.status_code == 200
@pytest.mark.asyncio
async def test_non_admin_cannot_create_group(client: AsyncClient, developer_token: str):
resp = await client.post(
"/api/v1/instance-groups",
json={"name": "非管理员组", "db_type": "mysql"},
headers=auth_header(developer_token),
)
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_non_admin_cannot_delete_group(client: AsyncClient, admin_token: str, developer_token: str):
create_resp = await client.post(
"/api/v1/instance-groups",
json={"name": "禁止删除组", "db_type": "mysql"},
headers=auth_header(admin_token),
)
group_id = create_resp.json()["id"]
resp = await client.delete(
f"/api/v1/instance-groups/{group_id}",
headers=auth_header(developer_token),
)
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_group_db_type_validation(client: AsyncClient, admin_token: str):
resp = await client.post(
"/api/v1/instance-groups",
json={"name": "无效类型", "db_type": "oracle"},
headers=auth_header(admin_token),
)
assert resp.status_code == 422