feat: add RBAC tools — list_path_roles, find_unauth_files, and permission registration script

This commit is contained in:
yumoqing 2026-05-26 09:18:04 +08:00
parent c53c16d54c
commit 0b456486db
7 changed files with 329 additions and 11 deletions

View File

@ -5,6 +5,7 @@ from .orgs import (
get_platform_providers get_platform_providers
) )
from .userperm import UserPermissions from .userperm import UserPermissions
from .user_stats import get_user_stats
from rbac.check_perm import ( from rbac.check_perm import (
objcheckperm, objcheckperm,
get_org_users, get_org_users,
@ -76,6 +77,7 @@ def load_rbac():
env.sor_get_org_users = sor_get_org_users env.sor_get_org_users = sor_get_org_users
env.get_owner_orgid = get_owner_orgid env.get_owner_orgid = get_owner_orgid
env.sor_add_user_roles = sor_add_user_roles env.sor_add_user_roles = sor_add_user_roles
env.get_user_stats = get_user_stats
# Cache invalidation methods for use after role/permission changes # Cache invalidation methods for use after role/permission changes
env.invalidate_user_perm_cache = env.userpermissions.invalidate_user_cache env.invalidate_user_perm_cache = env.userpermissions.invalidate_user_cache
env.invalidate_all_perm_caches = env.userpermissions.invalidate_all_user_caches env.invalidate_all_perm_caches = env.userpermissions.invalidate_all_user_caches

59
rbac/user_stats.py Normal file
View File

@ -0,0 +1,59 @@
from sqlor.dbpools import get_sor_context
from appPublic.timeUtils import curDateString, timestampstr
from datetime import datetime, timedelta
from appPublic.log import debug, exception
async def get_user_stats(request):
"""Get user statistics for the platform"""
env = request._run_ns
today = curDateString()
tomorrow = (datetime.now() + timedelta(days=1)).strftime('%Y-%m-%d')
month_start = datetime.now().strftime('%Y-%m-01')
stats = {
'total_users': 0,
'active_users_today': 0,
'new_users_this_month': 0,
'total_orgs': 0
}
async with get_sor_context(env, 'rbac') as sor:
# Total users
sql_users = """
SELECT COUNT(*) as cnt FROM users
"""
recs = await sor.sqlExe(sql_users, {})
if recs:
stats['total_users'] = int(recs[0].cnt or 0)
# Active users today (users with llmusage records today)
sql_active = """
SELECT COUNT(DISTINCT userid) as cnt FROM llmusage
WHERE use_date >= ${today}$
AND use_date < ${tomorrow}$
"""
recs = await sor.sqlExe(sql_active, {
'today': today,
'tomorrow': tomorrow
})
if recs:
stats['active_users_today'] = int(recs[0].cnt or 0)
# New users this month
sql_new = """
SELECT COUNT(*) as cnt FROM users
WHERE created_date >= ${month_start}$
"""
recs = await sor.sqlExe(sql_new, {'month_start': month_start})
if recs:
stats['new_users_this_month'] = int(recs[0].cnt or 0)
# Total organizations
sql_orgs = """
SELECT COUNT(*) as cnt FROM organization
"""
recs = await sor.sqlExe(sql_orgs, {})
if recs:
stats['total_orgs'] = int(recs[0].cnt or 0)
return stats

View File

@ -0,0 +1,87 @@
#!/usr/bin/env python
"""
注册 RBAC 工具的权限到数据库
运行在 Sage Python 虚拟环境中
用法:
./py3/bin/python ../rbac/script/register_rbac_tools_perm.py
或在 Sage 根目录执行:
cd ~/repos/sage && ./py3/bin/python ../rbac/script/register_rbac_tools_perm.py
"""
import os
import sys
import asyncio
# 确保 Sage 路径在 sys.path 中
sage_root = os.environ.get('SAGE_ROOT')
if sage_root and sage_root not in sys.path:
sys.path.insert(0, sage_root)
from sqlor.dbpools import DBPools
from appPublic.jsonConfig import getConfig
from appPublic.uniqueID import getID
# 需要注册的权限列表: (path, role)
permissions = [
('/rbac/list_path_roles.ui', 'owner.superuser'),
('/rbac/list_path_roles.dspy', 'owner.superuser'),
('/rbac/find_unauth_files.dspy', 'owner.superuser'),
('/rbac/admin_menu.ui', 'owner.superuser'),
]
async def main():
config = getConfig('.')
db = DBPools(config.databases)
registered = 0
async with db.sqlorContext('sage') as sor:
# 查找 superuser 角色 ID
role_recs = await sor.sqlExe(
"SELECT id FROM role WHERE orgtypeid='owner' AND name='superuser'", {}
)
if not role_recs:
print("错误: 未找到 owner.superuser 角色")
sys.exit(1)
superuser_id = role_recs[0].id
print(f"superuser role_id: {superuser_id}")
for path, role in permissions:
# 检查 permission 是否已存在
existing_perm = await sor.sqlExe(
"SELECT id FROM permission WHERE path=${path}$", {'path': path}
)
if existing_perm:
perm_id = existing_perm[0].id
print(f" permission 已存在: {path} (id={perm_id})")
else:
perm_id = getID()
await sor.C('permission', {'id': perm_id, 'path': path})
print(f" + permission: {path}")
# 检查 rolepermission 是否已存在
existing_rp = await sor.sqlExe(
"SELECT id FROM rolepermission WHERE roleid=${roleid}$ AND permid=${permid}$",
{'roleid': superuser_id, 'permid': perm_id}
)
if existing_rp:
print(f" rolepermission 已存在")
else:
await sor.C('rolepermission', {
'id': getID(),
'roleid': superuser_id,
'permid': perm_id
})
registered += 1
print(f" + rolepermission: superuser -> {path}")
print(f"\n共注册 {registered} 条新权限。")
if registered > 0:
print("请重启 Sage 以刷新权限缓存。")
else:
print("所有权限已存在,无需操作。")
if __name__ == '__main__':
asyncio.get_event_loop().run_until_complete(main())

View File

@ -2,25 +2,36 @@
import os import os
wwwroot = params_kw.get('wwwroot', '').strip() wwwroot = params_kw.get('wwwroot', '').strip()
if not wwwroot:
wwwroot = None
from appPublic.dictObject import DictObject
# 定位 wwwroot # 定位 wwwroot
if not wwwroot: if not wwwroot:
# 默认: 当前模块 wwwroot 的父目录 wwwroot
# rbac/wwwroot -> 找 sage/wwwroot
# 如果 sage_root 环境变量存在
sage_root = os.environ.get('SAGE_ROOT') sage_root = os.environ.get('SAGE_ROOT')
if sage_root: if sage_root:
wwwroot = os.path.join(sage_root, 'wwwroot') wwwroot = os.path.join(sage_root, 'wwwroot')
else: else:
# 尝试从当前文件路径推断: rbac/wwwroot/xxx.dspy -> sage/wwwroot # 从当前文件路径向上找 wwwroot
# 生产: ~/token/sage/wwwroot/rbac/find_unauth_files.dspy
# 开发: ~/repos/sage/wwwroot/rbac/find_unauth_files.dspy (通过symlink)
# 或: ~/repos/rbac/wwwroot/find_unauth_files.dspy (rbac repo自身)
this_file = os.path.abspath(__file__) this_file = os.path.abspath(__file__)
# 通常在 repos/sage/wwwroot/rbac/find_unauth_files.dspy # 如果路径中包含 /wwwroot/ 段,直接截断
# wwwroot 是上一层 if '/wwwroot/' in this_file:
wwwroot = os.path.dirname(os.path.dirname(this_file)) idx = this_file.index('/wwwroot/')
wwwroot = this_file[:idx + len('/wwwroot')]
else:
# 尝试找 wwwroot 子目录
for level in range(5):
candidate = this_file
for _ in range(level):
candidate = os.path.dirname(candidate)
if not candidate or candidate == '/':
break
wr = os.path.join(candidate, 'wwwroot')
if os.path.isdir(wr):
wwwroot = wr
break
if not wwwroot:
return UiError(title='错误', message='无法自动定位 wwwroot 目录,请通过参数传入 wwwroot 路径,或设置 SAGE_ROOT 环境变量。')
wwwroot = os.path.abspath(wwwroot) wwwroot = os.path.abspath(wwwroot)
if not os.path.isdir(wwwroot): if not os.path.isdir(wwwroot):

View File

@ -0,0 +1,53 @@
{% set stats = get_user_stats(request) %}
{
"widgettype": "VBox",
"options": {
"bgcolor": "#1E293B",
"padding": "20px",
"borderRadius": "12px",
"border": "1px solid #334155",
"flex": "1",
"minHeight": "110px"
},
"subwidgets": [
{
"widgettype": "HBox",
"options": {
"alignItems": "center",
"marginBottom": "12px"
},
"subwidgets": [
{
"widgettype": "Svg",
"options": {
"svg": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#22C55E\" stroke-width=\"2\"><path d=\"M15.91 11.672a.375.375 0 010 .656l-5.603 3.113a.375.375 0 01-.557-.328V8.887c0-.286.307-.466.557-.327l5.603 3.112z\"/><path d=\"M21 12a9 9 0 11-18 0 9 9 0 0118 0z\"/></svg>",
"width": "24px",
"height": "24px"
}
},
{
"widgettype": "Filler"
}
]
},
{
"widgettype": "Text",
"options": {
"text": "{{stats.active_users_today}}",
"fontSize": "32px",
"fontWeight": "700",
"color": "#F1F5F9",
"lineHeight": "1.1"
}
},
{
"widgettype": "Text",
"options": {
"text": "今日活跃用户",
"fontSize": "14px",
"color": "#94A3B8",
"marginTop": "4px"
}
}
]
}

View File

@ -0,0 +1,53 @@
{% set stats = get_user_stats(request) %}
{
"widgettype": "VBox",
"options": {
"bgcolor": "#1E293B",
"padding": "20px",
"borderRadius": "12px",
"border": "1px solid #334155",
"flex": "1",
"minHeight": "110px"
},
"subwidgets": [
{
"widgettype": "HBox",
"options": {
"alignItems": "center",
"marginBottom": "12px"
},
"subwidgets": [
{
"widgettype": "Svg",
"options": {
"svg": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#8B5CF6\" stroke-width=\"2\"><path d=\"M19 7.5v3m0 0v3m0 0v3m0 0v3m0 0h-3m0 0h-3m0 0h-3m0 0h-3m0 0v-3m0 0V12m0 0V7.5M5 21h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2z\"/></svg>",
"width": "24px",
"height": "24px"
}
},
{
"widgettype": "Filler"
}
]
},
{
"widgettype": "Text",
"options": {
"text": "{{stats.total_orgs}}",
"fontSize": "32px",
"fontWeight": "700",
"color": "#F1F5F9",
"lineHeight": "1.1"
}
},
{
"widgettype": "Text",
"options": {
"text": "组织机构数",
"fontSize": "14px",
"color": "#94A3B8",
"marginTop": "4px"
}
}
]
}

View File

@ -0,0 +1,53 @@
{% set stats = get_user_stats(request) %}
{
"widgettype": "VBox",
"options": {
"bgcolor": "#1E293B",
"padding": "20px",
"borderRadius": "12px",
"border": "1px solid #334155",
"flex": "1",
"minHeight": "110px"
},
"subwidgets": [
{
"widgettype": "HBox",
"options": {
"alignItems": "center",
"marginBottom": "12px"
},
"subwidgets": [
{
"widgettype": "Svg",
"options": {
"svg": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#3B82F6\" stroke-width=\"2\"><path d=\"M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z\"/></svg>",
"width": "24px",
"height": "24px"
}
},
{
"widgettype": "Filler"
}
]
},
{
"widgettype": "Text",
"options": {
"text": "{{stats.total_users}}",
"fontSize": "32px",
"fontWeight": "700",
"color": "#F1F5F9",
"lineHeight": "1.1"
}
},
{
"widgettype": "Text",
"options": {
"text": "总用户数",
"fontSize": "14px",
"color": "#94A3B8",
"marginTop": "4px"
}
}
]
}