dashboard-nanobot/backend/services/sys_auth_service.py

1216 lines
43 KiB
Python

import hashlib
import hmac
import os
import secrets
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
import bcrypt
import jwt
from sqlalchemy import delete
from sqlmodel import Session, select
from core.cache import cache
from core.settings import JWT_ALGORITHM, JWT_SECRET
from models.bot import BotInstance
from models.sys_auth import (
SysMenu,
SysPermission,
SysRole,
SysRoleMenu,
SysRolePermission,
SysUser,
SysUserBot,
)
DEFAULT_ADMIN_USERNAME = (str(os.getenv("SYS_ADMIN_DEFAULT_USERNAME") or "admin").strip().lower() or "admin")
DEFAULT_ADMIN_PASSWORD = str(os.getenv("SYS_ADMIN_DEFAULT_PASSWORD") or "admin123").strip() or "admin123"
PASSWORD_ITERATIONS = 120_000
AUTH_TOKEN_CACHE_PREFIX = "sys_auth:token:"
SUPER_ADMIN_ROLE_KEY = "super_admin"
NORMAL_USER_ROLE_KEY = "normal_user"
ROLE_SEEDS: List[Dict[str, Any]] = [
{
"role_key": SUPER_ADMIN_ROLE_KEY,
"name": "Super Admin",
"description": "平台超级管理员,拥有全部菜单与权限。",
"sort_order": 10,
},
{
"role_key": NORMAL_USER_ROLE_KEY,
"name": "Normal User",
"description": "普通用户,仅可访问已绑定的 Bot 和个人相关页面。",
"sort_order": 20,
},
]
MENU_SEEDS: List[Dict[str, Any]] = [
{
"menu_key": "general",
"parent_key": "",
"title": "General",
"title_en": "General",
"menu_type": "group",
"route_path": "",
"icon": "layout-grid",
"permission_key": "",
"sort_order": 10,
},
{
"menu_key": "general_dashboard",
"parent_key": "general",
"title": "Dashboard",
"title_en": "Dashboard",
"menu_type": "item",
"route_path": "/dashboard",
"icon": "layout-dashboard",
"permission_key": "general.dashboard.view",
"sort_order": 10,
},
{
"menu_key": "general_chat",
"parent_key": "general",
"title": "Chat",
"title_en": "Chat",
"menu_type": "item",
"route_path": "/chat",
"icon": "message-circle",
"permission_key": "general.chat.view",
"sort_order": 15,
},
{
"menu_key": "general_edge",
"parent_key": "general",
"title": "Edge 管理",
"title_en": "Edge Management",
"menu_type": "item",
"route_path": "/dashboard/edges",
"icon": "waypoints",
"permission_key": "general.edge.manage",
"sort_order": 20,
},
{
"menu_key": "admin",
"parent_key": "",
"title": "Admin",
"title_en": "Admin",
"menu_type": "group",
"route_path": "",
"icon": "shield",
"permission_key": "",
"sort_order": 20,
},
{
"menu_key": "admin_skills",
"parent_key": "admin",
"title": "技能管理",
"title_en": "Skill Management",
"menu_type": "item",
"route_path": "/admin/skills",
"icon": "wrench",
"permission_key": "admin.skills.manage",
"sort_order": 10,
},
{
"menu_key": "admin_settings",
"parent_key": "admin",
"title": "系统参数",
"title_en": "System Settings",
"menu_type": "item",
"route_path": "/admin/settings",
"icon": "sliders-horizontal",
"permission_key": "admin.settings.manage",
"sort_order": 20,
},
{
"menu_key": "admin_templates",
"parent_key": "admin",
"title": "模版管理",
"title_en": "Template Management",
"menu_type": "item",
"route_path": "/admin/templates",
"icon": "files",
"permission_key": "admin.templates.manage",
"sort_order": 30,
},
{
"menu_key": "admin_deploy",
"parent_key": "admin",
"title": "迁移/部署",
"title_en": "Migration / Deploy",
"menu_type": "item",
"route_path": "/admin/deploy",
"icon": "rocket",
"permission_key": "admin.deploy.manage",
"sort_order": 40,
},
{
"menu_key": "admin_users",
"parent_key": "admin",
"title": "用户管理",
"title_en": "User Management",
"menu_type": "item",
"route_path": "/admin/users",
"icon": "users",
"permission_key": "admin.users.manage",
"sort_order": 50,
},
{
"menu_key": "admin_roles",
"parent_key": "admin",
"title": "角色管理",
"title_en": "Role Management",
"menu_type": "item",
"route_path": "/admin/roles",
"icon": "badge-check",
"permission_key": "admin.roles.manage",
"sort_order": 60,
},
{
"menu_key": "profile",
"parent_key": "",
"title": "Profile",
"title_en": "Profile",
"menu_type": "group",
"route_path": "",
"icon": "user-round",
"permission_key": "",
"sort_order": 30,
},
{
"menu_key": "profile_usage_logs",
"parent_key": "profile",
"title": "使用日志",
"title_en": "Usage Logs",
"menu_type": "item",
"route_path": "/profile/usage-logs",
"icon": "history",
"permission_key": "profile.usage_logs.view",
"sort_order": 10,
},
{
"menu_key": "profile_api_tokens",
"parent_key": "profile",
"title": "API 令牌",
"title_en": "API Tokens",
"menu_type": "item",
"route_path": "/profile/api-tokens",
"icon": "key-round",
"permission_key": "profile.api_tokens.view",
"sort_order": 20,
},
]
PERMISSION_SEEDS: List[Dict[str, Any]] = [
{
"permission_key": "general.dashboard.view",
"name": "查看仪表板",
"menu_key": "general_dashboard",
"action": "view",
"description": "查看平台首页仪表板。",
"sort_order": 10,
},
{
"permission_key": "general.edge.manage",
"name": "管理 Edge 节点",
"menu_key": "general_edge",
"action": "manage",
"description": "查看与维护 Edge 节点。",
"sort_order": 20,
},
{
"permission_key": "general.chat.view",
"name": "访问 Bot Chat",
"menu_key": "general_chat",
"action": "view",
"description": "访问已绑定 Bot 的聊天与运行页。",
"sort_order": 25,
},
{
"permission_key": "admin.skills.manage",
"name": "管理技能市场",
"menu_key": "admin_skills",
"action": "manage",
"description": "管理技能包、上传归档与元数据。",
"sort_order": 30,
},
{
"permission_key": "admin.settings.manage",
"name": "管理系统参数",
"menu_key": "admin_settings",
"action": "manage",
"description": "维护系统参数与运行配置。",
"sort_order": 40,
},
{
"permission_key": "admin.templates.manage",
"name": "管理平台模板",
"menu_key": "admin_templates",
"action": "manage",
"description": "维护平台级模版内容。",
"sort_order": 50,
},
{
"permission_key": "admin.deploy.manage",
"name": "管理迁移部署",
"menu_key": "admin_deploy",
"action": "manage",
"description": "查看部署概览、迁移与部署入口。",
"sort_order": 60,
},
{
"permission_key": "admin.users.manage",
"name": "管理系统用户",
"menu_key": "admin_users",
"action": "manage",
"description": "管理后台用户、状态与默认资料。",
"sort_order": 70,
},
{
"permission_key": "admin.roles.manage",
"name": "管理系统角色",
"menu_key": "admin_roles",
"action": "manage",
"description": "管理角色与菜单、权限分配。",
"sort_order": 80,
},
{
"permission_key": "profile.usage_logs.view",
"name": "查看使用日志",
"menu_key": "profile_usage_logs",
"action": "view",
"description": "查看个人使用日志页面。",
"sort_order": 90,
},
{
"permission_key": "profile.api_tokens.view",
"name": "查看 API 令牌",
"menu_key": "profile_api_tokens",
"action": "view",
"description": "查看个人 API 令牌页面。",
"sort_order": 100,
},
]
LEGACY_MENU_KEYS = {"admin_permissions", "admin_menus"}
LEGACY_PERMISSION_KEYS = {"admin.permissions.manage", "admin.menus.manage"}
def _utcnow() -> datetime:
return datetime.utcnow()
def _dt_to_iso(value: Optional[datetime]) -> Optional[str]:
if value is None:
return None
return value.isoformat() + "Z"
def _new_salt() -> str:
return secrets.token_hex(16)
def hash_password(password: str, salt: str) -> str:
_ = salt
return bcrypt.hashpw(str(password or "").encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def _verify_legacy_pbkdf2_password(password: str, salt: str, stored_hash: str) -> bool:
if not salt or not stored_hash:
return False
payload = hashlib.pbkdf2_hmac(
"sha256",
str(password or "").encode("utf-8"),
bytes.fromhex(str(salt or "")),
PASSWORD_ITERATIONS,
)
return hmac.compare_digest(payload.hex(), str(stored_hash or ""))
def verify_password(password: str, salt: str, stored_hash: str) -> bool:
if not stored_hash:
return False
normalized_hash = str(stored_hash or "")
if normalized_hash.startswith("$2a$") or normalized_hash.startswith("$2b$") or normalized_hash.startswith("$2y$"):
try:
return bool(bcrypt.checkpw(str(password or "").encode("utf-8"), normalized_hash.encode("utf-8")))
except Exception:
return False
return _verify_legacy_pbkdf2_password(password, salt, stored_hash)
def hash_token(token: str) -> str:
return hashlib.sha256(str(token or "").encode("utf-8")).hexdigest()
def _auth_token_cache_key(jti: str) -> str:
return f"{AUTH_TOKEN_CACHE_PREFIX}{str(jti or '').strip()}"
def _session_payload(user: SysUser, *, jti: str, expires_at: datetime) -> Dict[str, Any]:
return {
"sub": str(int(user.id or 0)),
"username": str(user.username or ""),
"jti": jti,
"iat": int(datetime.now(timezone.utc).timestamp()),
"exp": int(expires_at.replace(tzinfo=timezone.utc).timestamp()),
}
def _ensure_auth_cache_ready() -> None:
if not cache.enabled or not cache.ping():
raise RuntimeError("Redis is required for user session storage")
def _seed_roles(session: Session) -> Dict[str, SysRole]:
result: Dict[str, SysRole] = {}
for item in ROLE_SEEDS:
row = session.exec(select(SysRole).where(SysRole.role_key == item["role_key"])).first()
if row is None:
row = SysRole(role_key=item["role_key"])
row.name = item["name"]
row.description = item["description"]
row.is_active = True
row.sort_order = int(item["sort_order"])
row.updated_at = _utcnow()
session.add(row)
result[item["role_key"]] = row
session.commit()
for key in list(result.keys()):
result[key] = session.exec(select(SysRole).where(SysRole.role_key == key)).first()
return result
def _seed_menus(session: Session) -> Dict[str, SysMenu]:
result: Dict[str, SysMenu] = {}
for item in MENU_SEEDS:
row = session.exec(select(SysMenu).where(SysMenu.menu_key == item["menu_key"])).first()
if row is None:
row = SysMenu(menu_key=item["menu_key"])
row.parent_key = item["parent_key"]
row.title = item["title"]
row.title_en = item["title_en"]
row.menu_type = item["menu_type"]
row.route_path = item["route_path"]
row.icon = item["icon"]
row.permission_key = item["permission_key"]
row.visible = True
row.sort_order = int(item["sort_order"])
row.updated_at = _utcnow()
session.add(row)
result[item["menu_key"]] = row
session.commit()
for key in list(result.keys()):
result[key] = session.exec(select(SysMenu).where(SysMenu.menu_key == key)).first()
return result
def _cleanup_legacy_auth_entries(session: Session) -> None:
seeded_menu_keys = {str(item["menu_key"]) for item in MENU_SEEDS}
seeded_permission_keys = {str(item["permission_key"]) for item in PERMISSION_SEEDS}
obsolete_permissions = session.exec(
select(SysPermission).where(
(~SysPermission.permission_key.in_(seeded_permission_keys)) | SysPermission.permission_key.in_(LEGACY_PERMISSION_KEYS)
)
).all()
permission_ids = [int(item.id) for item in obsolete_permissions if item.id is not None]
if permission_ids:
session.exec(delete(SysRolePermission).where(SysRolePermission.permission_id.in_(permission_ids)))
session.exec(delete(SysPermission).where(SysPermission.id.in_(permission_ids)))
session.commit()
obsolete_menus = session.exec(
select(SysMenu).where((~SysMenu.menu_key.in_(seeded_menu_keys)) | SysMenu.menu_key.in_(LEGACY_MENU_KEYS))
).all()
menu_ids = [int(item.id) for item in obsolete_menus if item.id is not None]
if menu_ids:
session.exec(delete(SysRoleMenu).where(SysRoleMenu.menu_id.in_(menu_ids)))
session.exec(delete(SysMenu).where(SysMenu.id.in_(menu_ids)))
session.commit()
def _seed_permissions(session: Session) -> Dict[str, SysPermission]:
result: Dict[str, SysPermission] = {}
for item in PERMISSION_SEEDS:
row = session.exec(select(SysPermission).where(SysPermission.permission_key == item["permission_key"])).first()
if row is None:
row = SysPermission(permission_key=item["permission_key"])
row.name = item["name"]
row.menu_key = item["menu_key"]
row.action = item["action"]
row.description = item["description"]
row.sort_order = int(item["sort_order"])
row.updated_at = _utcnow()
session.add(row)
result[item["permission_key"]] = row
session.commit()
for key in list(result.keys()):
result[key] = session.exec(select(SysPermission).where(SysPermission.permission_key == key)).first()
return result
def _ensure_role_menu(session: Session, role_id: int, menu_id: int) -> None:
exists = session.exec(
select(SysRoleMenu).where(SysRoleMenu.role_id == role_id, SysRoleMenu.menu_id == menu_id)
).first()
if exists is None:
session.add(SysRoleMenu(role_id=role_id, menu_id=menu_id))
def _ensure_role_permission(session: Session, role_id: int, permission_id: int) -> None:
exists = session.exec(
select(SysRolePermission).where(
SysRolePermission.role_id == role_id,
SysRolePermission.permission_id == permission_id,
)
).first()
if exists is None:
session.add(SysRolePermission(role_id=role_id, permission_id=permission_id))
def seed_sys_auth(session: Session) -> None:
_cleanup_legacy_auth_entries(session)
roles = _seed_roles(session)
menus = _seed_menus(session)
permissions = _seed_permissions(session)
admin_role = roles[SUPER_ADMIN_ROLE_KEY]
admin_has_menu_bindings = False
admin_has_permission_bindings = False
if admin_role.id is not None:
admin_has_menu_bindings = (
session.exec(select(SysRoleMenu).where(SysRoleMenu.role_id == admin_role.id)).first() is not None
)
admin_has_permission_bindings = (
session.exec(select(SysRolePermission).where(SysRolePermission.role_id == admin_role.id)).first() is not None
)
if admin_role.id is not None and not admin_has_menu_bindings:
for menu in menus.values():
if menu.id is not None:
_ensure_role_menu(session, admin_role.id, menu.id)
if admin_role.id is not None and not admin_has_permission_bindings:
for permission in permissions.values():
if permission.id is not None:
_ensure_role_permission(session, admin_role.id, permission.id)
normal_user_role = roles.get(NORMAL_USER_ROLE_KEY)
if normal_user_role is not None and normal_user_role.id is not None:
normal_user_menu_keys = {"general_chat", "profile_usage_logs", "profile_api_tokens"}
normal_user_permission_keys = {
"general.chat.view",
"profile.usage_logs.view",
"profile.api_tokens.view",
}
for menu_key in normal_user_menu_keys:
menu = menus.get(menu_key)
if menu is not None and menu.id is not None:
_ensure_role_menu(session, int(normal_user_role.id), int(menu.id))
for permission_key in normal_user_permission_keys:
permission = permissions.get(permission_key)
if permission is not None and permission.id is not None:
_ensure_role_permission(session, int(normal_user_role.id), int(permission.id))
session.commit()
user_count = session.exec(select(SysUser)).all()
if len(user_count) == 0:
salt = _new_salt()
session.add(
SysUser(
username=DEFAULT_ADMIN_USERNAME,
display_name="Administrator",
password_salt=salt,
password_hash=hash_password(DEFAULT_ADMIN_PASSWORD, salt),
role_id=admin_role.id,
is_active=True,
created_at=_utcnow(),
updated_at=_utcnow(),
)
)
session.commit()
def _normalize_bot_ids(bot_ids: List[str] | None) -> List[str]:
normalized: List[str] = []
for item in list(bot_ids or []):
bot_id = str(item or "").strip()
if bot_id and bot_id not in normalized:
normalized.append(bot_id)
return normalized
def _invalidate_user_bot_access_cache(user_id: int) -> None:
normalized_user_id = int(user_id or 0)
if normalized_user_id <= 0:
return
cache.delete(f"bots:list:user:{normalized_user_id}")
def _sync_user_bot_bindings(session: Session, *, user_id: int, bot_ids: List[str]) -> None:
session.exec(delete(SysUserBot).where(SysUserBot.user_id == user_id))
normalized_bot_ids = _normalize_bot_ids(bot_ids)
if normalized_bot_ids:
rows = session.exec(select(BotInstance.id).where(BotInstance.id.in_(normalized_bot_ids))).all()
existing_bot_ids = {str(item or "").strip() for item in rows}
missing = [bot_id for bot_id in normalized_bot_ids if bot_id not in existing_bot_ids]
if missing:
raise ValueError(f"Bots not found: {', '.join(missing[:5])}")
for bot_id in normalized_bot_ids:
session.add(SysUserBot(user_id=user_id, bot_id=bot_id))
session.commit()
_invalidate_user_bot_access_cache(user_id)
def _list_user_bot_ids(session: Session, user_id: int) -> List[str]:
rows = session.exec(
select(SysUserBot.bot_id).where(SysUserBot.user_id == user_id).order_by(SysUserBot.bot_id.asc())
).all()
return [str(item or "").strip() for item in rows if str(item or "").strip()]
def _list_user_assigned_bots(session: Session, user_id: int) -> List[Dict[str, Any]]:
bound_bot_ids = _list_user_bot_ids(session, user_id)
if not bound_bot_ids:
return []
bots = session.exec(select(BotInstance).where(BotInstance.id.in_(bound_bot_ids))).all()
bot_map = {str(bot.id or "").strip(): bot for bot in bots if str(bot.id or "").strip()}
items: List[Dict[str, Any]] = []
for bot_id in bound_bot_ids:
bot = bot_map.get(bot_id)
if bot is None:
continue
items.append(
{
"id": bot_id,
"name": str(bot.name or bot_id),
"enabled": bool(getattr(bot, "enabled", True)),
"node_id": str(getattr(bot, "node_id", "") or ""),
"node_display_name": str(getattr(bot, "node_id", "") or ""),
"docker_status": str(getattr(bot, "docker_status", "STOPPED") or "STOPPED"),
"image_tag": str(getattr(bot, "image_tag", "") or ""),
}
)
return items
def _is_super_admin(role: Optional[SysRole]) -> bool:
return str(getattr(role, "role_key", "") or "") == SUPER_ADMIN_ROLE_KEY
def find_user_by_username(session: Session, username: str) -> Optional[SysUser]:
normalized = str(username or "").strip().lower()
if not normalized:
return None
return session.exec(select(SysUser).where(SysUser.username == normalized)).first()
def authenticate_user(session: Session, username: str, password: str) -> Optional[SysUser]:
user = find_user_by_username(session, username)
if user is None or not bool(user.is_active):
return None
stored_hash = str(user.password_hash or "")
stored_salt = str(user.password_salt or "")
if not verify_password(password, stored_salt, stored_hash):
return None
if stored_hash and not stored_hash.startswith("$2"):
user.password_hash = hash_password(password, "")
user.password_salt = ""
user.updated_at = _utcnow()
session.add(user)
session.commit()
session.refresh(user)
return user
def issue_user_token(session: Session, user: SysUser) -> tuple[str, datetime]:
_ensure_auth_cache_ready()
from services.platform_settings_service import get_sys_auth_token_ttl_days
ttl_days = get_sys_auth_token_ttl_days(session)
expires_at = _utcnow() + timedelta(days=ttl_days)
jti = uuid.uuid4().hex
token = jwt.encode(_session_payload(user, jti=jti, expires_at=expires_at), JWT_SECRET, algorithm=JWT_ALGORITHM)
cache.set_json(
_auth_token_cache_key(jti),
{
"user_id": int(user.id or 0),
"username": str(user.username or ""),
"token_hash": hash_token(token),
},
ttl=max(1, ttl_days * 24 * 60 * 60),
)
user.current_token_hash = None
user.current_token_expires_at = None
user.last_login_at = _utcnow()
user.updated_at = _utcnow()
session.add(user)
session.commit()
session.refresh(user)
return token, expires_at
def revoke_user_token(token: str) -> None:
try:
payload = jwt.decode(str(token or "").strip(), JWT_SECRET, algorithms=[JWT_ALGORITHM])
except Exception:
return
jti = str(payload.get("jti") or "").strip()
if not jti:
return
cache.delete(_auth_token_cache_key(jti))
def resolve_user_by_token(session: Session, token: str) -> Optional[SysUser]:
candidate = str(token or "").strip()
if not candidate:
return None
try:
payload = jwt.decode(candidate, JWT_SECRET, algorithms=[JWT_ALGORITHM])
except Exception:
return None
user_id = int(payload.get("sub") or 0)
jti = str(payload.get("jti") or "").strip()
if user_id <= 0 or not jti:
return None
cached = cache.get_json(_auth_token_cache_key(jti))
if not isinstance(cached, dict):
return None
if int(cached.get("user_id") or 0) != user_id:
return None
if str(cached.get("token_hash") or "").strip() != hash_token(candidate):
return None
user = session.get(SysUser, user_id)
if user is None or not bool(user.is_active):
return None
return user
def _role_payload(role: Optional[SysRole]) -> Optional[Dict[str, Any]]:
if role is None:
return None
return {
"id": int(role.id or 0),
"role_key": role.role_key,
"name": role.name,
}
def _list_role_permissions(session: Session, role_id: Optional[int]) -> List[str]:
if not role_id:
return []
rows = session.exec(
select(SysPermission)
.join(SysRolePermission, SysRolePermission.permission_id == SysPermission.id)
.where(SysRolePermission.role_id == role_id)
.order_by(SysPermission.sort_order.asc(), SysPermission.permission_key.asc())
).all()
return [str(row.permission_key or "").strip() for row in rows if str(row.permission_key or "").strip()]
def _list_role_menus(session: Session, role_id: Optional[int]) -> List[SysMenu]:
if not role_id:
return []
explicit_menus = session.exec(
select(SysMenu)
.join(SysRoleMenu, SysRoleMenu.menu_id == SysMenu.id)
.where(SysRoleMenu.role_id == role_id, SysMenu.visible == True)
.order_by(SysMenu.sort_order.asc(), SysMenu.id.asc())
).all()
permission_bound_menus = session.exec(
select(SysMenu)
.join(SysPermission, SysPermission.menu_key == SysMenu.menu_key)
.join(SysRolePermission, SysRolePermission.permission_id == SysPermission.id)
.where(SysRolePermission.role_id == role_id, SysMenu.visible == True)
.order_by(SysMenu.sort_order.asc(), SysMenu.id.asc())
).all()
menu_map: Dict[str, SysMenu] = {}
for row in [*explicit_menus, *permission_bound_menus]:
key = str(row.menu_key or "").strip()
if key:
menu_map[key] = row
if not menu_map:
return []
all_visible_menus = session.exec(select(SysMenu).where(SysMenu.visible == True)).all()
all_menu_map = {str(row.menu_key or "").strip(): row for row in all_visible_menus if str(row.menu_key or "").strip()}
pending_parent_keys = [str(row.parent_key or "").strip() for row in menu_map.values() if str(row.parent_key or "").strip()]
while pending_parent_keys:
parent_key = pending_parent_keys.pop()
if not parent_key or parent_key in menu_map:
continue
parent = all_menu_map.get(parent_key)
if parent is None:
continue
menu_map[parent_key] = parent
next_parent_key = str(parent.parent_key or "").strip()
if next_parent_key and next_parent_key not in menu_map:
pending_parent_keys.append(next_parent_key)
return sorted(menu_map.values(), key=lambda row: (int(row.sort_order or 100), int(row.id or 0)))
def _build_menu_tree(rows: List[SysMenu]) -> List[Dict[str, Any]]:
menu_map: Dict[str, Dict[str, Any]] = {}
roots: List[Dict[str, Any]] = []
for row in rows:
menu_key = str(row.menu_key or "").strip()
if not menu_key:
continue
menu_map[menu_key] = {
"menu_key": menu_key,
"parent_key": str(row.parent_key or "").strip(),
"title": row.title,
"title_en": row.title_en,
"menu_type": row.menu_type,
"route_path": row.route_path,
"icon": row.icon,
"permission_key": row.permission_key,
"sort_order": int(row.sort_order or 100),
"children": [],
}
for item in sorted(menu_map.values(), key=lambda value: (value["sort_order"], value["menu_key"])):
parent_key = str(item["parent_key"] or "").strip()
if parent_key and parent_key in menu_map:
menu_map[parent_key]["children"].append(item)
else:
roots.append(item)
return roots
def _normalize_role_key(value: str) -> str:
return str(value or "").strip().lower().replace(" ", "_")
def _normalize_username(value: str) -> str:
return str(value or "").strip().lower()
def _sync_role_bindings(
session: Session,
role: SysRole,
*,
menu_keys: List[str],
permission_keys: List[str],
) -> None:
role_id = int(role.id or 0)
if role_id <= 0:
raise ValueError("Role id is required")
normalized_menu_keys = sorted({str(key or "").strip() for key in menu_keys if str(key or "").strip()})
normalized_permission_keys = sorted({str(key or "").strip() for key in permission_keys if str(key or "").strip()})
session.exec(delete(SysRoleMenu).where(SysRoleMenu.role_id == role_id))
session.exec(delete(SysRolePermission).where(SysRolePermission.role_id == role_id))
session.commit()
if normalized_menu_keys:
menus = session.exec(select(SysMenu).where(SysMenu.menu_key.in_(normalized_menu_keys))).all()
menu_map = {str(row.menu_key or "").strip(): row for row in menus}
for menu_key in normalized_menu_keys:
row = menu_map.get(menu_key)
if row is not None and row.id is not None:
session.add(SysRoleMenu(role_id=role_id, menu_id=int(row.id)))
if normalized_permission_keys:
permissions = session.exec(
select(SysPermission).where(SysPermission.permission_key.in_(normalized_permission_keys))
).all()
permission_map = {str(row.permission_key or "").strip(): row for row in permissions}
for permission_key in normalized_permission_keys:
row = permission_map.get(permission_key)
if row is not None and row.id is not None:
session.add(SysRolePermission(role_id=role_id, permission_id=int(row.id)))
session.commit()
def list_sys_users(session: Session) -> List[Dict[str, Any]]:
roles = session.exec(select(SysRole)).all()
role_map = {int(role.id or 0): role for role in roles if role.id is not None}
users = session.exec(select(SysUser).order_by(SysUser.updated_at.desc(), SysUser.id.desc())).all()
binding_rows = session.exec(select(SysUserBot)).all()
bot_ids_map: Dict[int, List[str]] = {}
for row in binding_rows:
user_id = int(row.user_id or 0)
bot_id = str(row.bot_id or "").strip()
if user_id <= 0 or not bot_id:
continue
bot_ids_map.setdefault(user_id, []).append(bot_id)
result: List[Dict[str, Any]] = []
for user in users:
role = role_map.get(int(user.role_id or 0))
result.append(
{
"id": int(user.id or 0),
"username": str(user.username or ""),
"display_name": str(user.display_name or user.username or ""),
"is_active": bool(user.is_active),
"last_login_at": _dt_to_iso(user.last_login_at),
"role": _role_payload(role),
"bot_ids": sorted(bot_ids_map.get(int(user.id or 0), [])),
}
)
return result
def create_sys_user(
session: Session,
*,
username: str,
display_name: str,
password: str,
role_id: int,
is_active: bool,
bot_ids: Optional[List[str]] = None,
) -> Dict[str, Any]:
normalized_username = _normalize_username(username)
if not normalized_username:
raise ValueError("Username is required")
if find_user_by_username(session, normalized_username) is not None:
raise ValueError("Username already exists")
if len(str(password or "")) < 6:
raise ValueError("Password must be at least 6 characters")
role = session.get(SysRole, role_id)
if role is None:
raise ValueError("Role not found")
salt = _new_salt()
row = SysUser(
username=normalized_username,
display_name=str(display_name or "").strip() or normalized_username,
password_salt=salt,
password_hash=hash_password(password, salt),
role_id=int(role.id or 0),
is_active=bool(is_active),
created_at=_utcnow(),
updated_at=_utcnow(),
)
session.add(row)
session.commit()
session.refresh(row)
_sync_user_bot_bindings(session, user_id=int(row.id or 0), bot_ids=list(bot_ids or []))
return {
"id": int(row.id or 0),
"username": str(row.username or ""),
"display_name": str(row.display_name or row.username or ""),
"is_active": bool(row.is_active),
"last_login_at": _dt_to_iso(row.last_login_at),
"role": _role_payload(role),
"bot_ids": _list_user_bot_ids(session, int(row.id or 0)),
}
def update_sys_user(
session: Session,
*,
user_id: int,
display_name: str,
password: str,
role_id: int,
is_active: bool,
bot_ids: Optional[List[str]] = None,
acting_user_id: int = 0,
) -> Dict[str, Any]:
row = session.get(SysUser, user_id)
if row is None:
raise ValueError("User not found")
if acting_user_id > 0 and int(row.id or 0) == acting_user_id and not is_active:
raise ValueError("Current user cannot be disabled")
role = session.get(SysRole, role_id)
if role is None:
raise ValueError("Role not found")
row.display_name = str(display_name or "").strip() or str(row.username or "")
row.role_id = int(role.id or 0)
row.is_active = bool(is_active)
if str(password or "").strip():
if len(str(password or "")) < 6:
raise ValueError("Password must be at least 6 characters")
row.password_hash = hash_password(password, "")
row.password_salt = ""
row.updated_at = _utcnow()
session.add(row)
session.commit()
_invalidate_user_bot_access_cache(int(row.id or 0))
_sync_user_bot_bindings(session, user_id=int(row.id or 0), bot_ids=list(bot_ids or []))
session.refresh(row)
return {
"id": int(row.id or 0),
"username": str(row.username or ""),
"display_name": str(row.display_name or row.username or ""),
"is_active": bool(row.is_active),
"last_login_at": _dt_to_iso(row.last_login_at),
"role": _role_payload(role),
"bot_ids": _list_user_bot_ids(session, int(row.id or 0)),
}
def update_current_sys_user_profile(
session: Session,
*,
user_id: int,
display_name: str,
password: str,
) -> SysUser:
row = session.get(SysUser, user_id)
if row is None:
raise ValueError("User not found")
row.display_name = str(display_name or "").strip() or str(row.username or "")
if str(password or "").strip():
if len(str(password or "")) < 6:
raise ValueError("Password must be at least 6 characters")
row.password_hash = hash_password(password, "")
row.password_salt = ""
row.updated_at = _utcnow()
session.add(row)
session.commit()
session.refresh(row)
return row
def delete_sys_user(session: Session, *, user_id: int, acting_user_id: int = 0) -> None:
row = session.get(SysUser, user_id)
if row is None:
return
if acting_user_id > 0 and int(row.id or 0) == acting_user_id:
raise ValueError("Current user cannot be deleted")
session.exec(delete(SysUserBot).where(SysUserBot.user_id == user_id))
session.commit()
_invalidate_user_bot_access_cache(user_id)
session.delete(row)
session.commit()
def list_sys_roles(session: Session) -> List[Dict[str, Any]]:
roles = session.exec(select(SysRole).order_by(SysRole.sort_order.asc(), SysRole.id.asc())).all()
users = session.exec(select(SysUser)).all()
user_count_map: Dict[int, int] = {}
for user in users:
role_id = int(user.role_id or 0)
if role_id > 0:
user_count_map[role_id] = user_count_map.get(role_id, 0) + 1
result: List[Dict[str, Any]] = []
for role in roles:
role_id = int(role.id or 0)
result.append(
{
"id": role_id,
"role_key": str(role.role_key or ""),
"name": str(role.name or ""),
"description": str(role.description or ""),
"is_active": bool(role.is_active),
"sort_order": int(role.sort_order or 100),
"user_count": user_count_map.get(role_id, 0),
"menu_keys": [str(key) for key in _list_role_menu_keys(session, role_id)],
"permission_keys": [str(key) for key in _list_role_permissions(session, role_id)],
}
)
return result
def _list_role_menu_keys(session: Session, role_id: Optional[int]) -> List[str]:
rows = _list_role_menus(session, role_id)
return [str(row.menu_key or "").strip() for row in rows if str(row.menu_key or "").strip()]
def list_role_grant_bootstrap(session: Session) -> Dict[str, Any]:
menus = session.exec(
select(SysMenu)
.where(SysMenu.visible == True)
.order_by(SysMenu.sort_order.asc(), SysMenu.id.asc())
).all()
permissions = session.exec(
select(SysPermission).order_by(SysPermission.sort_order.asc(), SysPermission.permission_key.asc())
).all()
menu_rows = [
{
"menu_key": str(row.menu_key or ""),
"parent_key": str(row.parent_key or ""),
"title": str(row.title or ""),
"title_en": str(row.title_en or ""),
"menu_type": str(row.menu_type or "item"),
"route_path": str(row.route_path or ""),
"icon": str(row.icon or ""),
"sort_order": int(row.sort_order or 100),
"children": [],
}
for row in menus
]
return {
"menus": _build_menu_tree_from_dict(menu_rows),
"permissions": [
{
"id": int(row.id or 0),
"permission_key": str(row.permission_key or ""),
"name": str(row.name or ""),
"menu_key": str(row.menu_key or ""),
"action": str(row.action or "view"),
"description": str(row.description or ""),
"sort_order": int(row.sort_order or 100),
}
for row in permissions
],
}
def _build_menu_tree_from_dict(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
menu_map = {str(item["menu_key"]): {**item, "children": []} for item in rows if str(item.get("menu_key") or "").strip()}
roots: List[Dict[str, Any]] = []
for item in sorted(menu_map.values(), key=lambda value: (int(value.get("sort_order") or 100), str(value.get("menu_key") or ""))):
parent_key = str(item.get("parent_key") or "").strip()
if parent_key and parent_key in menu_map:
menu_map[parent_key]["children"].append(item)
else:
roots.append(item)
return roots
def create_sys_role(
session: Session,
*,
role_key: str,
name: str,
description: str,
is_active: bool,
sort_order: int,
menu_keys: List[str],
permission_keys: List[str],
) -> Dict[str, Any]:
normalized_role_key = _normalize_role_key(role_key)
if not normalized_role_key:
raise ValueError("Role key is required")
exists = session.exec(select(SysRole).where(SysRole.role_key == normalized_role_key)).first()
if exists is not None:
raise ValueError("Role key already exists")
row = SysRole(
role_key=normalized_role_key,
name=str(name or "").strip() or normalized_role_key,
description=str(description or "").strip(),
is_active=bool(is_active),
sort_order=int(sort_order or 100),
created_at=_utcnow(),
updated_at=_utcnow(),
)
session.add(row)
session.commit()
session.refresh(row)
_sync_role_bindings(session, row, menu_keys=menu_keys, permission_keys=permission_keys)
return get_sys_role_detail(session, int(row.id or 0))
def update_sys_role(
session: Session,
*,
role_id: int,
name: str,
description: str,
is_active: bool,
sort_order: int,
menu_keys: List[str],
permission_keys: List[str],
) -> Dict[str, Any]:
row = session.get(SysRole, role_id)
if row is None:
raise ValueError("Role not found")
if str(row.role_key or "") == "super_admin" and not bool(is_active):
raise ValueError("Super Admin cannot be disabled")
row.name = str(name or "").strip() or str(row.role_key or "")
row.description = str(description or "").strip()
row.is_active = bool(is_active)
row.sort_order = int(sort_order or 100)
row.updated_at = _utcnow()
session.add(row)
session.commit()
session.refresh(row)
_sync_role_bindings(session, row, menu_keys=menu_keys, permission_keys=permission_keys)
return get_sys_role_detail(session, role_id)
def get_sys_role_detail(session: Session, role_id: int) -> Dict[str, Any]:
role = session.get(SysRole, role_id)
if role is None:
raise ValueError("Role not found")
users = session.exec(select(SysUser).where(SysUser.role_id == role_id)).all()
return {
"id": int(role.id or 0),
"role_key": str(role.role_key or ""),
"name": str(role.name or ""),
"description": str(role.description or ""),
"is_active": bool(role.is_active),
"sort_order": int(role.sort_order or 100),
"user_count": len(users),
"menu_keys": _list_role_menu_keys(session, role_id),
"permission_keys": _list_role_permissions(session, role_id),
}
def delete_sys_role(session: Session, *, role_id: int) -> None:
row = session.get(SysRole, role_id)
if row is None:
return
if str(row.role_key or "") == SUPER_ADMIN_ROLE_KEY:
raise ValueError("Super Admin cannot be deleted")
bound_users = session.exec(select(SysUser).where(SysUser.role_id == role_id)).all()
if bound_users:
raise ValueError("Role is still assigned to users")
session.exec(delete(SysRoleMenu).where(SysRoleMenu.role_id == role_id))
session.exec(delete(SysRolePermission).where(SysRolePermission.role_id == role_id))
session.commit()
session.delete(row)
session.commit()
def build_user_bootstrap(session: Session, user: SysUser, *, token: str = "", expires_at: Optional[datetime] = None) -> Dict[str, Any]:
role = session.get(SysRole, user.role_id) if user.role_id else None
permissions = _list_role_permissions(session, user.role_id)
menus = _build_menu_tree(_list_role_menus(session, user.role_id))
assigned_bots = [] if _is_super_admin(role) else _list_user_assigned_bots(session, int(user.id or 0))
home_path = "/dashboard"
for group in menus:
children = list(group.get("children") or [])
if children:
home_path = str(children[0].get("route_path") or "/dashboard")
break
return {
"token": token,
"expires_at": _dt_to_iso(expires_at),
"user": {
"id": int(user.id or 0),
"username": str(user.username or ""),
"display_name": str(user.display_name or user.username or ""),
"role": _role_payload(role),
},
"menus": menus,
"permissions": permissions,
"home_path": home_path,
"assigned_bots": assigned_bots,
}
def list_accessible_bots_for_user(session: Session, user: SysUser) -> List[BotInstance]:
role = session.get(SysRole, user.role_id) if user.role_id else None
if _is_super_admin(role):
return session.exec(select(BotInstance).order_by(BotInstance.updated_at.desc(), BotInstance.id.asc())).all()
bot_ids = _list_user_bot_ids(session, int(user.id or 0))
if not bot_ids:
return []
bots = session.exec(select(BotInstance).where(BotInstance.id.in_(bot_ids))).all()
bot_map = {str(bot.id or "").strip(): bot for bot in bots if str(bot.id or "").strip()}
return [bot_map[bot_id] for bot_id in bot_ids if bot_id in bot_map]
def user_can_access_bot(session: Session, user: SysUser, bot_id: str) -> bool:
normalized_bot_id = str(bot_id or "").strip()
if not normalized_bot_id:
return False
role = session.get(SysRole, user.role_id) if user.role_id else None
if _is_super_admin(role):
return True
row = session.exec(
select(SysUserBot).where(SysUserBot.user_id == int(user.id or 0), SysUserBot.bot_id == normalized_bot_id)
).first()
return row is not None