diff --git a/backend/auth/dependencies.py b/backend/auth/dependencies.py index b2e2df0..9a77896 100644 --- a/backend/auth/dependencies.py +++ b/backend/auth/dependencies.py @@ -6,7 +6,14 @@ from sqlalchemy.ext.asyncio import AsyncSession from models.database import get_db from models.models import User from .jwt_utils import decode_access_token -from .roles import can_manage_users, can_write, is_admin +from .permissions import ( + ACCOUNTS_WRITE, + MESSAGES_WRITE, + RULES_WRITE, + USERS_MANAGE, + WRITE_PERMISSIONS, +) +from .roles import can_write, has_permission, is_admin bearer_scheme = HTTPBearer(auto_error=False) @@ -32,6 +39,20 @@ async def get_current_user( return user +def require_permission(permission: str): + """FastAPI dependency factory that checks a single permission code.""" + + async def _checker(user: User = Depends(get_current_user)) -> User: + if has_permission(user.role, permission): + return user + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"缺少权限:{permission}", + ) + + return _checker + + async def require_admin(user: User = Depends(get_current_user)) -> User: if not is_admin(user.role): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要管理员权限") @@ -39,12 +60,45 @@ async def require_admin(user: User = Depends(get_current_user)) -> User: async def require_write(user: User = Depends(get_current_user)) -> User: - if not can_write(user.role): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="当前角色只读,无法执行此操作") - return user + """Any write-capable permission (accounts/messages/rules) or legacy can_write.""" + if is_admin(user.role) or can_write(user.role): + return user + if any(has_permission(user.role, code) for code in WRITE_PERMISSIONS): + return user + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="当前角色只读,无法执行此操作", + ) + + +async def require_accounts_write(user: User = Depends(get_current_user)) -> User: + if has_permission(user.role, ACCOUNTS_WRITE): + return user + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="缺少权限:accounts.write", + ) + + +async def require_messages_write(user: User = Depends(get_current_user)) -> User: + if has_permission(user.role, MESSAGES_WRITE): + return user + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="缺少权限:messages.write", + ) + + +async def require_rules_write(user: User = Depends(get_current_user)) -> User: + if has_permission(user.role, RULES_WRITE): + return user + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="缺少权限:rules.write", + ) async def require_user_manager(user: User = Depends(get_current_user)) -> User: - if not can_manage_users(user.role): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要管理员权限") - return user + if has_permission(user.role, USERS_MANAGE): + return user + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要用户管理权限") diff --git a/backend/auth/permissions.py b/backend/auth/permissions.py new file mode 100644 index 0000000..9bfb5b7 --- /dev/null +++ b/backend/auth/permissions.py @@ -0,0 +1,162 @@ +"""Fixed permission catalog for menus and actions. + +UI and APIs only select from this list; new codes must be added in code. +""" + +from __future__ import annotations + +from typing import Any + + +# Menu visibility +MENU_DASHBOARD = "menu.dashboard" +MENU_ACCOUNTS = "menu.accounts" +MENU_MESSAGES = "menu.messages" +MENU_RULES = "menu.rules" +MENU_LOGS = "menu.logs" +MENU_RECEIVED_MESSAGES = "menu.received_messages" +MENU_SYSTEM_LOGS = "menu.system_logs" +MENU_DOWNLOAD = "menu.download" +MENU_HELP = "menu.help" +MENU_USERS = "menu.users" +MENU_SETTINGS = "menu.settings" +MENU_DESKTOP_UPDATE = "menu.desktop_update" +MENU_PAYMENT_SETTINGS = "menu.payment_settings" +MENU_PAYMENT_ORDERS = "menu.payment_orders" + +# Actions +ACCOUNTS_WRITE = "accounts.write" +MESSAGES_WRITE = "messages.write" +RULES_WRITE = "rules.write" +LOGS_READ = "logs.read" +RECEIVED_MESSAGES_READ = "received_messages.read" +SYSTEM_LOGS_READ = "system_logs.read" +USERS_MANAGE = "users.manage" +SETTINGS_MANAGE = "settings.manage" +DESKTOP_MANAGE = "desktop.manage" +PAYMENTS_MANAGE = "payments.manage" +ORDERS_READ = "orders.read" + +ALL_PERMISSIONS: tuple[str, ...] = ( + MENU_DASHBOARD, + MENU_ACCOUNTS, + MENU_MESSAGES, + MENU_RULES, + MENU_LOGS, + MENU_RECEIVED_MESSAGES, + MENU_SYSTEM_LOGS, + MENU_DOWNLOAD, + MENU_HELP, + MENU_USERS, + MENU_SETTINGS, + MENU_DESKTOP_UPDATE, + MENU_PAYMENT_SETTINGS, + MENU_PAYMENT_ORDERS, + ACCOUNTS_WRITE, + MESSAGES_WRITE, + RULES_WRITE, + LOGS_READ, + RECEIVED_MESSAGES_READ, + SYSTEM_LOGS_READ, + USERS_MANAGE, + SETTINGS_MANAGE, + DESKTOP_MANAGE, + PAYMENTS_MANAGE, + ORDERS_READ, +) + +PERMISSION_SET = frozenset(ALL_PERMISSIONS) + +WRITE_PERMISSIONS = frozenset( + { + ACCOUNTS_WRITE, + MESSAGES_WRITE, + RULES_WRITE, + } +) + +_PERMISSION_META: dict[str, dict[str, str]] = { + MENU_DASHBOARD: {"group": "menu", "label": "数据概览"}, + MENU_ACCOUNTS: {"group": "menu", "label": "账号管理"}, + MENU_MESSAGES: {"group": "menu", "label": "私信收发"}, + MENU_RULES: {"group": "menu", "label": "自动回复规则"}, + MENU_LOGS: {"group": "menu", "label": "回复日志面板"}, + MENU_RECEIVED_MESSAGES: {"group": "menu", "label": "接收消息日志"}, + MENU_SYSTEM_LOGS: {"group": "menu", "label": "系统诊断日志"}, + MENU_DOWNLOAD: {"group": "menu", "label": "软件下载"}, + MENU_HELP: {"group": "menu", "label": "帮助中心"}, + MENU_USERS: {"group": "menu", "label": "用户与角色"}, + MENU_SETTINGS: {"group": "menu", "label": "系统设置"}, + MENU_DESKTOP_UPDATE: {"group": "menu", "label": "桌面端升级"}, + MENU_PAYMENT_SETTINGS: {"group": "menu", "label": "支付配置"}, + MENU_PAYMENT_ORDERS: {"group": "menu", "label": "我的订单"}, + ACCOUNTS_WRITE: {"group": "action", "label": "账号写操作(启动/停止/改凭证/删除)"}, + MESSAGES_WRITE: {"group": "action", "label": "发送私信"}, + RULES_WRITE: {"group": "action", "label": "编辑自动回复规则"}, + LOGS_READ: {"group": "action", "label": "查看回复日志"}, + RECEIVED_MESSAGES_READ: {"group": "action", "label": "查看接收消息日志"}, + SYSTEM_LOGS_READ: {"group": "action", "label": "查看系统诊断日志"}, + USERS_MANAGE: {"group": "action", "label": "管理用户与角色"}, + SETTINGS_MANAGE: {"group": "action", "label": "管理系统设置"}, + DESKTOP_MANAGE: {"group": "action", "label": "管理桌面端升级"}, + PAYMENTS_MANAGE: {"group": "action", "label": "管理支付配置"}, + ORDERS_READ: {"group": "action", "label": "查看我的订单"}, +} + +OPERATOR_PERMISSIONS: tuple[str, ...] = ( + MENU_DASHBOARD, + MENU_ACCOUNTS, + MENU_MESSAGES, + MENU_RULES, + MENU_LOGS, + MENU_RECEIVED_MESSAGES, + MENU_DOWNLOAD, + MENU_HELP, + MENU_PAYMENT_ORDERS, + ACCOUNTS_WRITE, + MESSAGES_WRITE, + RULES_WRITE, + LOGS_READ, + RECEIVED_MESSAGES_READ, + ORDERS_READ, +) + +VIEWER_PERMISSIONS: tuple[str, ...] = ( + MENU_DASHBOARD, + MENU_ACCOUNTS, + MENU_MESSAGES, + MENU_RULES, + MENU_LOGS, + MENU_RECEIVED_MESSAGES, + MENU_DOWNLOAD, + MENU_HELP, + LOGS_READ, + RECEIVED_MESSAGES_READ, +) + + +def normalize_permissions(codes: list[str] | tuple[str, ...] | None) -> list[str]: + if not codes: + return [] + seen: set[str] = set() + result: list[str] = [] + for code in codes: + value = str(code or "").strip() + if not value or value not in PERMISSION_SET or value in seen: + continue + seen.add(value) + result.append(value) + return result + + +def permission_catalog() -> dict[str, Any]: + menus = [] + actions = [] + for code in ALL_PERMISSIONS: + meta = _PERMISSION_META[code] + item = {"code": code, "label": meta["label"]} + if meta["group"] == "menu": + menus.append(item) + else: + actions.append(item) + return {"menus": menus, "actions": actions} diff --git a/backend/auth/role_service.py b/backend/auth/role_service.py new file mode 100644 index 0000000..81b2206 --- /dev/null +++ b/backend/auth/role_service.py @@ -0,0 +1,284 @@ +"""Persist and cache roles; seed built-ins on startup.""" + +from __future__ import annotations + +import json +import logging +import re +from datetime import datetime + +from fastapi import HTTPException, status +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from models.models import Role, User +from .permissions import ALL_PERMISSIONS, normalize_permissions +from .roles import ( + ROLE_ADMIN, + RoleRecord, + default_role_seeds, + ensure_role, + get_cached_role, + is_admin, + list_cached_roles, + role_label, + sanitize_role_permissions, + set_role_cache, +) + +logger = logging.getLogger("auth.roles") + +_ROLE_CODE_RE = re.compile(r"^[a-z][a-z0-9_]{1,49}$") + + +def _encode_permissions(codes: list[str]) -> str: + return json.dumps(codes, ensure_ascii=False) + + +def _decode_permissions(raw: str | None) -> list[str]: + if not raw: + return [] + try: + data = json.loads(raw) + except Exception: + return [] + if not isinstance(data, list): + return [] + return normalize_permissions([str(item) for item in data]) + + +def role_to_record(row: Role) -> RoleRecord: + perms = list(ALL_PERMISSIONS) if row.is_admin else _decode_permissions(row.permissions) + return RoleRecord( + code=row.code, + label=row.label, + description=row.description or "", + is_system=bool(row.is_system), + is_admin=bool(row.is_admin), + permissions=perms, + ) + + +async def refresh_role_cache(db: AsyncSession) -> list[RoleRecord]: + result = await db.execute(select(Role).order_by(Role.id.asc())) + rows = result.scalars().all() + records = [role_to_record(row) for row in rows] + if not records: + records = default_role_seeds() + set_role_cache(records) + return records + + +async def seed_builtin_roles(db: AsyncSession) -> None: + """Insert missing built-in roles and keep admin permissions complete.""" + seeds = {seed.code: seed for seed in default_role_seeds()} + result = await db.execute(select(Role)) + existing = {row.code: row for row in result.scalars().all()} + changed = False + + for code, seed in seeds.items(): + row = existing.get(code) + payload = _encode_permissions(seed.permissions) + if row is None: + db.add( + Role( + code=seed.code, + label=seed.label, + description=seed.description, + is_system=True, + is_admin=seed.is_admin, + permissions=payload, + ) + ) + changed = True + continue + # Keep system flags and admin full permission set in sync. + if not row.is_system: + row.is_system = True + changed = True + if seed.is_admin and (not row.is_admin or row.permissions != payload): + row.is_admin = True + row.permissions = payload + row.label = seed.label + changed = True + elif not row.label: + row.label = seed.label + changed = True + + if changed: + await db.commit() + await refresh_role_cache(db) + logger.info("Role cache loaded: %s", ", ".join(r.code for r in list_cached_roles())) + + +async def list_roles(db: AsyncSession) -> list[RoleRecord]: + await refresh_role_cache(db) + return list_cached_roles() + + +async def get_role_or_404(db: AsyncSession, code: str) -> Role: + result = await db.execute(select(Role).where(Role.code == code)) + row = result.scalar_one_or_none() + if not row: + raise HTTPException(status_code=404, detail="角色不存在") + return row + + +async def count_users_with_role(db: AsyncSession, code: str) -> int: + result = await db.execute( + select(func.count()).select_from(User).where(User.role == code) + ) + return int(result.scalar() or 0) + + +async def count_admin_users(db: AsyncSession) -> int: + result = await db.execute( + select(func.count()).select_from(User).where(User.role == ROLE_ADMIN) + ) + return int(result.scalar() or 0) + + +def validate_role_code(code: str) -> str: + value = str(code or "").strip().lower() + if not _ROLE_CODE_RE.match(value): + raise HTTPException( + status_code=400, + detail="角色码需为小写字母开头,仅含小写字母/数字/下划线,长度 2-50", + ) + return value + + +async def create_role( + db: AsyncSession, + *, + code: str, + label: str, + description: str | None, + permissions: list[str] | None, +) -> RoleRecord: + role_code = validate_role_code(code) + if get_cached_role(role_code) or ( + await db.execute(select(Role).where(Role.code == role_code)) + ).scalar_one_or_none(): + raise HTTPException(status_code=400, detail="角色码已存在") + name = (label or "").strip() or role_code + perms = sanitize_role_permissions(permissions, force_all=False) + row = Role( + code=role_code, + label=name, + description=(description or "").strip() or None, + is_system=False, + is_admin=False, + permissions=_encode_permissions(perms), + ) + db.add(row) + await db.commit() + await db.refresh(row) + await refresh_role_cache(db) + return role_to_record(row) + + +async def update_role( + db: AsyncSession, + code: str, + *, + label: str | None = None, + description: str | None = None, + permissions: list[str] | None = None, +) -> RoleRecord: + row = await get_role_or_404(db, code) + if row.is_admin or row.code == ROLE_ADMIN: + # Admin role always keeps full permissions; label/description may update. + if label is not None: + row.label = (label or "").strip() or row.label + if description is not None: + row.description = (description or "").strip() or None + row.permissions = _encode_permissions(list(ALL_PERMISSIONS)) + row.is_admin = True + row.is_system = True + row.updated_at = datetime.utcnow() + await db.commit() + await db.refresh(row) + await refresh_role_cache(db) + return role_to_record(row) + + if label is not None: + row.label = (label or "").strip() or row.label + if description is not None: + row.description = (description or "").strip() or None + if permissions is not None: + row.permissions = _encode_permissions( + sanitize_role_permissions(permissions, force_all=False) + ) + row.is_admin = False + row.updated_at = datetime.utcnow() + await db.commit() + await db.refresh(row) + await refresh_role_cache(db) + return role_to_record(row) + + +async def delete_role(db: AsyncSession, code: str) -> None: + row = await get_role_or_404(db, code) + if row.is_system or row.is_admin or row.code == ROLE_ADMIN: + raise HTTPException(status_code=400, detail="系统内置角色不可删除") + used = await count_users_with_role(db, code) + if used > 0: + raise HTTPException( + status_code=400, + detail=f"仍有 {used} 个用户使用该角色,请先调整用户角色后再删除", + ) + await db.delete(row) + await db.commit() + await refresh_role_cache(db) + + +async def ensure_role_assignable(db: AsyncSession, role_code: str) -> str: + """Validate role exists in DB (refresh cache if needed).""" + code = str(role_code or "").strip() + if not code: + raise HTTPException(status_code=400, detail="角色不能为空") + if get_cached_role(code) is None: + await refresh_role_cache(db) + try: + return ensure_role(code) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +async def guard_last_admin_change( + db: AsyncSession, + *, + user: User, + new_role: str | None = None, + deactivating: bool = False, + deleting: bool = False, +) -> None: + """Prevent removing the last admin user.""" + if not is_admin(user.role): + return + admin_count = await count_admin_users(db) + if admin_count > 1: + return + if deleting or deactivating: + raise HTTPException( + status_code=400, + detail="不能删除或禁用最后一个管理员账号", + ) + if new_role is not None and not is_admin(new_role): + raise HTTPException( + status_code=400, + detail="不能将最后一个管理员改为非管理员角色", + ) + + +def user_permission_payload(role_code: str) -> dict: + record = get_cached_role(role_code) + admin = bool(record.is_admin) if record else is_admin(role_code) + from .roles import permissions_for_role + + return { + "role_label": role_label(role_code), + "is_admin": admin, + "permissions": permissions_for_role(role_code), + } diff --git a/backend/auth/roles.py b/backend/auth/roles.py index 2c91060..9e56be7 100644 --- a/backend/auth/roles.py +++ b/backend/auth/roles.py @@ -1,5 +1,18 @@ +"""Role code helpers and an in-memory role registry backed by the roles table.""" + +from __future__ import annotations + +from dataclasses import dataclass, field from typing import Iterable +from .permissions import ( + ALL_PERMISSIONS, + OPERATOR_PERMISSIONS, + VIEWER_PERMISSIONS, + WRITE_PERMISSIONS, + normalize_permissions, +) + ROLE_ADMIN = "admin" ROLE_OPERATOR = "operator" ROLE_VIEWER = "viewer" @@ -13,19 +26,142 @@ ROLE_LABELS = { } -def is_admin(role: str) -> bool: - return role == ROLE_ADMIN +@dataclass +class RoleRecord: + code: str + label: str + description: str = "" + is_system: bool = False + is_admin: bool = False + permissions: list[str] = field(default_factory=list) -def can_write(role: str) -> bool: - return role in (ROLE_ADMIN, ROLE_OPERATOR) +_ROLE_CACHE: dict[str, RoleRecord] = {} -def can_manage_users(role: str) -> bool: - return role == ROLE_ADMIN +def default_role_seeds() -> list[RoleRecord]: + return [ + RoleRecord( + code=ROLE_ADMIN, + label=ROLE_LABELS[ROLE_ADMIN], + description="拥有全部菜单与操作权限,可管理全局数据", + is_system=True, + is_admin=True, + permissions=list(ALL_PERMISSIONS), + ), + RoleRecord( + code=ROLE_OPERATOR, + label=ROLE_LABELS[ROLE_OPERATOR], + description="管理自己的账号、规则与私信", + is_system=True, + is_admin=False, + permissions=list(OPERATOR_PERMISSIONS), + ), + RoleRecord( + code=ROLE_VIEWER, + label=ROLE_LABELS[ROLE_VIEWER], + description="仅查看自己的业务数据,不可修改", + is_system=True, + is_admin=False, + permissions=list(VIEWER_PERMISSIONS), + ), + ] + + +def set_role_cache(roles: Iterable[RoleRecord]) -> None: + global _ROLE_CACHE + _ROLE_CACHE = {role.code: role for role in roles} + + +def get_cached_role(code: str | None) -> RoleRecord | None: + if not code: + return None + return _ROLE_CACHE.get(str(code)) + + +def list_cached_roles() -> list[RoleRecord]: + return list(_ROLE_CACHE.values()) + + +def role_label(code: str | None) -> str: + role = get_cached_role(code) + if role: + return role.label + return ROLE_LABELS.get(str(code or ""), str(code or "")) + + +def is_admin(role: str | None) -> bool: + """True when the role has global data scope (built-in admin).""" + record = get_cached_role(role) + if record is not None: + return bool(record.is_admin) + # Fallback before cache is warm / for unit tests. + return str(role or "") == ROLE_ADMIN + + +def can_write(role: str | None) -> bool: + record = get_cached_role(role) + if record is not None: + if record.is_admin: + return True + return any(code in WRITE_PERMISSIONS for code in record.permissions) + return str(role or "") in (ROLE_ADMIN, ROLE_OPERATOR) + + +def can_manage_users(role: str | None) -> bool: + return has_permission(role, "users.manage") + + +def has_permission(role: str | None, permission: str) -> bool: + code = str(permission or "").strip() + if not code: + return False + record = get_cached_role(role) + if record is None: + if str(role or "") == ROLE_ADMIN: + return True + if str(role or "") == ROLE_OPERATOR: + return code in OPERATOR_PERMISSIONS + if str(role or "") == ROLE_VIEWER: + return code in VIEWER_PERMISSIONS + return False + if record.is_admin: + return True + return code in record.permissions + + +def permissions_for_role(role: str | None) -> list[str]: + record = get_cached_role(role) + if record is None: + if str(role or "") == ROLE_ADMIN: + return list(ALL_PERMISSIONS) + if str(role or "") == ROLE_OPERATOR: + return list(OPERATOR_PERMISSIONS) + if str(role or "") == ROLE_VIEWER: + return list(VIEWER_PERMISSIONS) + return [] + if record.is_admin: + return list(ALL_PERMISSIONS) + return list(record.permissions) def ensure_role(role: str) -> str: - if role not in ALL_ROLES: - raise ValueError(f"无效角色: {role}") - return role + """Validate that a role code exists (cache or built-in fallback).""" + code = str(role or "").strip() + if not code: + raise ValueError("角色不能为空") + if get_cached_role(code) is not None: + return code + if code in ALL_ROLES: + return code + raise ValueError(f"无效角色: {code}") + + +def sanitize_role_permissions( + codes: list[str] | None, + *, + force_all: bool = False, +) -> list[str]: + if force_all: + return list(ALL_PERMISSIONS) + return normalize_permissions(codes) diff --git a/backend/auth/router.py b/backend/auth/router.py index 42fd3f5..a2dea91 100644 --- a/backend/auth/router.py +++ b/backend/auth/router.py @@ -25,16 +25,30 @@ from .email_verification import create_verification_token, mask_email, verify_em from .password_reset import create_password_reset_token, verify_password_reset_token from .jwt_utils import create_access_token from .passwords import hash_password, verify_password -from .roles import ALL_ROLES, ROLE_LABELS, ROLE_OPERATOR, ensure_role, is_admin +from .permissions import permission_catalog +from .role_service import ( + count_users_with_role, + create_role, + delete_role, + ensure_role_assignable, + guard_last_admin_change, + list_roles as list_role_records, + update_role, + user_permission_payload, +) +from .roles import ROLE_OPERATOR, is_admin from .schemas import ( LoginRequest, MessageResponse, ForgotPasswordRequest, ForgotPasswordResponse, + PermissionCatalogResponse, RegisterRequest, RegisterResponse, ResendVerificationRequest, ResetPasswordRequest, + RoleCreate, + RoleUpdate, RolesResponse, RoleInfo, TokenResponse, @@ -52,6 +66,10 @@ router = APIRouter(prefix="/api/auth", tags=["auth"]) async def _build_user_response(db: AsyncSession, user: User, with_count: bool = False) -> UserResponse: payload = UserResponse.model_validate(user) + perm = user_permission_payload(user.role) + payload.role_label = perm["role_label"] + payload.is_admin = perm["is_admin"] + payload.permissions = perm["permissions"] if with_count: breakdown = await count_user_account_breakdown(db, user.id) payload.account_count = breakdown["total"] @@ -371,13 +389,29 @@ async def get_me(user: User = Depends(get_current_user), db: AsyncSession = Depe @router.get("/roles", response_model=RolesResponse) -async def list_roles(_: User = Depends(get_current_user)): +async def list_auth_roles( + db: AsyncSession = Depends(get_db), + _: User = Depends(get_current_user), +): + """Lightweight role list for dropdowns (any logged-in user).""" + records = await list_role_records(db) return RolesResponse( - roles=[RoleInfo(value=r, label=ROLE_LABELS.get(r, r)) for r in ALL_ROLES] + roles=[ + RoleInfo( + value=item.code, + label=item.label, + description=item.description or None, + is_system=item.is_system, + is_admin=item.is_admin, + permissions=list(item.permissions), + ) + for item in records + ] ) users_router = APIRouter(prefix="/api/users", tags=["users"]) +roles_router = APIRouter(prefix="/api/roles", tags=["roles"]) @users_router.get("", response_model=list[UserResponse]) @@ -403,10 +437,7 @@ async def create_user( exists = await db.execute(select(User).where(User.username == body.username)) if exists.scalar_one_or_none(): raise HTTPException(status_code=400, detail="用户名已存在") - try: - role = ensure_role(body.role) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) + role = await ensure_role_assignable(db, body.role) email = await _ensure_email_available(db, str(body.email) if body.email else None) if settings.email_binding_required and not is_admin(role) and not email: raise HTTPException(status_code=400, detail="系统已开启「登录必须绑定邮箱」,请填写邮箱") @@ -445,14 +476,19 @@ async def update_user( settings = await load_settings(db) if user.id == current.id and body.is_active is False: raise HTTPException(status_code=400, detail="不能禁用当前登录账号") + + updates = body.model_dump(exclude_unset=True) + if body.is_active is False: + await guard_last_admin_change(db, user=user, deactivating=True) + if body.role is not None: + new_role = await ensure_role_assignable(db, body.role) + await guard_last_admin_change(db, user=user, new_role=new_role) + if body.display_name is not None: user.display_name = body.display_name if body.role is not None: prev_role = user.role - try: - user.role = ensure_role(body.role) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) + user.role = await ensure_role_assignable(db, body.role) if is_admin(user.role): user.max_accounts = UNLIMITED_ACCOUNTS await sync_user_account_quota(db, user, stop_worker=default_stop_worker) @@ -465,7 +501,6 @@ async def update_user( if body.password: user.password_hash = hash_password(body.password) - updates = body.model_dump(exclude_unset=True) if "max_accounts" in updates and not is_admin(user.role): user.max_accounts = normalize_max_accounts(updates["max_accounts"], user.role) await sync_user_account_quota(db, user, stop_worker=default_stop_worker) @@ -506,6 +541,93 @@ async def delete_user( user = result.scalar_one_or_none() if not user: raise HTTPException(status_code=404, detail="用户不存在") + await guard_last_admin_change(db, user=user, deleting=True) await db.delete(user) await db.commit() return {"message": "用户已删除"} + + +@roles_router.get("", response_model=RolesResponse) +async def admin_list_roles( + db: AsyncSession = Depends(get_db), + _: User = Depends(require_user_manager), +): + records = await list_role_records(db) + roles = [] + for item in records: + roles.append( + RoleInfo( + value=item.code, + label=item.label, + description=item.description or None, + is_system=item.is_system, + is_admin=item.is_admin, + permissions=list(item.permissions), + user_count=await count_users_with_role(db, item.code), + ) + ) + return RolesResponse(roles=roles) + + +@roles_router.get("/catalog", response_model=PermissionCatalogResponse) +async def get_permission_catalog(_: User = Depends(require_user_manager)): + return PermissionCatalogResponse(**permission_catalog()) + + +@roles_router.post("", response_model=RoleInfo) +async def create_custom_role( + body: RoleCreate, + db: AsyncSession = Depends(get_db), + _: User = Depends(require_user_manager), +): + record = await create_role( + db, + code=body.code, + label=body.label, + description=body.description, + permissions=body.permissions, + ) + return RoleInfo( + value=record.code, + label=record.label, + description=record.description or None, + is_system=record.is_system, + is_admin=record.is_admin, + permissions=list(record.permissions), + user_count=0, + ) + + +@roles_router.put("/{code}", response_model=RoleInfo) +async def update_custom_role( + code: str, + body: RoleUpdate, + db: AsyncSession = Depends(get_db), + _: User = Depends(require_user_manager), +): + record = await update_role( + db, + code, + label=body.label, + description=body.description, + permissions=body.permissions, + ) + return RoleInfo( + value=record.code, + label=record.label, + description=record.description or None, + is_system=record.is_system, + is_admin=record.is_admin, + permissions=list(record.permissions), + user_count=await count_users_with_role(db, record.code), + ) + + +@roles_router.delete("/{code}") +async def delete_custom_role( + code: str, + db: AsyncSession = Depends(get_db), + _: User = Depends(require_user_manager), +): + await delete_role(db, code) + return {"message": "角色已删除"} diff --git a/backend/auth/schemas.py b/backend/auth/schemas.py index c35c183..b9a1834 100644 --- a/backend/auth/schemas.py +++ b/backend/auth/schemas.py @@ -67,6 +67,9 @@ class UserResponse(BaseModel): email: Optional[str] = None display_name: Optional[str] = None role: str + role_label: str = "" + is_admin: bool = False + permissions: list[str] = Field(default_factory=list) is_active: bool email_verified: bool = False max_accounts: int = 3 @@ -102,7 +105,30 @@ class UserUpdate(BaseModel): class RoleInfo(BaseModel): value: str label: str + description: Optional[str] = None + is_system: bool = False + is_admin: bool = False + permissions: list[str] = Field(default_factory=list) + user_count: Optional[int] = None class RolesResponse(BaseModel): roles: list[RoleInfo] + + +class RoleCreate(BaseModel): + code: str = Field(min_length=2, max_length=50) + label: str = Field(min_length=1, max_length=100) + description: Optional[str] = Field(default=None, max_length=255) + permissions: list[str] = Field(default_factory=list) + + +class RoleUpdate(BaseModel): + label: Optional[str] = Field(default=None, min_length=1, max_length=100) + description: Optional[str] = Field(default=None, max_length=255) + permissions: Optional[list[str]] = None + + +class PermissionCatalogResponse(BaseModel): + menus: list[dict[str, Any]] + actions: list[dict[str, Any]] diff --git a/backend/auth/scopes.py b/backend/auth/scopes.py index 7cc85df..26f0fdd 100644 --- a/backend/auth/scopes.py +++ b/backend/auth/scopes.py @@ -5,7 +5,8 @@ from sqlalchemy import or_, select from sqlalchemy.ext.asyncio import AsyncSession from models.models import Account, AutoReplyRule, MessageLog, ReceivedMessageLog, SystemLog, User -from .roles import is_admin +from .permissions import ACCOUNTS_WRITE, RULES_WRITE +from .roles import has_permission, is_admin async def get_owned_account( @@ -23,7 +24,7 @@ async def get_owned_account( return account if account.owner_id != user.id: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问该账号") - if write and user.role == "viewer": + if write and not has_permission(user.role, ACCOUNTS_WRITE): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只读用户无法修改") return account @@ -79,17 +80,15 @@ async def get_accessible_rule(db: AsyncSession, user: User, rule_id: int, *, wri raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="规则不存在") if is_admin(user.role): - if write and user.role == "viewer": - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只读用户无法修改") return rule if rule.account_id is None: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问全局规则") - account = await get_owned_account(db, user, rule.account_id, write=write) + account = await get_owned_account(db, user, rule.account_id, write=False) if rule.owner_id and rule.owner_id != user.id and account.owner_id != user.id: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问该规则") - if write and user.role == "viewer": + if write and not has_permission(user.role, RULES_WRITE): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只读用户无法修改") return rule diff --git a/backend/auth/settings_router.py b/backend/auth/settings_router.py index 9baf2b3..2e6e4f0 100644 --- a/backend/auth/settings_router.py +++ b/backend/auth/settings_router.py @@ -14,7 +14,8 @@ from models.db_config import ( ) from models.db_transfer import inspect_sqlite_source, migrate_sqlite_to_target from models.models import User -from .dependencies import require_admin +from .dependencies import require_permission +from .permissions import SETTINGS_MANAGE from .email_service import send_test_email from .system_settings import ( PASSWORD_PLACEHOLDER, @@ -208,7 +209,7 @@ async def get_public_settings(db: AsyncSession = Depends(get_db)): @router.get("", response_model=SystemSettingsResponse) async def get_system_settings( db: AsyncSession = Depends(get_db), - _: User = Depends(require_admin), + _: User = Depends(require_permission(SETTINGS_MANAGE)), ): data = await load_settings(db) return SystemSettingsResponse(**settings_to_admin_response(data)) @@ -218,7 +219,7 @@ async def get_system_settings( async def update_system_settings( body: SystemSettingsUpdate, db: AsyncSession = Depends(get_db), - _: User = Depends(require_admin), + _: User = Depends(require_permission(SETTINGS_MANAGE)), ): updates = body.model_dump(exclude_unset=True) if "app_url" in updates and updates["app_url"]: @@ -230,7 +231,7 @@ async def update_system_settings( @router.get("/payment", response_model=PaymentSettingsResponse) async def get_payment_settings( db: AsyncSession = Depends(get_db), - _: User = Depends(require_admin), + _: User = Depends(require_permission(SETTINGS_MANAGE)), ): data = await load_settings(db) return PaymentSettingsResponse(**settings_to_payment_response(data)) @@ -240,7 +241,7 @@ async def get_payment_settings( async def update_payment_settings( body: PaymentSettingsUpdate, db: AsyncSession = Depends(get_db), - _: User = Depends(require_admin), + _: User = Depends(require_permission(SETTINGS_MANAGE)), ): updates = body.model_dump(exclude_unset=True) data = await save_settings(db, updates) @@ -251,7 +252,7 @@ async def update_payment_settings( async def test_smtp_email( body: TestEmailRequest, db: AsyncSession = Depends(get_db), - _: User = Depends(require_admin), + _: User = Depends(require_permission(SETTINGS_MANAGE)), ): data = await load_settings(db) overrides = body.model_dump(exclude_unset=True, exclude={"to_email"}) @@ -276,14 +277,14 @@ async def test_smtp_email( @router.get("/database", response_model=DatabaseSettingsResponse) -async def get_database_settings(_: User = Depends(require_admin)): +async def get_database_settings(_: User = Depends(require_permission(SETTINGS_MANAGE))): return DatabaseSettingsResponse(**database_config_to_response()) @router.put("/database", response_model=MessageResponse) async def update_database_settings( body: DatabaseSettingsUpdate, - _: User = Depends(require_admin), + _: User = Depends(require_permission(SETTINGS_MANAGE)), ): payload = body.model_dump(exclude_unset=True) if payload.get("db_password") in (None, "", DB_PASSWORD_PLACEHOLDER): @@ -302,7 +303,7 @@ async def update_database_settings( @router.post("/database/test", response_model=MessageResponse) async def test_database_settings( body: DatabaseTestRequest, - _: User = Depends(require_admin), + _: User = Depends(require_permission(SETTINGS_MANAGE)), ): payload = body.model_dump(exclude_unset=True) if payload.get("db_password") in (None, "", DB_PASSWORD_PLACEHOLDER): @@ -317,7 +318,7 @@ async def test_database_settings( @router.get("/database/migrate/preview", response_model=DatabaseMigratePreviewResponse) async def preview_database_migration( source_db_path: str | None = None, - _: User = Depends(require_admin), + _: User = Depends(require_permission(SETTINGS_MANAGE)), ): return DatabaseMigratePreviewResponse(**await inspect_sqlite_source(source_db_path)) @@ -325,7 +326,7 @@ async def preview_database_migration( @router.post("/database/migrate", response_model=DatabaseMigrateResponse) async def migrate_database_data( body: DatabaseMigrateRequest, - _: User = Depends(require_admin), + _: User = Depends(require_permission(SETTINGS_MANAGE)), ): payload = body.model_dump(exclude_unset=True) clear_target = bool(payload.pop("clear_target", False)) diff --git a/backend/desktop_router.py b/backend/desktop_router.py index 3162bdf..9a4e89d 100644 --- a/backend/desktop_router.py +++ b/backend/desktop_router.py @@ -19,7 +19,8 @@ from fastapi.responses import FileResponse from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession -from auth.dependencies import require_admin +from auth.dependencies import require_permission +from auth.permissions import DESKTOP_MANAGE from auth.system_settings import get_cached_settings from desktop_release import ( INSTALLER_DIR, @@ -120,7 +121,7 @@ async def desktop_download(db: AsyncSession = Depends(get_db)): async def get_release( request: Request, db: AsyncSession = Depends(get_db), - _: User = Depends(require_admin), + _: User = Depends(require_permission(DESKTOP_MANAGE)), ): data = await load_release(db) return _to_response(data, request) @@ -131,7 +132,7 @@ async def update_release( body: DesktopReleaseUpdate, request: Request, db: AsyncSession = Depends(get_db), - _: User = Depends(require_admin), + _: User = Depends(require_permission(DESKTOP_MANAGE)), ): updates = body.model_dump(exclude_unset=True) if "version" in updates and updates["version"] is not None: @@ -152,7 +153,7 @@ async def upload_installer( request: Request, file: UploadFile = File(...), db: AsyncSession = Depends(get_db), - _: User = Depends(require_admin), + _: User = Depends(require_permission(DESKTOP_MANAGE)), ): filename = (file.filename or "").strip() if not filename.lower().endswith(".exe"): @@ -194,7 +195,7 @@ async def upload_installer( async def delete_installer( request: Request, db: AsyncSession = Depends(get_db), - _: User = Depends(require_admin), + _: User = Depends(require_permission(DESKTOP_MANAGE)), ): remove_installer() data = await save_release(db, {"installer_name": "", "installer_size": 0}) diff --git a/backend/kefu.db-shm b/backend/kefu.db-shm index 3b97620..ba4863e 100644 Binary files a/backend/kefu.db-shm and b/backend/kefu.db-shm differ diff --git a/backend/kefu.db-wal b/backend/kefu.db-wal index 36b8833..a96fe9d 100644 Binary files a/backend/kefu.db-wal and b/backend/kefu.db-wal differ diff --git a/backend/main.py b/backend/main.py index 098bead..68a1bb8 100644 --- a/backend/main.py +++ b/backend/main.py @@ -30,17 +30,26 @@ from models.db_migrate import ( migrate_account_videos_table as _migrate_account_videos_table, migrate_message_logs_table as _migrate_message_logs_table, migrate_payment_orders_table as _migrate_payment_orders_table, + migrate_roles_table as _migrate_roles_table, migrate_rules_table as _migrate_rules_table, migrate_users_table as _migrate_users_table, ) from models.db_config import database_config_to_response from models.models import Account, AccountProfileDetail, AccountVideo, AutoReplyRule, MessageLog, ReceivedMessageLog, SystemLog, User -from auth.router import router as auth_router, users_router +from auth.router import router as auth_router, users_router, roles_router from auth.settings_router import router as settings_router +from auth.role_service import seed_builtin_roles from payments.router import router as payments_router from desktop_router import router as desktop_router from link_cards_router import router as link_cards_router, UPLOAD_DIR as LINK_CARD_UPLOAD_DIR -from auth.dependencies import get_current_user, require_admin, require_write +from auth.dependencies import ( + get_current_user, + require_accounts_write, + require_admin, + require_messages_write, + require_rules_write, + require_write, +) from auth.account_limits import ensure_can_add_account from auth.scopes import ( accounts_for_user, @@ -52,7 +61,8 @@ from auth.scopes import ( received_logs_for_user, system_logs_for_user, ) -from auth.roles import is_admin +from auth.roles import has_permission, is_admin +from auth.permissions import LOGS_READ, RECEIVED_MESSAGES_READ, SYSTEM_LOGS_READ from auth.passwords import hash_password from rpa_engine.batch_start import BatchStartQueue from rpa_engine.playwright_worker import DouyinWorker @@ -201,6 +211,7 @@ app.mount("/api/media/link-cards", StaticFiles(directory=LINK_CARD_UPLOAD_DIR), app.include_router(auth_router) app.include_router(users_router) +app.include_router(roles_router) app.include_router(settings_router) app.include_router(payments_router) app.include_router(desktop_router) @@ -610,6 +621,7 @@ async def startup(): async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) + await conn.run_sync(_migrate_roles_table) await conn.run_sync(_migrate_accounts_table) await conn.run_sync(_migrate_rules_table) await conn.run_sync(_migrate_message_logs_table) @@ -618,6 +630,8 @@ async def startup(): await conn.run_sync(_migrate_payment_orders_table) await conn.run_sync(_migrate_accounts_quota_disabled) await _seed_app_config() + async with AsyncSessionLocal() as db: + await seed_builtin_roles(db) await _seed_admin_user() # 进程启动时没有任何内存 Worker;复位异常退出遗留的运行状态。 # 同时,账号数量/并发限制已移除,清理历史“额度停用”标记。 @@ -1618,7 +1632,7 @@ async def update_account( account_id: int, body: AccountUpdate, db: AsyncSession = Depends(get_db), - user: User = Depends(require_write), + user: User = Depends(require_accounts_write), ): account = await get_owned_account(db, user, account_id, write=True) follow_config_changed = bool( @@ -1776,7 +1790,7 @@ async def send_account_queued_reply_now( account_id: int, job_id: str, db: AsyncSession = Depends(get_db), - user: User = Depends(require_write), + user: User = Depends(require_messages_write), ): """将指定任务原子移入紧急队列,并把它后面的普通任务前移一槽。""" await get_owned_account(db, user, account_id, write=True) @@ -1931,7 +1945,7 @@ async def update_account_cookie( account_id: int, body: AccountCookieUpdate, db: AsyncSession = Depends(get_db), - user: User = Depends(require_write), + user: User = Depends(require_accounts_write), ): # Validate first: malformed input must not take a healthy hosted account # offline. Filesystem and database mutations happen only after the worker @@ -1980,7 +1994,7 @@ async def update_account_cookie( async def delete_account_cookie( account_id: int, db: AsyncSession = Depends(get_db), - user: User = Depends(require_write), + user: User = Depends(require_accounts_write), ): # Deleting credentials uses the same preparation lock as starting a # worker, preventing a new worker from appearing after stop_worker but @@ -2009,7 +2023,7 @@ async def delete_account_cookie( async def create_account( account_in: AccountCreate, db: AsyncSession = Depends(get_db), - user: User = Depends(require_write), + user: User = Depends(require_accounts_write), ): cookie_data = (account_in.cookie_data or "").strip() standard_json_str = None @@ -2047,7 +2061,7 @@ async def create_account( async def delete_account( account_id: int, db: AsyncSession = Depends(get_db), - user: User = Depends(require_write), + user: User = Depends(require_accounts_write), ): await get_owned_account(db, user, account_id, write=True) # 停止运行中的任务 @@ -2082,7 +2096,7 @@ async def validate_account_credential( async def reset_account_credentials( account_id: int, db: AsyncSession = Depends(get_db), - user: User = Depends(require_write), + user: User = Depends(require_accounts_write), ): await get_owned_account(db, user, account_id, write=True) await _release_db_connection(db) @@ -2237,7 +2251,7 @@ async def start_account_rpa( account_id: int, body: StartAccountRequest = StartAccountRequest(), db: AsyncSession = Depends(get_db), - user: User = Depends(require_write), + user: User = Depends(require_accounts_write), ): account = await get_owned_account(db, user, account_id, write=True) # Cancelling waits for an in-flight queued start, and the preparation lock @@ -2253,7 +2267,7 @@ async def start_account_rpa( async def submit_account_start_batch( body: BatchStartRequest, db: AsyncSession = Depends(get_db), - user: User = Depends(require_write), + user: User = Depends(require_accounts_write), ): requested_ids = list( dict.fromkeys(int(value) for value in body.account_ids if int(value) > 0) @@ -2317,7 +2331,7 @@ async def get_account_start_batch( async def stop_account_rpa( account_id: int, db: AsyncSession = Depends(get_db), - user: User = Depends(require_write), + user: User = Depends(require_accounts_write), ): account = await get_owned_account(db, user, account_id, write=True) @@ -2395,7 +2409,7 @@ async def get_rules( async def create_rule( rule_in: RuleCreate, db: AsyncSession = Depends(get_db), - user: User = Depends(require_write), + user: User = Depends(require_rules_write), ): if rule_in.match_type == "default": rule_in.keyword = "" @@ -2429,7 +2443,7 @@ async def update_rule( rule_in: RuleCreate, is_active: Optional[bool] = None, db: AsyncSession = Depends(get_db), - user: User = Depends(require_write), + user: User = Depends(require_rules_write), ): rule = await get_accessible_rule(db, user, rule_id, write=True) @@ -2457,7 +2471,7 @@ async def update_rule( async def toggle_rule( rule_id: int, db: AsyncSession = Depends(get_db), - user: User = Depends(require_write), + user: User = Depends(require_rules_write), ): rule = await get_accessible_rule(db, user, rule_id, write=True) @@ -2470,7 +2484,7 @@ async def toggle_rule( async def delete_rule( rule_id: int, db: AsyncSession = Depends(get_db), - user: User = Depends(require_write), + user: User = Depends(require_rules_write), ): await get_accessible_rule(db, user, rule_id, write=True) await db.execute(delete(AutoReplyRule).where(AutoReplyRule.id == rule_id)) @@ -2487,7 +2501,7 @@ async def move_rule( rule_id: int, body: RuleMove, db: AsyncSession = Depends(get_db), - user: User = Depends(require_write), + user: User = Depends(require_rules_write), ): """在同账号规则内上移/下移一位(服务端交换排序,适配前端分页)。""" rule = await get_accessible_rule(db, user, rule_id, write=True) @@ -2512,7 +2526,7 @@ async def move_rule( async def reorder_rules( body: RuleReorder, db: AsyncSession = Depends(get_db), - user: User = Depends(require_write), + user: User = Depends(require_rules_write), ): if not body.rule_ids: return {"message": "No rules to reorder."} @@ -2531,6 +2545,8 @@ async def get_logs_stats( user: User = Depends(get_current_user), ): """消息日志全量统计(数据库计数,不受列表 limit 限制)。""" + if not has_permission(user.role, LOGS_READ): + raise HTTPException(status_code=403, detail="缺少权限:logs.read") if account_id is not None: await get_owned_account(db, user, account_id) # Select only the indexed status column and calculate both counters in one @@ -2564,6 +2580,8 @@ async def get_logs( db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): + if not has_permission(user.role, LOGS_READ): + raise HTTPException(status_code=403, detail="缺少权限:logs.read") if account_id is not None: await get_owned_account(db, user, account_id) limit = max(1, min(int(limit or 50), 500)) @@ -2585,6 +2603,8 @@ async def get_received_messages( user: User = Depends(get_current_user), ): """接收消息原始日志:仅包含收到的消息,内容为接口/通道原样记录。""" + if not has_permission(user.role, RECEIVED_MESSAGES_READ): + raise HTTPException(status_code=403, detail="缺少权限:received_messages.read") if account_id is not None: await get_owned_account(db, user, account_id) limit = max(1, min(int(limit or 100), 500)) @@ -2607,6 +2627,8 @@ async def get_system_logs( user: User = Depends(get_current_user), ): """系统诊断日志:私信收发 / 实时连接 / 鉴权 等链路事件,用于排查失败原因。""" + if not has_permission(user.role, SYSTEM_LOGS_READ): + raise HTTPException(status_code=403, detail="缺少权限:system_logs.read") if account_id is not None: await get_owned_account(db, user, account_id) entries = system_logger.get_logs( @@ -2688,7 +2710,7 @@ async def upload_message_image( account_id: int, file: UploadFile = File(...), db: AsyncSession = Depends(get_db), - user: User = Depends(require_write), + user: User = Depends(require_messages_write), ): account = await get_owned_account(db, user, account_id, write=True) if not file.content_type or not file.content_type.startswith("image/"): @@ -2779,7 +2801,7 @@ async def send_account_message( account_id: int, body: SendMessageRequest, db: AsyncSession = Depends(get_db), - user: User = Depends(require_write), + user: User = Depends(require_messages_write), ): account = await get_owned_account(db, user, account_id, write=True) diff --git a/backend/models/db_migrate.py b/backend/models/db_migrate.py index 34a66e8..6726efe 100644 --- a/backend/models/db_migrate.py +++ b/backend/models/db_migrate.py @@ -196,6 +196,54 @@ def migrate_rules_table(conn) -> None: ) +def migrate_roles_table(conn) -> None: + """Ensure roles table exists (create_all usually handles this; keep as safety net).""" + try: + insp = inspect(conn) + if insp.has_table("roles"): + return + except Exception: + return + dialect = _dialect(conn) + if dialect == "postgresql": + conn.execute( + text( + """ + CREATE TABLE roles ( + id SERIAL PRIMARY KEY, + code VARCHAR(50) NOT NULL UNIQUE, + label VARCHAR(100) NOT NULL, + description VARCHAR(255), + is_system BOOLEAN DEFAULT FALSE, + is_admin BOOLEAN DEFAULT FALSE, + permissions TEXT NOT NULL DEFAULT '[]', + created_at TIMESTAMP, + updated_at TIMESTAMP + ) + """ + ) + ) + else: + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS roles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code VARCHAR(50) NOT NULL UNIQUE, + label VARCHAR(100) NOT NULL, + description VARCHAR(255), + is_system BOOLEAN DEFAULT 0, + is_admin BOOLEAN DEFAULT 0, + permissions TEXT NOT NULL DEFAULT '[]', + created_at DATETIME, + updated_at DATETIME + ) + """ + ) + ) + add_index_if_missing(conn, "roles", "ix_roles_code", ("code",)) + + def migrate_users_table(conn) -> None: add_column_if_missing( conn, diff --git a/backend/models/models.py b/backend/models/models.py index 793c1a3..45ee6fa 100644 --- a/backend/models/models.py +++ b/backend/models/models.py @@ -5,6 +5,28 @@ from .database import Base from utils.log_limits import bound_error_log_content, bound_message_log_content +class Role(Base): + """Assignable role with a fixed permission-code list. + + ``users.role`` stores ``Role.code``. Built-in roles are seeded on startup; + custom roles are owned by admins via the users.manage permission. + """ + + __tablename__ = "roles" + + id = Column(Integer, primary_key=True, index=True) + code = Column(String(50), unique=True, index=True, nullable=False) + label = Column(String(100), nullable=False) + description = Column(String(255), nullable=True) + is_system = Column(Boolean, default=False) + # Global data scope + unlimited account quota. Only the built-in admin + # role may be true; custom roles are always own-scoped. + is_admin = Column(Boolean, default=False) + permissions = Column(Text, nullable=False, default="[]") # JSON string list + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + class User(Base): __tablename__ = "users" @@ -13,7 +35,7 @@ class User(Base): email = Column(String(255), unique=True, index=True, nullable=True) password_hash = Column(String(255), nullable=False) display_name = Column(String(100), nullable=True) - role = Column(String(20), default="operator", index=True) # admin, operator, viewer + role = Column(String(50), default="operator", index=True) # roles.code is_active = Column(Boolean, default=True) email_verified = Column(Boolean, default=False) email_verified_at = Column(DateTime, nullable=True) diff --git a/backend/payments/router.py b/backend/payments/router.py index 30f6081..7a8085c 100644 --- a/backend/payments/router.py +++ b/backend/payments/router.py @@ -2,7 +2,8 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi.responses import PlainTextResponse from sqlalchemy.ext.asyncio import AsyncSession -from auth.dependencies import get_current_user, require_admin +from auth.dependencies import get_current_user, require_permission +from auth.permissions import PAYMENTS_MANAGE from auth.system_settings import load_settings from models.database import get_db from models.models import User @@ -89,7 +90,7 @@ async def admin_update_payment_order_status( order_no: str, body: AdminUpdateOrderStatusRequest, db: AsyncSession = Depends(get_db), - _: User = Depends(require_admin), + _: User = Depends(require_permission(PAYMENTS_MANAGE)), ): order = await service.admin_update_order_status(db, order_no, body.status) return PaymentOrderListItem(**order) @@ -99,7 +100,7 @@ async def admin_update_payment_order_status( async def admin_delete_payment_order( order_no: str, db: AsyncSession = Depends(get_db), - _: User = Depends(require_admin), + _: User = Depends(require_permission(PAYMENTS_MANAGE)), ): await service.admin_delete_order(db, order_no) return MessageResponse(message="订单已删除") diff --git a/backend/tests/test_roles_rbac.py b/backend/tests/test_roles_rbac.py new file mode 100644 index 0000000..2c5c35c --- /dev/null +++ b/backend/tests/test_roles_rbac.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import os +import sys +import unittest +from pathlib import Path + +from fastapi import HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.orm import sessionmaker + + +BACKEND_DIR = Path(__file__).resolve().parents[1] +os.environ["KEFU_DB_TYPE"] = "sqlite" +os.environ["KEFU_DATABASE_URL"] = "sqlite+aiosqlite:///:memory:" +if str(BACKEND_DIR) not in sys.path: + sys.path.insert(0, str(BACKEND_DIR)) + +from models.models import Base, Role, User # noqa: E402 +from auth.passwords import hash_password # noqa: E402 +from auth.permissions import ACCOUNTS_WRITE, ALL_PERMISSIONS, MENU_USERS # noqa: E402 +from auth.role_service import ( # noqa: E402 + create_role, + delete_role, + guard_last_admin_change, + seed_builtin_roles, + update_role, +) +from auth.roles import has_permission, is_admin, permissions_for_role # noqa: E402 +from auth import router as auth_router # noqa: E402 + + +class RolesRbacTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with self.engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + self.session_factory = sessionmaker( + self.engine, class_=AsyncSession, expire_on_commit=False + ) + self.db = self.session_factory() + await seed_builtin_roles(self.db) + + async def asyncTearDown(self): + await self.db.close() + await self.engine.dispose() + + async def test_seed_creates_builtin_roles(self): + result = await self.db.execute(select(Role)) + codes = {row.code for row in result.scalars().all()} + self.assertEqual(codes, {"admin", "operator", "viewer"}) + self.assertTrue(is_admin("admin")) + self.assertFalse(is_admin("operator")) + self.assertIn(MENU_USERS, permissions_for_role("admin")) + self.assertNotIn(MENU_USERS, permissions_for_role("operator")) + self.assertFalse(has_permission("viewer", ACCOUNTS_WRITE)) + + async def test_custom_role_crud(self): + created = await create_role( + self.db, + code="ops_leader", + label="运营主管", + description="可管账号", + permissions=[MENU_USERS, ACCOUNTS_WRITE], + ) + self.assertEqual(created.code, "ops_leader") + self.assertTrue(has_permission("ops_leader", ACCOUNTS_WRITE)) + self.assertFalse(is_admin("ops_leader")) + + updated = await update_role( + self.db, + "ops_leader", + label="主管", + permissions=[ACCOUNTS_WRITE], + ) + self.assertEqual(updated.label, "主管") + self.assertFalse(has_permission("ops_leader", MENU_USERS)) + + await delete_role(self.db, "ops_leader") + self.assertFalse(has_permission("ops_leader", ACCOUNTS_WRITE)) + + async def test_cannot_delete_system_role(self): + with self.assertRaises(HTTPException) as caught: + await delete_role(self.db, "operator") + self.assertEqual(caught.exception.status_code, 400) + + async def test_admin_permissions_always_full(self): + await update_role( + self.db, + "admin", + label="管理员", + permissions=[ACCOUNTS_WRITE], + ) + self.assertEqual(permissions_for_role("admin"), list(ALL_PERMISSIONS)) + + async def test_me_payload_includes_permissions(self): + user = User( + username="u1", + password_hash=hash_password("password1"), + display_name="U1", + role="operator", + is_active=True, + email_verified=True, + ) + self.db.add(user) + await self.db.commit() + await self.db.refresh(user) + payload = await auth_router._build_user_response(self.db, user) + self.assertEqual(payload.role_label, "运营") + self.assertFalse(payload.is_admin) + self.assertIn(ACCOUNTS_WRITE, payload.permissions) + self.assertNotIn(MENU_USERS, payload.permissions) + + async def test_last_admin_cannot_be_demoted(self): + admin = User( + username="admin1", + password_hash=hash_password("password1"), + role="admin", + is_active=True, + email_verified=True, + ) + self.db.add(admin) + await self.db.commit() + await self.db.refresh(admin) + + with self.assertRaises(HTTPException) as caught: + await guard_last_admin_change(self.db, user=admin, new_role="operator") + self.assertEqual(caught.exception.status_code, 400) + + +if __name__ == "__main__": + unittest.main() diff --git a/frontend/src/App.vue b/frontend/src/App.vue index e6d485f..49003a8 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,5 +1,5 @@