diff --git a/backend/app/api/v1/endpoints/instance_group.py b/backend/app/api/v1/endpoints/instance_group.py new file mode 100644 index 0000000..794ad42 --- /dev/null +++ b/backend/app/api/v1/endpoints/instance_group.py @@ -0,0 +1,330 @@ +import json +from typing import Optional, List + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select, func as sa_func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.core.security import get_current_user, require_role +from app.models.user import User +from app.models.instance import Instance +from app.models.instance_group import InstanceGroup, user_groups +from app.schemas.instance_group import ( + InstanceGroupCreate, InstanceGroupUpdate, + InstanceGroupResponse, InstanceGroupDetailResponse, + InstanceAssignRequest, UserAssignRequest, UserGroupUpdateRequest, +) + +router = APIRouter(prefix="/instance-groups", tags=["权限组管理"]) + + +def _serialize_group(group: InstanceGroup, instance_count: int = 0, user_count: int = 0) -> dict: + return { + "id": group.id, + "name": group.name, + "description": group.description, + "db_type": group.db_type, + "permissions": group.get_permissions(), + "is_active": group.is_active, + "created_at": group.created_at, + "updated_at": group.updated_at, + "instance_count": instance_count, + "user_count": user_count, + } + + +def _serialize_group_detail(group: InstanceGroup) -> dict: + d = _serialize_group( + group, + instance_count=len(group.instances) if group.instances else 0, + user_count=len(group.users) if group.users else 0, + ) + d["instances"] = [ + { + "id": inst.id, + "name": inst.name, + "db_type": inst.db_type, + "host": inst.host, + "port": inst.port, + "env": inst.env, + "status": inst.status, + } + for inst in (group.instances or []) + ] + d["users"] = [ + { + "id": u.id, + "username": u.username, + "display_name": u.display_name, + "role": u.role, + } + for u in (group.users or []) + ] + return d + + +@router.get("", response_model=List[InstanceGroupResponse]) +async def list_instance_groups( + db_type: Optional[str] = None, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + stmt = select(InstanceGroup).order_by(InstanceGroup.id) + if db_type: + stmt = stmt.where(InstanceGroup.db_type == db_type) + result = await db.execute(stmt) + groups = result.scalars().all() + return [_serialize_group_detail(g) for g in groups] + + +@router.post("", response_model=InstanceGroupResponse, status_code=201) +async def create_instance_group( + data: InstanceGroupCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_role("admin")), +): + # Check name uniqueness + existing = await db.execute( + select(InstanceGroup).where(InstanceGroup.name == data.name) + ) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="权限组名称已存在") + + group = InstanceGroup( + name=data.name, + description=data.description, + db_type=data.db_type, + permissions=json.dumps(data.permissions, ensure_ascii=False) if data.permissions else None, + is_active=data.is_active, + ) + db.add(group) + await db.commit() + await db.refresh(group) + return _serialize_group(group) + + +@router.get("/{group_id}", response_model=InstanceGroupDetailResponse) +async def get_instance_group( + group_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + result = await db.execute( + select(InstanceGroup).where(InstanceGroup.id == group_id) + ) + group = result.scalar_one_or_none() + if not group: + raise HTTPException(status_code=404, detail="权限组不存在") + return _serialize_group_detail(group) + + +@router.put("/{group_id}", response_model=InstanceGroupResponse) +async def update_instance_group( + group_id: int, + data: InstanceGroupUpdate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_role("admin")), +): + result = await db.execute( + select(InstanceGroup).where(InstanceGroup.id == group_id) + ) + group = result.scalar_one_or_none() + if not group: + raise HTTPException(status_code=404, detail="权限组不存在") + + update_data = data.model_dump(exclude_unset=True) + if "name" in update_data: + # Check uniqueness + existing = await db.execute( + select(InstanceGroup).where( + InstanceGroup.name == update_data["name"], + InstanceGroup.id != group_id, + ) + ) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="权限组名称已存在") + + if "permissions" in update_data: + update_data["permissions"] = ( + json.dumps(update_data["permissions"], ensure_ascii=False) + if update_data["permissions"] + else None + ) + + for key, value in update_data.items(): + setattr(group, key, value) + await db.commit() + await db.refresh(group) + return _serialize_group(group) + + +@router.delete("/{group_id}") +async def delete_instance_group( + group_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_role("admin")), +): + result = await db.execute( + select(InstanceGroup).where(InstanceGroup.id == group_id) + ) + group = result.scalar_one_or_none() + if not group: + raise HTTPException(status_code=404, detail="权限组不存在") + + # Detach instances + inst_result = await db.execute( + select(Instance).where(Instance.group_id == group_id) + ) + for inst in inst_result.scalars().all(): + inst.group_id = None + + await db.delete(group) + await db.commit() + return {"message": "权限组已删除"} + + +@router.post("/{group_id}/instances") +async def assign_instances( + group_id: int, + data: InstanceAssignRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_role("admin")), +): + result = await db.execute( + select(InstanceGroup).where(InstanceGroup.id == group_id) + ) + group = result.scalar_one_or_none() + if not group: + raise HTTPException(status_code=404, detail="权限组不存在") + + for inst_id in data.instance_ids: + inst_result = await db.execute( + select(Instance).where(Instance.id == inst_id) + ) + inst = inst_result.scalar_one_or_none() + if inst: + inst.group_id = group_id + await db.commit() + return {"message": "实例已关联"} + + +@router.delete("/{group_id}/instances/{instance_id}") +async def remove_instance( + group_id: int, + instance_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_role("admin")), +): + inst_result = await db.execute( + select(Instance).where(Instance.id == instance_id, Instance.group_id == group_id) + ) + inst = inst_result.scalar_one_or_none() + if not inst: + raise HTTPException(status_code=404, detail="实例未关联该权限组") + inst.group_id = None + await db.commit() + return {"message": "实例已取消关联"} + + +@router.get("/{group_id}/users") +async def list_group_users( + group_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + result = await db.execute( + select(InstanceGroup).where(InstanceGroup.id == group_id) + ) + group = result.scalar_one_or_none() + if not group: + raise HTTPException(status_code=404, detail="权限组不存在") + + # Query user_groups for permissions override + ug_result = await db.execute( + select(user_groups).where(user_groups.c.group_id == group_id) + ) + ug_rows = ug_result.all() + perm_map = {} + for row in ug_rows: + perms = json.loads(row.permissions) if row.permissions else None + perm_map[row.user_id] = perms + + users_data = [] + for u in (group.users or []): + users_data.append({ + "id": u.id, + "username": u.username, + "display_name": u.display_name, + "role": u.role, + "permissions": perm_map.get(u.id), + }) + return users_data + + +@router.post("/{group_id}/users") +async def assign_users( + group_id: int, + data: UserAssignRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_role("admin")), +): + result = await db.execute( + select(InstanceGroup).where(InstanceGroup.id == group_id) + ) + group = result.scalar_one_or_none() + if not group: + raise HTTPException(status_code=404, detail="权限组不存在") + + perm_json = json.dumps(data.permissions, ensure_ascii=False) if data.permissions else None + + for user_id in data.user_ids: + # Check if already assigned + check = await db.execute( + select(user_groups).where( + user_groups.c.user_id == user_id, + user_groups.c.group_id == group_id, + ) + ) + if not check.first(): + await db.execute( + user_groups.insert().values( + user_id=user_id, + group_id=group_id, + permissions=perm_json, + ) + ) + await db.commit() + return {"message": "用户已分配"} + + +@router.put("/users/{user_id}/groups") +async def update_user_groups( + user_id: int, + data: UserGroupUpdateRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_role("admin")), +): + user_result = await db.execute( + select(User).where(User.id == user_id) + ) + user = user_result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + + # Remove existing group associations + await db.execute( + user_groups.delete().where(user_groups.c.user_id == user_id) + ) + + # Add new ones + for gid in data.group_ids: + group_result = await db.execute( + select(InstanceGroup).where(InstanceGroup.id == gid) + ) + if group_result.scalar_one_or_none(): + await db.execute( + user_groups.insert().values(user_id=user_id, group_id=gid) + ) + await db.commit() + return {"message": "用户权限组已更新"} diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 6a1fbeb..e34e2c6 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -4,6 +4,7 @@ from app.api.v1.endpoints import ( auth, user, instance, ticket, query, dashboard, audit, audit_rule, announcement, notification, ldap_config, instance_detail, backup, redis, dictionary, + instance_group, ) api_router = APIRouter(prefix="/api/v1") @@ -23,3 +24,4 @@ api_router.include_router(instance_detail.router) api_router.include_router(backup.router) api_router.include_router(redis.router) api_router.include_router(dictionary.router, prefix="/dictionary", tags=["dictionary"]) +api_router.include_router(instance_group.router) diff --git a/backend/app/main.py b/backend/app/main.py index 47914c1..25b5b32 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -53,6 +53,7 @@ async def lifespan(app: FastAPI): import app.models.notification # noqa import app.models.ldap_config # noqa import app.models.backup # noqa + import app.models.instance_group # noqa await init_db() await seed_data() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index e69de29..bf05c9a 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -0,0 +1 @@ +from app.models.instance_group import InstanceGroup, user_groups # noqa: F401 diff --git a/backend/app/models/instance.py b/backend/app/models/instance.py index 3501883..b669fe2 100644 --- a/backend/app/models/instance.py +++ b/backend/app/models/instance.py @@ -1,4 +1,5 @@ -from sqlalchemy import Column, Integer, String, Text, DateTime, func +from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, func +from sqlalchemy.orm import relationship from app.core.database import Base @@ -25,5 +26,11 @@ class Instance(Base): excluded_databases = Column(Text, nullable=True) # JSON: ["sys", "information_schema", ...] mask_fields = Column(Text, nullable=True) # JSON: [{table, field, rule}] + # 权限组关联 + group_id = Column(Integer, ForeignKey("instance_groups.id", ondelete="SET NULL"), nullable=True) + created_at = Column(DateTime, server_default=func.now()) updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + # 关系 + group = relationship("InstanceGroup", back_populates="instances") diff --git a/backend/app/models/instance_group.py b/backend/app/models/instance_group.py new file mode 100644 index 0000000..7f67131 --- /dev/null +++ b/backend/app/models/instance_group.py @@ -0,0 +1,42 @@ +import json +from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, Table, func +from sqlalchemy.orm import relationship + +from app.core.database import Base + +# 用户-权限组关联表 +user_groups = Table( + "user_groups", + Base.metadata, + Column("user_id", Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True), + Column("group_id", Integer, ForeignKey("instance_groups.id", ondelete="CASCADE"), primary_key=True), + Column("permissions", Text, nullable=True), # JSON: 覆盖组默认权限 +) + + +class InstanceGroup(Base): + __tablename__ = "instance_groups" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(100), nullable=False, unique=True) + description = Column(Text, nullable=True) + db_type = Column(String(20), nullable=False, default="all") # mysql/redis/pg/mongo/all + permissions = Column(Text, nullable=True) # JSON: ["query:select", ...] + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + # 关系 + instances = relationship("Instance", back_populates="group", lazy="selectin") + users = relationship("User", secondary=user_groups, back_populates="groups", lazy="selectin") + + def get_permissions(self) -> list: + if self.permissions: + try: + return json.loads(self.permissions) + except (json.JSONDecodeError, TypeError): + return [] + return [] + + def set_permissions(self, perms: list): + self.permissions = json.dumps(perms, ensure_ascii=False) if perms else None diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 459c7da..951300e 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,4 +1,5 @@ from sqlalchemy import Column, Integer, String, Boolean, DateTime, func +from sqlalchemy.orm import relationship from app.core.database import Base @@ -17,3 +18,6 @@ class User(Base): last_login = Column(DateTime, nullable=True) created_at = Column(DateTime, server_default=func.now()) updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + # Many-to-Many relationship with InstanceGroup + groups = relationship("InstanceGroup", secondary="user_groups", back_populates="users", lazy="selectin") diff --git a/backend/app/schemas/instance_group.py b/backend/app/schemas/instance_group.py new file mode 100644 index 0000000..6d6c7e9 --- /dev/null +++ b/backend/app/schemas/instance_group.py @@ -0,0 +1,53 @@ +from datetime import datetime +from typing import Optional, List +from pydantic import BaseModel, Field + + +class InstanceGroupCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=100) + description: Optional[str] = None + db_type: str = Field(default="all", pattern=r"^(mysql|redis|pg|mongo|all)$") + permissions: Optional[List[str]] = None + is_active: bool = True + + +class InstanceGroupUpdate(BaseModel): + name: Optional[str] = Field(default=None, min_length=1, max_length=100) + description: Optional[str] = None + db_type: Optional[str] = Field(default=None, pattern=r"^(mysql|redis|pg|mongo|all)$") + permissions: Optional[List[str]] = None + is_active: Optional[bool] = None + + +class InstanceGroupResponse(BaseModel): + id: int + name: str + description: Optional[str] = None + db_type: str + permissions: Optional[List[str]] = None + is_active: bool + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + instance_count: int = 0 + user_count: int = 0 + + class Config: + from_attributes = True + + +class InstanceGroupDetailResponse(InstanceGroupResponse): + instances: List[dict] = [] + users: List[dict] = [] + + +class InstanceAssignRequest(BaseModel): + instance_ids: List[int] = Field(..., min_length=1) + + +class UserAssignRequest(BaseModel): + user_ids: List[int] = Field(..., min_length=1) + permissions: Optional[List[str]] = None + + +class UserGroupUpdateRequest(BaseModel): + group_ids: List[int] = Field(default_factory=list) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 79fb63e..b957074 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -9,6 +9,7 @@ from app.core.database import Base, get_db from app.core.security import hash_password, create_access_token from app.models.user import User from app.models.instance import Instance +from app.models.instance_group import InstanceGroup, user_groups from app.models.ticket import Ticket from app.models.audit_log import AuditLog from app.models.audit_rule import AuditRule diff --git a/backend/tests/test_instance_group.py b/backend/tests/test_instance_group.py new file mode 100644 index 0000000..859ed1e --- /dev/null +++ b/backend/tests/test_instance_group.py @@ -0,0 +1,291 @@ +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient + +from app.core.security import hash_password, create_access_token +from app.models.user import User +from app.models.instance import Instance +from app.models.instance_group import InstanceGroup +from tests.conftest import auth_header + + +@pytest.mark.asyncio +async def test_create_instance_group(client: AsyncClient, admin_token: str): + resp = await client.post( + "/api/v1/instance-groups", + json={ + "name": "生产MySQL组", + "description": "生产环境MySQL实例", + "db_type": "mysql", + "permissions": ["query:select", "query:insert", "ddl:create_table"], + "is_active": True, + }, + headers=auth_header(admin_token), + ) + assert resp.status_code == 201 + data = resp.json() + assert data["name"] == "生产MySQL组" + assert data["db_type"] == "mysql" + assert "query:select" in data["permissions"] + + +@pytest.mark.asyncio +async def test_create_instance_group_duplicate_name(client: AsyncClient, admin_token: str): + payload = {"name": "重复组", "db_type": "mysql"} + resp1 = await client.post("/api/v1/instance-groups", json=payload, headers=auth_header(admin_token)) + assert resp1.status_code == 201 + resp2 = await client.post("/api/v1/instance-groups", json=payload, headers=auth_header(admin_token)) + assert resp2.status_code == 400 + + +@pytest.mark.asyncio +async def test_list_instance_groups(client: AsyncClient, admin_token: str): + # Create two groups + await client.post( + "/api/v1/instance-groups", + json={"name": "组A", "db_type": "mysql"}, + headers=auth_header(admin_token), + ) + await client.post( + "/api/v1/instance-groups", + json={"name": "组B", "db_type": "redis"}, + headers=auth_header(admin_token), + ) + resp = await client.get("/api/v1/instance-groups", headers=auth_header(admin_token)) + assert resp.status_code == 200 + data = resp.json() + assert len(data) >= 2 + + +@pytest.mark.asyncio +async def test_list_instance_groups_filter_db_type(client: AsyncClient, admin_token: str): + await client.post( + "/api/v1/instance-groups", + json={"name": "过滤MySQL", "db_type": "mysql"}, + headers=auth_header(admin_token), + ) + resp = await client.get( + "/api/v1/instance-groups", + params={"db_type": "mysql"}, + headers=auth_header(admin_token), + ) + assert resp.status_code == 200 + data = resp.json() + assert all(g["db_type"] == "mysql" for g in data) + + +@pytest.mark.asyncio +async def test_get_instance_group(client: AsyncClient, admin_token: str): + create_resp = await client.post( + "/api/v1/instance-groups", + json={"name": "详情组", "db_type": "pg", "permissions": ["query:select"]}, + headers=auth_header(admin_token), + ) + group_id = create_resp.json()["id"] + resp = await client.get( + f"/api/v1/instance-groups/{group_id}", + headers=auth_header(admin_token), + ) + assert resp.status_code == 200 + data = resp.json() + assert data["name"] == "详情组" + assert data["permissions"] == ["query:select"] + + +@pytest.mark.asyncio +async def test_get_instance_group_not_found(client: AsyncClient, admin_token: str): + resp = await client.get("/api/v1/instance-groups/9999", headers=auth_header(admin_token)) + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_update_instance_group(client: AsyncClient, admin_token: str): + create_resp = await client.post( + "/api/v1/instance-groups", + json={"name": "更新前", "db_type": "mysql"}, + headers=auth_header(admin_token), + ) + group_id = create_resp.json()["id"] + resp = await client.put( + f"/api/v1/instance-groups/{group_id}", + json={"name": "更新后", "description": "已更新"}, + headers=auth_header(admin_token), + ) + assert resp.status_code == 200 + data = resp.json() + assert data["name"] == "更新后" + assert data["description"] == "已更新" + + +@pytest.mark.asyncio +async def test_delete_instance_group(client: AsyncClient, admin_token: str): + create_resp = await client.post( + "/api/v1/instance-groups", + json={"name": "删除组", "db_type": "redis"}, + headers=auth_header(admin_token), + ) + group_id = create_resp.json()["id"] + resp = await client.delete( + f"/api/v1/instance-groups/{group_id}", + headers=auth_header(admin_token), + ) + assert resp.status_code == 200 + # Verify deleted + get_resp = await client.get( + f"/api/v1/instance-groups/{group_id}", + headers=auth_header(admin_token), + ) + assert get_resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_assign_instances_to_group(client: AsyncClient, admin_token: str, db): + # Create group + create_resp = await client.post( + "/api/v1/instance-groups", + json={"name": "实例关联组", "db_type": "mysql"}, + headers=auth_header(admin_token), + ) + group_id = create_resp.json()["id"] + + # Create instance directly + inst = Instance( + name="测试实例1", db_type="mysql", host="127.0.0.1", + port=3306, username="root", password="test", database="test", + ) + db.add(inst) + await db.commit() + await db.refresh(inst) + + resp = await client.post( + f"/api/v1/instance-groups/{group_id}/instances", + json={"instance_ids": [inst.id]}, + headers=auth_header(admin_token), + ) + assert resp.status_code == 200 + + # Verify via group detail + detail_resp = await client.get( + f"/api/v1/instance-groups/{group_id}", + headers=auth_header(admin_token), + ) + data = detail_resp.json() + assert data["instance_count"] >= 1 + assert any(i["id"] == inst.id for i in data["instances"]) + + +@pytest.mark.asyncio +async def test_remove_instance_from_group(client: AsyncClient, admin_token: str, db): + # Create group + instance + create_resp = await client.post( + "/api/v1/instance-groups", + json={"name": "移除实例组", "db_type": "mysql"}, + headers=auth_header(admin_token), + ) + group_id = create_resp.json()["id"] + + inst = Instance( + name="移除实例", db_type="mysql", host="127.0.0.1", + port=3306, username="root", password="test", database="test", + ) + db.add(inst) + await db.commit() + await db.refresh(inst) + + # Assign first + await client.post( + f"/api/v1/instance-groups/{group_id}/instances", + json={"instance_ids": [inst.id]}, + headers=auth_header(admin_token), + ) + # Remove + resp = await client.delete( + f"/api/v1/instance-groups/{group_id}/instances/{inst.id}", + headers=auth_header(admin_token), + ) + assert resp.status_code == 200 + + +@pytest.mark.asyncio +async def test_assign_users_to_group(client: AsyncClient, admin_token: str, admin_user): + create_resp = await client.post( + "/api/v1/instance-groups", + json={"name": "用户关联组", "db_type": "all"}, + headers=auth_header(admin_token), + ) + group_id = create_resp.json()["id"] + + resp = await client.post( + f"/api/v1/instance-groups/{group_id}/users", + json={"user_ids": [admin_user.id], "permissions": ["query:select"]}, + headers=auth_header(admin_token), + ) + assert resp.status_code == 200 + + # Verify via group users + users_resp = await client.get( + f"/api/v1/instance-groups/{group_id}/users", + headers=auth_header(admin_token), + ) + assert users_resp.status_code == 200 + users_data = users_resp.json() + assert any(u["id"] == admin_user.id for u in users_data) + + +@pytest.mark.asyncio +async def test_update_user_groups(client: AsyncClient, admin_token: str, admin_user): + # Create two groups + g1 = await client.post( + "/api/v1/instance-groups", + json={"name": "用户组1", "db_type": "mysql"}, + headers=auth_header(admin_token), + ) + g2 = await client.post( + "/api/v1/instance-groups", + json={"name": "用户组2", "db_type": "redis"}, + headers=auth_header(admin_token), + ) + g1_id = g1.json()["id"] + g2_id = g2.json()["id"] + + resp = await client.put( + f"/api/v1/instance-groups/users/{admin_user.id}/groups", + json={"group_ids": [g1_id, g2_id]}, + headers=auth_header(admin_token), + ) + assert resp.status_code == 200 + + +@pytest.mark.asyncio +async def test_non_admin_cannot_create_group(client: AsyncClient, developer_token: str): + resp = await client.post( + "/api/v1/instance-groups", + json={"name": "非管理员组", "db_type": "mysql"}, + headers=auth_header(developer_token), + ) + assert resp.status_code == 403 + + +@pytest.mark.asyncio +async def test_non_admin_cannot_delete_group(client: AsyncClient, admin_token: str, developer_token: str): + create_resp = await client.post( + "/api/v1/instance-groups", + json={"name": "禁止删除组", "db_type": "mysql"}, + headers=auth_header(admin_token), + ) + group_id = create_resp.json()["id"] + resp = await client.delete( + f"/api/v1/instance-groups/{group_id}", + headers=auth_header(developer_token), + ) + assert resp.status_code == 403 + + +@pytest.mark.asyncio +async def test_group_db_type_validation(client: AsyncClient, admin_token: str): + resp = await client.post( + "/api/v1/instance-groups", + json={"name": "无效类型", "db_type": "oracle"}, + headers=auth_header(admin_token), + ) + assert resp.status_code == 422 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 20cc988..274ff5a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -20,6 +20,7 @@ import InstanceDetailPage from '@/pages/InstanceDetailPage'; import RedisDetailPage from '@/pages/RedisDetailPage'; import TicketCreatePage from '@/pages/TicketCreatePage'; import TicketDetailPage from '@/pages/TicketDetailPage'; +import InstanceGroupPage from '@/pages/InstanceGroupPage'; // 路由守卫:未登录跳转登录页,无权限跳转仪表盘 function RouteGuard() { @@ -56,6 +57,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/instanceGroup.ts b/frontend/src/api/instanceGroup.ts new file mode 100644 index 0000000..623e2b7 --- /dev/null +++ b/frontend/src/api/instanceGroup.ts @@ -0,0 +1,33 @@ +import request from './request'; + +export interface InstanceGroup { + id: number; + name: string; + description?: string; + db_type: string; + permissions?: string[]; + is_active: boolean; + created_at?: string; + updated_at?: string; + instance_count?: number; + user_count?: number; + instances?: { id: number; name: string; db_type: string; host: string; port: number; env: string; status: string }[]; + users?: { id: number; username: string; display_name: string; role: string }[]; +} + +export const instanceGroupApi = { + list: (params?: any) => request.get('/instance-groups', { params }), + get: (id: number) => request.get(`/instance-groups/${id}`), + create: (data: any) => request.post('/instance-groups', data), + update: (id: number, data: any) => request.put(`/instance-groups/${id}`, data), + delete: (id: number) => request.delete(`/instance-groups/${id}`), + assignInstances: (groupId: number, instanceIds: number[]) => + request.post(`/instance-groups/${groupId}/instances`, { instance_ids: instanceIds }), + removeInstance: (groupId: number, instanceId: number) => + request.delete(`/instance-groups/${groupId}/instances/${instanceId}`), + listUsers: (groupId: number) => request.get(`/instance-groups/${groupId}/users`), + assignUsers: (groupId: number, userIds: number[], permissions?: string[]) => + request.post(`/instance-groups/${groupId}/users`, { user_ids: userIds, permissions }), + updateUserGroups: (userId: number, groupIds: number[]) => + request.put(`/instance-groups/users/${userId}/groups`, { group_ids: groupIds }), +}; diff --git a/frontend/src/hooks/useAuth.tsx b/frontend/src/hooks/useAuth.tsx index 8e51329..272b20d 100644 --- a/frontend/src/hooks/useAuth.tsx +++ b/frontend/src/hooks/useAuth.tsx @@ -37,6 +37,7 @@ const roleNameMap: Record = { const routePermissionMap: Record = { dashboard: ['admin', 'dba', 'developer', 'guest'], instances: ['admin', 'dba'], + 'instance-groups': ['admin'], 'data-dictionary': ['admin', 'dba', 'developer'], tickets: ['admin', 'dba', 'developer'], queries: ['admin', 'dba', 'developer', 'guest'], @@ -67,6 +68,7 @@ export const actionPermissionMap: Record = { 'config.manage': ['admin'], 'announcement.manage': ['admin'], 'auto-task.manage': ['admin'], + 'instance-group.manage': ['admin'], }; const AuthContext = createContext(null); diff --git a/frontend/src/lib/mock.ts b/frontend/src/lib/mock.ts index 1b8c8b4..c63f42a 100644 --- a/frontend/src/lib/mock.ts +++ b/frontend/src/lib/mock.ts @@ -23,6 +23,7 @@ export const menuSections = [ export const menuItems = [ { id: 'dashboard', label: '仪表盘', icon: 'LayoutDashboard', section: 'overview', roles: ['admin', 'dba', 'developer', 'guest'] }, { id: 'instances', label: '数据源', icon: 'Database', section: 'datasource', roles: ['admin', 'dba'] }, + { id: 'instance-groups', label: '权限组', icon: 'Shield', section: 'datasource', roles: ['admin'] }, { id: 'tickets', label: '数据变更', icon: 'ClipboardList', section: 'ticket', roles: ['admin', 'dba', 'developer'] }, { id: 'queries', label: '查询审计', icon: 'Search', section: 'ticket', roles: ['admin', 'dba', 'developer', 'guest'] }, { id: 'roles', label: '角色权限', icon: 'UserCog', section: 'permission', roles: ['admin'] }, diff --git a/frontend/src/pages/InstanceGroupPage.tsx b/frontend/src/pages/InstanceGroupPage.tsx new file mode 100644 index 0000000..653a948 --- /dev/null +++ b/frontend/src/pages/InstanceGroupPage.tsx @@ -0,0 +1,512 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Plus, Search, Edit2, Trash2, Server, Users, ChevronRight, X } from 'lucide-react'; +import { Card, CardContent } from '@/components/ui/card'; +import { instanceGroupApi, type InstanceGroup } from '@/api/instanceGroup'; +import { instanceApi } from '@/api/instance'; +import { userApi } from '@/api/user'; +import { useActionPermission } from '@/hooks/useAuth'; + +// 权限预设 +var PERMISSION_CATEGORIES = [ + { + label: '查询权限', + items: [ + { code: 'query:select', name: 'SELECT 查询' }, + { code: 'query:insert', name: 'INSERT 插入' }, + { code: 'query:update', name: 'UPDATE 更新' }, + { code: 'query:delete', name: 'DELETE 删除' }, + ], + }, + { + label: 'DDL 权限', + items: [ + { code: 'ddl:create_table', name: '创建表' }, + { code: 'ddl:alter_table', name: '修改表' }, + { code: 'ddl:drop_table', name: '删除表' }, + { code: 'ddl:create_index', name: '创建索引' }, + { code: 'ddl:drop_index', name: '删除索引' }, + ], + }, + { + label: '工单权限', + items: [ + { code: 'ticket:create', name: '创建工单' }, + { code: 'ticket:approve', name: '审批工单' }, + { code: 'ticket:reject', name: '拒绝工单' }, + { code: 'ticket:execute', name: '执行工单' }, + ], + }, + { + label: '数据操作', + items: [ + { code: 'export:data', name: '导出数据' }, + { code: 'import:data', name: '导入数据' }, + ], + }, + { + label: '管理权限', + items: [ + { code: 'instance:connect', name: '连接实例' }, + { code: 'instance:view_slow_query', name: '查看慢查询' }, + { code: 'instance:kill_process', name: '终止进程' }, + ], + }, +]; + +var DB_TYPE_OPTIONS = [ + { value: 'all', label: '全部类型' }, + { value: 'mysql', label: 'MySQL' }, + { value: 'pg', label: 'PostgreSQL' }, + { value: 'redis', label: 'Redis' }, + { value: 'mongo', label: 'MongoDB' }, +]; + +var DB_TYPE_COLORS: Record = { + mysql: 'bg-[rgba(0,117,143,0.15)] text-[#26a69a]', + pg: 'bg-[rgba(51,103,145,0.15)] text-[#42a5f5]', + redis: 'bg-[rgba(220,50,32,0.15)] text-[#ef5350]', + mongo: 'bg-[rgba(71,162,72,0.15)] text-[#66bb6a]', + all: 'bg-[rgba(148,163,184,0.15)] text-[var(--dg-text-secondary)]', +}; + +export default function InstanceGroupPage() { + var [groups, setGroups] = useState([]); + var [loading, setLoading] = useState(true); + var [search, setSearch] = useState(''); + var [selectedId, setSelectedId] = useState(null); + var [showCreateModal, setShowCreateModal] = useState(false); + var [showEditModal, setShowEditModal] = useState(false); + var [showInstanceModal, setShowInstanceModal] = useState(false); + var [showUserModal, setShowUserModal] = useState(false); + var canManage = useActionPermission('instance-group.manage'); + + // 表单状态 + var [formName, setFormName] = useState(''); + var [formDesc, setFormDesc] = useState(''); + var [formDbType, setFormDbType] = useState('all'); + var [formPermissions, setFormPermissions] = useState([]); + var [formIsActive, setFormIsActive] = useState(true); + + // 关联数据 + var [allInstances, setAllInstances] = useState([]); + var [allUsers, setAllUsers] = useState([]); + var [selectedInstanceIds, setSelectedInstanceIds] = useState([]); + var [selectedUserIds, setSelectedUserIds] = useState([]); + + var selectedGroup = groups.find(g => g.id === selectedId); + + var fetchGroups = useCallback(async () => { + try { + var data = await instanceGroupApi.list(); + setGroups(data); + setLoading(false); + } catch { + setLoading(false); + } + }, []); + + useEffect(() => { fetchGroups(); }, [fetchGroups]); + + // 加载关联数据 + useEffect(() => { + if (showInstanceModal) { + instanceApi.list().then(setAllInstances).catch(() => {}); + } + if (showUserModal) { + userApi.list().then(setAllUsers).catch(() => {}); + } + }, [showInstanceModal, showUserModal]); + + var filteredGroups = groups.filter(g => + !search || g.name.includes(search) || (g.description || '').includes(search) + ); + + var resetForm = () => { + setFormName(''); + setFormDesc(''); + setFormDbType('all'); + setFormPermissions([]); + setFormIsActive(true); + }; + + var handleCreate = async () => { + if (!formName.trim()) return; + await instanceGroupApi.create({ + name: formName, + description: formDesc || undefined, + db_type: formDbType, + permissions: formPermissions.length > 0 ? formPermissions : undefined, + is_active: formIsActive, + }); + setShowCreateModal(false); + resetForm(); + fetchGroups(); + }; + + var handleEdit = async () => { + if (!selectedId || !formName.trim()) return; + await instanceGroupApi.update(selectedId, { + name: formName, + description: formDesc || undefined, + db_type: formDbType, + permissions: formPermissions.length > 0 ? formPermissions : undefined, + is_active: formIsActive, + }); + setShowEditModal(false); + resetForm(); + fetchGroups(); + }; + + var handleDelete = async (id: number) => { + if (!confirm('确定删除该权限组?')) return; + await instanceGroupApi.delete(id); + if (selectedId === id) setSelectedId(null); + fetchGroups(); + }; + + var openEditModal = (group: InstanceGroup) => { + setFormName(group.name); + setFormDesc(group.description || ''); + setFormDbType(group.db_type); + setFormPermissions(group.permissions || []); + setFormIsActive(group.is_active); + setShowEditModal(true); + }; + + var handleAssignInstances = async () => { + if (!selectedId || selectedInstanceIds.length === 0) return; + await instanceGroupApi.assignInstances(selectedId, selectedInstanceIds); + setShowInstanceModal(false); + setSelectedInstanceIds([]); + fetchGroups(); + }; + + var handleAssignUsers = async () => { + if (!selectedId || selectedUserIds.length === 0) return; + await instanceGroupApi.assignUsers(selectedId, selectedUserIds); + setShowUserModal(false); + setSelectedUserIds([]); + fetchGroups(); + }; + + var togglePermission = (code: string) => { + setFormPermissions(prev => + prev.includes(code) ? prev.filter(p => p !== code) : [...prev, code] + ); + }; + + var toggleCategoryAll = (items: { code: string }[], checked: boolean) => { + var codes = items.map(i => i.code); + if (checked) { + setFormPermissions(prev => [...new Set([...prev, ...codes])]); + } else { + setFormPermissions(prev => prev.filter(p => !codes.includes(p))); + } + }; + + // 渲染权限组表单 + var renderGroupForm = (onSubmit: () => void, submitLabel: string) => ( +
+
+ + setFormName(e.target.value)} placeholder="权限组名称" /> +
+
+ +