From 1f37344d03e91713979c0e3d040a752913772396 Mon Sep 17 00:00:00 2001 From: hz4th_coder Date: Thu, 13 Aug 2026 13:03:57 +0800 Subject: [PATCH] =?UTF-8?q?V3.1=20=E8=87=AA=E5=AE=9A=E4=B9=89=E8=A7=92?= =?UTF-8?q?=E8=89=B2=20+=20Worker=20=E6=9D=83=E9=99=90=E7=BB=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 角色管理(管理员可编辑角色权限功能): - 18 个权限功能点按模块分组(仪表盘/项目/Worker/协作/评估/模板/报表/日志/告警/API/设置/企业版) - 内置角色(admin/auditor/member)权限点可编辑、不可删除;自定义角色任意创建/删除/编辑 - 用户可绑定多个自定义角色,权限自动合并(内置基础 ∪ 自定义),即时生效无需重启 - 前端侧边栏按有效权限点自动显隐;未授权模块 API 返回 403 Worker 权限组(批量授权): - 把一批 Worker+权限(查看/使用/管理)打包成组,整组授权给用户 - 与直接授权并行生效取高值;组内 Worker 自动对绑定用户可见 - 企业版用户与权限新增三个子页:用户列表/角色管理/Worker 权限组 新表:roles / user_roles / worker_perm_groups / worker_perm_group_members / user_worker_groups --- README.md | 15 ++- app.py | 204 +++++++++++++++++++++++++++++++++++-- db.py | 82 +++++++++++++++ enterprise.py | 248 +++++++++++++++++++++++++++++++++++++++++++-- static/app.js | 249 ++++++++++++++++++++++++++++++++++++++++------ static/index.html | 24 ++--- 6 files changed, 758 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index dd010de..2a1d217 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ > 以「项目」为中心、以「AI Worker」为执行单元的项目管理平台。 > 把大模型团队变成一支可指挥、可审计、可控成本的"虚拟团队"。 -**当前版本:V3.0**(交付体系 / 精准权限 / 多 Agent 协作 / 自动评估 / 模板市场 / 企业版) +**当前版本:V3.1**(自定义角色 / Worker 权限组 / 交付体系 / 精准权限 / 多 Agent 协作 / 企业版) --- @@ -67,6 +67,13 @@ | 🎭 角色体系 | 管理员=全部;审计员=全量**只读**;成员=仅可见被授权内容,仪表盘/报表/日志/协作/评估全部按权限过滤 | | 🗺️ 授权总览 | 一键查看所有用户的 项目×Worker 授权矩阵,杜绝越权 | +### 🎭 自定义角色 + 🤖 Worker 权限组(V3.1 新增) +| 能力 | 说明 | +|---|---| +| 🎭 角色管理 | 18 个权限功能点按模块分组勾选(仪表盘/项目/Worker/协作/评估/模板/报表/日志/告警/API/设置/企业版);**内置角色可编辑权限点**、自定义角色任意创建/删除/多用户绑定,权限自动合并即时生效 | +| 🤖 Worker 权限组 | 把一批 Worker + 权限(查看/使用/管理)打包成组,整组授权给一个或多个用户;与直接授权并行生效取高值;组内 Worker 自动对绑定用户可见,批量管理不再逐条授权 | +| 📱 权限驱动菜单 | 前端侧边栏按用户有效权限点自动显隐;未授权模块 API 返回 403(如无 agent.run 无法发起协作运行) | + ## 快速开始 ```bash @@ -109,7 +116,7 @@ ai-worker-platform/ ├── agents.py # V2 多 Agent 协作引擎:supervisor/review/debate ├── eval.py # V2 自动评估:数据集/LLM 评委/沉淀/排行榜 ├── templates.py # V2 模板市场:三类模板 + 占位符渲染 + 应用 -├── enterprise.py # V2/V3 企业版:用户/RBAC/SSO/审计/合规 + 项目/Worker 精准授权 +├── enterprise.py # V2/V3 企业版:用户/RBAC/SSO/审计/合规 + 项目/Worker 精准授权 + 自定义角色 + Worker 权限组 ├── delivery.py # V3 交付体系:工作目录/Demo 部署/打包/邮件送达/难关通知 ├── llm_gateway.py # 统一模型网关 ├── rag.py # 知识库:分块 + BM25 检索 @@ -139,7 +146,9 @@ ai-worker-platform/ 企业版: - `GET/POST /api/enterprise/users`(管理员)| `PUT/DELETE /api/enterprise/users/` -- `GET/PUT /api/enterprise/users//grants` 精准授权(项目/Worker 权限)| `GET /api/enterprise/grants/overview` 授权总览 +- `GET/PUT /api/enterprise/users//grants` 精准授权(项目/Worker/角色/权限组)| `GET /api/enterprise/grants/overview` 授权总览 +- `GET /api/enterprise/perm_points` 权限点目录 | `GET/POST /api/enterprise/roles` 角色 | `GET/PUT/DELETE /api/enterprise/roles/` +- `GET/POST /api/enterprise/worker_groups` Worker 权限组 | `GET/PUT/DELETE /api/enterprise/worker_groups/` - `GET/PUT /api/enterprise/sso` SSO 配置 | `POST /api/enterprise/sso/oidc/login` 发起 OIDC | `GET /api/enterprise/sso/oidc/callback` 回调 - `POST /api/enterprise/sso/ldap/test` LDAP 连通测试 - `GET /api/enterprise/audit` 审计日志 | `GET /api/enterprise/audit/export` CSV 导出 diff --git a/app.py b/app.py index 0e5546d..a7f2412 100644 --- a/app.py +++ b/app.py @@ -137,11 +137,45 @@ def me(): if authed: uname = session.get('username', 'admin') row = enterprise.get_user(uname) + uid = row['id'] if row else None u = {'username': uname, 'role': session.get('role') or (row['role'] if row else 'admin'), 'display_name': row['display_name'] if row else uname} + if uid: + perms = enterprise.user_perms(uid) + u['perms'] = sorted(perms) + else: + u['perms'] = sorted(enterprise.ALL_POINTS) return jsonify({'ok': True, 'authed': authed, 'user': u}) +def _check_perm_point(point): + """模块权限点拦截:返回 None=通过,否则 (jsonify, code)。admin/token 恒通过。""" + if not _auth_ok(): + return jsonify({'ok': False, 'error': '未登录或 Token 无效'}), 401 + actor = enterprise.current_actor() + if actor.startswith('token:'): + return None + u = _session_user() + if not u: + return jsonify({'ok': False, 'error': '未登录'}), 401 + if u['role'] == 'admin' or enterprise.has_perm(u['id'], point): + return None + return jsonify({'ok': False, 'error': f'无该功能权限({point})'}), 403 + + +def require_perm(point): + """模块权限点装饰器""" + def deco(fn): + @functools.wraps(fn) + def wrapper(*args, **kwargs): + err = _check_perm_point(point) + if err: + return err + return fn(*args, **kwargs) + return wrapper + return deco + + def _auth_ok(): """会话或 API Token 任一通过即可""" if not auth_enabled(): @@ -173,7 +207,7 @@ def require_auth(fn): def require_admin(fn): - """RBAC:仅管理员(含 token 调用者视为管理员)可访问""" + """RBAC:管理员(admin 角色 / token 调用者 / 拥有 enterprise.manage 权限点的角色)可访问""" @functools.wraps(fn) def wrapper(*args, **kwargs): if not _auth_ok(): @@ -186,8 +220,8 @@ def require_admin(fn): actor = enterprise.current_actor() if actor.startswith('token:'): return fn(*args, **kwargs) - username = session.get('username') - if username and enterprise.is_admin(username): + u = _session_user() + if u and (u['role'] == 'admin' or enterprise.has_perm(u['id'], 'enterprise.manage')): return fn(*args, **kwargs) return jsonify({'ok': False, 'error': '需要管理员权限'}), 403 return wrapper @@ -195,7 +229,7 @@ def require_admin(fn): @app.route('/api/health') def health(): - return jsonify({'ok': True, 'service': 'ai-worker-platform', 'version': 'v3.0.0'}) + return jsonify({'ok': True, 'service': 'ai-worker-platform', 'version': 'v3.1.0'}) # --------------------------------------------------------------------------- @@ -304,6 +338,9 @@ def _check_task_perm(tid, need='view'): @require_auth def projects(): if request.method == 'POST': + err = _check_perm_point('project.create') + if err: + return err d = request.get_json(force=True) deliver_email = (d.get('deliver_email') or '').strip() if not deliver_email: @@ -330,6 +367,9 @@ def projects(): enterprise.audit(enterprise.current_actor(), 'project.create', f'project#{pid}', f'「{d.get("name","")}」送达者 {deliver_email}', request.remote_addr or '') return jsonify({'ok': True, 'id': pid}) + err = _check_perm_point('project.view') + if err: + return err rows = db.q('SELECT * FROM projects ORDER BY id DESC') visible = _visible_project_ids() if visible is not None: @@ -346,6 +386,9 @@ def projects(): @require_auth def project_detail(pid): if request.method == 'GET': + err = _check_perm_point('project.view') + if err: + return err err = _check_project_perm(pid, 'view') if err: return err @@ -399,6 +442,9 @@ def project_detail(pid): @require_auth def workers(): if request.method == 'POST': + err = _check_perm_point('worker.create') + if err: + return err if not (enterprise.current_actor().startswith('token:') or _role() == 'admin'): return jsonify({'ok': False, 'error': '仅管理员可创建 Worker'}), 403 d = request.get_json(force=True) @@ -413,6 +459,9 @@ def workers(): float(d.get('monthly_cost_limit') or 0), d.get('status', 'enabled'), db.now(), db.now())) return jsonify({'ok': True, 'id': wid}) + err = _check_perm_point('worker.view') + if err: + return err rows = db.q('SELECT * FROM workers ORDER BY id DESC') visible = _visible_worker_ids() if visible is not None: @@ -427,6 +476,9 @@ def workers(): @require_auth def worker_detail(wid): if request.method == 'GET': + err = _check_perm_point('worker.view') + if err: + return err err = _check_worker_perm(wid, 'view') if err: return err @@ -466,6 +518,9 @@ def worker_detail(wid): @app.route('/api/workers//test', methods=['POST']) @require_auth def worker_test(wid): + err = _check_perm_point('worker.view') + if err: + return err err = _check_worker_perm(wid, 'use') if err: return err @@ -485,6 +540,9 @@ def worker_test(wid): @require_auth def worker_vision_test(wid): """视觉智能体能力测试:传入图片(URL 或 base64)与问题,验证多模态理解""" + err = _check_perm_point('worker.view') + if err: + return err err = _check_worker_perm(wid, 'use') if err: return err @@ -774,6 +832,7 @@ def task_review(tid): # --------------------------------------------------------------------------- @app.route('/api/reports/cost') @require_auth +@require_perm('report.view') def report_cost(): visible = _visible_project_ids() scope_sql, scope_args = '', [] @@ -809,6 +868,7 @@ def report_cost(): @app.route('/api/stats') @require_auth +@require_perm('dashboard.view') def stats(): visible = _visible_project_ids() scope_sql, scope_args = '', [] @@ -881,6 +941,7 @@ def scope_args_ph(ids): @app.route('/api/logs') @require_auth +@require_perm('log.view') def logs(): limit = min(int(request.args.get('limit', 100)), 500) visible = _visible_project_ids() @@ -1321,6 +1382,7 @@ def demo_serve_root(pid): # --------------------------------------------------------------------------- @app.route('/api/alerts') @require_auth +@require_perm('alert.view') def alerts(): limit = min(int(request.args.get('limit', 100)), 500) rows = db.q('SELECT * FROM alerts ORDER BY id DESC LIMIT ?', (limit,)) @@ -1329,6 +1391,7 @@ def alerts(): @app.route('/api/alerts/unread_count') @require_auth +@require_perm('alert.view') def alerts_unread(): c = db.q('SELECT COUNT(*) c FROM alerts WHERE read=0')[0]['c'] return jsonify({'ok': True, 'count': c}) @@ -1353,6 +1416,7 @@ def alerts_read_all(): # --------------------------------------------------------------------------- @app.route('/api/tokens', methods=['GET', 'POST']) @require_auth +@require_perm('api.manage') def api_tokens(): if request.method == 'POST': d = request.get_json(force=True) @@ -1437,6 +1501,7 @@ def events(): # --------------------------------------------------------------------------- @app.route('/api/settings', methods=['GET', 'PUT']) @require_auth +@require_perm('setting.manage') def settings(): if request.method == 'PUT': d = request.get_json(force=True) @@ -1477,6 +1542,7 @@ AGENT_MODES = [ @app.route('/api/agents/modes') @require_auth +@require_perm('agent.view') def agent_modes(): return jsonify({'ok': True, 'data': AGENT_MODES}) @@ -1485,6 +1551,9 @@ def agent_modes(): @require_auth def agent_runs(): if request.method == 'POST': + err = _check_perm_point('agent.run') + if err: + return err d = request.get_json(force=True) mode = d.get('mode') if mode not in ('supervisor', 'review', 'debate'): @@ -1519,6 +1588,9 @@ def agent_runs(): enterprise.audit(enterprise.current_actor(), 'agent.run', f'run#{rid}', f'mode={mode} workers={worker_ids}', request.remote_addr or '') return jsonify({'ok': True, 'id': rid}) + err = _check_perm_point('agent.view') + if err: + return err rows = db.q('SELECT * FROM agent_runs ORDER BY id DESC LIMIT 100') vw = _visible_worker_ids() if vw is not None: @@ -1537,6 +1609,7 @@ def _run_visible(run, visible_worker_ids): @app.route('/api/agents/runs/', methods=['GET', 'DELETE']) @require_auth +@require_perm('agent.view') def agent_run_detail(rid): run = db.q('SELECT * FROM agent_runs WHERE id=?', (rid,), one=True) if not run: @@ -1567,6 +1640,9 @@ def agent_run_detail(rid): @require_auth def eval_datasets(): if request.method == 'POST': + err = _check_perm_point('eval.run') + if err: + return err d = request.get_json(force=True) did = db.w( 'INSERT INTO eval_datasets (name, description, rubric, tags, is_builtin, created_at, updated_at) ' @@ -1577,6 +1653,9 @@ def eval_datasets(): db.w('INSERT INTO eval_cases (dataset_id, input, expected, tags, created_at) VALUES (?,?,?,?,?)', (did, c.get('input', ''), c.get('expected', ''), _json.dumps(d.get('tags') or []), db.now())) return jsonify({'ok': True, 'id': did}) + err = _check_perm_point('eval.view') + if err: + return err rows = db.q('SELECT * FROM eval_datasets ORDER BY id DESC') for r in rows: r['case_count'] = db.q('SELECT COUNT(*) c FROM eval_cases WHERE dataset_id=?', (r['id'],))[0]['c'] @@ -1588,6 +1667,7 @@ def eval_datasets(): @app.route('/api/eval/datasets/', methods=['GET', 'PUT', 'DELETE']) @require_auth +@require_perm('eval.view') def eval_dataset_detail(did): ds = db.q('SELECT * FROM eval_datasets WHERE id=?', (did,), one=True) if not ds: @@ -1620,6 +1700,7 @@ def eval_dataset_detail(did): @app.route('/api/eval/datasets//cases', methods=['POST']) @require_auth +@require_perm('eval.run') def eval_case_create(did): d = request.get_json(force=True) cid = db.w('INSERT INTO eval_cases (dataset_id, input, expected, tags, created_at) VALUES (?,?,?,?,?)', @@ -1629,6 +1710,7 @@ def eval_case_create(did): @app.route('/api/eval/cases/', methods=['PUT', 'DELETE']) @require_auth +@require_perm('eval.view') def eval_case_detail(cid): case = db.q('SELECT * FROM eval_cases WHERE id=?', (cid,), one=True) if not case: @@ -1729,6 +1811,9 @@ def eval_leaderboard(): @require_auth def templates_api(): if request.method == 'POST': + err = _check_perm_point('template.manage') + if err: + return err d = request.get_json(force=True) tid = db.w( 'INSERT INTO templates (type, name, description, content, tags, author, is_builtin, ' @@ -1753,6 +1838,7 @@ def templates_api(): @app.route('/api/templates/', methods=['GET', 'PUT', 'DELETE']) @require_auth +@require_perm('template.view') def template_detail(tid): t = tplmod.get_template(tid) if not t: @@ -1890,9 +1976,12 @@ def ent_user_grants(uid): d = request.get_json(force=True) or {} out = enterprise.set_user_grants(uid, projects=d.get('projects'), - workers=d.get('workers')) + workers=d.get('workers'), + roles=d.get('roles'), + worker_groups=d.get('worker_groups')) enterprise.audit(enterprise.current_actor(), 'user.grants', f'user#{uid}', - f'{u["username"]} 授权更新:项目 {out["projects"]} 项 / Worker {out["workers"]} 项', + f'{u["username"]} 授权更新:项目 {out["projects"]} / Worker {out["workers"]} / ' + f'角色 {out["roles"]} / 权限组 {out["worker_groups"]}', request.remote_addr or '') return jsonify({'ok': True, **out}) data = enterprise.user_grants(uid) @@ -1913,9 +2002,112 @@ def ent_grants_overview(): 'perm': r['perm']} for r in up if r['user_id'] == u['id']] u['workers'] = [{'worker_id': r['worker_id'], 'worker_name': wname.get(r['worker_id'], f'#{r["worker_id"]}'), 'perm': r['perm']} for r in uw if r['user_id'] == u['id']] + u['roles'] = [{'role_id': r['role_id'], 'role_name': + db.q('SELECT name FROM roles WHERE id=?', (r['role_id'],), one=True)['name']} + for r in db.q('SELECT role_id FROM user_roles WHERE user_id=?', (u['id'],))] + u['worker_groups'] = [{'group_id': r['group_id'], 'group_name': + db.q('SELECT name FROM worker_perm_groups WHERE id=?', (r['group_id'],), one=True)['name']} + for r in db.q('SELECT group_id FROM user_worker_groups WHERE user_id=?', (u['id'],))] return jsonify({'ok': True, 'data': users}) +@app.route('/api/enterprise/perm_points') +@require_admin +def ent_perm_points(): + """权限功能点目录(角色编辑勾选用)""" + groups = [] + seen = {} + for p in enterprise.PERM_POINTS: + if p['group'] not in seen: + seen[p['group']] = len(groups) + groups.append({'name': p['group'], 'points': []}) + groups[seen[p['group']]]['points'].append(p) + return jsonify({'ok': True, 'data': groups}) + + +@app.route('/api/enterprise/roles', methods=['GET', 'POST']) +@require_admin +def ent_roles(): + if request.method == 'POST': + d = request.get_json(force=True) + rid, err = enterprise.create_role(d.get('name'), d.get('description', ''), d.get('perms')) + if err: + return jsonify({'ok': False, 'error': err}), 400 + enterprise.audit(enterprise.current_actor(), 'role.create', f'role#{rid}', + f'新建角色「{d.get("name","")}」', request.remote_addr or '') + return jsonify({'ok': True, 'id': rid}) + return jsonify({'ok': True, 'data': enterprise.list_roles()}) + + +@app.route('/api/enterprise/roles/', methods=['GET', 'PUT', 'DELETE']) +@require_admin +def ent_role_detail(rid): + role = db.q('SELECT * FROM roles WHERE id=?', (rid,), one=True) + if not role: + return jsonify({'ok': False, 'error': '角色不存在'}), 404 + if request.method == 'GET': + role['perms'] = sorted(enterprise.role_perms(role)) + role['user_count'] = db.q('SELECT COUNT(*) c FROM user_roles WHERE role_id=?', (rid,))[0]['c'] + return jsonify({'ok': True, 'data': role}) + if request.method == 'DELETE': + _, err = enterprise.delete_role(rid) + if err: + return jsonify({'ok': False, 'error': err}), 400 + enterprise.audit(enterprise.current_actor(), 'role.delete', f'role#{rid}', + f'删除角色「{role["name"]}」', request.remote_addr or '') + return jsonify({'ok': True}) + d = request.get_json(force=True) + _, err = enterprise.update_role(rid, d) + if err: + return jsonify({'ok': False, 'error': err}), 400 + enterprise.audit(enterprise.current_actor(), 'role.update', f'role#{rid}', + f'编辑角色「{d.get("name") or role["name"]}」', request.remote_addr or '') + return jsonify({'ok': True}) + + +@app.route('/api/enterprise/worker_groups', methods=['GET', 'POST']) +@require_admin +def ent_worker_groups(): + if request.method == 'POST': + d = request.get_json(force=True) + gid, err = enterprise.save_worker_group(None, d) + if err: + return jsonify({'ok': False, 'error': err}), 400 + enterprise.audit(enterprise.current_actor(), 'workergroup.create', f'group#{gid}', + f'新建 Worker 权限组「{d.get("name","")}」', request.remote_addr or '') + return jsonify({'ok': True, 'id': gid}) + return jsonify({'ok': True, 'data': enterprise.list_worker_groups()}) + + +@app.route('/api/enterprise/worker_groups/', methods=['GET', 'PUT', 'DELETE']) +@require_admin +def ent_worker_group_detail(gid): + g = db.q('SELECT * FROM worker_perm_groups WHERE id=?', (gid,), one=True) + if not g: + return jsonify({'ok': False, 'error': '权限组不存在'}), 404 + if request.method == 'GET': + g['members'] = db.q('SELECT w.id AS worker_id, w.name AS worker_name, m.perm ' + 'FROM worker_perm_group_members m JOIN workers w ON w.id=m.worker_id ' + 'WHERE m.group_id=?', (gid,)) + g['users'] = db.q('SELECT u.id AS user_id, u.username FROM user_worker_groups ug ' + 'JOIN users u ON u.id=ug.user_id WHERE ug.group_id=?', (gid,)) + return jsonify({'ok': True, 'data': g}) + if request.method == 'DELETE': + _, err = enterprise.delete_worker_group(gid) + if err: + return jsonify({'ok': False, 'error': err}), 400 + enterprise.audit(enterprise.current_actor(), 'workergroup.delete', f'group#{gid}', + f'删除 Worker 权限组「{g["name"]}」', request.remote_addr or '') + return jsonify({'ok': True}) + d = request.get_json(force=True) + _, err = enterprise.save_worker_group(gid, d) + if err: + return jsonify({'ok': False, 'error': err}), 400 + enterprise.audit(enterprise.current_actor(), 'workergroup.update', f'group#{gid}', + f'编辑 Worker 权限组「{d.get("name") or g["name"]}」', request.remote_addr or '') + return jsonify({'ok': True}) + + @app.route('/api/enterprise/audit') @require_auth def ent_audit(): diff --git a/db.py b/db.py index b22b206..931ef52 100644 --- a/db.py +++ b/db.py @@ -297,11 +297,60 @@ CREATE TABLE IF NOT EXISTS user_workers ( UNIQUE(user_id, worker_id) ); +-- =================================================================== +-- V3.1 表结构:自定义角色(权限功能点)+ Worker 权限组(批量授权) +-- =================================================================== +CREATE TABLE IF NOT EXISTS roles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + description TEXT DEFAULT '', + perms TEXT DEFAULT '[]', -- JSON: 权限点 id 列表 + is_builtin INTEGER DEFAULT 0, -- 内置角色(admin/auditor/member)不可删除 + created_at INTEGER, + updated_at INTEGER +); + +CREATE TABLE IF NOT EXISTS user_roles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + role_id INTEGER NOT NULL, + created_at INTEGER, + UNIQUE(user_id, role_id) +); + +CREATE TABLE IF NOT EXISTS worker_perm_groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT DEFAULT '', + created_at INTEGER, + updated_at INTEGER +); + +CREATE TABLE IF NOT EXISTS worker_perm_group_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + group_id INTEGER NOT NULL, + worker_id INTEGER NOT NULL, + perm TEXT DEFAULT 'view', -- 组内该 Worker 权限(可覆盖组默认) + created_at INTEGER, + UNIQUE(group_id, worker_id) +); + +CREATE TABLE IF NOT EXISTS user_worker_groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + group_id INTEGER NOT NULL, + created_at INTEGER, + UNIQUE(user_id, group_id) +); + CREATE INDEX IF NOT EXISTS idx_deliverables_project ON project_deliverables(project_id); CREATE INDEX IF NOT EXISTS idx_user_projects_user ON user_projects(user_id); CREATE INDEX IF NOT EXISTS idx_user_projects_project ON user_projects(project_id); CREATE INDEX IF NOT EXISTS idx_user_workers_user ON user_workers(user_id); CREATE INDEX IF NOT EXISTS idx_user_workers_worker ON user_workers(worker_id); +CREATE INDEX IF NOT EXISTS idx_user_roles_user ON user_roles(user_id); +CREATE INDEX IF NOT EXISTS idx_wpg_members_group ON worker_perm_group_members(group_id); +CREATE INDEX IF NOT EXISTS idx_uwg_user ON user_worker_groups(user_id); CREATE INDEX IF NOT EXISTS idx_agent_steps_run ON agent_steps(run_id); CREATE INDEX IF NOT EXISTS idx_agent_runs_status ON agent_runs(status); CREATE INDEX IF NOT EXISTS idx_eval_cases_ds ON eval_cases(dataset_id); @@ -401,6 +450,39 @@ def init_db(): conn.close() _migrate() migrate_v2() + seed_builtin_roles() + + +# V3.1 内置角色权限点定义(admin 为特殊值 ALL,表示全部权限) +BUILTIN_ROLE_PERMS = { + 'admin': ['*'], + # 审计员:全量只读 + 'auditor': ['dashboard.view', 'project.view', 'worker.view', 'agent.view', 'eval.view', + 'template.view', 'report.view', 'log.view', 'alert.view'], + # 成员:基础功能(资源级仍受项目/Worker 授权约束) + 'member': ['dashboard.view', 'project.view', 'project.create', 'project.deliver', + 'worker.view', 'agent.view', 'agent.run', 'eval.view', 'eval.run', + 'template.view', 'report.view', 'log.view', 'alert.view'], +} + + +def seed_builtin_roles(): + """幂等:仅首次创建 admin/auditor/member 内置角色(不覆盖管理员后续编辑)""" + import json as _json + conn = get_conn() + try: + for name, perms in BUILTIN_ROLE_PERMS.items(): + r = conn.execute('SELECT id FROM roles WHERE name=?', (name,)).fetchone() + if not r: + conn.execute( + 'INSERT INTO roles (name, description, perms, is_builtin, created_at, updated_at) ' + 'VALUES (?,?,?,1,?,?)', + (name, {'admin': '超级管理员:全部权限', 'auditor': '审计员:全量只读', + 'member': '成员:基础功能,资源级按项目/Worker 授权'}[name], + _json.dumps(perms), now(), now())) + conn.commit() + finally: + conn.close() def recover_stale_runs(): diff --git a/enterprise.py b/enterprise.py index 37ea739..e51fbb9 100644 --- a/enterprise.py +++ b/enterprise.py @@ -296,7 +296,7 @@ def export_all(): 'eval_results', 'templates', 'users', 'audit_logs', 'project_deliverables', 'user_projects', 'user_workers'] out = {'exported_at': time.strftime('%Y-%m-%d %H:%M:%S'), - 'platform': 'ai-worker-platform', 'version': 'v2.0.0'} + 'platform': 'ai-worker-platform', 'version': 'v3.1.0'} for t in tables: try: out[t] = db.q(f'SELECT * FROM {t}') @@ -363,10 +363,16 @@ def user_project_perm(user_id, project_id): def user_worker_perm(user_id, worker_id): - """用户在 Worker 上的权限:None / view / use / manage""" + """用户在 Worker 上的权限:None / view / use / manage(直接授权 ∪ 权限组授权,取高)""" r = db.q('SELECT perm FROM user_workers WHERE user_id=? AND worker_id=?', (user_id, worker_id), one=True) - return r['perm'] if r else None + direct = r['perm'] if r else None + grouped = grouped_worker_perm(user_id, worker_id) + if direct is None: + return grouped + if grouped is None: + return direct + return direct if PERM_LEVEL.get(direct, 0) >= PERM_LEVEL.get(grouped, 0) else grouped def visible_project_ids(user_id): @@ -379,18 +385,20 @@ def visible_project_ids(user_id): def visible_worker_ids(user_id): - """用户可见的 Worker id 列表(admin/auditor 返回 None 表示全部)""" + """用户可见的 Worker id 列表 = 直接授权 ∪ 权限组授权(admin/auditor 返回 None 表示全部)""" u = db.q('SELECT role FROM users WHERE id=?', (user_id,), one=True) if u and u['role'] in ('admin', 'auditor'): return None rows = db.q('SELECT worker_id FROM user_workers WHERE user_id=?', (user_id,)) - return [r['worker_id'] for r in rows] + ids = {r['worker_id'] for r in rows} + ids |= set(grouped_worker_ids(user_id)) + return sorted(ids) -def set_user_grants(user_id, projects=None, workers=None): - """批量覆盖用户授权。projects=[{project_id, perm}], workers=[{worker_id, perm}] - perm 传空/None 表示收回该授权。返回 {'projects': n, 'workers': m}。""" - out = {'projects': 0, 'workers': 0} +def set_user_grants(user_id, projects=None, workers=None, roles=None, worker_groups=None): + """批量覆盖用户授权。projects=[{project_id, perm}], workers=[{worker_id, perm}], + roles=[role_id], worker_groups=[group_id]。perm 传空/None 表示收回该授权。""" + out = {'projects': 0, 'workers': 0, 'roles': 0, 'worker_groups': 0} if projects is not None: db.w('DELETE FROM user_projects WHERE user_id=?', (user_id,)) for g in projects: @@ -415,15 +423,233 @@ def set_user_grants(user_id, projects=None, workers=None): db.w('INSERT INTO user_workers (user_id, worker_id, perm, created_at) VALUES (?,?,?,?)', (user_id, wid, perm, db.now())) out['workers'] += 1 + if roles is not None: + set_user_roles(user_id, roles) + out['roles'] = len(roles or []) + if worker_groups is not None: + db.w('DELETE FROM user_worker_groups WHERE user_id=?', (user_id,)) + for gid in (worker_groups or []): + if db.q('SELECT id FROM worker_perm_groups WHERE id=?', (int(gid),), one=True): + db.w('INSERT INTO user_worker_groups (user_id, group_id, created_at) VALUES (?,?,?)', + (user_id, int(gid), db.now())) + out['worker_groups'] += 1 return out def user_grants(user_id): - """用户现有授权 + 全部可选项目/Worker,供管理界面展示""" + """用户现有授权 + 全部可选项目/Worker/角色/权限组,供管理界面展示""" projects = db.q('SELECT p.id, p.name, p.status FROM projects p ORDER BY p.id DESC') workers = db.q('SELECT id, name, provider, model, status FROM workers ORDER BY id DESC') for p in projects: p['perm'] = user_project_perm(user_id, p['id']) for w in workers: w['perm'] = user_worker_perm(user_id, w['id']) - return {'projects': projects, 'workers': workers} + roles = db.q('SELECT * FROM roles ORDER BY is_builtin DESC, id') + for r in roles: + r['perms'] = sorted(role_perms(r)) + r['bound'] = r['id'] in user_role_ids(user_id) + groups = db.q('SELECT * FROM worker_perm_groups ORDER BY id') + bound_gids = {g['group_id'] for g in db.q('SELECT group_id FROM user_worker_groups WHERE user_id=?', + (user_id,))} + for g in groups: + g['member_count'] = db.q('SELECT COUNT(*) c FROM worker_perm_group_members WHERE group_id=?', + (g['id'],))[0]['c'] + g['bound'] = g['id'] in bound_gids + return {'projects': projects, 'workers': workers, 'roles': roles, 'worker_groups': groups} + + +# --------------------------------------------------------------------------- +# V3.1 自定义角色(权限功能点) +# --------------------------------------------------------------------------- +PERM_POINTS = [ + {'id': 'dashboard.view', 'name': '仪表盘', 'group': '总览'}, + {'id': 'project.view', 'name': '项目查看', 'group': '项目'}, + {'id': 'project.create', 'name': '创建项目', 'group': '项目'}, + {'id': 'project.deliver', 'name': '交付操作(上传/部署/打包/发送/完成)', 'group': '项目'}, + {'id': 'worker.view', 'name': 'Worker 查看', 'group': 'Worker'}, + {'id': 'worker.create', 'name': 'Worker 创建', 'group': 'Worker'}, + {'id': 'worker.manage', 'name': 'Worker 编辑/删除', 'group': 'Worker'}, + {'id': 'agent.view', 'name': '多Agent协作-查看', 'group': '协作评估'}, + {'id': 'agent.run', 'name': '多Agent协作-运行', 'group': '协作评估'}, + {'id': 'eval.view', 'name': '自动评估-查看', 'group': '协作评估'}, + {'id': 'eval.run', 'name': '自动评估-运行', 'group': '协作评估'}, + {'id': 'template.view', 'name': '模板市场-查看/应用', 'group': '模板'}, + {'id': 'template.manage', 'name': '模板市场-管理', 'group': '模板'}, + {'id': 'report.view', 'name': '成本报表', 'group': '报表日志'}, + {'id': 'log.view', 'name': '运行日志', 'group': '报表日志'}, + {'id': 'alert.view', 'name': '告警中心', 'group': '报表日志'}, + {'id': 'api.manage', 'name': '开放API Token 管理', 'group': '平台管理'}, + {'id': 'setting.manage', 'name': '通知设置', 'group': '平台管理'}, + {'id': 'enterprise.manage', 'name': '企业版管理(用户/角色/授权/SSO/审计/合规)', 'group': '平台管理'}, +] +ALL_POINTS = {p['id'] for p in PERM_POINTS} + + +def role_perms(role): + """角色权限点集合(admin 的 ['*'] 展开为全部)""" + try: + perms = json.loads(role.get('perms') or '[]') + except Exception: + perms = [] + if '*' in perms: + return set(ALL_POINTS) + return set(perms) & ALL_POINTS + + +def user_role_ids(user_id): + return [r['role_id'] for r in db.q('SELECT role_id FROM user_roles WHERE user_id=?', (user_id,))] + + +def user_perms(user_id): + """用户有效权限点集合 = 内置角色基础(roles 表,可编辑)∪ 自定义角色并集;admin 返回全部""" + u = db.q('SELECT username, role FROM users WHERE id=?', (user_id,), one=True) + if not u: + return set() + # 内置角色基础:从 roles 表读取(管理员可编辑内置角色权限点) + base_role = db.q('SELECT * FROM roles WHERE name=?', (u['role'],), one=True) + s = role_perms(base_role) if base_role else set() + for rid in user_role_ids(user_id): + r = db.q('SELECT * FROM roles WHERE id=?', (rid,), one=True) + if r: + s |= role_perms(r) + return s + + +def has_perm(user_id, point): + return point in user_perms(user_id) + + +def list_roles(): + rows = db.q('SELECT * FROM roles ORDER BY is_builtin DESC, id') + for r in rows: + r['perms'] = sorted(role_perms(r)) + r['user_count'] = db.q('SELECT COUNT(*) c FROM user_roles WHERE role_id=?', (r['id'],))[0]['c'] + return rows + + +def create_role(name, description='', perms=None): + name = (name or '').strip() + if not name: + return None, '角色名不能为空' + if db.q('SELECT id FROM roles WHERE name=?', (name,), one=True): + return None, '角色已存在' + rid = db.w('INSERT INTO roles (name, description, perms, is_builtin, created_at, updated_at) ' + 'VALUES (?,?,?,0,?,?)', + (name, description, json.dumps(sorted(set(perms or []) & ALL_POINTS)), db.now(), db.now())) + return rid, None + + +def update_role(role_id, data): + role = db.q('SELECT * FROM roles WHERE id=?', (role_id,), one=True) + if not role: + return None, '角色不存在' + if 'name' in data and (data['name'] or '').strip(): + nm = data['name'].strip() + dup = db.q('SELECT id FROM roles WHERE name=? AND id!=?', (nm, role_id), one=True) + if dup: + return None, '角色名已存在' + db.w('UPDATE roles SET name=? WHERE id=?', (nm, role_id)) + if 'description' in data: + db.w('UPDATE roles SET description=? WHERE id=?', (data['description'], role_id)) + if 'perms' in data: + db.w('UPDATE roles SET perms=? WHERE id=?', + (json.dumps(sorted(set(data['perms'] or []) & ALL_POINTS)), role_id)) + db.w('UPDATE roles SET updated_at=? WHERE id=?', (db.now(), role_id)) + return role_id, None + + +def delete_role(role_id): + role = db.q('SELECT * FROM roles WHERE id=?', (role_id,), one=True) + if not role: + return None, '角色不存在' + if role['is_builtin']: + return None, '内置角色不可删除(可编辑权限点)' + db.w('DELETE FROM user_roles WHERE role_id=?', (role_id,)) + db.w('DELETE FROM roles WHERE id=?', (role_id,)) + return role_id, None + + +def set_user_roles(user_id, role_ids): + """覆盖设置用户绑定的自定义角色""" + db.w('DELETE FROM user_roles WHERE user_id=?', (user_id,)) + for rid in (role_ids or []): + if db.q('SELECT id FROM roles WHERE id=?', (rid,), one=True): + db.w('INSERT INTO user_roles (user_id, role_id, created_at) VALUES (?,?,?)', + (user_id, rid, db.now())) + + +# --------------------------------------------------------------------------- +# V3.1 Worker 权限组(批量授权) +# --------------------------------------------------------------------------- +def list_worker_groups(): + rows = db.q('SELECT * FROM worker_perm_groups ORDER BY id') + for g in rows: + g['members'] = db.q('SELECT w.id AS worker_id, w.name AS worker_name, m.perm ' + 'FROM worker_perm_group_members m JOIN workers w ON w.id=m.worker_id ' + 'WHERE m.group_id=? ORDER BY m.id', (g['id'],)) + g['users'] = db.q('SELECT u.id AS user_id, u.username, u.display_name ' + 'FROM user_worker_groups ug JOIN users u ON u.id=ug.user_id ' + 'WHERE ug.group_id=? ORDER BY u.id', (g['id'],)) + return rows + + +def save_worker_group(group_id, data): + """新建/更新 Worker 权限组:members=[{worker_id, perm}],users=[user_id]""" + name = (data.get('name') or '').strip() + if not name: + return None, '组名不能为空' + if group_id: + g = db.q('SELECT * FROM worker_perm_groups WHERE id=?', (group_id,), one=True) + if not g: + return None, '权限组不存在' + db.w('UPDATE worker_perm_groups SET name=?, description=?, updated_at=? WHERE id=?', + (name, data.get('description', ''), db.now(), group_id)) + else: + group_id = db.w('INSERT INTO worker_perm_groups (name, description, created_at, updated_at) ' + 'VALUES (?,?,?,?)', (name, data.get('description', ''), db.now(), db.now())) + # 成员 + db.w('DELETE FROM worker_perm_group_members WHERE group_id=?', (group_id,)) + for m in (data.get('members') or []): + wid = int(m.get('worker_id') or 0) + perm = (m.get('perm') or 'view').strip() + if perm not in WORKER_PERMS: + perm = 'view' + if db.q('SELECT id FROM workers WHERE id=?', (wid,), one=True): + db.w('INSERT INTO worker_perm_group_members (group_id, worker_id, perm, created_at) ' + 'VALUES (?,?,?,?)', (group_id, wid, perm, db.now())) + # 授权用户 + db.w('DELETE FROM user_worker_groups WHERE group_id=?', (group_id,)) + for uid in (data.get('users') or []): + if db.q('SELECT id FROM users WHERE id=?', (int(uid),), one=True): + db.w('INSERT INTO user_worker_groups (user_id, group_id, created_at) VALUES (?,?,?)', + (int(uid), group_id, db.now())) + return group_id, None + + +def delete_worker_group(group_id): + db.w('DELETE FROM worker_perm_group_members WHERE group_id=?', (group_id,)) + db.w('DELETE FROM user_worker_groups WHERE group_id=?', (group_id,)) + db.w('DELETE FROM worker_perm_groups WHERE id=?', (group_id,)) + return group_id, None + + +def grouped_worker_perm(user_id, worker_id): + """用户通过权限组获得的 Worker 权限(取最高)""" + rows = db.q( + 'SELECT m.perm FROM worker_perm_group_members m ' + 'JOIN user_worker_groups ug ON ug.group_id=m.group_id ' + 'WHERE ug.user_id=? AND m.worker_id=?', (user_id, worker_id)) + best = None + for r in rows: + if best is None or PERM_LEVEL.get(r['perm'], 0) > PERM_LEVEL.get(best, 0): + best = r['perm'] + return best + + +def grouped_worker_ids(user_id): + """用户通过权限组获得的 worker_id 列表(含组内权限)""" + rows = db.q( + 'SELECT DISTINCT m.worker_id FROM worker_perm_group_members m ' + 'JOIN user_worker_groups ug ON ug.group_id=m.group_id ' + 'WHERE ug.user_id=?', (user_id,)) + return [r['worker_id'] for r in rows] diff --git a/static/app.js b/static/app.js index 252ab3c..2fb8997 100644 --- a/static/app.js +++ b/static/app.js @@ -43,16 +43,21 @@ function toast(msg, type = '') { /* ---------- Login ---------- */ function showLogin() { $('#login-mask').style.display = 'flex'; } function hideLogin() { $('#login-mask').style.display = 'none'; } -function applyRoleUI(role) { +let MY_PERMS = null; // null = 全部权限(admin/token) +function applyRoleUI(role, perms) { CURRENT_ROLE = role || 'admin'; - $$('#sidebar nav a').forEach(a => { a.style.display = (a.dataset.route === 'enterprise' && CURRENT_ROLE !== 'admin') ? 'none' : ''; }); + MY_PERMS = (perms && perms.length) ? new Set(perms) : null; + $$('#sidebar nav a').forEach(a => { + const need = a.dataset.perm; + a.style.display = (!need || !MY_PERMS || MY_PERMS.has(need)) ? '' : 'none'; + }); } $('#login-btn').addEventListener('click', async () => { try { await api('/api/login', {method:'POST', body:{username: $('#login-user').value, password: $('#login-pwd').value}}); hideLogin(); $('#logout-btn').style.display = 'block'; const me = await api('/api/me'); - applyRoleUI(me.user?.role || 'admin'); + applyRoleUI(me.user?.role || 'admin', me.user?.perms); router(); } catch (e) { $('#login-err').textContent = e.message; } }); @@ -2020,15 +2025,17 @@ async function tplRestore() { toast('内置模板已就绪', 'ok'); pageTemplates(); } -/* ---------- V2 · 企业版 ---------- */ +/* ---------- V2/V3 · 企业版 ---------- */ let entTab = 'users'; +let entSub = 'list'; // users 子页:list 用户列表 / roles 角色管理 / groups Worker 权限组 async function pageEnterprise() { - const [users, audit, sso, comp] = await Promise.all([ + const [users, audit, sso, comp, roles, groups] = await Promise.all([ api('/api/enterprise/users'), api('/api/enterprise/audit?limit=100'), - api('/api/enterprise/sso'), api('/api/enterprise/compliance') + api('/api/enterprise/sso'), api('/api/enterprise/compliance'), + api('/api/enterprise/roles'), api('/api/enterprise/worker_groups') ]); $('#main').innerHTML = ` -

企业版私有化 · SSO · 审计 · 合规

+

企业版私有化 · 角色权限 · Worker 权限组 · SSO · 审计 · 合规

👥 用户与权限 🔐 SSO 单点登录 @@ -2036,32 +2043,28 @@ async function pageEnterprise() { ⚖️ 合规中心
`; - window._entData = {users: users.data, audit: audit.data, sso: sso.data, comp: comp.data}; + window._entData = {users: users.data, audit: audit.data, sso: sso.data, comp: comp.data, + roles: roles.data, groups: groups.data}; entRender(); } function entSwitch(tab) { entTab = tab; entRender(); } +function entSubSwitch(sub) { entSub = sub; entRender(); } function entRender() { const d = window._entData || {}; const box = $('#ent-body'); if (entTab === 'users') { box.innerHTML = ` -
👥 用户列表(RBAC + 精准授权)
- - - - ${(d.users || []).map(u => ` - - - - - - `).join('')} -
用户名显示名角色来源状态最近登录操作
${esc(u.username)}${esc(u.display_name)}${({admin:'👑 管理员',member:'成员',auditor:'审计员'})[u.role] || u.role}${({local:'本地',oidc:'OIDC',ldap:'LDAP'})[u.source] || u.source}${u.status === 'active' ? '✅ 启用' : '🚫 停用'}${fmtTime(u.last_login_at)} -
-
💡 权限体系:角色(管理员=全部;审计员=全量只读;成员=按授权)+ 项目授权(查看/管理/管理员)+ Worker 授权(查看/使用/管理)。成员仅能看到被授权的项目与 Worker,创建项目者自动成为该项目管理员。V1 口令 admin123 兼容登录为 admin。
-
`; + +
`; + if (entSub === 'roles') entRenderRoles(); + else if (entSub === 'groups') entRenderGroups(); + else entRenderUsers(); } else if (entTab === 'sso') { const s = d.sso || {}; box.innerHTML = ` @@ -2173,6 +2176,178 @@ function entRender() { } } +function entRenderUsers() { + const d = window._entData || {}; + const box = $('#ent-sub-body'); + api('/api/enterprise/grants/overview').then(r => { + const gmap = {}; + (r.data || []).forEach(u => gmap[u.id] = u); + box.innerHTML = ` +
👥 用户列表(RBAC + 精准授权)
+ + + + ${(d.users || []).map(u => { + const g = gmap[u.id] || {}; + const extra = [...(g.roles || []).map(rr => `🎭 ${esc(rr.role_name)}`), ...(g.worker_groups || []).map(wg => `🤖 ${esc(wg.group_name)}`)].join('
'); + return ` + + + + + `; + }).join('')} +
用户名显示名角色自定义角色/权限组来源状态操作
${esc(u.username)}${esc(u.display_name)}${({admin:'👑 管理员',member:'成员',auditor:'审计员'})[u.role] || u.role}${extra || ''}${({local:'本地',oidc:'OIDC',ldap:'LDAP'})[u.source] || u.source}${u.status === 'active' ? '✅ 启用' : '🚫 停用'} +
+
💡 权限体系:角色(内置角色可编辑权限点;自定义角色任意创建)+ 项目授权(查看/管理/管理员)+ Worker 授权(查看/使用/管理,可直接授权或通过权限组批量授权)。成员仅能看到被授权的项目与 Worker,创建项目者自动成为该项目管理员。
+
`; + }).catch(() => {}); +} + +/* ---------- 🎭 角色管理 ---------- */ +const PERM_NAMES = {}; +let PERM_GROUPS = []; +async function entRenderRoles() { + const [roles, pts] = await Promise.all([api('/api/enterprise/roles'), api('/api/enterprise/perm_points')]); + window._entData.roles = roles.data; + PERM_GROUPS = pts.data; + pts.data.forEach(g => g.points.forEach(p => PERM_NAMES[p.id] = p.name)); + $('#ent-sub-body').innerHTML = ` +
🎭 角色管理 角色 = 一组权限功能点;管理员可自定义角色、可编辑内置角色权限 +
+ + ${roles.data.map(r => ` + + + + + + `).join('')} +
角色说明权限功能点绑定用户类型操作
${r.is_builtin ? {admin:'👑 ', auditor:'🔍 ', member:'👤 '}[r.name] || '' : '🎭 '}${esc(r.name)}${esc(r.description || '—')}${(r.perms || []).map(p => `${esc(PERM_NAMES[p] || p)}`).join(' ') || ''}${r.user_count} 人${r.is_builtin ? '内置' : '自定义'} + ${r.is_builtin ? '' : ``}
+
💡 自定义角色可被多个用户绑定(在用户授权弹窗中勾选),权限自动合并:内置角色基础权限 ∪ 自定义角色权限。内置角色不可删除但可编辑权限点。
+
`; +} + +async function entRoleModal(rid) { + let r = {name: '', description: '', perms: []}; + if (rid) r = (await api(`/api/enterprise/roles/${rid}`)).data; + const has = new Set(r.perms || []); + openModal(` +

${rid ? '编辑角色' : '新建角色'}

+ + +
权限功能点
+ ${PERM_GROUPS.map(g => ` +
+ ${esc(g.name)} + +
+ ${g.points.map(p => ``).join('')} +
+
`).join('')} + `); + // 组内全选/取消 + $$('#modal-box [data-pgroup]').forEach(group => { + const cbs = group.querySelectorAll('[data-pp]'); + group.querySelector('.pg-all').addEventListener('change', e => cbs.forEach(cb => cb.checked = e.target.checked)); + cbs.forEach(cb => cb.addEventListener('change', () => { + group.querySelector('.pg-all').checked = [...cbs].every(c => c.checked); + })); + }); + $('#rm-save').addEventListener('click', async () => { + const perms = [...$$('#modal-box input[data-pp]:checked')].map(cb => cb.dataset.pp); + if (!$('#rm-name').value.trim()) return toast('请填写角色名', 'err'); + try { + if (rid) await api(`/api/enterprise/roles/${rid}`, {method:'PUT', body:{name: $('#rm-name').value.trim(), description: $('#rm-desc').value, perms}}); + else await api('/api/enterprise/roles', {method:'POST', body:{name: $('#rm-name').value.trim(), description: $('#rm-desc').value, perms}}); + toast('✅ 角色已保存', 'ok'); closeModal(); entRenderRoles(); + } catch (e) { toast(e.message, 'err'); } + }); +} + +async function entRoleDel(rid) { + if (!confirm('删除该角色?绑定该角色的用户将失去其附加权限。')) return; + await api(`/api/enterprise/roles/${rid}`, {method:'DELETE'}); + toast('已删除', 'ok'); entRenderRoles(); +} + +/* ---------- 🤖 Worker 权限组 ---------- */ +async function entRenderGroups() { + const groups = (await api('/api/enterprise/worker_groups')).data; + window._entData.groups = groups; + $('#ent-sub-body').innerHTML = ` +
🤖 Worker 权限组 把一批 Worker+权限打包成组,整组授权给用户,批量管理 +
+ + ${groups.map(g => ` + + + + + `).join('') || ''} +
组名说明成员 Worker(权限)授权用户操作
${esc(g.name)}${esc(g.description || '—')}${(g.members || []).map(m => `${esc(m.worker_name)} · ${{view:'查看',use:'使用',manage:'管理'}[m.perm] || m.perm}`).join(' ') || '空组'}${(g.users || []).map(u => `${esc(u.username)}`).join(' ') || '未授权'} +
还没有权限组,新建一个把常用 Worker 打包吧
+
💡 权限组与直接授权并行生效(取两者更高权限);给用户绑定权限组后,组内 Worker 自动对用户可见。典型用法:把「文案小助手+设计助手」设为「文案组」=use,一次性授权给所有内容运营。
+
`; +} + +async function entGroupModal(gid) { + let g = {name: '', description: '', members: [], users: []}; + if (gid) g = (await api(`/api/enterprise/worker_groups/${gid}`)).data; + const [ws, us] = await Promise.all([api('/api/workers'), api('/api/enterprise/users')]); + const memberMap = {}; + (g.members || []).forEach(m => memberMap[m.worker_id] = m.perm); + const userSet = new Set((g.users || []).map(u => u.user_id)); + openModal(` +

${gid ? '编辑权限组' : '新建权限组'}

+ + +
组内 Worker(勾选 + 权限)
+ + ${ws.data.map(w => ` + + `).join('') || ''} +
暂无 Worker
+
授权给用户
+
+ ${us.data.filter(u => u.role !== 'admin').map(u => ``).join('')} +
+ `); + $$('#modal-box [data-gw]').forEach(cb => cb.addEventListener('change', () => { + const sel = $(`[data-gwperm="${cb.dataset.gw}"]`); + if (sel) sel.disabled = !cb.checked; + })); + $('#gm-save').addEventListener('click', async () => { + const members = [...$$('#modal-box [data-gw]:checked')].map(cb => ({ + worker_id: Number(cb.dataset.gw), + perm: $(`[data-gwperm="${cb.dataset.gw}"]`).value + })); + const users = [...$$('#modal-box [data-gu]:checked')].map(cb => Number(cb.dataset.gu)); + if (!$('#gm-name').value.trim()) return toast('请填写组名', 'err'); + try { + const body = {name: $('#gm-name').value.trim(), description: $('#gm-desc').value, members, users}; + if (gid) await api(`/api/enterprise/worker_groups/${gid}`, {method:'PUT', body}); + else await api('/api/enterprise/worker_groups', {method:'POST', body}); + toast('✅ 权限组已保存', 'ok'); closeModal(); entRenderGroups(); + } catch (e) { toast(e.message, 'err'); } + }); +} + +async function entGroupDel(gid) { + if (!confirm('删除该权限组?组内 Worker 授权将从用户身上移除。')) return; + await api(`/api/enterprise/worker_groups/${gid}`, {method:'DELETE'}); + toast('已删除', 'ok'); entRenderGroups(); +} + function entAddUser() { openModal(`

新建用户

@@ -2224,21 +2399,28 @@ const WORKER_PERMS = [['', '🚫 无'], ['view', '👁️ 查看'], ['use', '⚡ async function entGrantUser(uid, uname) { const r = await api(`/api/enterprise/users/${uid}/grants`); - const {projects, workers} = r.data; + const {projects, workers, roles, worker_groups} = r.data; openModal(`

🔑 精准授权:${esc(uname)}

-
项目权限:查看=看板/DAG/知识库只读;管理=可建任务/执行/上传交付物/发送;管理员=项目全部操作。
- Worker 权限:查看=可见档案;使用=可指派给任务;管理=可改配置。授权后用户登录即可见对应内容。
+
项目权限:查看=只读;管理=建任务/执行/交付;管理员=全部。Worker 权限:查看/使用/管理(可直接授权,也可通过权限组批量)。自定义角色决定功能模块可见性。
+
🎭 绑定自定义角色(可多选,权限与内置角色合并)
+
+ ${(roles || []).filter(rr => !rr.is_builtin || rr.name !== 'admin').map(rr => ``).join('') || '暂无自定义角色'} +
+
🤖 绑定 Worker 权限组(可多选)
+
+ ${(worker_groups || []).map(g => ``).join('') || '暂无权限组,去「Worker 权限组」创建'} +
📁 项目授权(${projects.length} 个)
- +
${projects.map(p => ` `).join('') || ''}
#${p.id} ${esc(p.name)} ${{active:'进行中',planning:'规划中',done:'已完成',archived:'已归档'}[p.status] || p.status}
暂无项目
-
🤖 Worker 授权(${workers.length} 个)
- +
🤖 Worker 直接授权(${workers.length} 个)
+
${workers.map(w => `
#${w.id} ${esc(w.name)} ${esc(w.provider)}/${esc(w.model)} ${w.status === 'enabled' ? '启用' : '停用'} +
用户角色项目授权Worker 授权
${r.data.map(u => ` + `).join('')}
用户角色自定义角色/权限组项目授权Worker 授权
${esc(u.username)}
${esc(u.display_name || '')}
${{admin:'👑 管理员',member:'成员',auditor:'审计员'}[u.role] || u.role}${[...(u.roles || []).map(rr => `🎭 ${esc(rr.role_name)}`), ...(u.worker_groups || []).map(g => `🤖 ${esc(g.group_name)}`)].join('
') || ''}
${(u.projects || []).map(p => `#${p.project_id} ${esc(p.project_name)} ${{view:'查看',manage:'管理',admin:'管理员'}[p.perm]}`).join('
') || ''}
${(u.workers || []).map(w => `#${w.worker_id} ${esc(w.worker_name)} ${{view:'查看',use:'使用',manage:'管理'}[w.perm]}`).join('
') || ''}
@@ -2281,7 +2466,7 @@ let CURRENT_ROLE = 'admin'; const me = await api('/api/me'); if (!me.authed) { showLogin(); $('#main').innerHTML = '
请先登录
'; return; } CURRENT_ROLE = me.user?.role || 'admin'; - applyRoleUI(CURRENT_ROLE); + applyRoleUI(CURRENT_ROLE, me.user?.perms); $('#logout-btn').style.display = 'block'; } catch (e) { showLogin(); return; } try { await api('/api/health'); } catch (e) { $('#conn-state').textContent = '● 服务异常'; $('#conn-state').className = 'conn err'; } diff --git a/static/index.html b/static/index.html index 42281f4..e4b49db 100644 --- a/static/index.html +++ b/static/index.html @@ -11,18 +11,18 @@