diff --git a/api/routers.py b/api/routers.py index 2110c17..b7a0541 100644 --- a/api/routers.py +++ b/api/routers.py @@ -16,6 +16,9 @@ from domain.virtual_fs import api as virtual_fs from domain.virtual_fs.mapping import s3_api, webdav_api from domain.virtual_fs.search import search_api from domain.audit import api as audit +from domain.permission import api as permission +from domain.user import api as user +from domain.role import api as role def include_routers(app: FastAPI): @@ -38,3 +41,6 @@ def include_routers(app: FastAPI): app.include_router(offline_downloads.router) app.include_router(email.router) app.include_router(audit.router) + app.include_router(permission.router) + app.include_router(user.router) + app.include_router(role.router) diff --git a/domain/auth/service.py b/domain/auth/service.py index 4edb658..23840db 100644 --- a/domain/auth/service.py +++ b/domain/auth/service.py @@ -140,6 +140,7 @@ class AuthService: email=user.email, full_name=user.full_name, disabled=user.disabled, + is_admin=user.is_admin, hashed_password=user.hashed_password, ) return None @@ -166,12 +167,14 @@ class AuthService: if exists: raise HTTPException(status_code=400, detail="用户名已存在") hashed = cls.get_password_hash(payload.password) + # 第一个用户自动成为超级管理员 user = await UserAccount.create( username=payload.username, email=payload.email, full_name=payload.full_name, hashed_password=hashed, disabled=False, + is_admin=True, # 第一个用户是超级管理员 ) return user @@ -195,6 +198,13 @@ class AuthService: detail="用户名或密码错误", headers={"WWW-Authenticate": "Bearer"}, ) + + # 更新最后登录时间 + db_user = await UserAccount.get_or_none(id=user.id) + if db_user: + db_user.last_login = _now() + await db_user.save(update_fields=["last_login"]) + access_token_expires = timedelta(minutes=cls.access_token_expire_minutes) access_token = await cls.create_access_token( data={"sub": user.username}, expires_delta=access_token_expires @@ -212,6 +222,7 @@ class AuthService: "email": getattr(user, "email", None), "full_name": getattr(user, "full_name", None), "gravatar_url": gravatar_url, + "is_admin": getattr(user, "is_admin", False), } @classmethod diff --git a/domain/auth/types.py b/domain/auth/types.py index d54d160..7ee8d55 100644 --- a/domain/auth/types.py +++ b/domain/auth/types.py @@ -16,6 +16,7 @@ class User(BaseModel): email: str | None = None full_name: str | None = None disabled: bool | None = None + is_admin: bool = False class UserInDB(User): diff --git a/domain/permission/__init__.py b/domain/permission/__init__.py new file mode 100644 index 0000000..51c9de7 --- /dev/null +++ b/domain/permission/__init__.py @@ -0,0 +1,4 @@ +from .service import PermissionService +from .matcher import PathMatcher + +__all__ = ["PermissionService", "PathMatcher"] diff --git a/domain/permission/api.py b/domain/permission/api.py new file mode 100644 index 0000000..2ebad01 --- /dev/null +++ b/domain/permission/api.py @@ -0,0 +1,41 @@ +from typing import Annotated +from fastapi import APIRouter, Depends + +from domain.auth.service import get_current_active_user +from domain.auth.types import User +from .service import PermissionService +from .types import ( + PathPermissionCheck, + PathPermissionResult, + UserPermissions, + PermissionInfo, +) + +router = APIRouter(prefix="/api", tags=["permissions"]) + + +@router.get("/permissions", response_model=list[PermissionInfo]) +async def get_all_permissions( + current_user: Annotated[User, Depends(get_current_active_user)] +) -> list[PermissionInfo]: + """获取所有权限定义""" + return await PermissionService.get_all_permissions() + + +@router.get("/me/permissions", response_model=UserPermissions) +async def get_my_permissions( + current_user: Annotated[User, Depends(get_current_active_user)] +) -> UserPermissions: + """获取当前用户的有效权限""" + return await PermissionService.get_user_permissions(current_user.id) + + +@router.post("/me/check-path", response_model=PathPermissionResult) +async def check_path_permission( + data: PathPermissionCheck, + current_user: Annotated[User, Depends(get_current_active_user)], +) -> PathPermissionResult: + """检查当前用户对某路径的权限""" + return await PermissionService.check_path_permission_detailed( + current_user.id, data.path, data.action + ) diff --git a/domain/permission/matcher.py b/domain/permission/matcher.py new file mode 100644 index 0000000..ba68309 --- /dev/null +++ b/domain/permission/matcher.py @@ -0,0 +1,158 @@ +import re +import fnmatch +from functools import lru_cache + + +class PathMatcher: + """路径匹配器,支持精确匹配、通配符匹配和正则匹配""" + + @classmethod + def normalize_path(cls, path: str) -> str: + """规范化路径""" + if not path: + return "/" + # 确保以 / 开头 + if not path.startswith("/"): + path = "/" + path + # 移除末尾的 /(除了根路径) + if path != "/" and path.endswith("/"): + path = path.rstrip("/") + return path + + @classmethod + def get_parent_path(cls, path: str) -> str | None: + """获取父目录路径""" + path = cls.normalize_path(path) + if path == "/": + return None + parent = "/".join(path.rsplit("/", 1)[:-1]) + return parent if parent else "/" + + @classmethod + def match_pattern(cls, path: str, pattern: str, is_regex: bool = False) -> bool: + """ + 匹配路径和模式 + + Args: + path: 要匹配的路径 + pattern: 匹配模式 + is_regex: 是否为正则表达式 + + Returns: + 是否匹配 + """ + path = cls.normalize_path(path) + pattern = cls.normalize_path(pattern) + + if is_regex: + return cls._match_regex(path, pattern) + else: + return cls._match_glob(path, pattern) + + @classmethod + def _match_regex(cls, path: str, pattern: str) -> bool: + """正则表达式匹配""" + try: + # 限制正则表达式的复杂度,防止 ReDoS 攻击 + if len(pattern) > 500: + return False + regex = re.compile(pattern) + return bool(regex.match(path)) + except re.error: + return False + + @classmethod + def _match_glob(cls, path: str, pattern: str) -> bool: + """ + 通配符匹配 + + 支持的语法: + - * : 匹配单层目录中的任意字符 + - ** : 匹配任意层级目录 + - ? : 匹配单个字符 + """ + # 精确匹配 + if pattern == path: + return True + + # 处理 ** 通配符 + if "**" in pattern: + return cls._match_double_star(path, pattern) + + # 使用 fnmatch 进行标准通配符匹配 + return fnmatch.fnmatch(path, pattern) + + @classmethod + def _match_double_star(cls, path: str, pattern: str) -> bool: + """处理 ** 通配符匹配""" + # 将 ** 替换为特殊标记 + parts = pattern.split("**") + + if len(parts) == 2: + prefix, suffix = parts + # 移除 prefix 末尾的 / 和 suffix 开头的 / + prefix = prefix.rstrip("/") if prefix else "" + suffix = suffix.lstrip("/") if suffix else "" + + # 检查前缀匹配 + if prefix and not path.startswith(prefix): + return False + + # 如果没有后缀,只需要前缀匹配 + if not suffix: + return True + + # 检查后缀匹配 + remaining = path[len(prefix):].lstrip("/") if prefix else path.lstrip("/") + + # 后缀可以出现在任意位置 + if "*" in suffix or "?" in suffix: + # 后缀包含通配符,逐层检查 + path_parts = remaining.split("/") + suffix_parts = suffix.split("/") + + # 简化处理:检查路径的最后几层是否与后缀匹配 + if len(path_parts) >= len(suffix_parts): + tail = "/".join(path_parts[-len(suffix_parts):]) + return fnmatch.fnmatch(tail, suffix) + return False + else: + # 后缀是精确字符串 + return remaining.endswith(suffix) or ("/" + suffix) in remaining or remaining == suffix + + # 多个 ** 的情况,使用简化匹配 + regex_pattern = pattern.replace("**", ".*").replace("*", "[^/]*").replace("?", ".") + try: + return bool(re.match(f"^{regex_pattern}$", path)) + except re.error: + return False + + @classmethod + def get_pattern_specificity(cls, pattern: str, is_regex: bool = False) -> int: + """ + 计算模式的具体程度(用于优先级排序) + + 返回值越大表示模式越具体 + """ + pattern = cls.normalize_path(pattern) + + if is_regex: + # 正则表达式具体程度较低 + return len(pattern) // 2 + + # 精确路径最具体 + if "*" not in pattern and "?" not in pattern: + return len(pattern) * 10 + + # 计算非通配符部分的长度 + specificity = 0 + parts = pattern.split("/") + for part in parts: + if part == "**": + specificity += 1 + elif "*" in part or "?" in part: + specificity += 5 + else: + specificity += 10 + + return specificity diff --git a/domain/permission/service.py b/domain/permission/service.py new file mode 100644 index 0000000..5ed152f --- /dev/null +++ b/domain/permission/service.py @@ -0,0 +1,351 @@ +from typing import List, Optional +from fastapi import HTTPException + +from models.database import ( + UserAccount, + Role, + UserRole, + Permission, + RolePermission, + PathRule, +) +from .matcher import PathMatcher +from .types import ( + PathAction, + PathRuleInfo, + PathPermissionResult, + UserPermissions, + PermissionInfo, + PERMISSION_DEFINITIONS, +) + + +class PermissionService: + """权限检查服务""" + + # 权限检查结果缓存(简单的内存缓存) + _cache: dict[str, tuple[bool, float]] = {} + _cache_ttl = 300 # 5分钟缓存 + + @classmethod + async def check_path_permission( + cls, user_id: int, path: str, action: str + ) -> bool: + """ + 检查用户对路径的操作权限 + + Args: + user_id: 用户ID + path: 要检查的路径 + action: 操作类型 (read/write/delete/share) + + Returns: + 是否有权限 + """ + import time + + # 检查缓存 + cache_key = f"{user_id}:{path}:{action}" + if cache_key in cls._cache: + result, timestamp = cls._cache[cache_key] + if time.time() - timestamp < cls._cache_ttl: + return result + + # 获取用户 + user = await UserAccount.get_or_none(id=user_id) + if not user: + return False + + # 超级管理员直接放行 + if user.is_admin: + cls._cache[cache_key] = (True, time.time()) + return True + + # 获取用户所有角色 + user_roles = await UserRole.filter(user_id=user_id).prefetch_related("role") + role_ids = [ur.role_id for ur in user_roles] + + if not role_ids: + cls._cache[cache_key] = (False, time.time()) + return False + + # 获取所有角色的路径规则 + path_rules = await PathRule.filter(role_id__in=role_ids).order_by("-priority") + + # 规范化路径 + normalized_path = PathMatcher.normalize_path(path) + + # 按优先级和具体程度匹配 + result = cls._match_path_rules(normalized_path, action, list(path_rules)) + + # 如果没有匹配到规则,检查父目录(继承) + if result is None: + parent_path = PathMatcher.get_parent_path(normalized_path) + if parent_path: + result = await cls.check_path_permission(user_id, parent_path, action) + else: + result = False # 默认拒绝 + + cls._cache[cache_key] = (result, time.time()) + return result + + @classmethod + def _match_path_rules( + cls, path: str, action: str, rules: List[PathRule] + ) -> Optional[bool]: + """ + 匹配路径规则 + + Returns: + True/False 表示明确的权限结果,None 表示没有匹配到规则 + """ + # 按优先级和具体程度排序 + sorted_rules = sorted( + rules, + key=lambda r: ( + r.priority, + PathMatcher.get_pattern_specificity(r.path_pattern, r.is_regex), + ), + reverse=True, + ) + + for rule in sorted_rules: + if PathMatcher.match_pattern(path, rule.path_pattern, rule.is_regex): + # 匹配到规则,检查具体操作权限 + if action == PathAction.READ: + return rule.can_read + elif action == PathAction.WRITE: + return rule.can_write + elif action == PathAction.DELETE: + return rule.can_delete + elif action == PathAction.SHARE: + return rule.can_share + else: + return False + + return None + + @classmethod + async def check_system_permission(cls, user_id: int, permission_code: str) -> bool: + """检查用户的系统/适配器权限""" + # 获取用户 + user = await UserAccount.get_or_none(id=user_id) + if not user: + return False + + # 超级管理员直接放行 + if user.is_admin: + return True + + # 获取用户所有角色 + user_roles = await UserRole.filter(user_id=user_id) + role_ids = [ur.role_id for ur in user_roles] + + if not role_ids: + return False + + # 检查角色是否有该权限 + permission = await Permission.get_or_none(code=permission_code) + if not permission: + return False + + role_permission = await RolePermission.filter( + role_id__in=role_ids, permission_id=permission.id + ).first() + + return role_permission is not None + + @classmethod + async def require_path_permission( + cls, user_id: int, path: str, action: str + ) -> None: + """要求用户具有路径权限,否则抛出 403""" + if not await cls.check_path_permission(user_id, path, action): + raise HTTPException(403, detail=f"没有权限执行此操作: {action}") + + @classmethod + async def require_system_permission( + cls, user_id: int, permission_code: str + ) -> None: + """要求用户具有系统权限,否则抛出 403""" + if not await cls.check_system_permission(user_id, permission_code): + raise HTTPException(403, detail=f"没有权限: {permission_code}") + + @classmethod + async def get_user_permissions(cls, user_id: int) -> UserPermissions: + """获取用户的所有权限""" + user = await UserAccount.get_or_none(id=user_id) + if not user: + raise HTTPException(404, detail="用户不存在") + + # 超级管理员拥有所有权限 + if user.is_admin: + all_permissions = await Permission.all() + all_path_rules = await PathRule.all() + return UserPermissions( + user_id=user_id, + is_admin=True, + permissions=[p.code for p in all_permissions], + path_rules=[ + PathRuleInfo( + id=r.id, + role_id=r.role_id, + path_pattern=r.path_pattern, + is_regex=r.is_regex, + can_read=r.can_read, + can_write=r.can_write, + can_delete=r.can_delete, + can_share=r.can_share, + priority=r.priority, + created_at=r.created_at, + ) + for r in all_path_rules + ], + ) + + # 获取用户角色 + user_roles = await UserRole.filter(user_id=user_id) + role_ids = [ur.role_id for ur in user_roles] + + # 获取权限 + permissions = [] + if role_ids: + role_permissions = await RolePermission.filter( + role_id__in=role_ids + ).prefetch_related("permission") + permissions = list(set(rp.permission.code for rp in role_permissions)) + + # 获取路径规则 + path_rules = [] + if role_ids: + rules = await PathRule.filter(role_id__in=role_ids) + path_rules = [ + PathRuleInfo( + id=r.id, + role_id=r.role_id, + path_pattern=r.path_pattern, + is_regex=r.is_regex, + can_read=r.can_read, + can_write=r.can_write, + can_delete=r.can_delete, + can_share=r.can_share, + priority=r.priority, + created_at=r.created_at, + ) + for r in rules + ] + + return UserPermissions( + user_id=user_id, + is_admin=False, + permissions=permissions, + path_rules=path_rules, + ) + + @classmethod + async def get_all_permissions(cls) -> List[PermissionInfo]: + """获取所有权限定义""" + permissions = await Permission.all() + return [ + PermissionInfo( + id=p.id, + code=p.code, + name=p.name, + category=p.category, + description=p.description, + ) + for p in permissions + ] + + @classmethod + async def check_path_permission_detailed( + cls, user_id: int, path: str, action: str + ) -> PathPermissionResult: + """检查路径权限并返回详细结果""" + user = await UserAccount.get_or_none(id=user_id) + if not user: + return PathPermissionResult(path=path, action=action, allowed=False) + + # 超级管理员 + if user.is_admin: + return PathPermissionResult(path=path, action=action, allowed=True) + + # 获取用户角色 + user_roles = await UserRole.filter(user_id=user_id) + role_ids = [ur.role_id for ur in user_roles] + + if not role_ids: + return PathPermissionResult(path=path, action=action, allowed=False) + + # 获取路径规则 + path_rules = await PathRule.filter(role_id__in=role_ids).order_by("-priority") + normalized_path = PathMatcher.normalize_path(path) + + # 查找匹配的规则 + matched_rule = None + for rule in sorted( + path_rules, + key=lambda r: ( + r.priority, + PathMatcher.get_pattern_specificity(r.path_pattern, r.is_regex), + ), + reverse=True, + ): + if PathMatcher.match_pattern( + normalized_path, rule.path_pattern, rule.is_regex + ): + matched_rule = rule + break + + # 检查权限 + allowed = False + if matched_rule: + if action == PathAction.READ: + allowed = matched_rule.can_read + elif action == PathAction.WRITE: + allowed = matched_rule.can_write + elif action == PathAction.DELETE: + allowed = matched_rule.can_delete + elif action == PathAction.SHARE: + allowed = matched_rule.can_share + + rule_info = None + if matched_rule: + rule_info = PathRuleInfo( + id=matched_rule.id, + role_id=matched_rule.role_id, + path_pattern=matched_rule.path_pattern, + is_regex=matched_rule.is_regex, + can_read=matched_rule.can_read, + can_write=matched_rule.can_write, + can_delete=matched_rule.can_delete, + can_share=matched_rule.can_share, + priority=matched_rule.priority, + created_at=matched_rule.created_at, + ) + + return PathPermissionResult( + path=path, action=action, allowed=allowed, matched_rule=rule_info + ) + + @classmethod + def clear_cache(cls, user_id: int | None = None) -> None: + """清除权限缓存""" + if user_id is None: + cls._cache.clear() + else: + # 清除特定用户的缓存 + keys_to_delete = [k for k in cls._cache if k.startswith(f"{user_id}:")] + for k in keys_to_delete: + del cls._cache[k] + + @classmethod + async def filter_paths_by_permission( + cls, user_id: int, paths: List[str], action: str + ) -> List[str]: + """过滤出用户有权限的路径列表""" + result = [] + for path in paths: + if await cls.check_path_permission(user_id, path, action): + result.append(path) + return result diff --git a/domain/permission/types.py b/domain/permission/types.py new file mode 100644 index 0000000..a9e9e4b --- /dev/null +++ b/domain/permission/types.py @@ -0,0 +1,108 @@ +from pydantic import BaseModel +from datetime import datetime + + +# 权限操作类型 +class PathAction: + READ = "read" + WRITE = "write" + DELETE = "delete" + SHARE = "share" + + +# 系统权限代码 +class SystemPermission: + USER_CREATE = "system.user.create" + USER_EDIT = "system.user.edit" + USER_DELETE = "system.user.delete" + USER_LIST = "system.user.list" + ROLE_MANAGE = "system.role.manage" + CONFIG_EDIT = "system.config.edit" + AUDIT_VIEW = "system.audit.view" + + +# 适配器权限代码 +class AdapterPermission: + CREATE = "adapter.create" + EDIT = "adapter.edit" + DELETE = "adapter.delete" + LIST = "adapter.list" + + +# 所有权限定义 +PERMISSION_DEFINITIONS = [ + # 系统权限 + {"code": SystemPermission.USER_CREATE, "name": "创建用户", "category": "system", "description": "允许创建新用户"}, + {"code": SystemPermission.USER_EDIT, "name": "编辑用户", "category": "system", "description": "允许编辑用户信息"}, + {"code": SystemPermission.USER_DELETE, "name": "删除用户", "category": "system", "description": "允许删除用户"}, + {"code": SystemPermission.USER_LIST, "name": "查看用户列表", "category": "system", "description": "允许查看用户列表"}, + {"code": SystemPermission.ROLE_MANAGE, "name": "管理角色和权限", "category": "system", "description": "允许管理角色和权限配置"}, + {"code": SystemPermission.CONFIG_EDIT, "name": "修改系统配置", "category": "system", "description": "允许修改系统配置"}, + {"code": SystemPermission.AUDIT_VIEW, "name": "查看审计日志", "category": "system", "description": "允许查看审计日志"}, + # 适配器权限 + {"code": AdapterPermission.CREATE, "name": "创建存储适配器", "category": "adapter", "description": "允许创建存储适配器"}, + {"code": AdapterPermission.EDIT, "name": "编辑存储适配器", "category": "adapter", "description": "允许编辑存储适配器"}, + {"code": AdapterPermission.DELETE, "name": "删除存储适配器", "category": "adapter", "description": "允许删除存储适配器"}, + {"code": AdapterPermission.LIST, "name": "查看存储适配器列表", "category": "adapter", "description": "允许查看存储适配器列表"}, +] + + +# Pydantic 模型 +class PermissionInfo(BaseModel): + id: int + code: str + name: str + category: str + description: str | None = None + + +class PathRuleInfo(BaseModel): + id: int + role_id: int + path_pattern: str + is_regex: bool + can_read: bool + can_write: bool + can_delete: bool + can_share: bool + priority: int + created_at: datetime + + +class PathRuleCreate(BaseModel): + path_pattern: str + is_regex: bool = False + can_read: bool = True + can_write: bool = False + can_delete: bool = False + can_share: bool = False + priority: int = 0 + + +class PathRuleUpdate(BaseModel): + path_pattern: str | None = None + is_regex: bool | None = None + can_read: bool | None = None + can_write: bool | None = None + can_delete: bool | None = None + can_share: bool | None = None + priority: int | None = None + + +class PathPermissionCheck(BaseModel): + path: str + action: str + + +class PathPermissionResult(BaseModel): + path: str + action: str + allowed: bool + matched_rule: PathRuleInfo | None = None + + +class UserPermissions(BaseModel): + user_id: int + is_admin: bool + permissions: list[str] # 系统/适配器权限代码列表 + path_rules: list[PathRuleInfo] # 路径权限规则 diff --git a/domain/role/__init__.py b/domain/role/__init__.py new file mode 100644 index 0000000..c5dffe7 --- /dev/null +++ b/domain/role/__init__.py @@ -0,0 +1,3 @@ +from .service import RoleService + +__all__ = ["RoleService"] diff --git a/domain/role/api.py b/domain/role/api.py new file mode 100644 index 0000000..7e8ef26 --- /dev/null +++ b/domain/role/api.py @@ -0,0 +1,142 @@ +from typing import Annotated + +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.types import PathRuleCreate, PathRuleInfo, SystemPermission +from domain.user.service import UserService +from domain.user.types import UserInfo + +from .service import RoleService +from .types import RoleCreate, RoleDetail, RoleInfo, RolePermissionsUpdate, RoleUpdate + +router = APIRouter(prefix="/api", tags=["role"]) + + +@router.get("/roles", response_model=list[RoleInfo]) +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) +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]) +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) +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) +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}") +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]) +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]) +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) +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) +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}") +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/role/service.py b/domain/role/service.py new file mode 100644 index 0000000..b981b03 --- /dev/null +++ b/domain/role/service.py @@ -0,0 +1,327 @@ +from typing import List +from fastapi import HTTPException + +from models.database import Role, RolePermission, Permission, PathRule, UserRole +from domain.permission.service import PermissionService +from domain.permission.types import PathRuleCreate, PathRuleInfo +from .types import RoleInfo, RoleDetail, RoleCreate, RoleUpdate, SystemRoles + + +class RoleService: + """角色管理服务""" + + @classmethod + async def get_all_roles(cls) -> List[RoleInfo]: + """获取所有角色""" + roles = await Role.all().order_by("id") + return [ + RoleInfo( + id=r.id, + name=r.name, + description=r.description, + is_system=r.is_system, + created_at=r.created_at, + ) + for r in roles + ] + + @classmethod + async def get_role(cls, role_id: int) -> RoleDetail: + """获取角色详情""" + role = await Role.get_or_none(id=role_id) + if not role: + raise HTTPException(404, detail="角色不存在") + + # 获取权限 + role_permissions = await RolePermission.filter(role_id=role_id).prefetch_related( + "permission" + ) + permissions = [rp.permission.code for rp in role_permissions] + + # 获取路径规则数量 + path_rules_count = await PathRule.filter(role_id=role_id).count() + + return RoleDetail( + id=role.id, + name=role.name, + description=role.description, + is_system=role.is_system, + created_at=role.created_at, + permissions=permissions, + path_rules_count=path_rules_count, + ) + + @classmethod + async def create_role(cls, data: RoleCreate) -> RoleInfo: + """创建角色""" + # 检查名称是否已存在 + existing = await Role.get_or_none(name=data.name) + if existing: + raise HTTPException(400, detail="角色名称已存在") + + role = await Role.create( + name=data.name, + description=data.description, + is_system=False, + ) + + return RoleInfo( + id=role.id, + name=role.name, + description=role.description, + is_system=role.is_system, + created_at=role.created_at, + ) + + @classmethod + async def update_role(cls, role_id: int, data: RoleUpdate) -> RoleInfo: + """更新角色""" + role = await Role.get_or_none(id=role_id) + if not role: + raise HTTPException(404, detail="角色不存在") + + if data.name is not None: + # 检查名称是否与其他角色冲突 + existing = await Role.filter(name=data.name).exclude(id=role_id).first() + if existing: + raise HTTPException(400, detail="角色名称已存在") + role.name = data.name + + if data.description is not None: + role.description = data.description + + await role.save() + + return RoleInfo( + id=role.id, + name=role.name, + description=role.description, + is_system=role.is_system, + created_at=role.created_at, + ) + + @classmethod + async def delete_role(cls, role_id: int) -> None: + """删除角色""" + role = await Role.get_or_none(id=role_id) + if not role: + raise HTTPException(404, detail="角色不存在") + + if role.is_system: + raise HTTPException(400, detail="系统内置角色不可删除") + + # 检查是否有用户使用此角色 + user_count = await UserRole.filter(role_id=role_id).count() + if user_count > 0: + raise HTTPException(400, detail=f"有 {user_count} 个用户正在使用此角色,无法删除") + + await role.delete() + # 清除权限缓存 + PermissionService.clear_cache() + + @classmethod + async def set_role_permissions(cls, role_id: int, permission_codes: List[str]) -> List[str]: + """设置角色的权限""" + role = await Role.get_or_none(id=role_id) + if not role: + raise HTTPException(404, detail="角色不存在") + + # 获取权限ID + permissions = await Permission.filter(code__in=permission_codes) + permission_map = {p.code: p.id for p in permissions} + + # 验证所有权限代码 + invalid_codes = set(permission_codes) - set(permission_map.keys()) + if invalid_codes: + raise HTTPException(400, detail=f"无效的权限代码: {', '.join(invalid_codes)}") + + # 删除现有权限 + await RolePermission.filter(role_id=role_id).delete() + + # 添加新权限 + for code in permission_codes: + await RolePermission.create( + role_id=role_id, + permission_id=permission_map[code], + ) + + # 清除权限缓存 + PermissionService.clear_cache() + + return permission_codes + + @classmethod + async def get_role_path_rules(cls, role_id: int) -> List[PathRuleInfo]: + """获取角色的路径规则""" + role = await Role.get_or_none(id=role_id) + if not role: + raise HTTPException(404, detail="角色不存在") + + rules = await PathRule.filter(role_id=role_id).order_by("-priority", "id") + return [ + PathRuleInfo( + id=r.id, + role_id=r.role_id, + path_pattern=r.path_pattern, + is_regex=r.is_regex, + can_read=r.can_read, + can_write=r.can_write, + can_delete=r.can_delete, + can_share=r.can_share, + priority=r.priority, + created_at=r.created_at, + ) + for r in rules + ] + + @classmethod + async def add_path_rule(cls, role_id: int, data: PathRuleCreate) -> PathRuleInfo: + """添加路径规则""" + role = await Role.get_or_none(id=role_id) + if not role: + raise HTTPException(404, detail="角色不存在") + + # 验证路径模式 + if data.is_regex: + import re + try: + re.compile(data.path_pattern) + except re.error as e: + raise HTTPException(400, detail=f"无效的正则表达式: {e}") + + rule = await PathRule.create( + role_id=role_id, + path_pattern=data.path_pattern, + is_regex=data.is_regex, + can_read=data.can_read, + can_write=data.can_write, + can_delete=data.can_delete, + can_share=data.can_share, + priority=data.priority, + ) + + # 清除权限缓存 + PermissionService.clear_cache() + + return PathRuleInfo( + id=rule.id, + role_id=rule.role_id, + path_pattern=rule.path_pattern, + is_regex=rule.is_regex, + can_read=rule.can_read, + can_write=rule.can_write, + can_delete=rule.can_delete, + can_share=rule.can_share, + priority=rule.priority, + created_at=rule.created_at, + ) + + @classmethod + async def update_path_rule(cls, rule_id: int, data: PathRuleCreate) -> PathRuleInfo: + """更新路径规则""" + rule = await PathRule.get_or_none(id=rule_id) + if not rule: + raise HTTPException(404, detail="路径规则不存在") + + # 验证路径模式 + if data.is_regex: + import re + try: + re.compile(data.path_pattern) + except re.error as e: + raise HTTPException(400, detail=f"无效的正则表达式: {e}") + + rule.path_pattern = data.path_pattern + rule.is_regex = data.is_regex + rule.can_read = data.can_read + rule.can_write = data.can_write + rule.can_delete = data.can_delete + rule.can_share = data.can_share + rule.priority = data.priority + await rule.save() + + # 清除权限缓存 + PermissionService.clear_cache() + + return PathRuleInfo( + id=rule.id, + role_id=rule.role_id, + path_pattern=rule.path_pattern, + is_regex=rule.is_regex, + can_read=rule.can_read, + can_write=rule.can_write, + can_delete=rule.can_delete, + can_share=rule.can_share, + priority=rule.priority, + created_at=rule.created_at, + ) + + @classmethod + async def delete_path_rule(cls, rule_id: int) -> None: + """删除路径规则""" + rule = await PathRule.get_or_none(id=rule_id) + if not rule: + raise HTTPException(404, detail="路径规则不存在") + + await rule.delete() + # 清除权限缓存 + PermissionService.clear_cache() + + @classmethod + async def ensure_system_roles(cls) -> None: + """确保系统内置角色存在""" + system_roles = [ + { + "name": SystemRoles.ADMIN, + "description": "管理员角色,拥有所有系统和适配器权限", + "is_system": True, + }, + { + "name": SystemRoles.USER, + "description": "普通用户角色,需要管理员配置路径权限", + "is_system": True, + }, + { + "name": SystemRoles.VIEWER, + "description": "只读用户角色,仅可查看文件", + "is_system": True, + }, + ] + + for role_data in system_roles: + existing = await Role.get_or_none(name=role_data["name"]) + if not existing: + await Role.create(**role_data) + + @classmethod + async def setup_admin_role_permissions(cls) -> None: + """为管理员角色设置所有权限""" + admin_role = await Role.get_or_none(name=SystemRoles.ADMIN) + if not admin_role: + return + + # 获取所有权限 + all_permissions = await Permission.all() + + # 清除现有权限 + await RolePermission.filter(role_id=admin_role.id).delete() + + # 添加所有权限 + for perm in all_permissions: + await RolePermission.create(role_id=admin_role.id, permission_id=perm.id) + + # 添加全路径访问规则 + existing_rule = await PathRule.filter( + role_id=admin_role.id, path_pattern="/**" + ).first() + if not existing_rule: + await PathRule.create( + role_id=admin_role.id, + path_pattern="/**", + is_regex=False, + can_read=True, + can_write=True, + can_delete=True, + can_share=True, + priority=100, + ) diff --git a/domain/role/types.py b/domain/role/types.py new file mode 100644 index 0000000..dd5e647 --- /dev/null +++ b/domain/role/types.py @@ -0,0 +1,36 @@ +from pydantic import BaseModel +from datetime import datetime + + +class RoleInfo(BaseModel): + id: int + name: str + description: str | None = None + is_system: bool + created_at: datetime + + +class RoleDetail(RoleInfo): + permissions: list[str] # 权限代码列表 + path_rules_count: int + + +class RoleCreate(BaseModel): + name: str + description: str | None = None + + +class RoleUpdate(BaseModel): + name: str | None = None + description: str | None = None + + +class RolePermissionsUpdate(BaseModel): + permission_codes: list[str] + + +# 预置角色名称 +class SystemRoles: + ADMIN = "Admin" + USER = "User" + VIEWER = "Viewer" diff --git a/domain/user/__init__.py b/domain/user/__init__.py new file mode 100644 index 0000000..b1228d4 --- /dev/null +++ b/domain/user/__init__.py @@ -0,0 +1,4 @@ +from .service import UserService + +__all__ = ["UserService"] + diff --git a/domain/user/api.py b/domain/user/api.py new file mode 100644 index 0000000..9dab68e --- /dev/null +++ b/domain/user/api.py @@ -0,0 +1,94 @@ +from typing import Annotated + +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.types import SystemPermission + +from .service import UserService +from .types import UserCreate, UserDetail, UserInfo, UserRoleAssign, UserUpdate + +router = APIRouter(prefix="/api", tags=["user"]) + + +@router.get("/users", response_model=list[UserInfo]) +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) +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) +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) +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}") +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]) +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]) +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/user/service.py b/domain/user/service.py new file mode 100644 index 0000000..d63b955 --- /dev/null +++ b/domain/user/service.py @@ -0,0 +1,190 @@ +from typing import List + +from fastapi import HTTPException + +from domain.auth.service import AuthService +from domain.permission.service import PermissionService +from models.database import Role, UserAccount, UserRole + +from .types import UserCreate, UserDetail, UserInfo, UserUpdate + + +class UserService: + """用户管理服务""" + + @classmethod + async def get_all_users(cls) -> List[UserInfo]: + users = await UserAccount.all().order_by("id") + return [ + UserInfo( + id=u.id, + username=u.username, + email=u.email, + full_name=u.full_name, + disabled=u.disabled, + is_admin=u.is_admin, + created_at=u.created_at, + last_login=u.last_login, + ) + for u in users + ] + + @classmethod + async def get_user(cls, user_id: int) -> UserDetail: + user = await UserAccount.get_or_none(id=user_id).prefetch_related("created_by") + if not user: + raise HTTPException(404, detail="用户不存在") + + user_roles = await UserRole.filter(user_id=user_id).prefetch_related("role") + roles = [ur.role.name for ur in user_roles] + + created_by_username = None + if user.created_by_id: + creator = await UserAccount.get_or_none(id=user.created_by_id) + if creator: + created_by_username = creator.username + + return UserDetail( + id=user.id, + username=user.username, + email=user.email, + full_name=user.full_name, + disabled=user.disabled, + is_admin=user.is_admin, + created_at=user.created_at, + last_login=user.last_login, + roles=roles, + created_by_username=created_by_username, + ) + + @classmethod + async def get_users_by_role(cls, role_id: int) -> List[UserInfo]: + role = await Role.get_or_none(id=role_id) + if not role: + raise HTTPException(404, detail="角色不存在") + + user_roles = await UserRole.filter(role_id=role_id).prefetch_related("user") + users = [ur.user for ur in user_roles if ur.user] + users.sort(key=lambda u: u.id) + return [ + UserInfo( + id=u.id, + username=u.username, + email=u.email, + full_name=u.full_name, + disabled=u.disabled, + is_admin=u.is_admin, + created_at=u.created_at, + last_login=u.last_login, + ) + for u in users + ] + + @classmethod + async def create_user(cls, data: UserCreate, creator_id: int) -> UserDetail: + existing = await UserAccount.get_or_none(username=data.username) + if existing: + raise HTTPException(400, detail="用户名已存在") + + if data.email: + existing_email = await UserAccount.get_or_none(email=data.email) + if existing_email: + raise HTTPException(400, detail="邮箱已被使用") + + hashed_password = AuthService.get_password_hash(data.password) + user = await UserAccount.create( + username=data.username, + email=data.email, + full_name=data.full_name, + hashed_password=hashed_password, + disabled=data.disabled, + is_admin=data.is_admin, + created_by_id=creator_id, + ) + + if data.role_ids: + for role_id in data.role_ids: + role = await Role.get_or_none(id=role_id) + if role: + await UserRole.create(user_id=user.id, role_id=role_id) + + return await cls.get_user(user.id) + + @classmethod + async def update_user(cls, user_id: int, data: UserUpdate, operator_id: int) -> UserDetail: + user = await UserAccount.get_or_none(id=user_id) + if not user: + raise HTTPException(404, detail="用户不存在") + + if data.is_admin is not None and user_id == operator_id: + raise HTTPException(400, detail="不能修改自己的管理员状态") + + if data.email is not None: + existing = await UserAccount.filter(email=data.email).exclude(id=user_id).first() + if existing: + raise HTTPException(400, detail="邮箱已被使用") + user.email = data.email + + if data.full_name is not None: + user.full_name = data.full_name + + if data.password is not None: + user.hashed_password = AuthService.get_password_hash(data.password) + + if data.is_admin is not None: + user.is_admin = data.is_admin + + if data.disabled is not None: + if user_id == operator_id and data.disabled: + raise HTTPException(400, detail="不能禁用自己") + user.disabled = data.disabled + + await user.save() + + PermissionService.clear_cache(user_id) + return await cls.get_user(user_id) + + @classmethod + async def delete_user(cls, user_id: int, operator_id: int) -> None: + if user_id == operator_id: + raise HTTPException(400, detail="不能删除自己") + + user = await UserAccount.get_or_none(id=user_id) + if not user: + raise HTTPException(404, detail="用户不存在") + + await UserRole.filter(user_id=user_id).delete() + await user.delete() + PermissionService.clear_cache(user_id) + + @classmethod + async def set_user_roles(cls, user_id: int, role_ids: List[int]) -> List[str]: + user = await UserAccount.get_or_none(id=user_id) + if not user: + raise HTTPException(404, detail="用户不存在") + + roles = await Role.filter(id__in=role_ids) + valid_role_ids = {r.id for r in roles} + invalid_ids = set(role_ids) - valid_role_ids + if invalid_ids: + raise HTTPException(400, detail=f"无效的角色ID: {invalid_ids}") + + await UserRole.filter(user_id=user_id).delete() + for role_id in role_ids: + await UserRole.create(user_id=user_id, role_id=role_id) + + PermissionService.clear_cache(user_id) + return [r.name for r in roles if r.id in role_ids] + + @classmethod + async def remove_user_role(cls, user_id: int, role_id: int) -> List[str]: + user = await UserAccount.get_or_none(id=user_id) + if not user: + raise HTTPException(404, detail="用户不存在") + + await UserRole.filter(user_id=user_id, role_id=role_id).delete() + PermissionService.clear_cache(user_id) + + user_roles = await UserRole.filter(user_id=user_id).prefetch_related("role") + return [ur.role.name for ur in user_roles] + diff --git a/domain/user/types.py b/domain/user/types.py new file mode 100644 index 0000000..e6d8ce1 --- /dev/null +++ b/domain/user/types.py @@ -0,0 +1,42 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class UserInfo(BaseModel): + id: int + username: str + email: str | None = None + full_name: str | None = None + disabled: bool + is_admin: bool + created_at: datetime + last_login: datetime | None = None + + +class UserDetail(UserInfo): + roles: list[str] + created_by_username: str | None = None + + +class UserCreate(BaseModel): + username: str + password: str + email: str | None = None + full_name: str | None = None + is_admin: bool = False + disabled: bool = False + role_ids: list[int] = [] + + +class UserUpdate(BaseModel): + email: str | None = None + full_name: str | None = None + password: str | None = None + is_admin: bool | None = None + disabled: bool | None = None + + +class UserRoleAssign(BaseModel): + role_ids: list[int] + diff --git a/domain/virtual_fs/api.py b/domain/virtual_fs/api.py index e31bf67..73531eb 100644 --- a/domain/virtual_fs/api.py +++ b/domain/virtual_fs/api.py @@ -5,6 +5,8 @@ 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.types import PathAction from .service import VirtualFSService from .types import MkdirRequest, MoveRequest @@ -18,6 +20,7 @@ async def get_file( 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")) @@ -50,6 +53,7 @@ async def get_temp_link( 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.SHARE) data = await VirtualFSService.create_temp_link(full_path, expires_in) return success(data) @@ -80,6 +84,7 @@ async def get_file_stat( 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) @@ -92,6 +97,7 @@ async def put_file( 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) @@ -104,6 +110,7 @@ async def api_mkdir( 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) @@ -116,6 +123,9 @@ async def api_move( 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) @@ -128,6 +138,8 @@ async def api_rename( 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) @@ -140,6 +152,9 @@ async def api_copy( 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) @@ -154,6 +169,7 @@ 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) @@ -169,7 +185,10 @@ async def browse_fs( sort_by: str = Query("name", description="按字段排序: name, size, mtime"), sort_order: str = Query("asc", description="排序顺序: asc, desc"), ): - data = await VirtualFSService.list_directory(full_path, page_num, page_size, sort_by, sort_order) + 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 + ) return success(data) @@ -180,6 +199,7 @@ async def api_delete( 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) @@ -194,5 +214,8 @@ async def root_listing( sort_by: str = Query("name", description="按字段排序: name, size, mtime"), sort_order: str = Query("asc", description="排序顺序: asc, desc"), ): - data = await VirtualFSService.list_directory("/", page_num, page_size, sort_by, sort_order) + # 根目录不需要权限检查,但需要过滤无权限的子目录 + data = await VirtualFSService.list_directory_with_permission( + "/", current_user.id, page_num, page_size, sort_by, sort_order + ) return success(data) diff --git a/domain/virtual_fs/listing.py b/domain/virtual_fs/listing.py index 310e103..ee43d46 100644 --- a/domain/virtual_fs/listing.py +++ b/domain/virtual_fs/listing.py @@ -5,6 +5,8 @@ from fastapi import HTTPException from api.response import page from domain.adapters import runtime_registry from domain.ai import FILE_COLLECTION_NAME, VECTOR_COLLECTION_NAME, VectorDBService +from domain.permission.service import PermissionService +from domain.permission.types import PathAction from .thumbnail import is_image_filename, is_video_filename from models import StorageAdapter @@ -245,3 +247,54 @@ class VirtualFSListingMixin(VirtualFSResolverMixin): info["vector_index"] = vector_index return info + + @classmethod + async def list_virtual_dir_with_permission( + cls, + path: str, + user_id: int, + page_num: int = 1, + page_size: int = 50, + sort_by: str = "name", + sort_order: str = "asc", + ) -> Dict: + """ + 带权限过滤的目录列表 + + 过滤掉用户没有读取权限的条目 + """ + # 首先获取完整的目录列表 + result = await cls.list_virtual_dir(path, page_num, page_size, sort_by, sort_order) + + # 检查用户是否是管理员(管理员可以看到所有内容) + from models.database import UserAccount + user = await UserAccount.get_or_none(id=user_id) + if user and user.is_admin: + return result + + # 过滤无权限的条目 + items = result.get("items", []) + if not items: + return result + + norm = cls._normalize_path(path).rstrip("/") or "/" + filtered_items = [] + + for item in items: + item_name = item.get("name", "") + if norm == "/": + item_path = f"/{item_name}" + else: + item_path = f"{norm}/{item_name}" + + # 检查用户是否有读取权限 + has_permission = await PermissionService.check_path_permission( + user_id, item_path, PathAction.READ + ) + if has_permission: + filtered_items.append(item) + + # 更新结果 + result["items"] = filtered_items + + return result diff --git a/domain/virtual_fs/service.py b/domain/virtual_fs/service.py index 59e9780..651d275 100644 --- a/domain/virtual_fs/service.py +++ b/domain/virtual_fs/service.py @@ -18,4 +18,40 @@ class VirtualFSService( VirtualFSResolverMixin, VirtualFSCommonMixin, ): - pass + @classmethod + async def list_directory( + cls, + path: str, + page_num: int = 1, + page_size: int = 50, + sort_by: str = "name", + sort_order: str = "asc", + ): + """列出目录内容""" + return await cls.list_virtual_dir(path, page_num, page_size, sort_by, sort_order) + + @classmethod + async def list_directory_with_permission( + cls, + path: str, + user_id: int, + page_num: int = 1, + page_size: int = 50, + sort_by: str = "name", + sort_order: str = "asc", + ): + """列出目录内容(带权限过滤)""" + full_path = cls._normalize_path(path).rstrip("/") or "/" + result = await cls.list_virtual_dir_with_permission( + full_path, user_id, page_num, page_size, sort_by, sort_order + ) + return { + "path": full_path, + "entries": result.get("items", []) if isinstance(result, dict) else [], + "pagination": { + "total": result.get("total", 0) if isinstance(result, dict) else 0, + "page": result.get("page", page_num) if isinstance(result, dict) else page_num, + "page_size": result.get("page_size", page_size) if isinstance(result, dict) else page_size, + "pages": result.get("pages", 0) if isinstance(result, dict) else 0, + }, + } diff --git a/models/__init__.py b/models/__init__.py index af4ce68..30d5d94 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -1,3 +1,19 @@ -from .database import StorageAdapter +from .database import ( + StorageAdapter, + UserAccount, + Role, + UserRole, + Permission, + RolePermission, + PathRule, +) -__all__ = ["StorageAdapter"] +__all__ = [ + "StorageAdapter", + "UserAccount", + "Role", + "UserRole", + "Permission", + "RolePermission", + "PathRule", +] diff --git a/models/database.py b/models/database.py index b9a39e4..846a778 100644 --- a/models/database.py +++ b/models/database.py @@ -22,11 +22,95 @@ class UserAccount(Model): full_name = fields.CharField(max_length=100, null=True) hashed_password = fields.CharField(max_length=128) disabled = fields.BooleanField(default=False) + is_admin = fields.BooleanField(default=False) + created_by: fields.ForeignKeyNullableRelation["UserAccount"] = fields.ForeignKeyField( + "models.UserAccount", null=True, related_name="created_users", on_delete=fields.SET_NULL + ) + created_at = fields.DatetimeField(auto_now_add=True) + last_login = fields.DatetimeField(null=True) class Meta: table = "user" +class Role(Model): + """角色表""" + + id = fields.IntField(pk=True) + name = fields.CharField(max_length=50, unique=True) # 角色名称 + description = fields.CharField(max_length=255, null=True) + is_system = fields.BooleanField(default=False) # 系统内置角色不可删除 + created_at = fields.DatetimeField(auto_now_add=True) + + class Meta: + table = "roles" + + +class UserRole(Model): + """用户-角色关联表""" + + id = fields.IntField(pk=True) + user: fields.ForeignKeyRelation[UserAccount] = fields.ForeignKeyField( + "models.UserAccount", related_name="user_roles", on_delete=fields.CASCADE + ) + role: fields.ForeignKeyRelation[Role] = fields.ForeignKeyField( + "models.Role", related_name="role_users", on_delete=fields.CASCADE + ) + + class Meta: + table = "user_roles" + unique_together = (("user", "role"),) + + +class Permission(Model): + """权限定义表""" + + id = fields.IntField(pk=True) + code = fields.CharField(max_length=50, unique=True) # 权限代码 + name = fields.CharField(max_length=100) # 权限名称 + category = fields.CharField(max_length=50) # 分类:system/adapter/file + description = fields.CharField(max_length=255, null=True) + + class Meta: + table = "permissions" + + +class RolePermission(Model): + """角色-权限关联表""" + + id = fields.IntField(pk=True) + role: fields.ForeignKeyRelation[Role] = fields.ForeignKeyField( + "models.Role", related_name="role_permissions", on_delete=fields.CASCADE + ) + permission: fields.ForeignKeyRelation[Permission] = fields.ForeignKeyField( + "models.Permission", related_name="permission_roles", on_delete=fields.CASCADE + ) + + class Meta: + table = "role_permissions" + unique_together = (("role", "permission"),) + + +class PathRule(Model): + """路径权限规则表""" + + id = fields.IntField(pk=True) + role: fields.ForeignKeyRelation[Role] = fields.ForeignKeyField( + "models.Role", related_name="path_rules", on_delete=fields.CASCADE + ) + path_pattern = fields.CharField(max_length=512) # 路径模式 + is_regex = fields.BooleanField(default=False) # 是否为正则表达式 + can_read = fields.BooleanField(default=True) + can_write = fields.BooleanField(default=False) + can_delete = fields.BooleanField(default=False) + can_share = fields.BooleanField(default=False) + priority = fields.IntField(default=0) # 优先级,数值越大优先级越高 + created_at = fields.DatetimeField(auto_now_add=True) + + class Meta: + table = "path_rules" + + class Configuration(Model): id = fields.IntField(pk=True) key = fields.CharField(max_length=100, unique=True) diff --git a/setup/foxel_cli.py b/setup/foxel_cli.py index ff962fb..66b965f 100644 --- a/setup/foxel_cli.py +++ b/setup/foxel_cli.py @@ -15,6 +15,8 @@ if str(PROJECT_ROOT) not in sys.path: from domain.config import VERSION from domain.auth import get_password_hash +from domain.permission.types import PERMISSION_DEFINITIONS +from domain.role.types import SystemRoles def _project_root() -> Path: @@ -127,6 +129,129 @@ def _cmd_reset_password(args: argparse.Namespace) -> int: return 0 +def _cmd_init_rbac(args: argparse.Namespace) -> int: + db_path = Path(args.db).expanduser() if args.db else _default_db_path() + + role_definitions = [ + { + "name": SystemRoles.ADMIN, + "description": "管理员角色,拥有所有系统和适配器权限", + }, + { + "name": SystemRoles.USER, + "description": "普通用户角色,需要管理员配置路径权限", + }, + { + "name": SystemRoles.VIEWER, + "description": "只读用户角色,仅可查看文件", + }, + ] + + conn = sqlite3.connect(str(db_path)) + try: + conn.execute("PRAGMA foreign_keys = ON") + cursor = conn.cursor() + + try: + cursor.execute("SELECT 1 FROM permissions LIMIT 1") + cursor.execute("SELECT 1 FROM roles LIMIT 1") + cursor.execute("SELECT 1 FROM role_permissions LIMIT 1") + cursor.execute("SELECT 1 FROM path_rules LIMIT 1") + except sqlite3.OperationalError as exc: + print(f"数据库未初始化(缺少表)。请先启动一次服务生成表。{exc}", file=sys.stderr) + return 1 + + # upsert permissions + for perm in PERMISSION_DEFINITIONS: + cursor.execute( + """ + INSERT INTO permissions (code, name, category, description) + VALUES (?, ?, ?, ?) + ON CONFLICT(code) DO UPDATE SET + name = excluded.name, + category = excluded.category, + description = excluded.description + """, + ( + perm["code"], + perm["name"], + perm["category"], + perm.get("description"), + ), + ) + + # upsert roles + for role in role_definitions: + cursor.execute( + """ + INSERT INTO roles (name, description, is_system) + VALUES (?, ?, 1) + ON CONFLICT(name) DO UPDATE SET + description = excluded.description, + is_system = 1 + """, + (role["name"], role["description"]), + ) + + # grant all permissions to Admin role + cursor.execute("SELECT id FROM roles WHERE name = ?", (SystemRoles.ADMIN,)) + admin_row = cursor.fetchone() + if not admin_row: + print("初始化失败:未找到 Admin 角色", file=sys.stderr) + return 1 + admin_role_id = int(admin_row[0]) + + cursor.execute("DELETE FROM role_permissions WHERE role_id = ?", (admin_role_id,)) + cursor.execute("SELECT id FROM permissions") + permission_ids = [int(row[0]) for row in cursor.fetchall()] + cursor.executemany( + "INSERT INTO role_permissions (role_id, permission_id) VALUES (?, ?)", + [(admin_role_id, pid) for pid in permission_ids], + ) + + # ensure Admin has full access path rule + cursor.execute( + "SELECT id FROM path_rules WHERE role_id = ? AND path_pattern = ? LIMIT 1", + (admin_role_id, "/**"), + ) + existing_rule = cursor.fetchone() + if existing_rule: + cursor.execute( + """ + UPDATE path_rules + SET is_regex = 0, + can_read = 1, + can_write = 1, + can_delete = 1, + can_share = 1, + priority = 100 + WHERE id = ? + """, + (int(existing_rule[0]),), + ) + else: + cursor.execute( + """ + INSERT INTO path_rules ( + role_id, path_pattern, is_regex, + can_read, can_write, can_delete, can_share, + priority + ) + VALUES (?, ?, 0, 1, 1, 1, 1, 100) + """, + (admin_role_id, "/**"), + ) + + conn.commit() + finally: + conn.close() + + print(f"已初始化权限: {len(PERMISSION_DEFINITIONS)} 条", file=sys.stderr) + print("已补齐内置角色: Admin / User / Viewer", file=sys.stderr) + print("已为 Admin 角色授予全部权限并设置 /** 全路径规则", file=sys.stderr) + return 0 + + def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="foxel") subparsers = parser.add_subparsers(dest="command", required=True) @@ -139,6 +264,10 @@ def _build_parser() -> argparse.ArgumentParser: reset_password.add_argument("--db", help="sqlite db 路径(默认 data/db/db.sqlite3)") reset_password.set_defaults(func=_cmd_reset_password) + init_rbac = subparsers.add_parser("init-rbac", help="初始化权限与内置角色") + init_rbac.add_argument("--db", help="sqlite db 路径(默认 data/db/db.sqlite3)") + init_rbac.set_defaults(func=_cmd_init_rbac) + return parser diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts index aaf1e2c..f437023 100644 --- a/web/src/api/auth.ts +++ b/web/src/api/auth.ts @@ -23,6 +23,7 @@ export interface MeResponse { email?: string | null; full_name?: string | null; gravatar_url: string; + is_admin?: boolean; } export interface UpdateMePayload { diff --git a/web/src/api/permissions.ts b/web/src/api/permissions.ts new file mode 100644 index 0000000..477c16c --- /dev/null +++ b/web/src/api/permissions.ts @@ -0,0 +1,27 @@ +import request from './client'; +import type { PathRuleInfo } from './roles'; + +export interface PermissionInfo { + id: number; + code: string; + name: string; + category: string; + description: string | null; +} + +export interface UserPermissions { + user_id: number; + is_admin: boolean; + permissions: string[]; + path_rules: PathRuleInfo[]; +} + +export const permissionsApi = { + listAll: async (): Promise => { + return await request('/permissions'); + }, + + getMine: async (): Promise => { + return await request('/me/permissions'); + }, +}; diff --git a/web/src/api/roles.ts b/web/src/api/roles.ts new file mode 100644 index 0000000..5a29d99 --- /dev/null +++ b/web/src/api/roles.ts @@ -0,0 +1,109 @@ +import request from './client'; +import type { UserInfo } from './users'; + +export interface RoleInfo { + id: number; + name: string; + description: string | null; + is_system: boolean; + created_at: string; +} + +export interface RoleDetail extends RoleInfo { + permissions: string[]; + path_rules_count: number; +} + +export interface RoleCreate { + name: string; + description?: string | null; +} + +export interface RoleUpdate { + name?: string | null; + description?: string | null; +} + +export interface PathRuleInfo { + id: number; + role_id: number; + path_pattern: string; + is_regex: boolean; + can_read: boolean; + can_write: boolean; + can_delete: boolean; + can_share: boolean; + priority: number; + created_at: string; +} + +export interface PathRuleCreate { + path_pattern: string; + is_regex?: boolean; + can_read?: boolean; + can_write?: boolean; + can_delete?: boolean; + can_share?: boolean; + priority?: number; +} + +export const rolesApi = { + list: async (): Promise => { + return await request('/roles'); + }, + + get: async (roleId: number): Promise => { + return await request(`/roles/${roleId}`); + }, + + getUsers: async (roleId: number): Promise => { + return await request(`/roles/${roleId}/users`); + }, + + create: async (data: RoleCreate): Promise => { + return await request('/roles', { + method: 'POST', + json: data, + }); + }, + + update: async (roleId: number, data: RoleUpdate): Promise => { + return await request(`/roles/${roleId}`, { + method: 'PUT', + json: data, + }); + }, + + remove: async (roleId: number): Promise => { + await request(`/roles/${roleId}`, { method: 'DELETE' }); + }, + + setPermissions: async (roleId: number, permissionCodes: string[]): Promise => { + return await request(`/roles/${roleId}/permissions`, { + method: 'POST', + json: { permission_codes: permissionCodes }, + }); + }, + + getPathRules: async (roleId: number): Promise => { + return await request(`/roles/${roleId}/path-rules`); + }, + + addPathRule: async (roleId: number, data: PathRuleCreate): Promise => { + return await request(`/roles/${roleId}/path-rules`, { + method: 'POST', + json: data, + }); + }, + + updatePathRule: async (ruleId: number, data: PathRuleCreate): Promise => { + return await request(`/path-rules/${ruleId}`, { + method: 'PUT', + json: data, + }); + }, + + deletePathRule: async (ruleId: number): Promise => { + await request(`/path-rules/${ruleId}`, { method: 'DELETE' }); + }, +}; diff --git a/web/src/api/users.ts b/web/src/api/users.ts new file mode 100644 index 0000000..d9404e2 --- /dev/null +++ b/web/src/api/users.ts @@ -0,0 +1,77 @@ +import request from './client'; + +export interface UserInfo { + id: number; + username: string; + email: string | null; + full_name: string | null; + disabled: boolean; + is_admin: boolean; + created_at: string; + last_login: string | null; +} + +export interface UserDetail extends UserInfo { + roles: string[]; + created_by_username: string | null; +} + +export interface UserCreate { + username: string; + password: string; + email?: string | null; + full_name?: string | null; + is_admin?: boolean; + disabled?: boolean; + role_ids?: number[]; +} + +export interface UserUpdate { + email?: string | null; + full_name?: string | null; + password?: string | null; + is_admin?: boolean | null; + disabled?: boolean | null; +} + +export const usersApi = { + list: async (): Promise => { + return await request('/users'); + }, + + get: async (userId: number): Promise => { + return await request(`/users/${userId}`); + }, + + create: async (data: UserCreate): Promise => { + return await request('/users', { + method: 'POST', + json: data, + }); + }, + + update: async (userId: number, data: UserUpdate): Promise => { + return await request(`/users/${userId}`, { + method: 'PUT', + json: data, + }); + }, + + remove: async (userId: number): Promise => { + await request(`/users/${userId}`, { method: 'DELETE' }); + }, + + setRoles: async (userId: number, roleIds: number[]): Promise => { + return await request(`/users/${userId}/roles`, { + method: 'POST', + json: { role_ids: roleIds }, + }); + }, + + removeRole: async (userId: number, roleId: number): Promise => { + return await request(`/users/${userId}/roles/${roleId}`, { + method: 'DELETE', + }); + }, +}; + diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index f409915..be954a6 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -750,5 +750,35 @@ "Created": "Created", "Moved": "Moved", "Renamed": "Renamed", - "Info": "Info" + "Info": "Info", + "User Management": "User Management", + "Role Management": "Role Management", + "Users": "Users", + "Create User": "Create User", + "Create Role": "Create Role", + "Super Admin": "Super Admin", + "Disabled": "Disabled", + "Active": "Active", + "Search users or roles": "Search users or roles", + "Last Login": "Last Login", + "Roles": "Roles", + "Quick Create Role": "Quick Create Role", + "Select roles": "Select roles", + "Created by": "Created by", + "New Password (leave empty to keep current)": "New Password (leave empty to keep current)", + "Role Name": "Role Name", + "Path Rules": "Path Rules", + "Add Path Rule": "Add Path Rule", + "Edit Path Rule": "Edit Path Rule", + "Path Pattern": "Path Pattern", + "Is Regex": "Is Regex", + "Priority": "Priority", + "Higher value = higher priority": "Higher value = higher priority", + "System Permissions": "System Permissions", + "Download and preview files": "Download and preview files", + "Upload and modify files": "Upload and modify files", + "Delete files and folders": "Delete files and folders", + "Create share links": "Create share links", + "permission.category.system": "System", + "permission.category.adapter": "Adapter" } diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 357888f..28db569 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -750,5 +750,39 @@ "Created": "已创建", "Moved": "已移动", "Renamed": "已重命名", - "Info": "信息" + "Info": "信息", + "User Management": "用户管理", + "Role Management": "角色管理", + "Users": "用户", + "Create User": "创建用户", + "Create Role": "创建角色", + "Edit": "编辑", + "Super Admin": "超级管理员", + "Disabled": "已禁用", + "Active": "已启用", + "Search users or roles": "搜索用户或角色", + "Last Login": "上次登录", + "Roles": "角色", + "Quick Create Role": "快速创建角色", + "Select roles": "选择角色", + "Created by": "创建者", + "New Password (leave empty to keep current)": "新密码(留空则不修改)", + "Role Name": "角色名称", + "Path Rules": "路径规则", + "Add Path Rule": "添加路径规则", + "Edit Path Rule": "编辑路径规则", + "Path Pattern": "路径模式", + "Is Regex": "正则表达式", + "Priority": "优先级", + "Higher value = higher priority": "数值越大优先级越高", + "Permissions": "权限", + "System Permissions": "系统权限", + "Download and preview files": "下载和预览文件", + "Upload and modify files": "上传和修改文件", + "Delete files and folders": "删除文件和目录", + "Create share links": "创建分享链接", + "Share": "分享", + "Delete": "删除", + "permission.category.system": "系统", + "permission.category.adapter": "存储适配器" } diff --git a/web/src/layout/SideNav.tsx b/web/src/layout/SideNav.tsx index 04b3389..008a341 100644 --- a/web/src/layout/SideNav.tsx +++ b/web/src/layout/SideNav.tsx @@ -1,7 +1,7 @@ import { Layout, Menu, theme, Button, Modal, Tag, Tooltip, Descriptions, Alert, Divider, Spin } from 'antd'; import { navGroups } from './nav.ts'; import type { NavItem, NavGroup } from './nav.ts'; -import { memo, useEffect, useState } from 'react'; +import { memo, useEffect, useState, useMemo } from 'react'; import { useSystemStatus } from '../contexts/SystemContext.tsx'; import { CheckCircleOutlined, @@ -19,6 +19,7 @@ import { useTheme } from '../contexts/ThemeContext'; import { useI18n } from '../i18n'; import { useAppWindows } from '../contexts/AppWindowsContext'; import WeChatModal from '../components/WeChatModal'; +import { useAuth } from '../contexts/AuthContext'; const { Sider } = Layout; export interface SideNavProps { @@ -33,12 +34,24 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle const { token } = theme.useToken(); const { resolvedMode } = useTheme(); const { t } = useI18n(); + const { user } = useAuth(); const [isModalOpen, setIsModalOpen] = useState(false); const [isVersionModalOpen, setIsVersionModalOpen] = useState(false); const [latestVersion, setLatestVersion] = useState<{ version: string; body: string; } | null>(null); + + // 根据用户权限过滤导航项 + const filteredNavGroups = useMemo(() => { + const isAdmin = user?.is_admin ?? false; + return navGroups + .map(group => ({ + ...group, + children: group.children.filter(item => !item.adminOnly || isAdmin) + })) + .filter(group => group.children.length > 0); + }, [user]); useEffect(() => { getLatestVersion().then(resp => { @@ -124,7 +137,7 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle {/* 分组渲染 */}
- {navGroups.map((group: NavGroup) => ( + {filteredNavGroups.map((group: NavGroup) => (
{group.title && (
([]); + const [permissions, setPermissions] = useState([]); + const [open, setOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [pathRules, setPathRules] = useState([]); + const [form] = Form.useForm(); + const [ruleForm] = Form.useForm(); + const [ruleDrawerOpen, setRuleDrawerOpen] = useState(false); + const [editingRule, setEditingRule] = useState(null); + const { t } = useI18n(); + + const fetchData = useCallback(async () => { + setLoading(true); + try { + const [roleList, permList] = await Promise.all([ + rolesApi.list(), + permissionsApi.listAll(), + ]); + setRoles(roleList); + setPermissions(permList); + } catch (e: any) { + message.error(e.message || t('Load failed')); + } finally { + setLoading(false); + } + }, [t]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + const openCreate = () => { + setEditing(null); + setPathRules([]); + form.resetFields(); + form.setFieldsValue({ + name: '', + description: '', + permissions: [], + }); + setOpen(true); + }; + + const openEdit = async (rec: RoleInfo) => { + try { + setLoading(true); + const [detail, rules] = await Promise.all([ + rolesApi.get(rec.id), + rolesApi.getPathRules(rec.id), + ]); + setEditing(detail); + setPathRules(rules); + form.resetFields(); + form.setFieldsValue({ + name: detail.name, + description: detail.description || '', + permissions: detail.permissions, + }); + setOpen(true); + } catch (e: any) { + message.error(e.message || t('Load failed')); + } finally { + setLoading(false); + } + }; + + const submit = async () => { + try { + const values = await form.validateFields(); + setLoading(true); + + if (editing) { + // 更新角色 + await rolesApi.update(editing.id, { + name: values.name.trim(), + description: values.description || null, + }); + // 更新权限 + await rolesApi.setPermissions(editing.id, values.permissions || []); + message.success(t('Updated successfully')); + } else { + // 创建角色 + const newRole = await rolesApi.create({ + name: values.name.trim(), + description: values.description || null, + }); + // 设置权限 + if (values.permissions?.length) { + await rolesApi.setPermissions(newRole.id, values.permissions); + } + message.success(t('Created successfully')); + } + + setOpen(false); + setEditing(null); + fetchData(); + } catch (e: any) { + if (e?.errorFields) return; + message.error(e.message || t('Operation failed')); + } finally { + setLoading(false); + } + }; + + const doDelete = async (rec: RoleInfo) => { + try { + await rolesApi.remove(rec.id); + message.success(t('Deleted')); + fetchData(); + } catch (e: any) { + message.error(e.message || t('Delete failed')); + } + }; + + // 路径规则管理 + const openAddRule = () => { + setEditingRule(null); + ruleForm.resetFields(); + ruleForm.setFieldsValue({ + path_pattern: '/', + is_regex: false, + can_read: true, + can_write: false, + can_delete: false, + can_share: false, + priority: 0, + }); + setRuleDrawerOpen(true); + }; + + const openEditRule = (rule: PathRuleInfo) => { + setEditingRule(rule); + ruleForm.resetFields(); + ruleForm.setFieldsValue({ + path_pattern: rule.path_pattern, + is_regex: rule.is_regex, + can_read: rule.can_read, + can_write: rule.can_write, + can_delete: rule.can_delete, + can_share: rule.can_share, + priority: rule.priority, + }); + setRuleDrawerOpen(true); + }; + + const submitRule = async () => { + if (!editing) return; + try { + const values = await ruleForm.validateFields(); + setLoading(true); + + const ruleData: PathRuleCreate = { + path_pattern: values.path_pattern, + is_regex: values.is_regex, + can_read: values.can_read, + can_write: values.can_write, + can_delete: values.can_delete, + can_share: values.can_share, + priority: values.priority, + }; + + if (editingRule) { + await rolesApi.updatePathRule(editingRule.id, ruleData); + message.success(t('Updated successfully')); + } else { + await rolesApi.addPathRule(editing.id, ruleData); + message.success(t('Created successfully')); + } + + // 刷新规则列表 + const rules = await rolesApi.getPathRules(editing.id); + setPathRules(rules); + setRuleDrawerOpen(false); + setEditingRule(null); + } catch (e: any) { + if (e?.errorFields) return; + message.error(e.message || t('Operation failed')); + } finally { + setLoading(false); + } + }; + + const deleteRule = async (rule: PathRuleInfo) => { + if (!editing) return; + try { + await rolesApi.deletePathRule(rule.id); + message.success(t('Deleted')); + const rules = await rolesApi.getPathRules(editing.id); + setPathRules(rules); + } catch (e: any) { + message.error(e.message || t('Delete failed')); + } + }; + + // 按分类分组权限 + const groupedPermissions = permissions.reduce((acc, p) => { + if (!acc[p.category]) acc[p.category] = []; + acc[p.category].push(p); + return acc; + }, {} as Record); + + const columns = [ + { + title: t('Role Name'), + dataIndex: 'name', + render: (value: string, rec: RoleInfo) => ( + + + {value} + {rec.is_system && {t('System')}} + + ), + }, + { title: t('Description'), dataIndex: 'description', render: (v: string | null) => v || '-' }, + { + title: t('Created At'), + dataIndex: 'created_at', + width: 180, + render: (v: string) => new Date(v).toLocaleString(), + }, + { + title: t('Actions'), + width: 160, + render: (_: any, rec: RoleInfo) => ( + + + {!rec.is_system && ( + doDelete(rec)}> + + + )} + + ), + }, + ]; + + const ruleColumns = [ + { + title: t('Path Pattern'), + dataIndex: 'path_pattern', + render: (v: string, rec: PathRuleInfo) => ( + + + {v} + {rec.is_regex && Regex} + + ), + }, + { + title: t('Permissions'), + render: (_: any, rec: PathRuleInfo) => ( + + {rec.can_read && {t('Read')}} + {rec.can_write && {t('Write')}} + {rec.can_delete && {t('Delete')}} + {rec.can_share && {t('Share')}} + + ), + }, + { + title: t('Priority'), + dataIndex: 'priority', + width: 80, + }, + { + title: t('Actions'), + width: 140, + render: (_: any, rec: PathRuleInfo) => ( + + + deleteRule(rec)}> + + + + ), + }, + ]; + + return ( + + + + + } + > + + + {/* 角色编辑抽屉 */} + { setOpen(false); setEditing(null); }} + destroyOnHidden + extra={ + + + + + } + > +
+ + + + + + + + {t('System Permissions')} + + + ({ + key: category, + label: t(`permission.category.${category}`) === `permission.category.${category}` + ? category.charAt(0).toUpperCase() + category.slice(1) + : t(`permission.category.${category}`), + children: ( + + {perms.map(p => ( + + {p.name} + {p.description && ( + + {p.description} + + )} + + ))} + + ), + }))} + /> + + + + {editing && ( + <> + {t('Path Rules')} + + + +
+ + )} + + + + {/* 路径规则编辑抽屉 */} + { setRuleDrawerOpen(false); setEditingRule(null); }} + destroyOnHidden + extra={ + + + + + } + > +
+ + + + + + + + + + {t('Permissions')} + + + {t('Read')} - {t('Download and preview files')} + + + {t('Write')} - {t('Upload and modify files')} + + + {t('Delete')} - {t('Delete files and folders')} + + + {t('Share')} - {t('Create share links')} + + + +
+ + ); +}); + +export default RolesPage; diff --git a/web/src/pages/AdminPage/UsersPage.tsx b/web/src/pages/AdminPage/UsersPage.tsx new file mode 100644 index 0000000..011655a --- /dev/null +++ b/web/src/pages/AdminPage/UsersPage.tsx @@ -0,0 +1,863 @@ +import { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { + Button, + Checkbox, + Collapse, + Divider, + Drawer, + Form, + Input, + InputNumber, + Modal, + Popconfirm, + Select, + Space, + Switch, + Table, + Tabs, + Tag, + Typography, + message, +} from 'antd'; +import { + CrownOutlined, + FolderOutlined, + LockOutlined, + UserOutlined, +} from '@ant-design/icons'; +import PageCard from '../../components/PageCard'; +import { usersApi, type UserDetail, type UserInfo } from '../../api/users'; +import { + rolesApi, + type PathRuleCreate, + type PathRuleInfo, + type RoleDetail, + type RoleInfo, +} from '../../api/roles'; +import { permissionsApi, type PermissionInfo } from '../../api/permissions'; +import { useI18n } from '../../i18n'; + +type TabKey = 'users' | 'roles'; + +const UsersPage = memo(function UsersPage() { + const { t } = useI18n(); + const [loading, setLoading] = useState(false); + const [activeTab, setActiveTab] = useState('users'); + const [searchText, setSearchText] = useState(''); + + const [users, setUsers] = useState([]); + const [roles, setRoles] = useState([]); + const [permissions, setPermissions] = useState([]); + + const [userDrawerOpen, setUserDrawerOpen] = useState(false); + const [editingUser, setEditingUser] = useState(null); + const [userForm] = Form.useForm(); + + const [roleDrawerOpen, setRoleDrawerOpen] = useState(false); + const [editingRole, setEditingRole] = useState(null); + const [pathRules, setPathRules] = useState([]); + const [roleUsers, setRoleUsers] = useState([]); + const [roleForm] = Form.useForm(); + + const [ruleDrawerOpen, setRuleDrawerOpen] = useState(false); + const [editingRule, setEditingRule] = useState(null); + const [ruleForm] = Form.useForm(); + + const [quickRoleModalOpen, setQuickRoleModalOpen] = useState(false); + const [quickRoleForm] = Form.useForm(); + + const fetchData = useCallback(async () => { + setLoading(true); + try { + const [userList, roleList, permList] = await Promise.all([ + usersApi.list(), + rolesApi.list(), + permissionsApi.listAll(), + ]); + setUsers(userList); + setRoles(roleList); + setPermissions(permList); + } catch (e: any) { + message.error(e.message || t('Load failed')); + } finally { + setLoading(false); + } + }, [t]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + const normalizedSearch = searchText.trim().toLowerCase(); + const filteredUsers = useMemo(() => { + if (!normalizedSearch) return users; + return users.filter((u) => { + const haystacks = [ + u.username, + u.email ?? '', + u.full_name ?? '', + ].map(v => v.toLowerCase()); + return haystacks.some(v => v.includes(normalizedSearch)); + }); + }, [normalizedSearch, users]); + const filteredRoles = useMemo(() => { + if (!normalizedSearch) return roles; + return roles.filter((r) => { + const haystacks = [ + r.name, + r.description ?? '', + ].map(v => v.toLowerCase()); + return haystacks.some(v => v.includes(normalizedSearch)); + }); + }, [normalizedSearch, roles]); + + const groupedPermissions = useMemo(() => { + return permissions.reduce((acc, p) => { + if (!acc[p.category]) acc[p.category] = []; + acc[p.category].push(p); + return acc; + }, {} as Record); + }, [permissions]); + + // --- User ops --- + const openCreateUser = () => { + setEditingUser(null); + userForm.resetFields(); + userForm.setFieldsValue({ + username: '', + password: '', + email: '', + full_name: '', + is_admin: false, + disabled: false, + role_ids: [], + }); + setUserDrawerOpen(true); + }; + + const openEditUser = async (rec: UserInfo) => { + try { + setLoading(true); + const detail = await usersApi.get(rec.id); + setEditingUser(detail); + userForm.resetFields(); + const roleIds = roles + .filter(r => detail.roles.includes(r.name)) + .map(r => r.id); + userForm.setFieldsValue({ + username: detail.username, + password: '', + email: detail.email || '', + full_name: detail.full_name || '', + is_admin: detail.is_admin, + disabled: detail.disabled, + role_ids: roleIds, + }); + setUserDrawerOpen(true); + } catch (e: any) { + message.error(e.message || t('Load failed')); + } finally { + setLoading(false); + } + }; + + const submitUser = async () => { + try { + const values = await userForm.validateFields(); + setLoading(true); + + if (editingUser) { + const updateData: any = { + email: values.email || null, + full_name: values.full_name || null, + is_admin: values.is_admin, + disabled: values.disabled, + }; + if (values.password) updateData.password = values.password; + await usersApi.update(editingUser.id, updateData); + await usersApi.setRoles(editingUser.id, values.role_ids || []); + message.success(t('Updated successfully')); + } else { + await usersApi.create({ + username: values.username.trim(), + password: values.password, + email: values.email || null, + full_name: values.full_name || null, + is_admin: values.is_admin, + disabled: values.disabled, + role_ids: values.role_ids || [], + }); + message.success(t('Created successfully')); + } + + setUserDrawerOpen(false); + setEditingUser(null); + await fetchData(); + + if (editingRole) { + const nextRoleUsers = await rolesApi.getUsers(editingRole.id); + setRoleUsers(nextRoleUsers); + } + } catch (e: any) { + if (e?.errorFields) return; + message.error(e.message || t('Operation failed')); + } finally { + setLoading(false); + } + }; + + const doDeleteUser = async (rec: UserInfo) => { + try { + await usersApi.remove(rec.id); + message.success(t('Deleted')); + fetchData(); + } catch (e: any) { + message.error(e.message || t('Delete failed')); + } + }; + + const handleToggleDisabled = async (rec: UserInfo, disabled: boolean) => { + try { + setLoading(true); + await usersApi.update(rec.id, { disabled }); + message.success(t('Status updated')); + fetchData(); + } catch (e: any) { + message.error(e.message || t('Update failed')); + } finally { + setLoading(false); + } + }; + + // --- Quick create role (for user drawer) --- + const openQuickCreateRole = () => { + quickRoleForm.resetFields(); + quickRoleForm.setFieldsValue({ + name: '', + description: '', + }); + setQuickRoleModalOpen(true); + }; + + const submitQuickRole = async () => { + try { + const values = await quickRoleForm.validateFields(); + setLoading(true); + const newRole = await rolesApi.create({ + name: values.name.trim(), + description: values.description || null, + }); + message.success(t('Created successfully')); + setRoles((prev) => [...prev, newRole].sort((a, b) => a.id - b.id)); + + const currentIds = (userForm.getFieldValue('role_ids') || []) as number[]; + const nextIds = Array.from(new Set([...currentIds, newRole.id])); + userForm.setFieldsValue({ role_ids: nextIds }); + + setQuickRoleModalOpen(false); + } catch (e: any) { + if (e?.errorFields) return; + message.error(e.message || t('Operation failed')); + } finally { + setLoading(false); + } + }; + + // --- Role ops --- + const openCreateRole = () => { + setEditingRole(null); + setPathRules([]); + setRoleUsers([]); + roleForm.resetFields(); + roleForm.setFieldsValue({ + name: '', + description: '', + permissions: [], + }); + setRoleDrawerOpen(true); + }; + + const openEditRole = async (rec: RoleInfo) => { + try { + setLoading(true); + const [detail, rules, usersUsingRole] = await Promise.all([ + rolesApi.get(rec.id), + rolesApi.getPathRules(rec.id), + rolesApi.getUsers(rec.id), + ]); + setEditingRole(detail); + setPathRules(rules); + setRoleUsers(usersUsingRole); + roleForm.resetFields(); + roleForm.setFieldsValue({ + name: detail.name, + description: detail.description || '', + permissions: detail.permissions, + }); + setRoleDrawerOpen(true); + } catch (e: any) { + message.error(e.message || t('Load failed')); + } finally { + setLoading(false); + } + }; + + const submitRole = async () => { + try { + const values = await roleForm.validateFields(); + setLoading(true); + + if (editingRole) { + await rolesApi.update(editingRole.id, { + name: values.name.trim(), + description: values.description || null, + }); + await rolesApi.setPermissions(editingRole.id, values.permissions || []); + message.success(t('Updated successfully')); + } else { + const newRole = await rolesApi.create({ + name: values.name.trim(), + description: values.description || null, + }); + if (values.permissions?.length) { + await rolesApi.setPermissions(newRole.id, values.permissions); + } + message.success(t('Created successfully')); + } + + setRoleDrawerOpen(false); + setEditingRole(null); + await fetchData(); + } catch (e: any) { + if (e?.errorFields) return; + message.error(e.message || t('Operation failed')); + } finally { + setLoading(false); + } + }; + + const doDeleteRole = async (rec: RoleInfo) => { + try { + await rolesApi.remove(rec.id); + message.success(t('Deleted')); + fetchData(); + } catch (e: any) { + message.error(e.message || t('Delete failed')); + } + }; + + // --- Path rules --- + const openAddRule = () => { + setEditingRule(null); + ruleForm.resetFields(); + ruleForm.setFieldsValue({ + path_pattern: '/', + is_regex: false, + can_read: true, + can_write: false, + can_delete: false, + can_share: false, + priority: 0, + }); + setRuleDrawerOpen(true); + }; + + const openEditRule = (rule: PathRuleInfo) => { + setEditingRule(rule); + ruleForm.resetFields(); + ruleForm.setFieldsValue({ + path_pattern: rule.path_pattern, + is_regex: rule.is_regex, + can_read: rule.can_read, + can_write: rule.can_write, + can_delete: rule.can_delete, + can_share: rule.can_share, + priority: rule.priority, + }); + setRuleDrawerOpen(true); + }; + + const submitRule = async () => { + if (!editingRole) return; + try { + const values = await ruleForm.validateFields(); + setLoading(true); + + const ruleData: PathRuleCreate = { + path_pattern: values.path_pattern, + is_regex: values.is_regex, + can_read: values.can_read, + can_write: values.can_write, + can_delete: values.can_delete, + can_share: values.can_share, + priority: values.priority, + }; + + if (editingRule) { + await rolesApi.updatePathRule(editingRule.id, ruleData); + message.success(t('Updated successfully')); + } else { + await rolesApi.addPathRule(editingRole.id, ruleData); + message.success(t('Created successfully')); + } + + const rules = await rolesApi.getPathRules(editingRole.id); + setPathRules(rules); + setRuleDrawerOpen(false); + setEditingRule(null); + } catch (e: any) { + if (e?.errorFields) return; + message.error(e.message || t('Operation failed')); + } finally { + setLoading(false); + } + }; + + const deleteRule = async (rule: PathRuleInfo) => { + if (!editingRole) return; + try { + await rolesApi.deletePathRule(rule.id); + message.success(t('Deleted')); + const rules = await rolesApi.getPathRules(editingRole.id); + setPathRules(rules); + } catch (e: any) { + message.error(e.message || t('Delete failed')); + } + }; + + const userColumns = [ + { + title: t('Username'), + dataIndex: 'username', + render: (value: string, rec: UserInfo) => ( + + {rec.is_admin ? : } + {value} + {rec.is_admin && {t('Admin')}} + + ), + }, + { title: t('Email'), dataIndex: 'email', render: (v: string | null) => v || '-' }, + { title: t('Full Name'), dataIndex: 'full_name', render: (v: string | null) => v || '-' }, + { + title: t('Status'), + dataIndex: 'disabled', + width: 100, + render: (disabled: boolean, rec: UserInfo) => ( + handleToggleDisabled(rec, !checked)} + checkedChildren={t('Active')} + unCheckedChildren={t('Disabled')} + /> + ), + }, + { + title: t('Last Login'), + dataIndex: 'last_login', + width: 180, + render: (v: string | null) => v ? new Date(v).toLocaleString() : '-', + }, + { + title: t('Actions'), + width: 160, + render: (_: any, rec: UserInfo) => ( + + + doDeleteUser(rec)}> + + + + ), + }, + ]; + + const roleColumns = [ + { + title: t('Role Name'), + dataIndex: 'name', + render: (value: string, rec: RoleInfo) => ( + + + {value} + {rec.is_system && {t('System')}} + + ), + }, + { title: t('Description'), dataIndex: 'description', render: (v: string | null) => v || '-' }, + { + title: t('Created At'), + dataIndex: 'created_at', + width: 180, + render: (v: string) => new Date(v).toLocaleString(), + }, + { + title: t('Actions'), + width: 160, + render: (_: any, rec: RoleInfo) => ( + + + {!rec.is_system && ( + doDeleteRole(rec)}> + + + )} + + ), + }, + ]; + + const ruleColumns = [ + { + title: t('Path Pattern'), + dataIndex: 'path_pattern', + render: (v: string, rec: PathRuleInfo) => ( + + + {v} + {rec.is_regex && Regex} + + ), + }, + { + title: t('Permissions'), + render: (_: any, rec: PathRuleInfo) => ( + + {rec.can_read && {t('Read')}} + {rec.can_write && {t('Write')}} + {rec.can_delete && {t('Delete')}} + {rec.can_share && {t('Share')}} + + ), + }, + { + title: t('Priority'), + dataIndex: 'priority', + width: 80, + }, + { + title: t('Actions'), + width: 140, + render: (_: any, rec: PathRuleInfo) => ( + + + deleteRule(rec)}> + + + + ), + }, + ]; + + const roleUserColumns = [ + { + title: t('Username'), + dataIndex: 'username', + render: (value: string, rec: UserInfo) => ( + + {rec.is_admin ? : } + {value} + {rec.is_admin && {t('Admin')}} + + ), + }, + { title: t('Email'), dataIndex: 'email', render: (v: string | null) => v || '-' }, + { + title: t('Status'), + dataIndex: 'disabled', + width: 90, + render: (disabled: boolean) => disabled ? {t('Disabled')} : {t('Active')}, + }, + { + title: t('Actions'), + width: 110, + render: (_: any, rec: UserInfo) => ( + + ), + }, + ]; + + const tabItems = [ + { + key: 'users', + label: `${t('Users')} (${filteredUsers.length})`, + children: ( +
+ ), + }, + { + key: 'roles', + label: `${t('Roles')} (${filteredRoles.length})`, + children: ( +
+ ), + }, + ]; + + return ( + + setSearchText(e.target.value)} + style={{ width: 260 }} + /> + + + + + } + > + setActiveTab(k as TabKey)} items={tabItems} /> + + {/* User editor */} + { setUserDrawerOpen(false); setEditingUser(null); }} + destroyOnHidden + extra={ + + + + + } + > +
+ + + + + + + + + + + + + + {t('Roles')} + + + )} + > + + + + + + + + + {/* Role editor */} + { setRoleDrawerOpen(false); setEditingRole(null); }} + destroyOnHidden + extra={ + + + + + } + > +
+ + + + + + + + {t('System Permissions')} + + + ({ + key: category, + label: t(`permission.category.${category}`) === `permission.category.${category}` + ? category.charAt(0).toUpperCase() + category.slice(1) + : t(`permission.category.${category}`), + children: ( + + {perms.map(p => ( + + {p.name} + {p.description && ( + + {p.description} + + )} + + ))} + + ), + }))} + /> + + + + {editingRole && ( + <> + {t('Path Rules')} + + + +
+ + {t('Users')} +
+ + )} + + + + {/* Path rule editor */} + { setRuleDrawerOpen(false); setEditingRule(null); }} + destroyOnHidden + extra={ + + + + + } + > +
+ + + + + + + + + + {t('Permissions')} + + + {t('Read')} - {t('Download and preview files')} + + + {t('Write')} - {t('Upload and modify files')} + + + {t('Delete')} - {t('Delete files and folders')} + + + {t('Share')} - {t('Create share links')} + + + +
+ + ); +}); + +export default UsersPage; diff --git a/web/src/router/LayoutShell.tsx b/web/src/router/LayoutShell.tsx index 622f27a..8d99084 100644 --- a/web/src/router/LayoutShell.tsx +++ b/web/src/router/LayoutShell.tsx @@ -14,6 +14,7 @@ import SystemSettingsPage from '../pages/SystemSettingsPage/SystemSettingsPage.t import AuditLogsPage from '../pages/AuditLogsPage.tsx'; import BackupPage from '../pages/SystemSettingsPage/BackupPage.tsx'; import PluginsPage from '../pages/PluginsPage.tsx'; +import UsersPage from '../pages/AdminPage/UsersPage.tsx'; import { AppWindowsProvider, useAppWindows } from '../contexts/AppWindowsContext'; import { AppWindowsLayer } from '../apps/AppWindowsLayer'; import AiAgentWidget from '../components/AiAgentWidget'; @@ -67,6 +68,7 @@ const ShellBody = memo(function ShellBody() { )} {navKey === 'audit' && } {navKey === 'backup' && } + {navKey === 'users' && }