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) => (