diff --git a/core/models.py b/core/models.py index d3d793d..c0bcce5 100644 --- a/core/models.py +++ b/core/models.py @@ -45,11 +45,35 @@ def _sqlite_pragma(dbapi_connection, connection_record): class User(UserMixin, db.Model): - """后台用户。""" + """后台用户。 + + 权限模型: + - is_admin=True:管理员,拥有全部权限,不受 perms 限制 + - is_admin=False:按 perms 授权(JSON 数组,如 ["tasks","devices"]) + - 权限位:tasks=任务管理, devices=设备控制, apks=应用管理, logs=日志查看 + - 用户管理本身只有管理员能做(web_server 层强制),不设普通权限位 + """ id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) password_hash = db.Column(db.String(255), nullable=False) is_admin = db.Column(db.Boolean, default=True) + perms = db.Column(db.Text, default="[]") # JSON 数组:业务权限位 + + def get_perms(self): + try: + return json.loads(self.perms or "[]") + except Exception: + return [] + + def set_perms(self, lst): + """设置权限位。管理员强制为全部权限(内部不区分)。""" + self.perms = json.dumps(list(lst or []), ensure_ascii=False) + + def has_perm(self, perm): + """是否拥有指定权限。管理员恒为 True。""" + if self.is_admin: + return True + return perm in self.get_perms() def set_password(self, password): self.password_hash = generate_password_hash(password) @@ -195,7 +219,8 @@ class ApkFile(db.Model): # 版本化 schema 迁移:新增结构变更时在此追加 (版本号, 说明, SQL) # 版本号单调递增,只执行比当前 schema_version 新的迁移。 SCHEMA_MIGRATIONS = [ - # (1, "初始 schema(由 create_all 建立,版本标记从 1 开始)", None), + (1, "用户权限位:user 表新增 perms 列(JSON 数组,默认空=无业务权限,管理员不受限)", + "ALTER TABLE user ADD COLUMN perms TEXT DEFAULT '[]'"), ] diff --git a/static/admin/monitor.js b/static/admin/monitor.js index b85a8c4..2b8398a 100644 --- a/static/admin/monitor.js +++ b/static/admin/monitor.js @@ -20,6 +20,29 @@ function _csrfHeaders(){ return _csrfToken?{'X-CSRF-Token':_csrfToken}:{}; } +// ===== 当前用户与权限 ===== +// _me 来自 /api/me:{username, is_admin, perms:[tasks/devices/apks/logs]} +// 管理员 perms 恒为全部。无权限的功能入口(tab/按钮)用 data-perm 标记或 _can() 判断隐藏。 +let _me={is_admin:false,perms:[],username:''}; +async function loadMe(){ + try{ + const r=await apiGet('/api/me'); + if(r&&r.ok)_me=r.user||{is_admin:false,perms:[],username:''}; + const un=document.getElementById('me-username'); + if(un)un.textContent=_me.username||''; + // 隐藏无权限的 tab 和按钮(data-perm="admin" 表示仅管理员) + document.querySelectorAll('[data-perm]').forEach(function(el){ + const p=el.dataset.perm; + const ok=(p==='admin')?_me.is_admin:(_me.perms||[]).includes(p); + if(!ok)el.style.display='none'; + }); + }catch(e){} +} +function _can(perm){ + return _me.is_admin||(_me.perms||[]).includes(perm); +} +const PERM_LABELS={tasks:'任务管理',devices:'设备控制',apks:'应用管理',logs:'日志查看'}; + async function apiGet(url){ const r=await fetch(url); if(r.status===401){window.location='/login';return null;} @@ -1892,8 +1915,10 @@ function renderActionRow(a){ ''+n+''+ ''+esc(a.created_at||'-')+''+ ''+ - ' '+ - ''+ + (_can('tasks')?( + ' '+ + '' + ):'只读')+ ''+ ''; } @@ -1976,8 +2001,10 @@ function renderGroupRow(g){ ''+serials.length+''+ ''+(serials.length?serials.map(esc).join(', '):'-')+''+ ''+ - ''+ - ''+ + (_can('tasks')?( + ''+ + '' + ):'只读')+ ''+ ''; } @@ -2086,13 +2113,29 @@ function renderUserRow(u){ ''; } +function togglePermBoxes(){ + // 勾选"管理员权限"时:权限位全选并禁用(管理员拥有全部权限) + const isAdmin=document.getElementById('u-is_admin').checked; + document.querySelectorAll('.u-perm').forEach(cb=>{ + cb.disabled=isAdmin; + if(isAdmin)cb.checked=true; + }); +} + function openUserModal(uid){ const isEdit=!!uid; document.getElementById('modal-title').textContent=isEdit?'编辑用户':'新建用户'; + const permBoxes=Object.keys(PERM_LABELS).map(p=> + '' + ).join(''); document.getElementById('modal-body').innerHTML= '
'+ '
'+ - '
'; + '
'+ + '
'+ + '
'+permBoxes+'
'+ + '
任务管理=任务/自定义动作/分组;设备控制=停止/释放/清异常/扫描/抓元素;应用管理=APK上传安装;日志查看=日志页
'+ + '
'; document.getElementById('modal-footer').innerHTML= ''+ @@ -2100,13 +2143,19 @@ function openUserModal(uid){ if(isEdit){ // 预填充(从表格数据获取) - fetch('/api/users').then(r=>r.json()).then(r=>{ - const u=r.users.find(x=>x.id===uid); + apiGet('/api/users').then(r=>{ + const u=(r&&r.users||[]).find(x=>x.id===uid); if(u){ document.getElementById('u-username').value=u.username; document.getElementById('u-is_admin').checked=u.is_admin; + document.querySelectorAll('.u-perm').forEach(cb=>{ + cb.checked=(u.perms||[]).includes(cb.value); + }); + togglePermBoxes(); } }); + }else{ + togglePermBoxes(); } showModal(); } @@ -2115,15 +2164,16 @@ async function saveUser(uid){ const username=document.getElementById('u-username').value.trim(); const password=document.getElementById('u-password').value; const is_admin=document.getElementById('u-is_admin').checked; + const perms=[...document.querySelectorAll('.u-perm:checked')].map(cb=>cb.value); if(uid){ - const data={is_admin:is_admin}; + const data={is_admin:is_admin,perms:perms}; if(password)data.password=password; const r=await apiPut('/api/users/'+uid,data); handleResult(r,()=>{closeModal();loadUsers();}); }else{ if(!username||!password){showToast('用户名和密码不能为空','error');return;} - const r=await apiPost('/api/users',{username:username,password:password,is_admin:is_admin}); + const r=await apiPost('/api/users',{username:username,password:password,is_admin:is_admin,perms:perms}); handleResult(r,()=>{closeModal();loadUsers();}); } } @@ -2520,4 +2570,5 @@ async function scanForeground(){ // ================== 初始化 ================== initCsrf(); // 获取 CSRF token(非 GET 请求需携带) +loadMe(); // 获取当前用户权限,隐藏无权限的功能入口 showTab('monitor'); diff --git a/templates/admin/monitor.html b/templates/admin/monitor.html index c22121e..50cde20 100644 --- a/templates/admin/monitor.html +++ b/templates/admin/monitor.html @@ -354,7 +354,7 @@ select.form-control{cursor:pointer}
自定义动作管理
把常用步骤打包成可复用的动作,拖入任务步骤编辑器使用;也可在任务编辑器里"☑选中步骤→打包"生成
- +
@@ -418,7 +418,7 @@ select.form-control{cursor:pointer}
设备分组管理
管理设备分组,用于任务按分组执行
- +
diff --git a/web_server.py b/web_server.py index 6d8e1c6..fbb51de 100644 --- a/web_server.py +++ b/web_server.py @@ -93,6 +93,77 @@ def api_csrf(): return jsonify({"ok": True, "token": _csrf_token()}) +# ================== 权限控制 ================== +# 业务权限位(is_admin 管理员拥有全部权限,不受 perms 限制): +# tasks — 任务管理:任务/自定义动作/分组的创建、修改、删除、启停、执行 +# devices — 设备控制:停止设备、释放占用、清除异常、前台扫描、元素抓取 +# apks — 应用管理:APK 上传、安装、删除 +# logs — 日志查看 +# 查看类接口(GET 状态/列表)所有登录用户可用;用户管理仅管理员可用(admin_required)。 +PERM_TASKS = "tasks" +PERM_DEVICES = "devices" +PERM_APKS = "apks" +PERM_LOGS = "logs" +ALL_PERMS = (PERM_TASKS, PERM_DEVICES, PERM_APKS, PERM_LOGS) +_PERM_LABELS = {PERM_TASKS: "任务管理", PERM_DEVICES: "设备控制", + PERM_APKS: "应用管理", PERM_LOGS: "日志查看"} + + +def _has_perm(perm): + """当前用户是否拥有指定权限。管理员恒为 True。""" + u = current_user + return bool(u and (u.is_admin or u.has_perm(perm))) + + +def _validate_perms(raw): + """校验权限位列表:只保留合法值、去重。返回合法列表。""" + valid = set(ALL_PERMS) + out = [] + for p in raw or []: + if p in valid and p not in out: + out.append(p) + return out + + +def perm_required(perm): + """路由装饰器:要求登录且拥有指定业务权限,否则 403。""" + def deco(fn): + @functools.wraps(fn) + @login_required + def wrapper(*args, **kwargs): + if not _has_perm(perm): + return jsonify({"ok": False, + "error": f"无权限执行此操作(需要权限: {_PERM_LABELS.get(perm, perm)})"}), 403 + return fn(*args, **kwargs) + return wrapper + return deco + + +def admin_required(fn): + """路由装饰器:仅管理员可用(用户管理类接口),否则 403。""" + @functools.wraps(fn) + @login_required + def wrapper(*args, **kwargs): + if not current_user.is_admin: + return jsonify({"ok": False, "error": "仅管理员可执行此操作"}), 403 + return fn(*args, **kwargs) + return wrapper + + +@app.route("/api/me") +@login_required +def api_me(): + """当前登录用户信息(含权限位),前端据此隐藏无权限的功能入口。""" + u = current_user + return jsonify({"ok": True, "user": { + "id": u.id, + "username": u.username, + "is_admin": bool(u.is_admin), + # 管理员返回全部权限位,前端统一用 perms 判断 + "perms": list(ALL_PERMS) if u.is_admin else u.get_perms(), + }}) + + # ================== 页面路由 ================== @app.route("/") @login_required @@ -209,7 +280,7 @@ def api_status(): @app.route("/api/scan_foreground", methods=["POST"]) -@login_required +@perm_required(PERM_DEVICES) def api_scan_foreground(): """手动触发前台 App 扫描(不打扰设备)。""" started = mgr._fg_scanner.scan_once() @@ -315,13 +386,13 @@ def api_jobs_delete(job_id): @app.route("/api/jobs//run", methods=["POST"]) -@login_required +@perm_required(PERM_TASKS) def api_jobs_run(job_id): return jsonify(mgr.run_job_now(job_id)) @app.route("/api/jobs//toggle", methods=["POST"]) -@login_required +@perm_required(PERM_TASKS) def api_jobs_toggle(job_id): enabled = (request.json or {}).get("enabled", True) job = mgr.toggle_job(job_id, enabled) @@ -338,7 +409,7 @@ def api_groups_list(): @app.route("/api/groups", methods=["POST"]) -@login_required +@perm_required(PERM_TASKS) def api_groups_create(): data = request.json or {} name = (data.get("name") or "").strip() @@ -351,7 +422,7 @@ def api_groups_create(): @app.route("/api/groups/", methods=["PUT"]) -@login_required +@perm_required(PERM_TASKS) def api_groups_update(name): if name not in mgr.groups: return jsonify({"ok": False, "error": "分组不存在"}), 404 @@ -372,14 +443,15 @@ def api_groups_delete(name): # ================== API:用户管理 CRUD ================== @app.route("/api/users") -@login_required +@admin_required def api_users_list(): - users = [{"id": u.id, "username": u.username, "is_admin": u.is_admin} for u in User.query.all()] + users = [{"id": u.id, "username": u.username, + "is_admin": u.is_admin, "perms": u.get_perms()} for u in User.query.all()] return jsonify({"ok": True, "users": users}) @app.route("/api/users", methods=["POST"]) -@login_required +@admin_required def api_users_create(): data = request.json or {} username = (data.get("username") or "").strip() @@ -388,16 +460,18 @@ def api_users_create(): return jsonify({"ok": False, "error": "用户名和密码不能为空"}), 400 if User.query.filter_by(username=username).first(): return jsonify({"ok": False, "error": "用户名已存在"}), 400 - u = User(username=username, is_admin=data.get("is_admin", True)) + # 默认普通用户(最小权限);管理员由 is_admin 决定,权限位照常保存(降级后生效) + u = User(username=username, is_admin=bool(data.get("is_admin", False))) + u.set_perms(_validate_perms(data.get("perms") or [])) u.set_password(password) db.session.add(u) db.session.commit() - _log.info(f"创建用户 {username}") + _log.info(f"创建用户 {username} (admin={u.is_admin}, perms={u.get_perms()})") return jsonify({"ok": True, "msg": "用户已创建"}) @app.route("/api/users/", methods=["PUT"]) -@login_required +@admin_required def api_users_update(uid): u = User.query.get(uid) if not u: @@ -406,14 +480,19 @@ def api_users_update(uid): if "password" in data and data["password"]: u.set_password(data["password"]) if "is_admin" in data: - u.is_admin = bool(data["is_admin"]) + new_admin = bool(data["is_admin"]) + if u.is_admin and not new_admin and User.query.filter_by(is_admin=True).count() <= 1: + return jsonify({"ok": False, "error": "不能取消最后一个管理员"}), 400 + u.is_admin = new_admin + if "perms" in data: + u.set_perms(_validate_perms(data["perms"])) db.session.commit() - _log.info(f"更新用户 {u.username}") + _log.info(f"更新用户 {u.username} (admin={u.is_admin}, perms={u.get_perms()})") return jsonify({"ok": True, "msg": "用户已更新"}) @app.route("/api/users/", methods=["DELETE"]) -@login_required +@admin_required def api_users_delete(uid): u = User.query.get(uid) if not u: @@ -422,6 +501,8 @@ def api_users_delete(uid): return jsonify({"ok": False, "error": "不能删除默认管理员"}), 400 if u.id == current_user.id: return jsonify({"ok": False, "error": "不能删除当前登录用户"}), 400 + if u.is_admin and User.query.filter_by(is_admin=True).count() <= 1: + return jsonify({"ok": False, "error": "不能删除最后一个管理员"}), 400 db.session.delete(u) db.session.commit() _log.info(f"删除用户 {u.username}") @@ -430,7 +511,7 @@ def api_users_delete(uid): # ================== API:日志查看 ================== @app.route("/api/logs") -@login_required +@perm_required(PERM_LOGS) def api_logs(): files = list(_MODULE_FILES.values()) current = request.args.get("file", "core.log") @@ -448,7 +529,7 @@ def api_logs(): # ================== API:运行控制 ================== @app.route("/api/stop_device", methods=["POST"]) -@login_required +@perm_required(PERM_DEVICES) def api_stop_device(): serial = (request.json or {}).get("serial", "") if mgr.stop_device(serial): @@ -535,7 +616,7 @@ def api_custom_actions_list(): @app.route("/api/custom_actions", methods=["POST"]) -@login_required +@perm_required(PERM_TASKS) def api_custom_actions_create(): data = request.json or {} name = (data.get("name") or "").strip() @@ -554,7 +635,7 @@ def api_custom_actions_create(): @app.route("/api/custom_actions/", methods=["PUT"]) -@login_required +@perm_required(PERM_TASKS) def api_custom_actions_update(action_id): row = CustomAction.query.get(action_id) if not row: @@ -576,7 +657,7 @@ def api_custom_actions_update(action_id): @app.route("/api/custom_actions/", methods=["DELETE"]) -@login_required +@perm_required(PERM_TASKS) def api_custom_actions_delete(action_id): row = CustomAction.query.get(action_id) if not row: @@ -587,7 +668,7 @@ def api_custom_actions_delete(action_id): @app.route("/api/uiauto/elements") -@login_required +@perm_required(PERM_DEVICES) def api_uiauto_elements(): """获取设备当前 UI 元素树(供步骤编辑器"抓取元素"用)。 @@ -705,7 +786,7 @@ def api_apks_list(): @app.route("/api/apks/upload", methods=["POST"]) -@login_required +@perm_required(PERM_APKS) def api_apks_upload(): """上传 APK 文件(multipart/form-data,字段名 file)。""" file = request.files.get("file") @@ -719,7 +800,7 @@ def api_apks_upload(): @app.route("/api/apks/", methods=["DELETE"]) -@login_required +@perm_required(PERM_APKS) def api_apks_delete(apk_id): """删除 APK 文件和记录。""" ok, msg = apk_mgr.delete(apk_id)