fix:[FL-220][菜单禁用访问控制]
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
+34
-8
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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,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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
- 背景:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user