fix:[FL-220][菜单禁用访问控制]

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
chengkai3
2026-06-20 18:40:29 +08:00
parent 899d5316cf
commit 6a653a4960
24 changed files with 392 additions and 97 deletions
+34 -8
View File
@@ -2,7 +2,13 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from ...core.database import get_db
from ...core.dependencies import CurrentUser, get_current_user, require_any_permission, require_permission
from ...core.dependencies import (
CurrentUser,
get_current_user,
require_any_permission,
require_enabled_menu_route,
require_permission,
)
from ...exceptions.menu_exceptions import MenuValidationError
from ...schemas.admin import (
AuditLogListResponse,
@@ -44,7 +50,11 @@ from ...services.seed_service import seed_defaults
router = APIRouter(prefix="/admin", tags=["admin"])
@router.get("/roles", response_model=RoleListResponse)
@router.get(
"/roles",
response_model=RoleListResponse,
dependencies=[Depends(require_enabled_menu_route)],
)
def get_roles(
keyword: str | None = Query(default=None),
limit: int = Query(default=50, ge=1, le=200),
@@ -55,7 +65,11 @@ def get_roles(
return list_roles(db, keyword=keyword, limit=limit, offset=offset)
@router.get("/roles-with-menus", response_model=RolesWithMenusResponse)
@router.get(
"/roles-with-menus",
response_model=RolesWithMenusResponse,
dependencies=[Depends(require_enabled_menu_route)],
)
def get_roles_with_menus(
keyword: str | None = Query(default=None),
limit: int = Query(default=50, ge=1, le=200),
@@ -66,7 +80,11 @@ def get_roles_with_menus(
return list_roles_with_menus(db, keyword=keyword, limit=limit, offset=offset)
@router.post("/roles", response_model=RolePublic)
@router.post(
"/roles",
response_model=RolePublic,
dependencies=[Depends(require_enabled_menu_route)],
)
def create_role_endpoint(
payload: RoleCreateRequest,
current_user: CurrentUser = Depends(require_permission("role.manage")),
@@ -78,7 +96,11 @@ def create_role_endpoint(
return created
@router.patch("/roles/{role_id}", response_model=RolePublic)
@router.patch(
"/roles/{role_id}",
response_model=RolePublic,
dependencies=[Depends(require_enabled_menu_route)],
)
def update_role_endpoint(
role_id: str,
payload: RoleUpdateRequest,
@@ -91,7 +113,7 @@ def update_role_endpoint(
return updated
@router.delete("/roles/{role_id}")
@router.delete("/roles/{role_id}", dependencies=[Depends(require_enabled_menu_route)])
def delete_role_endpoint(
role_id: str,
current_user: CurrentUser = Depends(require_permission("role.manage")),
@@ -103,7 +125,7 @@ def delete_role_endpoint(
return {"success": True}
@router.get("/roles/{role_id}/menus")
@router.get("/roles/{role_id}/menus", dependencies=[Depends(require_enabled_menu_route)])
def get_role_menus(
role_id: str,
_: CurrentUser = Depends(require_any_permission("role.read", "role.manage")),
@@ -115,7 +137,11 @@ def get_role_menus(
return {"menu_ids": menu_ids or []}
@router.put("/roles/{role_id}/menus", response_model=RolePublic)
@router.put(
"/roles/{role_id}/menus",
response_model=RolePublic,
dependencies=[Depends(require_enabled_menu_route)],
)
def replace_role_menus_endpoint(
role_id: str,
payload: RoleMenuUpdateRequest,
+2 -2
View File
@@ -3,7 +3,7 @@ from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from ...core.database import get_db
from ...core.dependencies import CurrentUser, require_any_permission, require_permission
from ...core.dependencies import CurrentUser, require_any_permission, require_enabled_menu_route, require_permission
from ...schemas.file_storage import (
FileCreateDirectoryRequest,
FileDeleteRequest,
@@ -23,7 +23,7 @@ from ...services.file_service import (
upload_file_to_path,
)
router = APIRouter(prefix="/admin/files", tags=["admin-files"])
router = APIRouter(prefix="/admin/files", tags=["admin-files"], dependencies=[Depends(require_enabled_menu_route)])
@router.get("", response_model=FileListResponse)
+2 -2
View File
@@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Upload
from sqlalchemy.orm import Session
from ...core.database import get_db
from ...core.dependencies import CurrentUser, require_any_permission, require_permission
from ...core.dependencies import CurrentUser, require_any_permission, require_enabled_menu_route, require_permission
from ...schemas.atp_asset import (
AtpAssetCreateRequest,
AtpAssetDetail,
@@ -42,7 +42,7 @@ from ...services.atp_asset_service import (
)
from ...services.atp_model_service import get_engine_status
router = APIRouter(prefix="/atp", tags=["atp-assets"])
router = APIRouter(prefix="/atp", tags=["atp-assets"], dependencies=[Depends(require_enabled_menu_route)])
@router.get("/engine/status", response_model=AtpEngineStatusResponse)
+2 -2
View File
@@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from ...core.database import get_db
from ...core.dependencies import CurrentUser, require_any_permission, require_permission
from ...core.dependencies import CurrentUser, require_any_permission, require_enabled_menu_route, require_permission
from ...schemas.atp_model import (
AtpEngineStatusResponse,
AtpModelCreateRequest,
@@ -38,7 +38,7 @@ from ...services.atp_model_service import (
update_model_version,
)
router = APIRouter(prefix="/atp/models", tags=["atp-models"])
router = APIRouter(prefix="/atp/models", tags=["atp-models"], dependencies=[Depends(require_enabled_menu_route)])
@router.get("/engine/status", response_model=AtpEngineStatusResponse)
+2 -2
View File
@@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Respon
from sqlalchemy.orm import Session
from ...core.database import get_db
from ...core.dependencies import CurrentUser, require_any_permission, require_permission
from ...core.dependencies import CurrentUser, require_any_permission, require_enabled_menu_route, require_permission
from ...schemas.elevation import (
ElevationApplyJobCreateRequest,
ElevationApplyJobCreateResponse,
@@ -75,7 +75,7 @@ from ...services.elevation_file_record_service import (
update_file_record,
)
router = APIRouter(prefix="/elevation", tags=["elevation"])
router = APIRouter(prefix="/elevation", tags=["elevation"], dependencies=[Depends(require_enabled_menu_route)])
# ============================================================================
+6 -2
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from ...core.dependencies import CurrentUser, require_any_permission
from ...core.dependencies import CurrentUser, require_any_permission, require_enabled_menu_route
from ...schemas.fault_recurrence import (
FaultRecurrenceAnalyzeResponse,
FaultRecurrenceStrokeMode,
@@ -10,7 +10,11 @@ from ...schemas.fault_recurrence import (
from ...services.fault_recurrence_service import build_fault_recurrence_report
router = APIRouter(prefix="/fault-recurrence", tags=["fault-recurrence"])
router = APIRouter(
prefix="/fault-recurrence",
tags=["fault-recurrence"],
dependencies=[Depends(require_enabled_menu_route)],
)
@router.post("/analyze", response_model=FaultRecurrenceAnalyzeResponse)
+2 -2
View File
@@ -5,7 +5,7 @@ from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from ...core.database import get_db
from ...core.dependencies import CurrentUser, require_any_permission
from ...core.dependencies import CurrentUser, require_any_permission, require_enabled_menu_route
from ...schemas.fl_analysis import (
FlAnalysisJobCreateRequest,
FlAnalysisJobCreateResponse,
@@ -28,7 +28,7 @@ from ...services.fl_analysis_service import (
from ...services.tower_profile_service import get_tower_profile_detail, upsert_tower_profile
from ...services.tower_topology import TowerGeometryValidationError
router = APIRouter(prefix="/fl-analysis", tags=["fl-analysis"])
router = APIRouter(prefix="/fl-analysis", tags=["fl-analysis"], dependencies=[Depends(require_enabled_menu_route)])
@router.get("/tower-profiles/{tower_id}", response_model=TowerProfileDetail)
+2 -2
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from fastapi import APIRouter, Depends, Query
from ...core.dependencies import CurrentUser, require_any_permission
from ...core.dependencies import CurrentUser, require_any_permission, require_enabled_menu_route
from ...schemas.flower_monitor import (
FlowerWorkerTaskOverviewResponse,
FlowerWorkersOverviewResponse,
@@ -12,7 +12,7 @@ from ...services.flower_monitor_service import (
build_workers_overview,
)
router = APIRouter(prefix="/admin/flower", tags=["admin-flower"])
router = APIRouter(prefix="/admin/flower", tags=["admin-flower"], dependencies=[Depends(require_enabled_menu_route)])
@router.get("/workers", response_model=FlowerWorkersOverviewResponse)
+6 -2
View File
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Upload
from sqlalchemy.orm import Session
from ...core.database import get_db
from ...core.dependencies import CurrentUser, require_any_permission, require_permission
from ...core.dependencies import CurrentUser, require_any_permission, require_enabled_menu_route, require_permission
from ...schemas.lightning import (
LightningCurrentEventListResponse,
LightningCurrentEventSummary,
@@ -45,7 +45,11 @@ from ...services.lightning_service import (
update_lightning_event,
)
router = APIRouter(prefix="/lightning-currents", tags=["lightning-currents"])
router = APIRouter(
prefix="/lightning-currents",
tags=["lightning-currents"],
dependencies=[Depends(require_enabled_menu_route)],
)
@router.get("", response_model=LightningCurrentEventListResponse)
+2 -2
View File
@@ -5,7 +5,7 @@ from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from ...core.database import get_db
from ...core.dependencies import CurrentUser, require_any_permission, require_permission
from ...core.dependencies import CurrentUser, require_any_permission, require_enabled_menu_route, require_permission
from ...schemas.line import (
LineCreateRequest,
LineListResponse,
@@ -34,7 +34,7 @@ from ...services.line_service import (
update_line_tower,
)
router = APIRouter(prefix="/lines", tags=["lines"])
router = APIRouter(prefix="/lines", tags=["lines"], dependencies=[Depends(require_enabled_menu_route)])
@router.get("", response_model=LineListResponse)
+6 -2
View File
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from ...core.database import get_db
from ...core.dependencies import CurrentUser, require_any_permission, require_permission
from ...core.dependencies import CurrentUser, require_any_permission, require_enabled_menu_route, require_permission
from ...schemas.scheduled_task import (
ScheduledTaskCreateRequest,
ScheduledTaskListResponse,
@@ -19,7 +19,11 @@ from ...services.scheduled_task_service import (
update_scheduled_task,
)
router = APIRouter(prefix="/admin/scheduled-tasks", tags=["admin-scheduled-tasks"])
router = APIRouter(
prefix="/admin/scheduled-tasks",
tags=["admin-scheduled-tasks"],
dependencies=[Depends(require_enabled_menu_route)],
)
@router.get("", response_model=ScheduledTaskListResponse)
+8 -3
View File
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from ...core.database import get_db
from ...core.dependencies import CurrentUser, get_current_user, require_permission
from ...core.dependencies import CurrentUser, get_current_user, require_enabled_menu_route, require_permission
from ...schemas.auth import MessageResponse
from ...schemas.system_message import (
SystemMessageCreateRequest,
@@ -22,7 +22,12 @@ from ...services.system_message_service import (
router = APIRouter(prefix="/system-messages", tags=["system_messages"])
@router.post("", response_model=SystemMessagePublic, status_code=status.HTTP_201_CREATED)
@router.post(
"",
response_model=SystemMessagePublic,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_enabled_menu_route)],
)
def create_message(
payload: SystemMessageCreateRequest,
_: CurrentUser = Depends(require_permission("admin.system_message")),
@@ -79,7 +84,7 @@ def mark_my_messages_read(
return {"affected": affected}
@router.delete("/{message_id}", response_model=MessageResponse)
@router.delete("/{message_id}", response_model=MessageResponse, dependencies=[Depends(require_enabled_menu_route)])
def remove_system_message(
message_id: str,
_: CurrentUser = Depends(require_permission("admin.system_message")),
+6 -2
View File
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from ...core.database import get_db
from ...core.dependencies import CurrentUser, require_any_permission, require_permission
from ...core.dependencies import CurrentUser, require_any_permission, require_enabled_menu_route, require_permission
from ...schemas.system_param import (
SystemParamCreateRequest,
SystemParamListResponse,
@@ -18,7 +18,11 @@ from ...services.system_param_service import (
update_system_param,
)
router = APIRouter(prefix="/admin/system-params", tags=["admin-system-params"])
router = APIRouter(
prefix="/admin/system-params",
tags=["admin-system-params"],
dependencies=[Depends(require_enabled_menu_route)],
)
@router.get("", response_model=SystemParamListResponse)
+6 -2
View File
@@ -1,10 +1,14 @@
from fastapi import APIRouter, Depends, Query
from ...core.dependencies import CurrentUser, require_any_permission
from ...core.dependencies import CurrentUser, require_any_permission, require_enabled_menu_route
from ...schemas.task_monitor import TaskMonitorOverviewResponse
from ...services.task_monitor_service import build_task_monitor_overview
router = APIRouter(prefix="/admin/task-monitor", tags=["admin-task-monitor"])
router = APIRouter(
prefix="/admin/task-monitor",
tags=["admin-task-monitor"],
dependencies=[Depends(require_enabled_menu_route)],
)
@router.get("/overview", response_model=TaskMonitorOverviewResponse)
+2 -2
View File
@@ -5,7 +5,7 @@ from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from ...core.database import get_db
from ...core.dependencies import CurrentUser, require_any_permission, require_permission
from ...core.dependencies import CurrentUser, require_any_permission, require_enabled_menu_route, require_permission
from ...schemas.tower_model import (
TowerModelCreateRequest,
TowerModelImageUploadResponse,
@@ -28,7 +28,7 @@ from ...services.tower_model_service import (
upload_tower_model_image,
)
router = APIRouter(prefix="/tower-models", tags=["tower-models"])
router = APIRouter(prefix="/tower-models", tags=["tower-models"], dependencies=[Depends(require_enabled_menu_route)])
@router.get("", response_model=TowerModelListResponse)
+2 -2
View File
@@ -4,12 +4,12 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ...core.database import get_db
from ...core.dependencies import CurrentUser, require_any_permission, require_permission
from ...core.dependencies import CurrentUser, require_any_permission, require_enabled_menu_route, require_permission
from ...schemas.tower_profile import TowerProfileDetail, TowerProfileUpsertRequest
from ...services.tower_profile_service import get_tower_profile_detail, upsert_tower_profile
from ...services.tower_topology import TowerGeometryValidationError
router = APIRouter(prefix="/tower-profiles", tags=["tower-profiles"])
router = APIRouter(prefix="/tower-profiles", tags=["tower-profiles"], dependencies=[Depends(require_enabled_menu_route)])
@router.get("/{tower_id}", response_model=TowerProfileDetail)
+2 -2
View File
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from ...core.database import get_db
from ...core.dependencies import CurrentUser, get_current_user, require_permission
from ...core.dependencies import CurrentUser, get_current_user, require_enabled_menu_route, require_permission
from ...schemas.auth import MessageResponse
from ...schemas.user import (
UserCreateRequest,
@@ -27,7 +27,7 @@ from ...services.user_service import (
update_user,
)
router = APIRouter(prefix="/users", tags=["users"])
router = APIRouter(prefix="/users", tags=["users"], dependencies=[Depends(require_enabled_menu_route)])
@router.get("/check-id/{user_id}", response_model=UserIdCheckResponse)
+2 -2
View File
@@ -4,12 +4,12 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from ...core.database import get_db
from ...core.dependencies import CurrentUser, require_any_permission, require_permission
from ...core.dependencies import CurrentUser, require_any_permission, require_enabled_menu_route, require_permission
from ...schemas.wine import WineRunDetail, WineRunListResponse, WineRunRequest, WineStatusResponse
from ...services.wine_service import create_run, get_run_detail, get_wine_status, list_runs
router = APIRouter(prefix="/wine", tags=["wine"])
router = APIRouter(prefix="/wine", tags=["wine"], dependencies=[Depends(require_enabled_menu_route)])
@router.get("/status", response_model=WineStatusResponse)
+84 -1
View File
@@ -1,11 +1,13 @@
from dataclasses import dataclass
from collections.abc import Callable
from fastapi import Depends, HTTPException, status
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from ..models.menu import Menu
from ..models.user import User
from ..services.legacy_authz_service import get_user_authorization, is_user_enabled
from .database import get_db
@@ -21,11 +23,78 @@ class CurrentUser:
permission_codes: set[str]
MENU_ROUTE_PREFIXES: tuple[tuple[str, str], ...] = (
("/api/v1/users", "/admin/users"),
("/api/v1/admin/roles-with-menus", "/admin/roles"),
("/api/v1/admin/roles", "/admin/roles"),
("/api/v1/admin/system-params", "/admin/system-params"),
("/api/v1/system-messages", "/admin/system-messages"),
("/api/v1/lines", "/admin/power-lines"),
("/api/v1/tower-profiles", "/admin/power-lines"),
("/api/v1/fl-analysis", "/admin/fl-analysis"),
("/api/v1/fault-recurrence", "/admin/fault-recurrence"),
("/api/v1/lightning-currents/stats/distribution", "/admin/lightning-distribution"),
("/api/v1/lightning-currents/import-distribution", "/admin/lightning-distribution"),
("/api/v1/lightning-currents", "/admin/lightning-currents"),
("/api/v1/admin/flower", "/admin/workers"),
("/api/v1/admin/task-monitor", "/admin/task-monitor"),
("/api/v1/admin/scheduled-tasks", "/admin/scheduled-tasks"),
("/api/v1/atp", "/admin/atp-models"),
("/api/v1/tower-models", "/admin/tower-models"),
("/api/v1/admin/files", "/admin/files"),
("/api/v1/elevation", "/admin/elevation"),
("/api/v1/wine", "/admin/wine-runner"),
)
def _load_user_with_rbac(db: Session, user_id: str) -> User | None:
stmt = select(User).where(User.id == user_id)
return db.execute(stmt).unique().scalar_one_or_none()
def _normalize_path(path: str | None) -> str | None:
normalized = (path or "").strip()
if not normalized:
return None
if not normalized.startswith("/"):
normalized = f"/{normalized}"
if normalized != "/":
normalized = normalized.rstrip("/")
return normalized
def _path_matches_prefix(path: str, prefix: str) -> bool:
return path == prefix or path.startswith(f"{prefix}/")
def _menu_path_for_api_path(api_path: str) -> str | None:
normalized_api_path = _normalize_path(api_path)
if not normalized_api_path:
return None
for api_prefix, menu_path in MENU_ROUTE_PREFIXES:
if _path_matches_prefix(normalized_api_path, api_prefix):
return menu_path
return None
def _enabled_menu_path_exists(db: Session, menu_path: str) -> bool:
normalized_menu_path = _normalize_path(menu_path)
if not normalized_menu_path:
return True
try:
menu = db.execute(
select(Menu.id, Menu.status, Menu.visible)
.where(Menu.path == normalized_menu_path)
.limit(1)
).first()
except SQLAlchemyError:
db.rollback()
return True
if not menu:
return True
return menu.status == "enabled" and bool(menu.visible)
def get_current_user(
db: Session = Depends(get_db),
token: str = Depends(oauth2_scheme),
@@ -81,3 +150,17 @@ def require_any_permission(*permission_codes: str) -> Callable[[CurrentUser], Cu
)
return dependency
def require_enabled_menu_route(
request: Request,
db: Session = Depends(get_db),
current_user: CurrentUser = Depends(get_current_user),
) -> CurrentUser:
menu_path = _menu_path_for_api_path(request.url.path)
if menu_path and not _enabled_menu_path_exists(db, menu_path):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Menu is disabled",
)
return current_user
+2 -1
View File
@@ -455,9 +455,10 @@ def build_menu_tree(db: Session, *, role_codes: set[str] | None = None) -> list[
menus = db.execute(_menu_stmt().order_by(Menu.sort_order.asc(), Menu.id.asc())).scalars().all()
menus = [menu for menu in menus if not _is_removed_menu_code(menu.code)]
menus = [menu for menu in menus if menu.status == "enabled" and menu.visible]
if role_codes is not None and "admin" not in role_codes:
allowed_ids = _get_allowed_menu_ids(db, role_codes)
menus = [menu for menu in menus if menu.id in allowed_ids and menu.status == "enabled" and menu.visible]
menus = [menu for menu in menus if menu.id in allowed_ids]
return _to_tree(menus)
+124
View File
@@ -0,0 +1,124 @@
from __future__ import annotations
import os
import unittest
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
os.environ.setdefault("MINIO_ENABLED", "false")
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app import models # noqa: F401
from app.api.v1.admin import router as admin_router
from app.api.v1.users import router as users_router
from app.core.database import Base, get_db
from app.core.dependencies import CurrentUser, get_current_user
from app.models.menu import Menu, RoleMenu
from app.models.rbac import Role
from app.models.user import User
class MenuRouteGuardTest(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine(
"sqlite+pysqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
self.SessionLocal = sessionmaker(
bind=self.engine,
autocommit=False,
autoflush=False,
expire_on_commit=False,
)
Base.metadata.create_all(bind=self.engine)
self.session = self.SessionLocal()
self.app = FastAPI()
self.app.include_router(admin_router, prefix="/api/v1")
self.app.include_router(users_router, prefix="/api/v1")
def override_get_db():
db = self.SessionLocal()
try:
yield db
finally:
db.close()
self.current_user = CurrentUser(
user=User(
id="admin",
email="admin@example.com",
username="admin",
password_hash="secret",
status="ENABLED",
),
role_codes={"admin"},
permission_codes={"user.manage", "menu.manage"},
)
self.app.dependency_overrides[get_db] = override_get_db
self.app.dependency_overrides[get_current_user] = lambda: self.current_user
self.client = TestClient(self.app)
def tearDown(self) -> None:
self.client.close()
self.app.dependency_overrides.clear()
self.session.close()
Base.metadata.drop_all(bind=self.engine)
self.engine.dispose()
def _seed_menu(self, *, status: str = "enabled", visible: bool = True) -> Menu:
role = Role(code="admin", name="Admin")
menu = Menu(
code="admin.users",
name="用户管理",
path="/admin/users",
type="menu",
status=status,
visible=visible,
sort_order=10,
permission_code="user.manage",
)
self.session.add_all([role, menu])
self.session.flush()
self.session.add(RoleMenu(role_id=role.id, menu_id=menu.id))
self.session.commit()
return menu
def test_disabled_menu_is_hidden_from_admin_menu_tree(self) -> None:
self._seed_menu(status="disabled")
response = self.client.get("/api/v1/admin/me/menus")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), [])
def test_disabled_menu_rejects_direct_api_access(self) -> None:
self._seed_menu(status="disabled")
response = self.client.get("/api/v1/users")
self.assertEqual(response.status_code, 403)
self.assertEqual(response.json()["detail"], "Menu is disabled")
def test_enabled_menu_allows_direct_api_access_to_continue(self) -> None:
self._seed_menu(status="enabled")
response = self.client.get("/api/v1/users")
self.assertNotEqual(response.status_code, 403)
def test_hidden_menu_rejects_direct_api_access(self) -> None:
menu = self._seed_menu(visible=False)
response = self.client.get("/api/v1/users")
self.assertEqual(response.status_code, 403)
self.assertIsNotNone(self.session.scalar(select(Menu).where(Menu.id == menu.id)))
if __name__ == "__main__":
unittest.main()
+28
View File
@@ -18,6 +18,34 @@
- 风险与关注点:
- 改动仅影响菜单管理页前端展示与提示机制,不改变菜单接口、字段结构或权限语义。
# Work Log - 菜单禁用访问控制修复(FL-220)
- 背景:
- 菜单状态设为 disabled 后,侧边栏依赖 WebSocket 推送刷新,推送丢失时可能继续显示;直接访问对应页面/API 时也缺少菜单状态防护。
- 本次处理:
- 后台 layout 的 `/api/v1/admin/me/menus` 改为 React Query 共享查询,并在 `admin.menus` / `auth` 推送后统一失效刷新。
- 菜单管理页创建、编辑、删除、启用/禁用成功后显式失效 `/api/v1/admin/me/menus`,确保侧边栏立即同步。
- 后台 layout 增加基于启用菜单树的路由 403 防护,当前路径不在可见菜单树时不再渲染页面内容。
- 后端新增菜单路由守卫,将菜单对应 API 前缀映射到菜单 path;命中 disabled/hidden 菜单时直接返回 403。
- 现代菜单树过滤改为 admin 与普通角色都排除 disabled/hidden 菜单。
- 新增 `api/tests/test_menu_route_guard.py` 覆盖禁用菜单隐藏、API 403、启用菜单继续放行、隐藏菜单 403。
- 验证:
- 基线:`npm --workspace web exec eslint src/app/admin/layout.tsx src/app/admin/menus/page.tsx --max-warnings=0` 通过但存在既有 layout `<img>` 与 unused callback warning。
- 基线:`npm --workspace web exec tsc --noEmit --pretty false` 通过。
- 基线:`python3 -m unittest api.tests.test_seed_defaults_contract api.tests.test_role_pagination_contract` 因本地解释器缺少 SQLAlchemy / PYTHONPATH 不满足失败。
- 修改后:`python3 -m py_compile ...` 覆盖本次改动后端文件与新增测试,通过。
- 修改后:`UV_CACHE_DIR=/tmp/fquiz-uv-cache UV_PYTHON_INSTALL_DIR=/tmp/fquiz-uv-python /home/ck/.local/bin/uv run --python 3.11 --with pytest --with fastapi --with pydantic-settings --with sqlalchemy --with PyJWT --with argon2-cffi --with email-validator --with python-multipart --with psycopg[binary] --with bcrypt --with 'celery[redis]' pytest api/tests/test_menu_route_guard.py` 通过,4 passed,存在既有 SQLAlchemy relationship warning。
- 修改后:`npm --workspace web exec tsc --noEmit --pretty false` 通过。
- 修改后:`npm --workspace web exec eslint src/app/admin/layout.tsx src/app/admin/menus/page.tsx --max-warnings=0` 仍仅有既有 layout `<img>` warning。
- 修改后:`git diff --check` 通过。
- 修改后:`graphify update .` 通过并更新 `graphify-out`
- 风险与关注点:
- 后端菜单路由守卫以明确 API 前缀映射菜单 path;新增菜单页若后续引入独立 API,需要同步补充映射。
- 菜单管理 API 本身未加禁用守卫,保留为恢复入口,避免禁用菜单管理后无法重新启用。
## Follow-up - 菜单管理页 React Query 架构对齐(FL-151
- 背景:
+58 -52
View File
@@ -1,5 +1,6 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState, type ComponentType, type ReactNode, type SVGProps } from "react";
import { usePathname } from "next/navigation";
@@ -31,7 +32,6 @@ import Icon, {
UserOutlined,
} from "@ant-design/icons";
import {
Alert,
Avatar,
Badge,
Button,
@@ -63,6 +63,7 @@ import { withBasePath } from "@/lib/base-path";
const { Header, Sider, Content } = AntLayout;
const AntResult = Result as unknown as ComponentType<ResultProps>;
const ADMIN_ME_MENUS_QUERY_KEY = ["/api/v1/admin/me/menus"] as const;
const ThemeSvgIcon = (props: SVGProps<SVGSVGElement>) => (
<svg width={20} height={20} viewBox="0 0 24 24" fill="currentColor" {...props}>
@@ -218,63 +219,59 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
const pathname = normalizeAppRoutePath(rawPathname) ?? rawPathname;
const screens = Grid.useBreakpoint();
const isDesktop = screens.md === true;
const queryClient = useQueryClient();
const { user, initializing, fetchWithAuth, logout } = useAuth();
const {
themePrimaryMode,
setThemePrimaryMode,
} = useThemeAppearance();
const [menuTree, setMenuTree] = useState<MenuTreeItem[]>([]);
const [loadingMenus, setLoadingMenus] = useState(true);
const [menuError, setMenuError] = useState("");
const [menuOpenKeys, setMenuOpenKeys] = useState<string[]>([]);
const [siderCollapsed, setSiderCollapsed] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [unreadMessageCount, setUnreadMessageCount] = useState(0);
const emptyMenuTree = useMemo<MenuTreeItem[]>(() => [], []);
const menusQueryKey = useMemo(
() => [...ADMIN_ME_MENUS_QUERY_KEY, user?.id ?? "anonymous"] as const,
[user?.id],
);
const loadMenus = useCallback(async () => {
if (!user) {
setMenuTree([]);
setMenuError("");
setLoadingMenus(false);
return;
const response = await fetchWithAuth("/api/v1/admin/me/menus");
if (!response.ok) {
throw new Error(await readApiError(response));
}
setLoadingMenus(true);
setMenuError("");
try {
const response = await fetchWithAuth("/api/v1/admin/me/menus");
if (!response.ok) {
setMenuTree([]);
setMenuError(await readApiError(response));
return;
}
const payload = (await response.json()) as MenuTreeItem[];
return normalizeMenuTreePaths(payload);
}, [fetchWithAuth]);
const payload = (await response.json()) as MenuTreeItem[];
setMenuTree(normalizeMenuTreePaths(payload));
} catch (error) {
setMenuTree([]);
setMenuError(error instanceof Error ? error.message : "菜单加载失败,请检查网络连接或后端服务。");
} finally {
setLoadingMenus(false);
}
}, [fetchWithAuth, user]);
useEffect(() => {
queueMicrotask(() => {
void loadMenus();
});
}, [loadMenus]);
const menusQuery = useQuery({
queryKey: menusQueryKey,
queryFn: loadMenus,
enabled: !!user,
});
useTopicSubscription("admin.menus", useCallback(() => {
void loadMenus();
}, [loadMenus]));
if (user) {
void queryClient.invalidateQueries({ queryKey: ADMIN_ME_MENUS_QUERY_KEY });
}
}, [queryClient, user]));
useTopicSubscription("auth", useCallback(() => {
void loadMenus();
}, [loadMenus]));
if (user) {
void queryClient.invalidateQueries({ queryKey: ADMIN_ME_MENUS_QUERY_KEY });
}
}, [queryClient, user]));
const menuTree = menusQuery.data ?? emptyMenuTree;
const menuItems = useMemo(() => buildMenuItems(menuTree), [menuTree]);
const activeMenuState = useMemo(() => findActiveMenuState(menuTree, pathname), [menuTree, pathname]);
const routeAllowed = useMemo(() => {
if (pathname === "/admin") {
return true;
}
return activeMenuState.selectedKeys.length > 0;
}, [activeMenuState.selectedKeys.length, pathname]);
useEffect(() => {
queueMicrotask(() => {
@@ -393,11 +390,6 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
}
}, [loadUnreadCount, user]);
const onSystemMessageClick = useCallback(() => {
setMessagePopoverOpen(true);
void loadMessages();
}, [loadMessages]);
const markAsRead = useCallback(async (messageIds: string[]) => {
try {
const response = await fetchWithAuth("/api/v1/system-messages/me/mark-read", {
@@ -425,7 +417,7 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
/>
);
if (initializing || loadingMenus) {
if (initializing || (user && menusQuery.isLoading)) {
return (
<AdminCenteredState>
<Space align="center" direction="vertical" size={12}>
@@ -453,6 +445,29 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
);
}
if (menusQuery.isError || !routeAllowed) {
const subTitle = menusQuery.isError
? menusQuery.error instanceof Error
? menusQuery.error.message
: "菜单加载失败,请检查网络连接或后端服务。"
: "该菜单已禁用或你没有访问该菜单的权限。";
return (
<AdminCenteredState>
<AntResult
status="403"
title="无法访问"
subTitle={subTitle}
extra={(
<Button type="primary">
<Link href="/admin"></Link>
</Button>
)}
/>
</AdminCenteredState>
);
}
return (
<AntLayout className="admin-design-shell">
<Header className="admin-design-header">
@@ -630,15 +645,6 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
<AntLayout className="admin-design-main">
<Content className="admin-design-content">
<div className="admin-design-page-body">
{menuError && (
<Alert
showIcon
type="error"
message="菜单加载失败"
description={menuError}
style={{ marginBottom: 24 }}
/>
)}
{children}
</div>
</Content>
+2
View File
@@ -80,6 +80,7 @@ const DEFAULT_FORM_VALUES: MenuFormValues = {
component: "",
};
const ADMIN_ME_MENUS_QUERY_KEY = ["/api/v1/admin/me/menus"] as const;
const MENU_TABLE_MIN_SCROLL_Y = 180;
const MENU_TABLE_VIEWPORT_GAP = 40;
const MENU_TABLE_FALLBACK_RESERVE = 220;
@@ -213,6 +214,7 @@ export default function AdminMenusPage() {
const refreshData = async () => {
await queryClient.refetchQueries({ queryKey: ["admin.menus"] });
await queryClient.invalidateQueries({ queryKey: ADMIN_ME_MENUS_QUERY_KEY });
};
// Update allLoadedMenus when menus data changes in card view