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 typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException 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 sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db from app.core.database import get_db
from app.core.security import get_current_user, require_role from app.core.security import get_current_user, require_role
from app.models.user import User from app.models.user import User
from app.models.instance import Instance 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 ( from app.schemas.instance_group import (
InstanceGroupCreate, InstanceGroupUpdate, InstanceGroupCreate, InstanceGroupUpdate,
InstanceGroupResponse, InstanceGroupDetailResponse, InstanceGroupResponse, InstanceGroupDetailResponse,
InstanceAssignRequest, UserAssignRequest, UserGroupUpdateRequest, InstanceAssignRequest, InstanceRemoveRequest,
UserAssignRequest, UserGroupUpdateRequest,
) )
router = APIRouter(prefix="/instance-groups", tags=["权限组管理"]) 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 { return {
"id": group.id, "id": group.id,
"name": group.name, "name": group.name,
"description": group.description, "description": group.description,
"db_type": group.db_type, "db_type": group.db_type,
# Legacy field
"permissions": group.get_permissions(), "permissions": group.get_permissions(),
# New fields
"access_type": group.access_type,
"allowed_databases": group.get_allowed_databases(), "allowed_databases": group.get_allowed_databases(),
"is_active": group.is_active, "is_active": group.is_active,
"created_at": group.created_at, "created_at": group.created_at,
"updated_at": group.updated_at, "updated_at": group.updated_at,
"instance_count": instance_count, "readonly_instance_count": ro_count,
"write_instance_count": wr_count,
"user_count": user_count, "user_count": user_count,
} }
def _serialize_group_detail(group: InstanceGroup) -> dict: async def _serialize_group_detail(group: InstanceGroup, db: AsyncSession) -> dict:
d = _serialize_group( d = await _serialize_group(group, db)
group, d["readonly_instances"] = await _get_member_instances(db, group.id, "readonly")
instance_count=len(group.instances) if group.instances else 0, d["write_instances"] = await _get_member_instances(db, group.id, "write")
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"] = [ d["users"] = [
{ {
"id": u.id, "id": u.id,
@@ -68,6 +87,36 @@ def _serialize_group_detail(group: InstanceGroup) -> dict:
return d 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]) @router.get("", response_model=List[InstanceGroupResponse])
async def list_instance_groups( async def list_instance_groups(
db_type: Optional[str] = None, db_type: Optional[str] = None,
@@ -79,7 +128,7 @@ async def list_instance_groups(
stmt = stmt.where(InstanceGroup.db_type == db_type) stmt = stmt.where(InstanceGroup.db_type == db_type)
result = await db.execute(stmt) result = await db.execute(stmt)
groups = result.scalars().all() 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) @router.post("", response_model=InstanceGroupResponse, status_code=201)
@@ -99,21 +148,23 @@ async def create_instance_group(
name=data.name, name=data.name,
description=data.description, description=data.description,
db_type=data.db_type, db_type=data.db_type,
# New fields
access_type=data.access_type,
allowed_databases=( allowed_databases=(
json.dumps(data.allowed_databases, ensure_ascii=False) json.dumps(data.allowed_databases, ensure_ascii=False)
if data.allowed_databases if data.allowed_databases
else None else None
), ),
# Legacy field (kept for compat)
permissions=json.dumps(data.permissions, ensure_ascii=False) if data.permissions else None, permissions=json.dumps(data.permissions, ensure_ascii=False) if data.permissions else None,
is_active=data.is_active, is_active=data.is_active,
) )
db.add(group) db.add(group)
await db.commit() await db.commit()
await db.refresh(group) 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) @router.get("/{group_id}", response_model=InstanceGroupDetailResponse)
@@ -128,7 +179,7 @@ async def get_instance_group(
group = result.scalar_one_or_none() group = result.scalar_one_or_none()
if not group: if not group:
raise HTTPException(status_code=404, detail="权限组不存在") 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) @router.put("/{group_id}", response_model=InstanceGroupResponse)
@@ -173,11 +224,25 @@ async def update_instance_group(
else None 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(): for key, value in update_data.items():
setattr(group, key, value) setattr(group, key, value)
await db.commit() await db.commit()
await db.refresh(group) 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}") @router.delete("/{group_id}")
@@ -193,12 +258,12 @@ async def delete_instance_group(
if not group: if not group:
raise HTTPException(status_code=404, detail="权限组不存在") raise HTTPException(status_code=404, detail="权限组不存在")
# Detach instances # Delete members first
inst_result = await db.execute( await db.execute(
select(Instance).where(Instance.group_id == group_id) 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.delete(group)
await db.commit() await db.commit()
@@ -225,7 +290,22 @@ async def assign_instances(
) )
inst = inst_result.scalar_one_or_none() inst = inst_result.scalar_one_or_none()
if inst: 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() await db.commit()
return {"message": "实例已关联"} return {"message": "实例已关联"}
@@ -234,16 +314,22 @@ async def assign_instances(
async def remove_instance( async def remove_instance(
group_id: int, group_id: int,
instance_id: int, instance_id: int,
access_type: Optional[str] = None,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")), current_user: User = Depends(require_role("admin")),
): ):
inst_result = await db.execute( conditions = [
select(Instance).where(Instance.id == instance_id, Instance.group_id == group_id) 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 result.rowcount == 0:
if not inst:
raise HTTPException(status_code=404, detail="实例未关联该权限组") raise HTTPException(status_code=404, detail="实例未关联该权限组")
inst.group_id = None
await db.commit() await db.commit()
return {"message": "实例已取消关联"} 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.system_setting import SystemSetting # noqa: F401
from app.models.query_history import QueryHistory, QuickQueryModel # 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", ...] excluded_databases = Column(Text, nullable=True) # JSON: ["sys", "information_schema", ...]
mask_fields = Column(Text, nullable=True) # JSON: [{table, field, rule}] 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) group_id = Column(Integer, ForeignKey("instance_groups.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime, server_default=func.now()) created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
# 关系 # 关系 — legacy group relationship removed; use instance_group_members instead
group = relationship("InstanceGroup", back_populates="instances")
workflow_def = relationship("WorkflowDefinition") workflow_def = relationship("WorkflowDefinition")
+14 -4
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import json import json
from typing import List, Optional 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 sqlalchemy.orm import relationship
from app.core.database import Base from app.core.database import Base
@@ -17,6 +17,17 @@ user_groups = Table(
Column("permissions", Text, nullable=True), # JSON: 覆盖组默认权限 (legacy, kept for compat) 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): class InstanceGroup(Base):
__tablename__ = "instance_groups" __tablename__ = "instance_groups"
@@ -26,14 +37,13 @@ class InstanceGroup(Base):
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
db_type = Column(String(20), nullable=False, default="all") # mysql/redis/pg/mongo/all 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) 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 allowed_databases = Column(Text, nullable=True) # JSON array of database names; empty = all databases
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True)
created_at = Column(DateTime, server_default=func.now()) created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
# 关系 # 关系 — users 保持不变
instances = relationship("Instance", back_populates="group", lazy="selectin")
users = relationship("User", secondary=user_groups, back_populates="groups", lazy="selectin") users = relationship("User", secondary=user_groups, back_populates="groups", lazy="selectin")
# ---- Legacy helpers (kept for backward compatibility) ---- # ---- 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) name: str = Field(..., min_length=1, max_length=100)
description: Optional[str] = None description: Optional[str] = None
db_type: str = Field(default="all", pattern=r"^(mysql|redis|pg|mongo|all)$") 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( allowed_databases: Optional[List[str]] = Field(
default=None, default=None,
description="允许访问的数据库名列表,为空表示关联实例的所有库", description="允许访问的数据库名列表,为空表示关联实例的所有库",
@@ -19,17 +14,15 @@ class InstanceGroupCreate(BaseModel):
# Legacy field kept for backward compatibility # Legacy field kept for backward compatibility
permissions: Optional[List[str]] = None permissions: Optional[List[str]] = None
is_active: bool = True 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): class InstanceGroupUpdate(BaseModel):
name: Optional[str] = Field(default=None, min_length=1, max_length=100) name: Optional[str] = Field(default=None, min_length=1, max_length=100)
description: Optional[str] = None description: Optional[str] = None
db_type: Optional[str] = Field(default=None, pattern=r"^(mysql|redis|pg|mongo|all)$") 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( allowed_databases: Optional[List[str]] = Field(
default=None, default=None,
description="允许访问的数据库名列表,为空表示关联实例的所有库", description="允许访问的数据库名列表,为空表示关联实例的所有库",
@@ -37,6 +30,9 @@ class InstanceGroupUpdate(BaseModel):
# Legacy field kept for backward compatibility # Legacy field kept for backward compatibility
permissions: Optional[List[str]] = None permissions: Optional[List[str]] = None
is_active: Optional[bool] = 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): class InstanceGroupResponse(BaseModel):
@@ -47,24 +43,30 @@ class InstanceGroupResponse(BaseModel):
# Legacy field # Legacy field
permissions: Optional[List[str]] = None permissions: Optional[List[str]] = None
# New fields # New fields
access_type: Optional[str] = None
allowed_databases: Optional[List[str]] = None allowed_databases: Optional[List[str]] = None
is_active: bool is_active: bool
created_at: Optional[datetime] = None created_at: Optional[datetime] = None
updated_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 user_count: int = 0
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
class InstanceGroupDetailResponse(InstanceGroupResponse): class InstanceGroupDetailResponse(InstanceGroupResponse):
instances: List[dict] = [] readonly_instances: List[dict] = []
write_instances: List[dict] = []
users: List[dict] = [] users: List[dict] = []
class InstanceAssignRequest(BaseModel): class InstanceAssignRequest(BaseModel):
instance_ids: List[int] = Field(..., min_length=1) 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): 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.user import User
from app.models.instance import Instance 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__) logger = logging.getLogger(__name__)
@@ -132,7 +132,7 @@ async def get_accessible_instances(
- admin/dba: 返回所有实例 - admin/dba: 返回所有实例
- auditor: readonly 返回所有,write 返回空 - auditor: readonly 返回所有,write 返回空
- developer: 返回其权限组关联的实例 - developer: 返回其权限组关联的实例(通过 instance_group_members)
""" """
role_config = _get_role_config(user.role) role_config = _get_role_config(user.role)
access_value = role_config.get(access_type) access_value = role_config.get(access_type)
@@ -144,27 +144,26 @@ async def get_accessible_instances(
if access_value is None: if access_value is None:
return [] return []
# "groups" — 查询权限组关联的实例 # "groups" — 查询权限组关联的实例 via instance_group_members
group_ids = await _get_user_group_ids(db, user) group_ids = await _get_user_group_ids(db, user)
if not group_ids: if not group_ids:
return [] return []
# 只返回匹配 access_type 的权限组关联的实例 # Query instance_group_members for the matching access_type
result = await db.execute( 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() instance_ids = list({row[0] for row in result.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)
if not matched_instance_ids: if not instance_ids:
return [] return []
inst_result = await db.execute( 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()) return list(inst_result.scalars().all())
@@ -195,12 +194,26 @@ async def _get_user_groups_detail(db: AsyncSession, user: User) -> List[dict]:
details = [] details = []
for g in groups: 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({ details.append({
"group_id": g.id, "group_id": g.id,
"group_name": g.name, "group_name": g.name,
"access_type": g.access_type, "readonly_instance_ids": ro_ids,
"instance_ids": instance_ids, "write_instance_ids": wr_ids,
"allowed_databases": g.get_allowed_databases(), "allowed_databases": g.get_allowed_databases(),
}) })
return details return details
@@ -221,28 +234,44 @@ async def _check_group_access(
if not instance: if not instance:
return False return False
# Public instance (no group_id) — readonly allowed
if instance.group_id is None: if instance.group_id is None:
return access_type == "readonly" return access_type == "readonly"
group_ids = await _get_user_group_ids(db, user) 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 return False
# Check allowed_databases from the group
group_result = await db.execute( 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() group = group_result.scalar_one_or_none()
if not group: if not group:
return False 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() allowed_dbs = group.get_allowed_databases()
if not allowed_dbs: if not allowed_dbs:
return True return True
+1 -1
View File
@@ -9,7 +9,7 @@ from app.core.database import Base, get_db
from app.core.security import hash_password, create_access_token from app.core.security import hash_password, create_access_token
from app.models.user import User from app.models.user import User
from app.models.instance import Instance 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.models.ticket import Ticket from app.models.ticket import Ticket
from app.models.audit_log import AuditLog from app.models.audit_log import AuditLog
from app.models.audit_rule import AuditRule from app.models.audit_rule import AuditRule
+200 -5
View File
@@ -5,7 +5,7 @@ from httpx import ASGITransport, AsyncClient
from app.core.security import hash_password, create_access_token from app.core.security import hash_password, create_access_token
from app.models.user import User from app.models.user import User
from app.models.instance import Instance from app.models.instance import Instance
from app.models.instance_group import InstanceGroup from app.models.instance_group import InstanceGroup, instance_group_members
from tests.conftest import auth_header from tests.conftest import auth_header
@@ -159,7 +159,7 @@ async def test_assign_instances_to_group(client: AsyncClient, admin_token: str,
resp = await client.post( resp = await client.post(
f"/api/v1/instance-groups/{group_id}/instances", f"/api/v1/instance-groups/{group_id}/instances",
json={"instance_ids": [inst.id]}, json={"instance_ids": [inst.id], "access_type": "readonly"},
headers=auth_header(admin_token), headers=auth_header(admin_token),
) )
assert resp.status_code == 200 assert resp.status_code == 200
@@ -170,8 +170,75 @@ async def test_assign_instances_to_group(client: AsyncClient, admin_token: str,
headers=auth_header(admin_token), headers=auth_header(admin_token),
) )
data = detail_resp.json() data = detail_resp.json()
assert data["instance_count"] >= 1 assert data["readonly_instance_count"] >= 1
assert any(i["id"] == inst.id for i in data["instances"]) assert any(i["id"] == inst.id for i in data["readonly_instances"])
@pytest.mark.asyncio
async def test_assign_write_instances_to_group(client: AsyncClient, admin_token: str, db):
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)
resp = await client.post(
f"/api/v1/instance-groups/{group_id}/instances",
json={"instance_ids": [inst.id], "access_type": "write"},
headers=auth_header(admin_token),
)
assert resp.status_code == 200
detail_resp = await client.get(
f"/api/v1/instance-groups/{group_id}",
headers=auth_header(admin_token),
)
data = detail_resp.json()
assert data["write_instance_count"] >= 1
assert any(i["id"] == inst.id for i in data["write_instances"])
@pytest.mark.asyncio
async def test_assign_instances_default_readonly(client: AsyncClient, admin_token: str, db):
"""Test that access_type defaults to readonly when not specified."""
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)
# Send without access_type — should default to readonly
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
detail_resp = await client.get(
f"/api/v1/instance-groups/{group_id}",
headers=auth_header(admin_token),
)
data = detail_resp.json()
assert data["readonly_instance_count"] >= 1
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -195,17 +262,110 @@ async def test_remove_instance_from_group(client: AsyncClient, admin_token: str,
# Assign first # Assign first
await client.post( await client.post(
f"/api/v1/instance-groups/{group_id}/instances", f"/api/v1/instance-groups/{group_id}/instances",
json={"instance_ids": [inst.id]}, json={"instance_ids": [inst.id], "access_type": "readonly"},
headers=auth_header(admin_token), headers=auth_header(admin_token),
) )
# Remove # Remove
resp = await client.delete( resp = await client.delete(
f"/api/v1/instance-groups/{group_id}/instances/{inst.id}", f"/api/v1/instance-groups/{group_id}/instances/{inst.id}",
params={"access_type": "readonly"},
headers=auth_header(admin_token), headers=auth_header(admin_token),
) )
assert resp.status_code == 200 assert resp.status_code == 200
@pytest.mark.asyncio
async def test_assign_instances_on_create(client: AsyncClient, admin_token: str, db):
"""Test creating a group with readonly_instance_ids and write_instance_ids."""
inst1 = Instance(
name="创建时只读实例", db_type="mysql", host="127.0.0.1",
port=3306, username="root", password="test", database="test",
)
inst2 = Instance(
name="创建时写入实例", db_type="mysql", host="127.0.0.1",
port=3307, username="root", password="test", database="test",
)
db.add(inst1)
db.add(inst2)
await db.commit()
await db.refresh(inst1)
await db.refresh(inst2)
resp = await client.post(
"/api/v1/instance-groups",
json={
"name": "创建时关联实例组",
"db_type": "mysql",
"readonly_instance_ids": [inst1.id],
"write_instance_ids": [inst2.id],
},
headers=auth_header(admin_token),
)
assert resp.status_code == 201
data = resp.json()
assert data["readonly_instance_count"] == 1
assert data["write_instance_count"] == 1
# Verify detail
detail = await client.get(
f"/api/v1/instance-groups/{data['id']}",
headers=auth_header(admin_token),
)
detail_data = detail.json()
assert len(detail_data["readonly_instances"]) == 1
assert len(detail_data["write_instances"]) == 1
@pytest.mark.asyncio
async def test_update_group_members(client: AsyncClient, admin_token: str, db):
"""Test updating a group's instance members."""
inst1 = Instance(
name="更新成员实例1", db_type="mysql", host="127.0.0.1",
port=3306, username="root", password="test", database="test",
)
inst2 = Instance(
name="更新成员实例2", db_type="mysql", host="127.0.0.1",
port=3307, username="root", password="test", database="test",
)
db.add(inst1)
db.add(inst2)
await db.commit()
await db.refresh(inst1)
await db.refresh(inst2)
create_resp = await client.post(
"/api/v1/instance-groups",
json={
"name": "更新成员组",
"db_type": "mysql",
"readonly_instance_ids": [inst1.id],
},
headers=auth_header(admin_token),
)
group_id = create_resp.json()["id"]
# Update to replace members
resp = await client.put(
f"/api/v1/instance-groups/{group_id}",
json={
"readonly_instance_ids": [inst2.id],
"write_instance_ids": [inst1.id],
},
headers=auth_header(admin_token),
)
assert resp.status_code == 200
detail = await client.get(
f"/api/v1/instance-groups/{group_id}",
headers=auth_header(admin_token),
)
data = detail.json()
assert data["readonly_instance_count"] == 1
assert data["write_instance_count"] == 1
assert data["readonly_instances"][0]["id"] == inst2.id
assert data["write_instances"][0]["id"] == inst1.id
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_assign_users_to_group(client: AsyncClient, admin_token: str, admin_user): async def test_assign_users_to_group(client: AsyncClient, admin_token: str, admin_user):
create_resp = await client.post( create_resp = await client.post(
@@ -289,3 +449,38 @@ async def test_group_db_type_validation(client: AsyncClient, admin_token: str):
headers=auth_header(admin_token), headers=auth_header(admin_token),
) )
assert resp.status_code == 422 assert resp.status_code == 422
@pytest.mark.asyncio
async def test_same_instance_readonly_and_write(client: AsyncClient, admin_token: str, db):
"""Test that the same instance can be both readonly and write in the same group."""
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)
create_resp = await client.post(
"/api/v1/instance-groups",
json={
"name": "双重权限组",
"db_type": "mysql",
"readonly_instance_ids": [inst.id],
"write_instance_ids": [inst.id],
},
headers=auth_header(admin_token),
)
assert create_resp.status_code == 201
data = create_resp.json()
assert data["readonly_instance_count"] == 1
assert data["write_instance_count"] == 1
detail = await client.get(
f"/api/v1/instance-groups/{data['id']}",
headers=auth_header(admin_token),
)
detail_data = detail.json()
assert any(i["id"] == inst.id for i in detail_data["readonly_instances"])
assert any(i["id"] == inst.id for i in detail_data["write_instances"])