From 6a653a49600ce28b0eb2c535509834a48ae7dd7b Mon Sep 17 00:00:00 2001 From: chengkai3 Date: Sat, 20 Jun 2026 18:40:29 +0800 Subject: [PATCH] =?UTF-8?q?fix:[FL-220][=E8=8F=9C=E5=8D=95=E7=A6=81?= =?UTF-8?q?=E7=94=A8=E8=AE=BF=E9=97=AE=E6=8E=A7=E5=88=B6]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: multica-agent --- api/app/api/v1/admin.py | 42 ++++++++-- api/app/api/v1/admin_files.py | 4 +- api/app/api/v1/atp_assets.py | 4 +- api/app/api/v1/atp_models.py | 4 +- api/app/api/v1/elevation.py | 4 +- api/app/api/v1/fault_recurrence.py | 8 +- api/app/api/v1/fl_analysis.py | 4 +- api/app/api/v1/flower_monitor.py | 4 +- api/app/api/v1/lightning.py | 8 +- api/app/api/v1/lines.py | 4 +- api/app/api/v1/scheduled_tasks.py | 8 +- api/app/api/v1/system_messages.py | 11 ++- api/app/api/v1/system_params.py | 8 +- api/app/api/v1/task_monitor.py | 8 +- api/app/api/v1/tower_models.py | 4 +- api/app/api/v1/tower_profiles.py | 4 +- api/app/api/v1/users.py | 4 +- api/app/api/v1/wine.py | 4 +- api/app/core/dependencies.py | 85 +++++++++++++++++++- api/app/services/admin_service.py | 3 +- api/tests/test_menu_route_guard.py | 124 +++++++++++++++++++++++++++++ memory/2026-06-20.md | 28 +++++++ web/src/app/admin/layout.tsx | 110 +++++++++++++------------ web/src/app/admin/menus/page.tsx | 2 + 24 files changed, 392 insertions(+), 97 deletions(-) create mode 100644 api/tests/test_menu_route_guard.py diff --git a/api/app/api/v1/admin.py b/api/app/api/v1/admin.py index 162c402..73099fc 100644 --- a/api/app/api/v1/admin.py +++ b/api/app/api/v1/admin.py @@ -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, diff --git a/api/app/api/v1/admin_files.py b/api/app/api/v1/admin_files.py index efefb94..5b04e83 100644 --- a/api/app/api/v1/admin_files.py +++ b/api/app/api/v1/admin_files.py @@ -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) diff --git a/api/app/api/v1/atp_assets.py b/api/app/api/v1/atp_assets.py index b2759ec..dbca9a5 100644 --- a/api/app/api/v1/atp_assets.py +++ b/api/app/api/v1/atp_assets.py @@ -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) diff --git a/api/app/api/v1/atp_models.py b/api/app/api/v1/atp_models.py index 044fb4e..fd98a13 100644 --- a/api/app/api/v1/atp_models.py +++ b/api/app/api/v1/atp_models.py @@ -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) diff --git a/api/app/api/v1/elevation.py b/api/app/api/v1/elevation.py index 9f3a7d6..f1d8a23 100644 --- a/api/app/api/v1/elevation.py +++ b/api/app/api/v1/elevation.py @@ -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)]) # ============================================================================ diff --git a/api/app/api/v1/fault_recurrence.py b/api/app/api/v1/fault_recurrence.py index 3a784af..376984e 100644 --- a/api/app/api/v1/fault_recurrence.py +++ b/api/app/api/v1/fault_recurrence.py @@ -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) diff --git a/api/app/api/v1/fl_analysis.py b/api/app/api/v1/fl_analysis.py index a9163be..7e8300f 100644 --- a/api/app/api/v1/fl_analysis.py +++ b/api/app/api/v1/fl_analysis.py @@ -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) diff --git a/api/app/api/v1/flower_monitor.py b/api/app/api/v1/flower_monitor.py index 3e952a8..b706274 100644 --- a/api/app/api/v1/flower_monitor.py +++ b/api/app/api/v1/flower_monitor.py @@ -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) diff --git a/api/app/api/v1/lightning.py b/api/app/api/v1/lightning.py index bf35c56..13856f9 100644 --- a/api/app/api/v1/lightning.py +++ b/api/app/api/v1/lightning.py @@ -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) diff --git a/api/app/api/v1/lines.py b/api/app/api/v1/lines.py index ef4b7f2..2aa3a9d 100644 --- a/api/app/api/v1/lines.py +++ b/api/app/api/v1/lines.py @@ -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) diff --git a/api/app/api/v1/scheduled_tasks.py b/api/app/api/v1/scheduled_tasks.py index 996053e..9a04746 100644 --- a/api/app/api/v1/scheduled_tasks.py +++ b/api/app/api/v1/scheduled_tasks.py @@ -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) diff --git a/api/app/api/v1/system_messages.py b/api/app/api/v1/system_messages.py index 7e78c8b..ce27a0d 100644 --- a/api/app/api/v1/system_messages.py +++ b/api/app/api/v1/system_messages.py @@ -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")), diff --git a/api/app/api/v1/system_params.py b/api/app/api/v1/system_params.py index 72f9890..ed37f6a 100644 --- a/api/app/api/v1/system_params.py +++ b/api/app/api/v1/system_params.py @@ -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) diff --git a/api/app/api/v1/task_monitor.py b/api/app/api/v1/task_monitor.py index f4102ab..474925b 100644 --- a/api/app/api/v1/task_monitor.py +++ b/api/app/api/v1/task_monitor.py @@ -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) diff --git a/api/app/api/v1/tower_models.py b/api/app/api/v1/tower_models.py index 8ff3a70..5ecf174 100644 --- a/api/app/api/v1/tower_models.py +++ b/api/app/api/v1/tower_models.py @@ -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) diff --git a/api/app/api/v1/tower_profiles.py b/api/app/api/v1/tower_profiles.py index 5564e58..07ae09a 100644 --- a/api/app/api/v1/tower_profiles.py +++ b/api/app/api/v1/tower_profiles.py @@ -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) diff --git a/api/app/api/v1/users.py b/api/app/api/v1/users.py index c4af6dc..f355026 100644 --- a/api/app/api/v1/users.py +++ b/api/app/api/v1/users.py @@ -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) diff --git a/api/app/api/v1/wine.py b/api/app/api/v1/wine.py index 8077674..5853648 100644 --- a/api/app/api/v1/wine.py +++ b/api/app/api/v1/wine.py @@ -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) diff --git a/api/app/core/dependencies.py b/api/app/core/dependencies.py index 7911666..27cf2cf 100644 --- a/api/app/core/dependencies.py +++ b/api/app/core/dependencies.py @@ -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 diff --git a/api/app/services/admin_service.py b/api/app/services/admin_service.py index 92b930e..d2d5164 100644 --- a/api/app/services/admin_service.py +++ b/api/app/services/admin_service.py @@ -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) diff --git a/api/tests/test_menu_route_guard.py b/api/tests/test_menu_route_guard.py new file mode 100644 index 0000000..9f1ca90 --- /dev/null +++ b/api/tests/test_menu_route_guard.py @@ -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() diff --git a/memory/2026-06-20.md b/memory/2026-06-20.md index 2359941..4771ff9 100644 --- a/memory/2026-06-20.md +++ b/memory/2026-06-20.md @@ -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 `` 与 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 `` warning。 + - 修改后:`git diff --check` 通过。 + - 修改后:`graphify update .` 通过并更新 `graphify-out`。 + +- 风险与关注点: + - 后端菜单路由守卫以明确 API 前缀映射菜单 path;新增菜单页若后续引入独立 API,需要同步补充映射。 + - 菜单管理 API 本身未加禁用守卫,保留为恢复入口,避免禁用菜单管理后无法重新启用。 + ## Follow-up - 菜单管理页 React Query 架构对齐(FL-151) - 背景: diff --git a/web/src/app/admin/layout.tsx b/web/src/app/admin/layout.tsx index 8fbb6e4..30da67b 100644 --- a/web/src/app/admin/layout.tsx +++ b/web/src/app/admin/layout.tsx @@ -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; +const ADMIN_ME_MENUS_QUERY_KEY = ["/api/v1/admin/me/menus"] as const; const ThemeSvgIcon = (props: SVGProps) => ( @@ -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([]); - const [loadingMenus, setLoadingMenus] = useState(true); - const [menuError, setMenuError] = useState(""); const [menuOpenKeys, setMenuOpenKeys] = useState([]); const [siderCollapsed, setSiderCollapsed] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [unreadMessageCount, setUnreadMessageCount] = useState(0); + const emptyMenuTree = useMemo(() => [], []); + 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 ( @@ -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 ( + + + 返回后台首页 + + )} + /> + + ); + } + return (
@@ -630,15 +645,6 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
- {menuError && ( - - )} {children}
diff --git a/web/src/app/admin/menus/page.tsx b/web/src/app/admin/menus/page.tsx index a92766e..49a2f7f 100644 --- a/web/src/app/admin/menus/page.tsx +++ b/web/src/app/admin/menus/page.tsx @@ -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