- 项目级验收:全部任务完成后进入「待验收」,邮件通知负责人,验收通过才算完成;公开验收链接 /review/<token> 免登录一键通过/打回(打回必填原因) - 打回自动返工:自动生成含负责人意见的返工任务,AI主管立即重做并再次提交验收;平台内也可验收 - 真实网页交付:任务HTML产出自动落盘工作目录(剥离围栏/前置叙述),Demo展示真实页面;返工产出覆盖入口页;老项目已回填 - 事件记录面板:合并AI主管动态+任务日志+交付记录,可展开/收起(记忆状态),任务事件可点击定位 - 邮件通道修复:补 Date/Message-ID 头(amavisd 拒收 invalid header section 根因),打包附件/通知恢复送达;notify.py 改标准 MIME - 规划重试:WBS 拆解失败自动重试3次,仍失败邮件通知负责人 - 项目状态新增 review(待验收),全端展示
664 lines
27 KiB
Python
664 lines
27 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
V2 企业版能力
|
||
- 用户体系 + RBAC(admin / member / auditor)
|
||
- SSO:OIDC 授权码模式 + LDAP 绑定(可选依赖),本地口令兜底
|
||
- 审计日志:全 API 关键操作留痕(actor/action/target/ip)
|
||
- 合规:数据导出(全量 JSON / 审计 CSV)、保留期清理、PII 掩码
|
||
"""
|
||
import csv
|
||
import io
|
||
import json
|
||
import time
|
||
import uuid
|
||
import db
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 审计
|
||
# ---------------------------------------------------------------------------
|
||
PII_PATTERNS = [
|
||
(r'[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}', '<email>'),
|
||
(r'\b1[3-9]\d{9}\b', '<phone>'),
|
||
(r'\b\d{17}[\dXx]\b', '<idcard>'),
|
||
]
|
||
|
||
|
||
def mask_pii(text):
|
||
"""合规:敏感信息掩码"""
|
||
if not text:
|
||
return text
|
||
import re
|
||
for pat, rep in PII_PATTERNS:
|
||
text = re.sub(pat, rep, text)
|
||
return text
|
||
|
||
|
||
def audit(actor, action, target='', detail='', ip='', user_agent=''):
|
||
"""写审计日志(失败不影响主流程)"""
|
||
try:
|
||
if db.get_setting('compliance_mask_pii', '0') == '1':
|
||
detail = mask_pii(detail)
|
||
db.w(
|
||
'INSERT INTO audit_logs (actor, action, target, detail, ip, user_agent, created_at) '
|
||
'VALUES (?,?,?,?,?,?,?)',
|
||
(str(actor)[:100], str(action)[:100], str(target)[:200], str(detail)[:2000],
|
||
str(ip)[:64], str(user_agent)[:200], db.now()))
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def current_actor():
|
||
"""从请求上下文推断操作者(由 app 注入 request-local 变量)"""
|
||
import flask
|
||
try:
|
||
req = flask.request
|
||
actor = getattr(flask.g, 'auth_actor', None)
|
||
if actor:
|
||
return actor
|
||
hdr = req.headers.get('Authorization', '')
|
||
if hdr.startswith('Bearer '):
|
||
r = db.q('SELECT name FROM api_tokens WHERE token=?', (hdr[7:].strip(),), one=True)
|
||
return f'token:{r["name"]}' if r else 'token:?'
|
||
username = flask.session.get('username')
|
||
if username:
|
||
return username
|
||
return 'anonymous'
|
||
except Exception:
|
||
return 'anonymous'
|
||
|
||
|
||
def audit_auto(action, target='', detail='', save_body_keys=None):
|
||
"""装饰器版自动审计:包装 flask 路由"""
|
||
import flask
|
||
import functools
|
||
|
||
def deco(fn):
|
||
@functools.wraps(fn)
|
||
def wrapper(*args, **kwargs):
|
||
resp = fn(*args, **kwargs)
|
||
try:
|
||
detail_text = ''
|
||
if save_body_keys and flask.request.method in ('POST', 'PUT'):
|
||
try:
|
||
body = flask.request.get_json(silent=True) or {}
|
||
detail_text = ' '.join(f'{k}={body.get(k)}' for k in save_body_keys if k in body)
|
||
except Exception:
|
||
pass
|
||
audit(current_actor(), action, target or (flask.request.path or ''),
|
||
detail_text, flask.request.remote_addr or '',
|
||
flask.request.headers.get('User-Agent', ''))
|
||
except Exception:
|
||
pass
|
||
return resp
|
||
return wrapper
|
||
return deco
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 用户 / RBAC
|
||
# ---------------------------------------------------------------------------
|
||
def get_user(username):
|
||
return db.q('SELECT * FROM users WHERE username=?', (username,), one=True)
|
||
|
||
|
||
def role_of(username):
|
||
u = get_user(username)
|
||
return u['role'] if u else 'anonymous'
|
||
|
||
|
||
def is_admin(username):
|
||
return role_of(username) == 'admin'
|
||
|
||
|
||
def create_local_user(username, password, display_name='', role='member', email=''):
|
||
if get_user(username):
|
||
return None, '用户已存在'
|
||
if not email:
|
||
return None, '必填:用户邮箱(用于项目送达/通知)'
|
||
uid = db.w(
|
||
'INSERT INTO users (username, password_hash, display_name, email, role, source, status, created_at) '
|
||
'VALUES (?,?,?,?,?,?,?,?)',
|
||
(username, db.hash_password(password), display_name or username, email, role, 'local', 'active', db.now()))
|
||
return uid, None
|
||
|
||
|
||
def verify_local(username, password):
|
||
u = get_user(username)
|
||
if not u or u['source'] != 'local' or u['status'] != 'active':
|
||
return None
|
||
if db.verify_password(password, u['password_hash']):
|
||
db.w('UPDATE users SET last_login_at=? WHERE id=?', (db.now(), u['id']))
|
||
return u
|
||
return None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# SSO:OIDC(授权码)+ LDAP(可选)
|
||
# ---------------------------------------------------------------------------
|
||
def sso_config():
|
||
cfg = {}
|
||
for k in ('oidc_enabled', 'oidc_name', 'oidc_discovery_url', 'oidc_client_id',
|
||
'oidc_client_secret', 'oidc_redirect_uri', 'oidc_scope', 'oidc_admin_group',
|
||
'ldap_enabled', 'ldap_url', 'ldap_base_dn', 'ldap_bind_dn', 'ldap_bind_password',
|
||
'ldap_user_filter', 'sso_auto_provision'):
|
||
cfg[k] = db.get_setting(k, '')
|
||
return cfg
|
||
|
||
|
||
def save_sso_config(data):
|
||
keys = list(data.keys())
|
||
for k in keys:
|
||
if k in ('oidc_client_secret', 'ldap_bind_password') and not data[k]:
|
||
continue # 留空不覆盖已保存的密钥
|
||
db.set_setting(k, str(data[k]))
|
||
|
||
|
||
def oidc_discovery():
|
||
"""读取 OIDC discovery 文档,返回端点字典"""
|
||
import requests
|
||
url = sso_config().get('oidc_discovery_url', '').strip().rstrip('/')
|
||
if not url:
|
||
raise ValueError('未配置 OIDC discovery URL')
|
||
r = requests.get(url, timeout=15)
|
||
if r.status_code != 200:
|
||
raise ValueError(f'Discovery 请求失败({r.status_code})')
|
||
return r.json()
|
||
|
||
|
||
def oidc_authorize_url(state):
|
||
"""生成授权跳转 URL"""
|
||
import urllib.parse
|
||
cfg = sso_config()
|
||
disc = oidc_discovery()
|
||
params = {
|
||
'response_type': 'code',
|
||
'client_id': cfg['oidc_client_id'],
|
||
'redirect_uri': cfg['oidc_redirect_uri'],
|
||
'scope': cfg.get('oidc_scope') or 'openid profile email',
|
||
'state': state,
|
||
}
|
||
return disc.get('authorization_endpoint') + '?' + urllib.parse.urlencode(params)
|
||
|
||
|
||
def oidc_exchange(code):
|
||
"""用授权码换 token + 用户信息"""
|
||
import requests
|
||
cfg = sso_config()
|
||
disc = oidc_discovery()
|
||
tok = requests.post(disc.get('token_endpoint'), data={
|
||
'grant_type': 'authorization_code',
|
||
'code': code,
|
||
'redirect_uri': cfg['oidc_redirect_uri'],
|
||
'client_id': cfg['oidc_client_id'],
|
||
'client_secret': cfg['oidc_client_secret'],
|
||
}, timeout=15)
|
||
if tok.status_code != 200:
|
||
raise ValueError(f'Token 交换失败({tok.status_code}): {tok.text[:200]}')
|
||
token_data = tok.json()
|
||
id_token = token_data.get('id_token', '')
|
||
userinfo = {}
|
||
# 优先 userinfo 端点
|
||
if token_data.get('access_token'):
|
||
ui = requests.get(disc.get('userinfo_endpoint'), headers={
|
||
'Authorization': f"Bearer {token_data['access_token']}"}, timeout=15)
|
||
if ui.status_code == 200:
|
||
userinfo = ui.json()
|
||
# 解析 id_token payload 兜底
|
||
if not userinfo and id_token:
|
||
import base64
|
||
try:
|
||
payload = id_token.split('.')[1]
|
||
payload += '=' * (-len(payload) % 4)
|
||
userinfo = json.loads(base64.urlsafe_b64decode(payload))
|
||
except Exception:
|
||
pass
|
||
return userinfo
|
||
|
||
|
||
def sso_login(userinfo):
|
||
"""SSO 登录回调:查找或自动开通用户"""
|
||
cfg = sso_config()
|
||
username = userinfo.get('preferred_username') or userinfo.get('email') or userinfo.get('sub') or ''
|
||
email = userinfo.get('email', '')
|
||
display = userinfo.get('name') or userinfo.get('display_name') or username
|
||
groups = userinfo.get('groups') or userinfo.get('roles') or []
|
||
if not username:
|
||
return None, '无法从 SSO 响应中解析用户名'
|
||
u = get_user(username)
|
||
if not u:
|
||
if cfg.get('sso_auto_provision') != '1':
|
||
return None, '用户未开通(自动开通未启用),请联系管理员'
|
||
role = 'admin' if cfg.get('oidc_admin_group') and cfg['oidc_admin_group'] in groups else 'member'
|
||
db.w(
|
||
'INSERT INTO users (username, password_hash, display_name, email, role, source, status, created_at) '
|
||
'VALUES (?,?,?,?,?,?,?,?)',
|
||
(username, '', display, email or f'{username}@sso.local', role, 'oidc', 'active', db.now()))
|
||
u = get_user(username)
|
||
elif u['status'] != 'active':
|
||
return None, '账号已停用'
|
||
elif u['source'] != 'oidc':
|
||
return None, f'用户名 {username} 已被本地账号占用'
|
||
# SSO 邮箱变更时同步(送达通知用最新邮箱)
|
||
if email and u.get('email') != email:
|
||
db.w('UPDATE users SET email=? WHERE id=?', (email, u['id']))
|
||
db.w('UPDATE users SET last_login_at=? WHERE id=?', (db.now(), u['id']))
|
||
return u, None
|
||
|
||
|
||
def ldap_authenticate(username, password):
|
||
"""LDAP 绑定认证(依赖 ldap3,未安装时返回 None)"""
|
||
try:
|
||
from ldap3 import Server, Connection, ALL
|
||
except ImportError:
|
||
return None, '未安装 ldap3,无法使用 LDAP SSO'
|
||
cfg = sso_config()
|
||
try:
|
||
server = Server(cfg['ldap_url'], get_info=ALL)
|
||
conn = Connection(server, user=cfg['ldap_bind_dn'], password=cfg['ldap_bind_password'],
|
||
auto_bind=True)
|
||
user_filter = cfg.get('ldap_user_filter') or '(uid={username})'
|
||
conn.search(cfg['ldap_base_dn'], user_filter.format(username=username), attributes=['cn', 'mail', 'displayName'])
|
||
if not conn.entries:
|
||
return None, 'LDAP 中未找到该用户'
|
||
entry = conn.entries[0]
|
||
user_dn = entry.entry_dn
|
||
conn.unbind()
|
||
conn2 = Connection(server, user=user_dn, password=password, auto_bind=True)
|
||
conn2.unbind()
|
||
return {'username': username,
|
||
'display': str(entry.displayName.value) if hasattr(entry, 'displayName') else username,
|
||
'email': str(entry.mail.value) if hasattr(entry, 'mail') else ''}, None
|
||
except Exception as e:
|
||
return None, f'LDAP 认证失败: {e}'
|
||
|
||
|
||
def ldap_login(username, password):
|
||
info, err = ldap_authenticate(username, password)
|
||
if err:
|
||
return None, err
|
||
u = get_user(username)
|
||
if not u:
|
||
if sso_config().get('sso_auto_provision') != '1':
|
||
return None, '用户未开通,请联系管理员'
|
||
db.w(
|
||
'INSERT INTO users (username, password_hash, display_name, email, role, source, status, created_at) '
|
||
'VALUES (?,?,?,?,?,?,?,?)',
|
||
(username, '', info.get('display') or username, info.get('email') or f'{username}@ldap.local',
|
||
'member', 'ldap', 'active', db.now()))
|
||
u = get_user(username)
|
||
elif u['status'] != 'active':
|
||
return None, '账号已停用'
|
||
if info.get('email') and u.get('email') != info['email']:
|
||
db.w('UPDATE users SET email=? WHERE id=?', (info['email'], u['id']))
|
||
db.w('UPDATE users SET last_login_at=? WHERE id=?', (db.now(), u['id']))
|
||
return u, None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 合规:导出 / 保留期
|
||
# ---------------------------------------------------------------------------
|
||
def export_all():
|
||
"""全量数据导出(JSON)"""
|
||
tables = ['projects', 'workers', 'tasks', 'task_logs', 'cost_records', 'documents',
|
||
'agent_runs', 'agent_steps', 'eval_datasets', 'eval_cases', 'eval_runs',
|
||
'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': 'v3.4.0'}
|
||
for t in tables:
|
||
try:
|
||
out[t] = db.q(f'SELECT * FROM {t}')
|
||
except Exception:
|
||
out[t] = []
|
||
return out
|
||
|
||
|
||
def export_audit_csv():
|
||
"""审计日志导出 CSV"""
|
||
rows = db.q('SELECT * FROM audit_logs ORDER BY id DESC LIMIT 10000')
|
||
buf = io.StringIO()
|
||
w = csv.writer(buf)
|
||
w.writerow(['ID', '时间', '操作者', '动作', '目标', '详情', 'IP', 'UA'])
|
||
for r in rows:
|
||
w.writerow([r['id'], time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(r['created_at'])),
|
||
r['actor'], r['action'], r['target'], r['detail'], r['ip'], r['user_agent']])
|
||
return buf.getvalue()
|
||
|
||
|
||
def apply_retention():
|
||
"""合规:按保留期清理审计日志 / 协作运行 / 评估结果(每天可跑一次)"""
|
||
days = int(db.get_setting('compliance_retention_days', '0') or 0)
|
||
if days <= 0:
|
||
return {'cleaned': 0, 'note': '未配置保留期(0=永久保留)'}
|
||
cutoff = db.now() - days * 86400
|
||
cleaned = 0
|
||
for table in ('audit_logs', 'agent_steps', 'agent_runs', 'eval_results', 'eval_runs'):
|
||
try:
|
||
cur = db.w(f'DELETE FROM {table} WHERE created_at<?', (cutoff,))
|
||
cleaned += cur or 0
|
||
except Exception:
|
||
pass
|
||
return {'cleaned': cleaned, 'retention_days': days}
|
||
|
||
|
||
def generate_consent_token():
|
||
"""生成数据使用同意记录 token(审计用途)"""
|
||
tok = uuid.uuid4().hex[:12]
|
||
audit('system', 'compliance.consent', '数据使用同意', f'consent_token={tok}')
|
||
return tok
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# V3 授权:用户 ↔ 项目 / 用户 ↔ Worker(精准权限)
|
||
# ---------------------------------------------------------------------------
|
||
PERM_LEVEL = {'view': 0, 'use': 1, 'manage': 2, 'admin': 3}
|
||
PROJECT_PERMS = ('view', 'manage', 'admin')
|
||
WORKER_PERMS = ('view', 'use', 'manage')
|
||
|
||
|
||
def perm_ok(have, need):
|
||
"""have 权限是否满足 need 权限(None 视为无权限)"""
|
||
if not have:
|
||
return False
|
||
return PERM_LEVEL.get(have, -1) >= PERM_LEVEL.get(need, 99)
|
||
|
||
|
||
def user_project_perm(user_id, project_id):
|
||
"""用户在项目上的权限:None / view / manage / admin"""
|
||
r = db.q('SELECT perm FROM user_projects WHERE user_id=? AND project_id=?',
|
||
(user_id, project_id), one=True)
|
||
return r['perm'] if r else None
|
||
|
||
|
||
def user_worker_perm(user_id, worker_id):
|
||
"""用户在 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)
|
||
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):
|
||
"""用户可见的项目 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 project_id FROM user_projects WHERE user_id=?', (user_id,))
|
||
return [r['project_id'] for r in rows]
|
||
|
||
|
||
def visible_worker_ids(user_id):
|
||
"""用户可见的 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,))
|
||
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, 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:
|
||
perm = (g.get('perm') or '').strip()
|
||
if perm not in PROJECT_PERMS:
|
||
continue
|
||
pid = int(g.get('project_id') or 0)
|
||
if not db.q('SELECT id FROM projects WHERE id=?', (pid,), one=True):
|
||
continue
|
||
db.w('INSERT INTO user_projects (user_id, project_id, perm, created_at) VALUES (?,?,?,?)',
|
||
(user_id, pid, perm, db.now()))
|
||
out['projects'] += 1
|
||
if workers is not None:
|
||
db.w('DELETE FROM user_workers WHERE user_id=?', (user_id,))
|
||
for g in workers:
|
||
perm = (g.get('perm') or '').strip()
|
||
if perm not in WORKER_PERMS:
|
||
continue
|
||
wid = int(g.get('worker_id') or 0)
|
||
if not db.q('SELECT id FROM workers WHERE id=?', (wid,), one=True):
|
||
continue
|
||
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/角色/权限组,供管理界面展示"""
|
||
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'])
|
||
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]
|