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:
1 parent
40868c61bf
commit
39d9600e82
8 files changed
+418
-97
No files matched your search
@@ -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,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
|
||||||
@@ -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")
|
||||||
@@ -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) ----
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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()
|
)
|
||||||
matched_instance_ids: set = set()
|
instance_ids = list({row[0] for row in result.all()})
|
||||||
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"])
|
||||||
Reference in new issue
Block a user