diff --git a/domain/adapters/api.py b/domain/adapters/api.py index 5daf156..7a3db35 100644 --- a/domain/adapters/api.py +++ b/domain/adapters/api.py @@ -5,6 +5,8 @@ from fastapi import APIRouter, Depends, Request from api.response import success from domain.audit import AuditAction, audit from domain.auth import User, get_current_active_user +from domain.permission import require_system_permission +from domain.permission.types import AdapterPermission from .service import AdapterService from .types import AdapterCreate @@ -17,6 +19,7 @@ router = APIRouter(prefix="/api/adapters", tags=["adapters"]) description="创建存储适配器", body_fields=["name", "type", "path", "sub_path", "enabled"], ) +@require_system_permission(AdapterPermission.CREATE) async def create_adapter( request: Request, data: AdapterCreate, @@ -28,6 +31,7 @@ async def create_adapter( @router.get("") @audit(action=AuditAction.READ, description="获取适配器列表") +@require_system_permission(AdapterPermission.LIST) async def list_adapters( request: Request, current_user: Annotated[User, Depends(get_current_active_user)] @@ -38,6 +42,7 @@ async def list_adapters( @router.get("/available") @audit(action=AuditAction.READ, description="获取可用适配器类型") +@require_system_permission(AdapterPermission.LIST) async def available_adapter_types( request: Request, current_user: Annotated[User, Depends(get_current_active_user)] @@ -48,6 +53,7 @@ async def available_adapter_types( @router.get("/{adapter_id}") @audit(action=AuditAction.READ, description="获取适配器详情") +@require_system_permission(AdapterPermission.LIST) async def get_adapter( request: Request, adapter_id: int, @@ -63,6 +69,7 @@ async def get_adapter( description="更新存储适配器", body_fields=["name", "type", "path", "sub_path", "enabled"], ) +@require_system_permission(AdapterPermission.EDIT) async def update_adapter( request: Request, adapter_id: int, @@ -75,6 +82,7 @@ async def update_adapter( @router.delete("/{adapter_id}") @audit(action=AuditAction.DELETE, description="删除存储适配器") +@require_system_permission(AdapterPermission.DELETE) async def delete_adapter( request: Request, adapter_id: int, diff --git a/domain/audit/api.py b/domain/audit/api.py index 30ffaeb..c47c383 100644 --- a/domain/audit/api.py +++ b/domain/audit/api.py @@ -5,6 +5,8 @@ from fastapi import APIRouter, Depends, HTTPException, Query from api import response from domain.auth import User, get_current_active_user +from domain.permission import require_system_permission +from domain.permission.types import SystemPermission from .service import AuditService from .types import AuditAction @@ -27,6 +29,7 @@ def _parse_iso(value: Optional[str], field: str): @router.get("/logs") +@require_system_permission(SystemPermission.AUDIT_VIEW) async def list_audit_logs( current_user: CurrentUser, page_num: int = Query(1, ge=1, alias="page", description="页码"), @@ -54,6 +57,7 @@ async def list_audit_logs( @router.delete("/logs") +@require_system_permission(SystemPermission.AUDIT_VIEW) async def clear_audit_logs( current_user: CurrentUser, start_time: str | None = Query(None, description="开始时间 (ISO 8601)"), diff --git a/domain/backup/api.py b/domain/backup/api.py index 842e0c7..7f847b2 100644 --- a/domain/backup/api.py +++ b/domain/backup/api.py @@ -1,10 +1,13 @@ import datetime +from typing import Annotated from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile from fastapi.responses import JSONResponse from domain.audit import AuditAction, audit -from domain.auth import get_current_active_user +from domain.auth import User, get_current_active_user +from domain.permission import require_system_permission +from domain.permission.types import SystemPermission from .service import BackupService router = APIRouter( @@ -16,8 +19,11 @@ router = APIRouter( @router.get("/export", summary="导出全站数据") @audit(action=AuditAction.DOWNLOAD, description="导出备份") +@require_system_permission(SystemPermission.CONFIG_EDIT) async def export_backup( - request: Request, sections: list[str] | None = Query(default=None) + request: Request, + current_user: Annotated[User, Depends(get_current_active_user)], + sections: list[str] | None = Query(default=None), ): data = await BackupService.export_data(sections=sections) timestamp = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S") @@ -27,8 +33,10 @@ async def export_backup( @router.post("/import", summary="导入数据") @audit(action=AuditAction.UPLOAD, description="导入备份") +@require_system_permission(SystemPermission.CONFIG_EDIT) async def import_backup( request: Request, + current_user: Annotated[User, Depends(get_current_active_user)], file: UploadFile = File(...), mode: str = Form("replace"), ): diff --git a/domain/config/api.py b/domain/config/api.py index 688caf0..8d92367 100644 --- a/domain/config/api.py +++ b/domain/config/api.py @@ -5,7 +5,7 @@ from fastapi import APIRouter, Depends, Form, Request from api.response import success from domain.audit import AuditAction, audit from domain.auth import User, get_current_active_user -from domain.permission.service import PermissionService +from domain.permission import require_system_permission from domain.permission.types import SystemPermission from .service import ConfigService from .types import ConfigItem @@ -23,42 +23,36 @@ PUBLIC_CONFIG_KEYS = [ @router.get("/") @audit(action=AuditAction.READ, description="获取配置") +@require_system_permission(SystemPermission.CONFIG_EDIT) async def get_config( request: Request, current_user: Annotated[User, Depends(get_current_active_user)], key: str, ): - await PermissionService.require_system_permission( - current_user.id, SystemPermission.CONFIG_EDIT - ) value = await ConfigService.get(key) return success(ConfigItem(key=key, value=value).model_dump()) @router.post("/") @audit(action=AuditAction.UPDATE, description="设置配置", body_fields=["key", "value"]) +@require_system_permission(SystemPermission.CONFIG_EDIT) async def set_config( request: Request, current_user: Annotated[User, Depends(get_current_active_user)], key: str = Form(...), value: str = Form(""), ): - await PermissionService.require_system_permission( - current_user.id, SystemPermission.CONFIG_EDIT - ) await ConfigService.set(key, value) return success(ConfigItem(key=key, value=value).model_dump()) @router.get("/all") @audit(action=AuditAction.READ, description="获取全部配置") +@require_system_permission(SystemPermission.CONFIG_EDIT) async def get_all_config( request: Request, current_user: Annotated[User, Depends(get_current_active_user)], ): - await PermissionService.require_system_permission( - current_user.id, SystemPermission.CONFIG_EDIT - ) configs = await ConfigService.get_all() return success(configs) @@ -66,7 +60,6 @@ async def get_all_config( @audit(action=AuditAction.READ, description="获取公开配置") async def get_public_config( request: Request, - current_user: Annotated[User, Depends(get_current_active_user)], ): data = {} for key in PUBLIC_CONFIG_KEYS: diff --git a/domain/offline_downloads/api.py b/domain/offline_downloads/api.py index f07afdd..bc346d5 100644 --- a/domain/offline_downloads/api.py +++ b/domain/offline_downloads/api.py @@ -5,6 +5,8 @@ from fastapi import APIRouter, Depends, Request from api.response import success from domain.audit import AuditAction, audit from domain.auth import User, get_current_active_user +from domain.permission import require_path_permission +from domain.permission.types import PathAction from .service import OfflineDownloadService from .types import OfflineDownloadCreate @@ -22,6 +24,7 @@ router = APIRouter( description="创建离线下载任务", body_fields=["url", "dest_dir", "filename"], ) +@require_path_permission(PathAction.WRITE, "payload.dest_dir") async def create_offline_download(request: Request, payload: OfflineDownloadCreate, current_user: CurrentUser): data = await OfflineDownloadService.create_download(payload, current_user) return success(data) diff --git a/domain/permission/__init__.py b/domain/permission/__init__.py index 51c9de7..df319a5 100644 --- a/domain/permission/__init__.py +++ b/domain/permission/__init__.py @@ -1,4 +1,10 @@ from .service import PermissionService from .matcher import PathMatcher +from .decorator import require_path_permission, require_system_permission -__all__ = ["PermissionService", "PathMatcher"] +__all__ = [ + "PermissionService", + "PathMatcher", + "require_system_permission", + "require_path_permission", +] diff --git a/domain/permission/decorator.py b/domain/permission/decorator.py new file mode 100644 index 0000000..3442b38 --- /dev/null +++ b/domain/permission/decorator.py @@ -0,0 +1,103 @@ +import inspect +from functools import wraps +from typing import Any, Iterable, Mapping + +from fastapi import HTTPException + +from .service import PermissionService + + +def _get_user_id(user: Any) -> int | None: + if user is None: + return None + if isinstance(user, Mapping): + raw = user.get("id") or user.get("user_id") + return int(raw) if isinstance(raw, int) else None + value = getattr(user, "id", None) or getattr(user, "user_id", None) + return int(value) if isinstance(value, int) else None + + +def _resolve_expr(bound_args: Mapping[str, Any], expr: str) -> Any: + parts = [p for p in (expr or "").split(".") if p] + if not parts: + return None + cur: Any = bound_args.get(parts[0]) + for part in parts[1:]: + if cur is None: + return None + if isinstance(cur, Mapping): + cur = cur.get(part) + else: + cur = getattr(cur, part, None) + return cur + + +def require_system_permission(permission_code: str, *, user_kw: str = "current_user"): + """ + 在 endpoint 内部执行系统/适配器权限校验。 + + 设计目标: + - 保持和当前“在函数体内手写 require_*”一致的行为:失败会被外层 @audit 捕获记录 + - 不依赖 FastAPI dependencies(避免权限失败发生在 endpoint 之外) + """ + + def decorator(func): + @wraps(func) + async def wrapper(*args, **kwargs): + bound = inspect.signature(func).bind_partial(*args, **kwargs) + bound.apply_defaults() + user_id = _get_user_id(bound.arguments.get(user_kw)) + if user_id is None: + raise HTTPException(status_code=401, detail="Unauthorized") + await PermissionService.require_system_permission(user_id, permission_code) + + result = func(*args, **kwargs) + if inspect.isawaitable(result): + result = await result + return result + + return wrapper + + return decorator + + +def require_path_permission(action: str, path_expr: str, *, user_kw: str = "current_user"): + """ + 在 endpoint 内部执行路径权限校验。 + + path_expr 支持: + - "full_path" + - "body.src" / "body.dst" + - "payload.paths"(list[str] 会逐个检查) + """ + + def decorator(func): + @wraps(func) + async def wrapper(*args, **kwargs): + bound = inspect.signature(func).bind_partial(*args, **kwargs) + bound.apply_defaults() + user_id = _get_user_id(bound.arguments.get(user_kw)) + if user_id is None: + raise HTTPException(status_code=401, detail="Unauthorized") + + value = _resolve_expr(bound.arguments, path_expr) + paths: Iterable[Any] + if isinstance(value, (list, tuple, set)): + paths = value + else: + paths = [value] + + for path in paths: + if path is None: + raise HTTPException(status_code=400, detail="Missing path") + await PermissionService.require_path_permission(user_id, str(path), action) + + result = func(*args, **kwargs) + if inspect.isawaitable(result): + result = await result + return result + + return wrapper + + return decorator + diff --git a/domain/plugins/api.py b/domain/plugins/api.py index b849213..8178b9b 100644 --- a/domain/plugins/api.py +++ b/domain/plugins/api.py @@ -2,12 +2,15 @@ 插件管理 API 路由 """ -from typing import List +from typing import Annotated, List -from fastapi import APIRouter, File, Request, UploadFile +from fastapi import APIRouter, Depends, File, Request, UploadFile from fastapi.responses import FileResponse from domain.audit import AuditAction, audit +from domain.auth import User, get_current_active_user +from domain.permission import require_system_permission +from domain.permission.types import SystemPermission from .service import PluginService from .types import ( PluginInstallResult, @@ -22,7 +25,12 @@ router = APIRouter(prefix="/api/plugins", tags=["plugins"]) @router.post("/install", response_model=PluginInstallResult) @audit(action=AuditAction.CREATE, description="安装插件包") -async def install_plugin(request: Request, file: UploadFile = File(...)): +@require_system_permission(SystemPermission.ROLE_MANAGE) +async def install_plugin( + request: Request, + current_user: Annotated[User, Depends(get_current_active_user)], + file: UploadFile = File(...), +): """ 安装 .foxpkg 插件包 @@ -37,14 +45,21 @@ async def install_plugin(request: Request, file: UploadFile = File(...)): @router.get("", response_model=List[PluginOut]) @audit(action=AuditAction.READ, description="获取插件列表") -async def list_plugins(request: Request): +async def list_plugins( + request: Request, + current_user: Annotated[User, Depends(get_current_active_user)], +): """获取已安装的插件列表""" return await PluginService.list_plugins() @router.get("/{key_or_id}", response_model=PluginOut) @audit(action=AuditAction.READ, description="获取插件详情") -async def get_plugin(request: Request, key_or_id: str): +async def get_plugin( + request: Request, + key_or_id: str, + current_user: Annotated[User, Depends(get_current_active_user)], +): """获取单个插件详情""" return await PluginService.get_plugin(key_or_id) @@ -54,7 +69,12 @@ async def get_plugin(request: Request, key_or_id: str): @router.delete("/{key_or_id}") @audit(action=AuditAction.DELETE, description="卸载插件") -async def delete_plugin(request: Request, key_or_id: str): +@require_system_permission(SystemPermission.ROLE_MANAGE) +async def delete_plugin( + request: Request, + key_or_id: str, + current_user: Annotated[User, Depends(get_current_active_user)], +): """卸载插件""" await PluginService.delete(key_or_id) return {"code": 0, "msg": "ok"} diff --git a/domain/processors/api.py b/domain/processors/api.py index 6cafe8d..755a6c8 100644 --- a/domain/processors/api.py +++ b/domain/processors/api.py @@ -5,6 +5,12 @@ from fastapi import APIRouter, Body, Depends, Request from api.response import success from domain.audit import AuditAction, audit from domain.auth import User, get_current_active_user +from domain.permission import require_path_permission +from domain.permission import require_system_permission +from domain.permission.service import PermissionService +from domain.permission.types import PathAction +from domain.permission.types import SystemPermission +from domain.processors.registry import get_config_schema from .service import ProcessorService from .types import ( ProcessDirectoryRequest, @@ -31,11 +37,18 @@ async def list_processors( description="处理单个文件", body_fields=["path", "processor_type", "save_to", "overwrite"], ) +@require_path_permission(PathAction.READ, "req.path") async def process_file_with_processor( request: Request, current_user: Annotated[User, Depends(get_current_active_user)], req: ProcessRequest = Body(...), ): + meta = get_config_schema(req.processor_type) or {} + if meta.get("produces_file"): + if req.overwrite: + await PermissionService.require_path_permission(current_user.id, req.path, PathAction.WRITE) + elif req.save_to: + await PermissionService.require_path_permission(current_user.id, req.save_to, PathAction.WRITE) data = await ProcessorService.process_file(req) return success(data) @@ -46,17 +59,22 @@ async def process_file_with_processor( description="批量处理目录", body_fields=["path", "processor_type", "overwrite", "max_depth", "suffix"], ) +@require_path_permission(PathAction.READ, "req.path") async def process_directory_with_processor( request: Request, current_user: Annotated[User, Depends(get_current_active_user)], req: ProcessDirectoryRequest = Body(...), ): + meta = get_config_schema(req.processor_type) or {} + if meta.get("produces_file"): + await PermissionService.require_path_permission(current_user.id, req.path, PathAction.WRITE) data = await ProcessorService.process_directory(req) return success(data) @router.get("/source/{processor_type}") @audit(action=AuditAction.READ, description="获取处理器源码") +@require_system_permission(SystemPermission.ROLE_MANAGE) async def get_processor_source( request: Request, processor_type: str, @@ -68,6 +86,7 @@ async def get_processor_source( @router.put("/source/{processor_type}") @audit(action=AuditAction.UPDATE, description="更新处理器源码") +@require_system_permission(SystemPermission.ROLE_MANAGE) async def update_processor_source( request: Request, processor_type: str, @@ -80,6 +99,7 @@ async def update_processor_source( @router.post("/reload") @audit(action=AuditAction.UPDATE, description="重载处理器模块") +@require_system_permission(SystemPermission.ROLE_MANAGE) async def reload_processor_modules( request: Request, current_user: Annotated[User, Depends(get_current_active_user)], diff --git a/domain/role/api.py b/domain/role/api.py index 7e8ef26..206037b 100644 --- a/domain/role/api.py +++ b/domain/role/api.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends from domain.auth.service import get_current_active_user from domain.auth.types import User -from domain.permission.service import PermissionService +from domain.permission import require_system_permission from domain.permission.types import PathRuleCreate, PathRuleInfo, SystemPermission from domain.user.service import UserService from domain.user.types import UserInfo @@ -16,127 +16,104 @@ router = APIRouter(prefix="/api", tags=["role"]) @router.get("/roles", response_model=list[RoleInfo]) +@require_system_permission(SystemPermission.ROLE_MANAGE) async def list_roles( current_user: Annotated[User, Depends(get_current_active_user)] ) -> list[RoleInfo]: - await PermissionService.require_system_permission( - current_user.id, SystemPermission.ROLE_MANAGE - ) return await RoleService.get_all_roles() @router.get("/roles/{role_id}", response_model=RoleDetail) +@require_system_permission(SystemPermission.ROLE_MANAGE) async def get_role( role_id: int, current_user: Annotated[User, Depends(get_current_active_user)], ) -> RoleDetail: - await PermissionService.require_system_permission( - current_user.id, SystemPermission.ROLE_MANAGE - ) return await RoleService.get_role(role_id) @router.get("/roles/{role_id}/users", response_model=list[UserInfo]) +@require_system_permission(SystemPermission.ROLE_MANAGE) async def list_role_users( role_id: int, current_user: Annotated[User, Depends(get_current_active_user)], ) -> list[UserInfo]: - await PermissionService.require_system_permission( - current_user.id, SystemPermission.ROLE_MANAGE - ) return await UserService.get_users_by_role(role_id) @router.post("/roles", response_model=RoleInfo) +@require_system_permission(SystemPermission.ROLE_MANAGE) async def create_role( data: RoleCreate, current_user: Annotated[User, Depends(get_current_active_user)], ) -> RoleInfo: - await PermissionService.require_system_permission( - current_user.id, SystemPermission.ROLE_MANAGE - ) return await RoleService.create_role(data) @router.put("/roles/{role_id}", response_model=RoleInfo) +@require_system_permission(SystemPermission.ROLE_MANAGE) async def update_role( role_id: int, data: RoleUpdate, current_user: Annotated[User, Depends(get_current_active_user)], ) -> RoleInfo: - await PermissionService.require_system_permission( - current_user.id, SystemPermission.ROLE_MANAGE - ) return await RoleService.update_role(role_id, data) @router.delete("/roles/{role_id}") +@require_system_permission(SystemPermission.ROLE_MANAGE) async def delete_role( role_id: int, current_user: Annotated[User, Depends(get_current_active_user)], ) -> dict: - await PermissionService.require_system_permission( - current_user.id, SystemPermission.ROLE_MANAGE - ) await RoleService.delete_role(role_id) return {"success": True} @router.post("/roles/{role_id}/permissions", response_model=list[str]) +@require_system_permission(SystemPermission.ROLE_MANAGE) async def set_role_permissions( role_id: int, data: RolePermissionsUpdate, current_user: Annotated[User, Depends(get_current_active_user)], ) -> list[str]: - await PermissionService.require_system_permission( - current_user.id, SystemPermission.ROLE_MANAGE - ) return await RoleService.set_role_permissions(role_id, data.permission_codes) @router.get("/roles/{role_id}/path-rules", response_model=list[PathRuleInfo]) +@require_system_permission(SystemPermission.ROLE_MANAGE) async def get_role_path_rules( role_id: int, current_user: Annotated[User, Depends(get_current_active_user)], ) -> list[PathRuleInfo]: - await PermissionService.require_system_permission( - current_user.id, SystemPermission.ROLE_MANAGE - ) return await RoleService.get_role_path_rules(role_id) @router.post("/roles/{role_id}/path-rules", response_model=PathRuleInfo) +@require_system_permission(SystemPermission.ROLE_MANAGE) async def add_path_rule( role_id: int, data: PathRuleCreate, current_user: Annotated[User, Depends(get_current_active_user)], ) -> PathRuleInfo: - await PermissionService.require_system_permission( - current_user.id, SystemPermission.ROLE_MANAGE - ) return await RoleService.add_path_rule(role_id, data) @router.put("/path-rules/{rule_id}", response_model=PathRuleInfo) +@require_system_permission(SystemPermission.ROLE_MANAGE) async def update_path_rule( rule_id: int, data: PathRuleCreate, current_user: Annotated[User, Depends(get_current_active_user)], ) -> PathRuleInfo: - await PermissionService.require_system_permission( - current_user.id, SystemPermission.ROLE_MANAGE - ) return await RoleService.update_path_rule(rule_id, data) @router.delete("/path-rules/{rule_id}") +@require_system_permission(SystemPermission.ROLE_MANAGE) async def delete_path_rule( rule_id: int, current_user: Annotated[User, Depends(get_current_active_user)], ) -> dict: - await PermissionService.require_system_permission( - current_user.id, SystemPermission.ROLE_MANAGE - ) await RoleService.delete_path_rule(rule_id) return {"success": True} - diff --git a/domain/share/api.py b/domain/share/api.py index 0f80712..76159a0 100644 --- a/domain/share/api.py +++ b/domain/share/api.py @@ -5,6 +5,8 @@ from fastapi import APIRouter, Depends, Request from api.response import success from domain.audit import AuditAction, audit from domain.auth import User, get_current_active_user +from domain.permission import require_path_permission +from domain.permission.types import PathAction from .service import ShareService from .types import ( ShareCreate, @@ -24,6 +26,7 @@ router = APIRouter(prefix="/api/shares", tags=["Share - Management"]) description="创建分享链接", body_fields=["name", "paths", "expires_in_days", "access_type"], ) +@require_path_permission(PathAction.SHARE, "payload.paths") async def create_share( request: Request, payload: ShareCreate, diff --git a/domain/user/api.py b/domain/user/api.py index 9dab68e..c9351e1 100644 --- a/domain/user/api.py +++ b/domain/user/api.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends from domain.auth.service import get_current_active_user from domain.auth.types import User -from domain.permission.service import PermissionService +from domain.permission import require_system_permission from domain.permission.types import SystemPermission from .service import UserService @@ -14,81 +14,66 @@ router = APIRouter(prefix="/api", tags=["user"]) @router.get("/users", response_model=list[UserInfo]) +@require_system_permission(SystemPermission.USER_LIST) async def list_users( current_user: Annotated[User, Depends(get_current_active_user)] ) -> list[UserInfo]: - await PermissionService.require_system_permission( - current_user.id, SystemPermission.USER_LIST - ) return await UserService.get_all_users() @router.get("/users/{user_id}", response_model=UserDetail) +@require_system_permission(SystemPermission.USER_LIST) async def get_user( user_id: int, current_user: Annotated[User, Depends(get_current_active_user)], ) -> UserDetail: - await PermissionService.require_system_permission( - current_user.id, SystemPermission.USER_LIST - ) return await UserService.get_user(user_id) @router.post("/users", response_model=UserDetail) +@require_system_permission(SystemPermission.USER_CREATE) async def create_user( data: UserCreate, current_user: Annotated[User, Depends(get_current_active_user)], ) -> UserDetail: - await PermissionService.require_system_permission( - current_user.id, SystemPermission.USER_CREATE - ) return await UserService.create_user(data, current_user.id) @router.put("/users/{user_id}", response_model=UserDetail) +@require_system_permission(SystemPermission.USER_EDIT) async def update_user( user_id: int, data: UserUpdate, current_user: Annotated[User, Depends(get_current_active_user)], ) -> UserDetail: - await PermissionService.require_system_permission( - current_user.id, SystemPermission.USER_EDIT - ) return await UserService.update_user(user_id, data, current_user.id) @router.delete("/users/{user_id}") +@require_system_permission(SystemPermission.USER_DELETE) async def delete_user( user_id: int, current_user: Annotated[User, Depends(get_current_active_user)], ) -> dict: - await PermissionService.require_system_permission( - current_user.id, SystemPermission.USER_DELETE - ) await UserService.delete_user(user_id, current_user.id) return {"success": True} @router.post("/users/{user_id}/roles", response_model=list[str]) +@require_system_permission(SystemPermission.USER_EDIT) async def set_user_roles( user_id: int, data: UserRoleAssign, current_user: Annotated[User, Depends(get_current_active_user)], ) -> list[str]: - await PermissionService.require_system_permission( - current_user.id, SystemPermission.USER_EDIT - ) return await UserService.set_user_roles(user_id, data.role_ids) @router.delete("/users/{user_id}/roles/{role_id}", response_model=list[str]) +@require_system_permission(SystemPermission.USER_EDIT) async def remove_user_role( user_id: int, role_id: int, current_user: Annotated[User, Depends(get_current_active_user)], ) -> list[str]: - await PermissionService.require_system_permission( - current_user.id, SystemPermission.USER_EDIT - ) return await UserService.remove_user_role(user_id, role_id) - diff --git a/domain/virtual_fs/api.py b/domain/virtual_fs/api.py index 28cbdcf..be99a0e 100644 --- a/domain/virtual_fs/api.py +++ b/domain/virtual_fs/api.py @@ -5,7 +5,7 @@ from fastapi import APIRouter, Depends, File, Query, Request, UploadFile from api.response import success from domain.audit import AuditAction, audit from domain.auth import User, get_current_active_user -from domain.permission.service import PermissionService +from domain.permission import require_path_permission from domain.permission.types import PathAction from .service import VirtualFSService from .types import MkdirRequest, MoveRequest @@ -15,12 +15,12 @@ router = APIRouter(prefix="/api/fs", tags=["virtual-fs"]) @router.get("/file/{full_path:path}") @audit(action=AuditAction.DOWNLOAD, description="获取文件") +@require_path_permission(PathAction.READ, "full_path") async def get_file( full_path: str, request: Request, current_user: Annotated[User, Depends(get_current_active_user)], ): - await PermissionService.require_path_permission(current_user.id, full_path, PathAction.READ) return await VirtualFSService.serve_file(full_path, request.headers.get("Range")) @@ -47,13 +47,13 @@ async def stream_endpoint( @router.get("/temp-link/{full_path:path}") @audit(action=AuditAction.SHARE, description="创建临时链接") +@require_path_permission(PathAction.READ, "full_path") async def get_temp_link( full_path: str, request: Request, current_user: Annotated[User, Depends(get_current_active_user)], expires_in: int = Query(3600, description="有效时间(秒), 0或负数表示永久"), ): - await PermissionService.require_path_permission(current_user.id, full_path, PathAction.READ) data = await VirtualFSService.create_temp_link(full_path, expires_in) return success(data) @@ -79,25 +79,25 @@ async def access_public_file_with_name( @router.get("/stat/{full_path:path}") @audit(action=AuditAction.READ, description="查看文件信息") +@require_path_permission(PathAction.READ, "full_path") async def get_file_stat( full_path: str, request: Request, current_user: Annotated[User, Depends(get_current_active_user)], ): - await PermissionService.require_path_permission(current_user.id, full_path, PathAction.READ) stat = await VirtualFSService.stat(full_path) return success(stat) @router.post("/file/{full_path:path}") @audit(action=AuditAction.UPLOAD, description="上传文件") +@require_path_permission(PathAction.WRITE, "full_path") async def put_file( request: Request, current_user: Annotated[User, Depends(get_current_active_user)], full_path: str, file: UploadFile = File(...), ): - await PermissionService.require_path_permission(current_user.id, full_path, PathAction.WRITE) data = await file.read() result = await VirtualFSService.write_uploaded_file(full_path, data) return success(result) @@ -105,62 +105,60 @@ async def put_file( @router.post("/mkdir") @audit(action=AuditAction.CREATE, description="创建目录", body_fields=["path"]) +@require_path_permission(PathAction.WRITE, "body.path") async def api_mkdir( request: Request, current_user: Annotated[User, Depends(get_current_active_user)], body: MkdirRequest, ): - await PermissionService.require_path_permission(current_user.id, body.path, PathAction.WRITE) result = await VirtualFSService.mkdir(body.path) return success(result) @router.post("/move") @audit(action=AuditAction.UPDATE, description="移动路径", body_fields=["src", "dst"]) +@require_path_permission(PathAction.WRITE, "body.dst") +@require_path_permission(PathAction.DELETE, "body.src") async def api_move( request: Request, current_user: Annotated[User, Depends(get_current_active_user)], body: MoveRequest, overwrite: bool = Query(False, description="是否允许覆盖已存在目标"), ): - # 移动需要源路径的删除权限和目标路径的写权限 - await PermissionService.require_path_permission(current_user.id, body.src, PathAction.DELETE) - await PermissionService.require_path_permission(current_user.id, body.dst, PathAction.WRITE) result = await VirtualFSService.move(body.src, body.dst, overwrite) return success(result) @router.post("/rename") @audit(action=AuditAction.UPDATE, description="重命名路径", body_fields=["src", "dst"]) +@require_path_permission(PathAction.WRITE, "body.src") async def api_rename( request: Request, current_user: Annotated[User, Depends(get_current_active_user)], body: MoveRequest, overwrite: bool = Query(False, description="是否允许覆盖已存在目标"), ): - # 重命名需要写权限 - await PermissionService.require_path_permission(current_user.id, body.src, PathAction.WRITE) result = await VirtualFSService.rename(body.src, body.dst, overwrite) return success(result) @router.post("/copy") @audit(action=AuditAction.CREATE, description="复制路径", body_fields=["src", "dst"]) +@require_path_permission(PathAction.WRITE, "body.dst") +@require_path_permission(PathAction.READ, "body.src") async def api_copy( request: Request, current_user: Annotated[User, Depends(get_current_active_user)], body: MoveRequest, overwrite: bool = Query(False, description="是否覆盖已存在目标"), ): - # 复制需要源路径的读权限和目标路径的写权限 - await PermissionService.require_path_permission(current_user.id, body.src, PathAction.READ) - await PermissionService.require_path_permission(current_user.id, body.dst, PathAction.WRITE) result = await VirtualFSService.copy(body.src, body.dst, overwrite) return success(result) @router.post("/upload/{full_path:path}") @audit(action=AuditAction.UPLOAD, description="流式上传文件") +@require_path_permission(PathAction.WRITE, "full_path") async def upload_stream( request: Request, current_user: Annotated[User, Depends(get_current_active_user)], @@ -169,13 +167,13 @@ async def upload_stream( overwrite: bool = Query(True, description="是否覆盖已存在文件"), chunk_size: int = Query(1024 * 1024, ge=8 * 1024, le=8 * 1024 * 1024, description="单次读取块大小"), ): - await PermissionService.require_path_permission(current_user.id, full_path, PathAction.WRITE) result = await VirtualFSService.upload_stream_from_upload_file(full_path, file, chunk_size, overwrite) return success(result) @router.get("/{full_path:path}") @audit(action=AuditAction.READ, description="浏览目录") +@require_path_permission(PathAction.READ, "full_path") async def browse_fs( request: Request, current_user: Annotated[User, Depends(get_current_active_user)], @@ -185,7 +183,6 @@ async def browse_fs( sort_by: str = Query("name", description="按字段排序: name, size, mtime"), sort_order: str = Query("asc", description="排序顺序: asc, desc"), ): - await PermissionService.require_path_permission(current_user.id, full_path, PathAction.READ) data = await VirtualFSService.list_directory_with_permission( full_path, current_user.id, page_num, page_size, sort_by, sort_order ) @@ -194,12 +191,12 @@ async def browse_fs( @router.delete("/{full_path:path}") @audit(action=AuditAction.DELETE, description="删除路径") +@require_path_permission(PathAction.DELETE, "full_path") async def api_delete( request: Request, current_user: Annotated[User, Depends(get_current_active_user)], full_path: str, ): - await PermissionService.require_path_permission(current_user.id, full_path, PathAction.DELETE) result = await VirtualFSService.delete(full_path) return success(result) diff --git a/domain/virtual_fs/mapping/webdav_api.py b/domain/virtual_fs/mapping/webdav_api.py index 9ae3d47..06ed5b3 100644 --- a/domain/virtual_fs/mapping/webdav_api.py +++ b/domain/virtual_fs/mapping/webdav_api.py @@ -11,6 +11,8 @@ import xml.etree.ElementTree as ET from domain.audit import AuditAction, audit from domain.auth import AuthService, User, UserInDB from domain.config import ConfigService +from domain.permission.service import PermissionService +from domain.permission.types import PathAction from domain.virtual_fs import VirtualFSService @@ -65,11 +67,26 @@ async def _get_basic_user(request: Request) -> User: if not user_or_false: raise HTTPException(401, detail="Invalid credentials", headers={"WWW-Authenticate": "Basic realm=webdav"}) u: UserInDB = user_or_false - return User(id=u.id, username=u.username, email=u.email, full_name=u.full_name, disabled=u.disabled) + return User( + id=u.id, + username=u.username, + email=u.email, + full_name=u.full_name, + disabled=u.disabled, + is_admin=u.is_admin, + ) elif scheme_lower == "bearer": if not param: raise HTTPException(401, detail="Invalid Bearer token") - return User(id=0, username="bearer", email=None, full_name=None, disabled=False) + u = await AuthService.get_current_user(param) + return User( + id=u.id, + username=u.username, + email=u.email, + full_name=u.full_name, + disabled=u.disabled, + is_admin=u.is_admin, + ) else: raise HTTPException(401, detail="Unsupported auth", headers={"WWW-Authenticate": "Basic realm=webdav"}) @@ -155,6 +172,8 @@ async def propfind( user: User = Depends(_get_basic_user), ): full_path = _normalize_fs_path(path) + if full_path != "/": + await PermissionService.require_path_permission(user.id, full_path, PathAction.READ) depth = request.headers.get("Depth", "1").lower() if depth not in ("0", "1", "infinity"): depth = "1" @@ -195,7 +214,9 @@ async def propfind( if depth in ("1", "infinity"): try: - listing = await VirtualFSService.list_virtual_dir(full_path, page_num=1, page_size=1000) + listing = await VirtualFSService.list_virtual_dir_with_permission( + full_path, user.id, page_num=1, page_size=1000 + ) for ent in (listing.get("items") or []): is_dir = bool(ent.get("is_dir")) name = ent.get("name") @@ -223,6 +244,8 @@ async def dav_get( user: User = Depends(_get_basic_user), ): full_path = _normalize_fs_path(path) + if full_path != "/": + await PermissionService.require_path_permission(user.id, full_path, PathAction.READ) range_header = request.headers.get("Range") return await VirtualFSService.stream_file(full_path, range_header) @@ -236,6 +259,8 @@ async def dav_head( user: User = Depends(_get_basic_user), ): full_path = _normalize_fs_path(path) + if full_path != "/": + await PermissionService.require_path_permission(user.id, full_path, PathAction.READ) try: st = await VirtualFSService.stat_file(full_path) except FileNotFoundError: @@ -264,6 +289,7 @@ async def dav_put( user: User = Depends(_get_basic_user), ): full_path = _normalize_fs_path(path) + await PermissionService.require_path_permission(user.id, full_path, PathAction.WRITE) async def body_iter(): async for chunk in request.stream(): if chunk: @@ -281,6 +307,7 @@ async def dav_delete( user: User = Depends(_get_basic_user), ): full_path = _normalize_fs_path(path) + await PermissionService.require_path_permission(user.id, full_path, PathAction.DELETE) await VirtualFSService.delete_path(full_path) return Response(status_code=204, headers=_dav_headers()) @@ -294,6 +321,7 @@ async def dav_mkcol( user: User = Depends(_get_basic_user), ): full_path = _normalize_fs_path(path) + await PermissionService.require_path_permission(user.id, full_path, PathAction.WRITE) await VirtualFSService.make_dir(full_path) return Response(status_code=201, headers=_dav_headers()) @@ -322,6 +350,8 @@ async def dav_move( dest_header = request.headers.get("Destination") dst = _parse_destination(dest_header or "") overwrite = request.headers.get("Overwrite", "T").upper() != "F" + await PermissionService.require_path_permission(user.id, full_src, PathAction.DELETE) + await PermissionService.require_path_permission(user.id, dst, PathAction.WRITE) await VirtualFSService.move_path(full_src, dst, overwrite=overwrite) return Response(status_code=204, headers=_dav_headers()) @@ -338,5 +368,7 @@ async def dav_copy( dest_header = request.headers.get("Destination") dst = _parse_destination(dest_header or "") overwrite = request.headers.get("Overwrite", "T").upper() != "F" + await PermissionService.require_path_permission(user.id, full_src, PathAction.READ) + await PermissionService.require_path_permission(user.id, dst, PathAction.WRITE) await VirtualFSService.copy_path(full_src, dst, overwrite=overwrite) return Response(status_code=201 if not overwrite else 204, headers=_dav_headers()) diff --git a/domain/virtual_fs/search/search_api.py b/domain/virtual_fs/search/search_api.py index 1be4cc5..fdeb0ef 100644 --- a/domain/virtual_fs/search/search_api.py +++ b/domain/virtual_fs/search/search_api.py @@ -2,6 +2,8 @@ from fastapi import APIRouter, Depends, Query from api.response import success from domain.auth import User, get_current_active_user +from domain.permission.service import PermissionService +from domain.permission.types import PathAction from .search_service import VirtualFSSearchService router = APIRouter(prefix="/api/fs/search", tags=["search"]) @@ -24,4 +26,14 @@ async def search_files( page_size = max(min(page_size, 100), 1) data = await VirtualFSSearchService.search(q, top_k, mode, page, page_size) + items = data.get("items") if isinstance(data, dict) else None + if isinstance(items, list) and items: + filtered = [] + for item in items: + path = getattr(item, "path", None) + if not path: + continue + if await PermissionService.check_path_permission(user.id, str(path), PathAction.READ): + filtered.append(item) + data["items"] = filtered return success(data)