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:
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": "用户权限组已更新"}
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from app.models.instance_group import InstanceGroup, user_groups # noqa: F401
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user