更新
This commit is contained in:
@@ -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="需要用户管理权限")
|
||||
|
||||
@@ -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}
|
||||
@@ -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),
|
||||
}
|
||||
+145
-9
@@ -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)
|
||||
|
||||
+134
-12
@@ -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": "角色已删除"}
|
||||
|
||||
@@ -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]]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user