refactor: 权限组从一组一类型改为一组同时包含readonly+write实例

- 新增 instance_group_members 中间表关联权限组和实例
- InstanceGroup 模型移除旧的 instances relationship,改为通过中间表查询
- Instance 模型保留 group_id FK(兼容)但移除 group relationship
- Schema 新增 readonly_instance_ids / write_instance_ids 字段
- InstanceGroupResponse 改为 readonly_instance_count + write_instance_count
- InstanceGroupDetailResponse 返回 readonly_instances + write_instances
- InstanceAssignRequest 新增 access_type 参数
- API create/update 支持同步写入 instance_group_members
- role_permissions 服务更新为使用中间表查询
- 新增迁移 SQL 并应用到 sqlite3.db
- 测试全部通过 (20/20)
This commit is contained in:
logan committed 2026-05-24 20:03:55 +08:00
1 parent 40868c61bf
commit 39d9600e82
8 files changed
+418 -97

No files matched your search

+130 -44
View File
@@ -2,60 +2,79 @@ import json
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, func as sa_func
from sqlalchemy import select, delete as sa_delete, 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.models.instance_group import InstanceGroup, user_groups, instance_group_members
from app.schemas.instance_group import (
InstanceGroupCreate, InstanceGroupUpdate,
InstanceGroupResponse, InstanceGroupDetailResponse,
InstanceAssignRequest, UserAssignRequest, UserGroupUpdateRequest,
InstanceAssignRequest, InstanceRemoveRequest,
UserAssignRequest, UserGroupUpdateRequest,
)
router = APIRouter(prefix="/instance-groups", tags=["权限组管理"])
INSTANCE_FIELDS = ["id", "name", "db_type", "host", "port", "env", "status"]
def _serialize_group(group: InstanceGroup, instance_count: int = 0, user_count: int = 0) -> dict:
def _serialize_instance(inst: Instance) -> dict:
return {k: getattr(inst, k, None) for k in INSTANCE_FIELDS}
async def _count_members(db: AsyncSession, group_id: int, access_type: str) -> int:
result = await db.execute(
select(sa_func.count()).select_from(instance_group_members).where(
instance_group_members.c.group_id == group_id,
instance_group_members.c.access_type == access_type,
)
)
return result.scalar() or 0
async def _get_member_instances(db: AsyncSession, group_id: int, access_type: str) -> List[dict]:
"""查询权限组中指定 access_type 的实例列表。"""
# Join instance_group_members with instances
result = await db.execute(
select(Instance, instance_group_members.c.access_type)
.join(instance_group_members, Instance.id == instance_group_members.c.instance_id)
.where(
instance_group_members.c.group_id == group_id,
instance_group_members.c.access_type == access_type,
)
)
rows = result.all()
return [_serialize_instance(row[0]) for row in rows]
async def _serialize_group(group: InstanceGroup, db: AsyncSession) -> dict:
ro_count = await _count_members(db, group.id, "readonly")
wr_count = await _count_members(db, group.id, "write")
user_count = len(group.users) if group.users else 0
return {
"id": group.id,
"name": group.name,
"description": group.description,
"db_type": group.db_type,
# Legacy field
"permissions": group.get_permissions(),
# New fields
"access_type": group.access_type,
"allowed_databases": group.get_allowed_databases(),
"is_active": group.is_active,
"created_at": group.created_at,
"updated_at": group.updated_at,
"instance_count": instance_count,
"readonly_instance_count": ro_count,
"write_instance_count": wr_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 [])
]
async def _serialize_group_detail(group: InstanceGroup, db: AsyncSession) -> dict:
d = await _serialize_group(group, db)
d["readonly_instances"] = await _get_member_instances(db, group.id, "readonly")
d["write_instances"] = await _get_member_instances(db, group.id, "write")
d["users"] = [
{
"id": u.id,
@@ -68,6 +87,36 @@ def _serialize_group_detail(group: InstanceGroup) -> dict:
return d
async def _sync_members(
db: AsyncSession,
group_id: int,
readonly_instance_ids: List[int],
write_instance_ids: List[int],
):
"""Clear and rewrite all instance_group_members for a group."""
await db.execute(
sa_delete(instance_group_members).where(
instance_group_members.c.group_id == group_id
)
)
for inst_id in readonly_instance_ids:
await db.execute(
instance_group_members.insert().values(
group_id=group_id,
instance_id=inst_id,
access_type="readonly",
)
)
for inst_id in write_instance_ids:
await db.execute(
instance_group_members.insert().values(
group_id=group_id,
instance_id=inst_id,
access_type="write",
)
)
@router.get("", response_model=List[InstanceGroupResponse])
async def list_instance_groups(
db_type: Optional[str] = None,
@@ -79,7 +128,7 @@ async def list_instance_groups(
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]
return [await _serialize_group_detail(g, db) for g in groups]
@router.post("", response_model=InstanceGroupResponse, status_code=201)
@@ -99,21 +148,23 @@ async def create_instance_group(
name=data.name,
description=data.description,
db_type=data.db_type,
# New fields
access_type=data.access_type,
allowed_databases=(
json.dumps(data.allowed_databases, ensure_ascii=False)
if data.allowed_databases
else None
),
# Legacy field (kept for compat)
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)
# Write members
await _sync_members(db, group.id, data.readonly_instance_ids, data.write_instance_ids)
await db.commit()
return await _serialize_group(group, db)
@router.get("/{group_id}", response_model=InstanceGroupDetailResponse)
@@ -128,7 +179,7 @@ async def get_instance_group(
group = result.scalar_one_or_none()
if not group:
raise HTTPException(status_code=404, detail="权限组不存在")
return _serialize_group_detail(group)
return await _serialize_group_detail(group, db)
@router.put("/{group_id}", response_model=InstanceGroupResponse)
@@ -173,11 +224,25 @@ async def update_instance_group(
else None
)
# Extract member lists before setting attributes
ro_ids = update_data.pop("readonly_instance_ids", None)
wr_ids = update_data.pop("write_instance_ids", None)
for key, value in update_data.items():
setattr(group, key, value)
await db.commit()
await db.refresh(group)
return _serialize_group(group)
# Update members if provided
if ro_ids is not None or wr_ids is not None:
await _sync_members(
db, group_id,
ro_ids if ro_ids is not None else [],
wr_ids if wr_ids is not None else [],
)
await db.commit()
return await _serialize_group(group, db)
@router.delete("/{group_id}")
@@ -193,12 +258,12 @@ async def delete_instance_group(
if not group:
raise HTTPException(status_code=404, detail="权限组不存在")
# Detach instances
inst_result = await db.execute(
select(Instance).where(Instance.group_id == group_id)
# Delete members first
await db.execute(
sa_delete(instance_group_members).where(
instance_group_members.c.group_id == group_id
)
)
for inst in inst_result.scalars().all():
inst.group_id = None
await db.delete(group)
await db.commit()
@@ -225,7 +290,22 @@ async def assign_instances(
)
inst = inst_result.scalar_one_or_none()
if inst:
inst.group_id = group_id
# Check for duplicate
existing = await db.execute(
select(instance_group_members).where(
instance_group_members.c.group_id == group_id,
instance_group_members.c.instance_id == inst_id,
instance_group_members.c.access_type == data.access_type,
)
)
if not existing.first():
await db.execute(
instance_group_members.insert().values(
group_id=group_id,
instance_id=inst_id,
access_type=data.access_type,
)
)
await db.commit()
return {"message": "实例已关联"}
@@ -234,16 +314,22 @@ async def assign_instances(
async def remove_instance(
group_id: int,
instance_id: int,
access_type: Optional[str] = None,
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)
conditions = [
instance_group_members.c.group_id == group_id,
instance_group_members.c.instance_id == instance_id,
]
if access_type:
conditions.append(instance_group_members.c.access_type == access_type)
result = await db.execute(
sa_delete(instance_group_members).where(*conditions)
)
inst = inst_result.scalar_one_or_none()
if not inst:
if result.rowcount == 0:
raise HTTPException(status_code=404, detail="实例未关联该权限组")
inst.group_id = None
await db.commit()
return {"message": "实例已取消关联"}
+1 -1
View File
@@ -1,3 +1,3 @@
from app.models.instance_group import InstanceGroup, user_groups # noqa: F401
from app.models.instance_group import InstanceGroup, user_groups, instance_group_members # noqa: F401
from app.models.system_setting import SystemSetting # noqa: F401
from app.models.query_history import QueryHistory, QuickQueryModel # noqa: F401
+2 -3
View File
@@ -26,12 +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}]
# 权限组关联
# 权限组关联 (legacy FK, kept for compat)
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")
# 关系 — legacy group relationship removed; use instance_group_members instead
workflow_def = relationship("WorkflowDefinition")
+14 -4
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import json
from typing import List, Optional
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, Table, func
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, Table, func, UniqueConstraint
from sqlalchemy.orm import relationship
from app.core.database import Base
@@ -17,6 +17,17 @@ user_groups = Table(
Column("permissions", Text, nullable=True), # JSON: 覆盖组默认权限 (legacy, kept for compat)
)
# 实例-权限组关联表(一个权限组可同时包含 readonly 和 write 实例)
instance_group_members = Table(
"instance_group_members",
Base.metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("group_id", Integer, ForeignKey("instance_groups.id", ondelete="CASCADE"), nullable=False),
Column("instance_id", Integer, ForeignKey("instances.id", ondelete="CASCADE"), nullable=False),
Column("access_type", String(20), nullable=False, default="readonly"), # readonly / write
UniqueConstraint("group_id", "instance_id", "access_type", name="uq_group_instance_access"),
)
class InstanceGroup(Base):
__tablename__ = "instance_groups"
@@ -26,14 +37,13 @@ class InstanceGroup(Base):
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", ...] (legacy, kept for compat)
access_type = Column(String(20), nullable=True, default="readonly") # readonly / write
access_type = Column(String(20), nullable=True, default="readonly") # legacy, kept for compat
allowed_databases = Column(Text, nullable=True) # JSON array of database names; empty = all databases
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 保持不变
users = relationship("User", secondary=user_groups, back_populates="groups", lazy="selectin")
# ---- Legacy helpers (kept for backward compatibility) ----
+15 -13
View File
@@ -7,11 +7,6 @@ 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)$")
access_type: str = Field(
default="readonly",
pattern=r"^(readonly|write)$",
description="权限组类型: readonly(只读查看) / write(写入变更)",
)
allowed_databases: Optional[List[str]] = Field(
default=None,
description="允许访问的数据库名列表,为空表示关联实例的所有库",
@@ -19,17 +14,15 @@ class InstanceGroupCreate(BaseModel):
# Legacy field kept for backward compatibility
permissions: Optional[List[str]] = None
is_active: bool = True
# New: instance ids by access_type
readonly_instance_ids: List[int] = Field(default_factory=list)
write_instance_ids: List[int] = Field(default_factory=list)
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)$")
access_type: Optional[str] = Field(
default=None,
pattern=r"^(readonly|write)$",
description="权限组类型: readonly(只读查看) / write(写入变更)",
)
allowed_databases: Optional[List[str]] = Field(
default=None,
description="允许访问的数据库名列表,为空表示关联实例的所有库",
@@ -37,6 +30,9 @@ class InstanceGroupUpdate(BaseModel):
# Legacy field kept for backward compatibility
permissions: Optional[List[str]] = None
is_active: Optional[bool] = None
# New: instance ids by access_type
readonly_instance_ids: Optional[List[int]] = None
write_instance_ids: Optional[List[int]] = None
class InstanceGroupResponse(BaseModel):
@@ -47,24 +43,30 @@ class InstanceGroupResponse(BaseModel):
# Legacy field
permissions: Optional[List[str]] = None
# New fields
access_type: Optional[str] = None
allowed_databases: Optional[List[str]] = None
is_active: bool
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
instance_count: int = 0
readonly_instance_count: int = 0
write_instance_count: int = 0
user_count: int = 0
model_config = ConfigDict(from_attributes=True)
class InstanceGroupDetailResponse(InstanceGroupResponse):
instances: List[dict] = []
readonly_instances: List[dict] = []
write_instances: List[dict] = []
users: List[dict] = []
class InstanceAssignRequest(BaseModel):
instance_ids: List[int] = Field(..., min_length=1)
access_type: str = Field(default="readonly", pattern=r"^(readonly|write)$")
class InstanceRemoveRequest(BaseModel):
instance_ids: List[int] = Field(..., min_length=1)
class UserAssignRequest(BaseModel):
+55 -26
View File
@@ -21,7 +21,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
from app.models.instance import Instance
from app.models.instance_group import InstanceGroup, user_groups
from app.models.instance_group import InstanceGroup, user_groups, instance_group_members
logger = logging.getLogger(__name__)
@@ -132,7 +132,7 @@ async def get_accessible_instances(
- admin/dba: 返回所有实例
- auditor: readonly 返回所有,write 返回空
- developer: 返回其权限组关联的实例
- developer: 返回其权限组关联的实例(通过 instance_group_members)
"""
role_config = _get_role_config(user.role)
access_value = role_config.get(access_type)
@@ -144,27 +144,26 @@ async def get_accessible_instances(
if access_value is None:
return []
# "groups" — 查询权限组关联的实例
# "groups" — 查询权限组关联的实例 via instance_group_members
group_ids = await _get_user_group_ids(db, user)
if not group_ids:
return []
# 只返回匹配 access_type 的权限组关联的实例
# Query instance_group_members for the matching access_type
result = await db.execute(
select(InstanceGroup).where(InstanceGroup.id.in_(group_ids))
select(instance_group_members.c.instance_id)
.where(
instance_group_members.c.group_id.in_(group_ids),
instance_group_members.c.access_type == access_type,
)
)
groups = result.scalars().all()
matched_instance_ids: set = set()
for g in groups:
if g.access_type == access_type:
for inst in (g.instances or []):
matched_instance_ids.add(inst.id)
instance_ids = list({row[0] for row in result.all()})
if not matched_instance_ids:
if not instance_ids:
return []
inst_result = await db.execute(
select(Instance).where(Instance.id.in_(matched_instance_ids))
select(Instance).where(Instance.id.in_(instance_ids))
)
return list(inst_result.scalars().all())
@@ -195,12 +194,26 @@ async def _get_user_groups_detail(db: AsyncSession, user: User) -> List[dict]:
details = []
for g in groups:
instance_ids = [inst.id for inst in (g.instances or [])]
# Get instance IDs by access_type from instance_group_members
ro_result = await db.execute(
select(instance_group_members.c.instance_id).where(
instance_group_members.c.group_id == g.id,
instance_group_members.c.access_type == "readonly",
)
)
wr_result = await db.execute(
select(instance_group_members.c.instance_id).where(
instance_group_members.c.group_id == g.id,
instance_group_members.c.access_type == "write",
)
)
ro_ids = [row[0] for row in ro_result.all()]
wr_ids = [row[0] for row in wr_result.all()]
details.append({
"group_id": g.id,
"group_name": g.name,
"access_type": g.access_type,
"instance_ids": instance_ids,
"readonly_instance_ids": ro_ids,
"write_instance_ids": wr_ids,
"allowed_databases": g.get_allowed_databases(),
})
return details
@@ -221,28 +234,44 @@ async def _check_group_access(
if not instance:
return False
# Public instance (no group_id) — readonly allowed
if instance.group_id is None:
return access_type == "readonly"
group_ids = await _get_user_group_ids(db, user)
if instance.group_id not in group_ids:
# Check if any of the user's groups contain this instance with matching access_type
result = await db.execute(
select(instance_group_members).where(
instance_group_members.c.instance_id == instance_id,
instance_group_members.c.group_id.in_(group_ids),
instance_group_members.c.access_type == access_type,
)
)
member_row = result.first()
if not member_row:
# For readonly access, write access_type also grants readonly
if access_type == "readonly":
wr_result = await db.execute(
select(instance_group_members).where(
instance_group_members.c.instance_id == instance_id,
instance_group_members.c.group_id.in_(group_ids),
instance_group_members.c.access_type == "write",
)
)
member_row = wr_result.first()
if not member_row:
return False
# Check allowed_databases from the group
group_result = await db.execute(
select(InstanceGroup).where(InstanceGroup.id == instance.group_id)
select(InstanceGroup).where(InstanceGroup.id == member_row.group_id)
)
group = group_result.scalar_one_or_none()
if not group:
return False
# readonly 权限组也可以被 write 请求使用(读+写包含只读)
if access_type == "write" and group.access_type == "readonly":
return False
# readonly 请求可以用 readonly 或 write 权限组
if access_type == "readonly" and group.access_type in ("readonly", "write"):
pass # ok
allowed_dbs = group.get_allowed_databases()
if not allowed_dbs:
return True