Compare commits

..

6 Commits

Author SHA1 Message Date
835a2ff3f7 fix: add filler css + overflowY:auto to content container 2026-05-26 13:57:42 +08:00
8cec17c042 feat: add cross-process cache invalidation via Redis Pub/Sub
- userperm.py: All invalidate_* and on_* handlers changed to async
  - Each invalidation now broadcasts via cache_sync.invalidate()
  - invalidate_user_cache() -> 'rbac:ur:{userid}'
  - invalidate_all_user_caches() -> 'rbac:ur:all'
  - invalidate_rp_cache() -> 'rbac:rp'

- init.py: Added start_cache_sync() async function
  - Starts Redis Pub/Sub subscription
  - Registers callbacks for rbac:rp and rbac:ur:all channels

- set_role_perms.py: CLI script now sends invalidation after execution
  - send_rbac_invalidation() starts cache_sync, publishes, then stops

Compatible with existing EventDispatcher (already supports async handlers)
2026-05-26 13:52:10 +08:00
1b21f46336 feat: add index.ui as module entry with user management, path roles, and unauth file scan cards 2026-05-26 12:11:32 +08:00
f8c8a4ce4d refactor: move RBAC tools logic to rbac/rbac_tools.py, dspy files call via request._run_ns 2026-05-26 09:32:38 +08:00
0b456486db feat: add RBAC tools — list_path_roles, find_unauth_files, and permission registration script 2026-05-26 09:18:04 +08:00
c53c16d54c feat: add RBAC tools — list_path_roles and find_unauth_files 2026-05-26 09:12:33 +08:00
14 changed files with 901 additions and 27 deletions

View File

@ -5,6 +5,11 @@ from .orgs import (
get_platform_providers
)
from .userperm import UserPermissions
from .user_stats import get_user_stats
from .rbac_tools import (
query_path_roles,
scan_unauth_files
)
from rbac.check_perm import (
objcheckperm,
get_org_users,
@ -21,6 +26,7 @@ from rbac.set_role_perms import (
set_role_perms
)
from appPublic.log import debug
from ahserver.cache_sync import get_cache_sync
async def get_owner_orgid(*args, **kw):
return '0'
@ -59,6 +65,27 @@ def _bind_rbac_events(dbpools, dbname, up):
debug(f'RBAC event bound: {event_name}')
async def start_cache_sync():
"""Start cache_sync and register RBAC reload callbacks."""
env = ServerEnv()
cache_sync = get_cache_sync()
# Get Redis URL from session config
try:
redis_url = env.conf.website.session_redis.url
except AttributeError:
redis_url = "redis://127.0.0.1:6379"
await cache_sync.start(redis_url)
debug(f'RBAC cache_sync started with Redis URL: {redis_url}')
# Register callbacks for cache invalidation messages from other processes
up = env.userpermissions
cache_sync.register('rbac:rp', up.invalidate_rp_cache)
cache_sync.register('rbac:ur:all', up.invalidate_all_user_caches)
# Note: rbac:ur:{userid} callbacks are handled by the invalidate_user_cache method itself
def load_rbac():
AuthAPI.checkUserPermission = objcheckperm
env = ServerEnv()
@ -76,6 +103,9 @@ def load_rbac():
env.sor_get_org_users = sor_get_org_users
env.get_owner_orgid = get_owner_orgid
env.sor_add_user_roles = sor_add_user_roles
env.get_user_stats = get_user_stats
env.query_path_roles = query_path_roles
env.scan_unauth_files = scan_unauth_files
# Cache invalidation methods for use after role/permission changes
env.invalidate_user_perm_cache = env.userpermissions.invalidate_user_cache
env.invalidate_all_perm_caches = env.userpermissions.invalidate_all_user_caches

228
rbac/rbac_tools.py Normal file
View File

@ -0,0 +1,228 @@
"""
RBAC 工具函数 权限查询与文件扫描
提供两个功能
1. 查询指定路径拥有权限的所有角色
2. 扫描 wwwroot 下符号链接目录中未授权的文件
.dspy 文件应调用此模块的函数通过 request._run_ns自身不做 import
每个函数返回 (title, message, is_error) 三元组.dspy 据此返回 UiMessage UiError
"""
import os
from collections import defaultdict
async def query_path_roles(sor, path):
"""
查询指定路径拥有权限的角色列表
Returns:
(title, message, is_error)
"""
if not path:
return ('错误', '请输入路径', True)
if not path.startswith('/'):
path = '/' + path
perm_recs = await sor.sqlExe(
"SELECT id, path FROM permission WHERE path=${path}$",
{'path': path}
)
if not perm_recs:
like_path = path.rstrip('/') + '/%'
like_recs = await sor.sqlExe(
"SELECT path FROM permission WHERE path LIKE ${lp}$",
{'lp': like_path}
)
msg = f"路径 '{path}' 未在 permission 表中注册。"
if like_recs:
sub = '<br>'.join([f' {r.path}' for r in like_recs[:10]])
if len(like_recs) > 10:
sub += f'<br>... 共 {len(like_recs)}'
msg += f'<br><br>模糊匹配到 {len(like_recs)} 个子路径:<br>{sub}'
return ('未找到', msg, True)
perm = perm_recs[0]
permid = perm.id
rp_recs = await sor.sqlExe(
"SELECT roleid FROM rolepermission WHERE permid=${permid}$",
{'permid': permid}
)
if not rp_recs:
msg = f"路径: <b>{path}</b> (perm_id: {permid})<br><br>无任何角色拥有此路径权限。"
return ('查询结果', msg, False)
role_ids = [r.roleid for r in rp_recs]
special = [rid for rid in role_ids if rid in ('any', 'anonymous', 'logined')]
normal = [rid for rid in role_ids if rid not in ('any', 'anonymous', 'logined')]
rows = []
for sp in special:
rows.append(f"<tr><td>{sp}</td><td>*</td><td>{sp}</td></tr>")
if normal:
placeholders = ','.join([f'${i}$' for i in range(len(normal))])
ns = {f'_{i}': rid for i, rid in enumerate(normal)}
role_recs = await sor.sqlExe(
f"SELECT id, orgtypeid, name FROM role WHERE id IN ({placeholders})",
ns
)
for r in role_recs:
rows.append(
f"<tr><td>{r.id}</td>"
f"<td>{getattr(r, 'orgtypeid', '*')}</td>"
f"<td>{getattr(r, 'name', r.id)}</td></tr>"
)
table = (
"<table style='border-collapse:collapse;width:100%;'>"
"<tr style='background:#334155;color:#fff;'>"
"<th style='padding:6px 12px;text-align:left;border:1px solid #475569;'>角色ID</th>"
"<th style='padding:6px 12px;text-align:left;border:1px solid #475569;'>orgtypeid</th>"
"<th style='padding:6px 12px;text-align:left;border:1px solid #475569;'>名称</th>"
"</tr>"
+ ''.join(rows) +
"</table>"
)
html = (
f"<p>路径: <b>{path}</b> (perm_id: {permid})</p>"
f"<p>共 {len(rp_recs)} 个角色有权限:</p>"
f"{table}"
)
return ('查询结果', html, False)
def find_symlink_dirs(root):
"""找出指定目录下直接子目录中是符号链接的目录。"""
symlinks = []
for entry in sorted(os.listdir(root)):
full = os.path.join(root, entry)
if os.path.islink(full) and os.path.isdir(full):
target = os.readlink(full)
symlinks.append((entry, full, target))
return symlinks
def walk_symlink_dirs(root, symlinks):
"""
遍历所有符号链接目录下的文件
Returns: list of (relative_path, absolute_path)
"""
real_root = os.path.realpath(root)
visited_real = set()
files = []
for name, link_path, target in symlinks:
for r, dirs, filenames in os.walk(link_path, followlinks=True):
real_r = os.path.realpath(r)
if real_r in visited_real:
dirs.clear()
continue
visited_real.add(real_r)
dirs[:] = [d for d in dirs if os.path.realpath(os.path.join(r, d)) != real_root]
for fname in sorted(filenames):
abs_path = os.path.join(r, fname)
rel = '/' + os.path.relpath(abs_path, root)
files.append((rel, abs_path))
return files
def resolve_wwwroot(wwwroot_param, file_path):
"""自动定位 wwwroot 目录。"""
if wwwroot_param:
wwwroot_param = os.path.abspath(wwwroot_param)
if os.path.isdir(wwwroot_param):
return wwwroot_param, None
return None, f"wwwroot 目录不存在: {wwwroot_param}"
sage_root = os.environ.get('SAGE_ROOT')
if sage_root:
wr = os.path.join(sage_root, 'wwwroot')
if os.path.isdir(wr):
return wr, None
return None, f"SAGE_ROOT 指向的 wwwroot 不存在: {wr}"
if '/wwwroot/' in file_path:
idx = file_path.index('/wwwroot/')
wr = file_path[:idx + len('/wwwroot')]
if os.path.isdir(wr):
return wr, None
candidate = file_path
for _ in range(5):
candidate = os.path.dirname(candidate)
if not candidate or candidate == '/':
break
wr = os.path.join(candidate, 'wwwroot')
if os.path.isdir(wr):
return wr, None
return None, '无法自动定位 wwwroot 目录,请传入 wwwroot 参数或设置 SAGE_ROOT 环境变量。'
async def scan_unauth_files(sor, wwwroot_param, file_path):
"""
扫描 wwwroot 下符号链接目录中未授权的文件
Returns:
(title, message, is_error)
"""
wwwroot, err = resolve_wwwroot(wwwroot_param, file_path)
if err:
return ('错误', err, True)
symlinks = find_symlink_dirs(wwwroot)
if not symlinks:
return ('扫描结果', f"{wwwroot} 中未发现任何符号链接目录。", False)
all_files = walk_symlink_dirs(wwwroot, symlinks)
symlink_info = '<br>'.join([
f" {name}/ &rarr; {target}" for name, _, target in symlinks
])
perm_recs = await sor.sqlExe("SELECT id, path FROM permission", {})
path_to_permid = {r.path: r.id for r in perm_recs}
rp_recs = await sor.sqlExe("SELECT DISTINCT permid FROM rolepermission", {})
perms_with_roles = {r.permid for r in rp_recs}
unauth = []
authed = 0
for rel_path, abs_path in all_files:
permid = path_to_permid.get(rel_path)
if permid is None:
unauth.append((rel_path, '路径未注册'))
elif permid not in perms_with_roles:
unauth.append((rel_path, '有记录但无角色'))
else:
authed += 1
by_module = defaultdict(list)
for rel_path, reason in unauth:
parts = rel_path.strip('/').split('/')
module = parts[0] if parts else 'root'
by_module[module].append((rel_path, reason))
html = (
f"<p><b>wwwroot:</b> {wwwroot}</p>"
f"<p><b>符号链接目录 ({len(symlinks)}):</b><br>{symlink_info}</p>"
f"<p>总文件数: {len(all_files)} | 已授权: {authed} | 未授权: {len(unauth)}</p>"
)
if not unauth:
html += "<p style='color:#22C55E;font-weight:bold;'>所有文件均有权限覆盖,无需处理。</p>"
else:
html += f"<p style='color:#EF4444;font-weight:bold;'>未授权文件 ({len(unauth)} 个):</p>"
for module in sorted(by_module.keys()):
items = by_module[module]
html += f"<h4 style='color:#FBBF24;'>{module}/ ({len(items)} 个文件)</h4>"
html += "<ul style='font-family:monospace;font-size:12px;'>"
for rel_path, reason in items[:50]:
color = '#EF4444' if reason == '路径未注册' else '#F97316'
html += f"<li style='color:{color};'>[{reason}] {rel_path}</li>"
if len(items) > 50:
html += f"<li>... 还有 {len(items) - 50} 个文件</li>"
html += "</ul>"
return ('扫描结果', html, False)

View File

@ -114,6 +114,31 @@ async def set_role_perms(dbname, module, orgtype, role, items):
for tblname in items:
await set_role_perm(dbname, module, orgtype, role, tblname)
async def send_rbac_invalidation():
"""Send cache invalidation message to all processes via Redis Pub/Sub."""
try:
from ahserver.cache_sync import get_cache_sync
cache_sync = get_cache_sync()
# Use default Redis URL for CLI scripts
try:
from ahserver.serverenv import ServerEnv
env = ServerEnv()
redis_url = env.conf.website.session_redis.url
except (AttributeError, Exception):
redis_url = "redis://127.0.0.1:6379"
await cache_sync.start(redis_url)
# Invalidate both role-permission and all user caches
# (CLI scripts typically change permissions/roles)
await cache_sync.invalidate('rbac:rp')
await cache_sync.invalidate('rbac:ur:all')
debug('RBAC CLI: sent cache invalidation messages')
# Give a moment for the message to be published
await asyncio.sleep(0.1)
await cache_sync.stop()
except Exception as e:
print(f'Warning: Failed to send cache invalidation: {e}')
if __name__ == '__main__':
async def main():
if len(sys.argv) < 6:
@ -124,6 +149,8 @@ if __name__ == '__main__':
orgtype = sys.argv[3]
role = sys.argv[4]
await set_role_perms(dbname, module, orgtype, role, sys.argv[5:])
# Send invalidation message to all running Sage processes
await send_rbac_invalidation()
def run(coro):
p = '.'

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

@ -4,6 +4,8 @@ from sqlor.dbpools import get_sor_context
from ahserver.serverenv import ServerEnv
from appPublic.Singleton import SingletonDecorator
from appPublic.log import debug, error
from ahserver.cache_sync import get_cache_sync
class LRUCache:
"""Async-safe LRU cache with TTL support.
@ -81,82 +83,82 @@ class UserPermissions:
# Async lock for rp_caches initialization (lazy init)
self._rp_lock = None
def on_user_update(self, data):
async def on_user_update(self, data):
"""Event handler for users table update.
Clears the specific user's permission cache.
"""
try:
userid = getattr(data, 'id', None)
if userid:
self.invalidate_user_cache(userid)
await self.invalidate_user_cache(userid)
debug(f'RBAC cache invalidated for user id={userid} (users update)')
except Exception as e:
error(f'RBAC on_user_update handler error: {e}')
def on_user_create(self, data):
async def on_user_create(self, data):
"""Event handler for users table insert.
Clears the specific user's permission cache.
"""
try:
userid = getattr(data, 'id', None)
if userid:
self.invalidate_user_cache(userid)
await self.invalidate_user_cache(userid)
debug(f'RBAC cache invalidated for user id={userid} (users create)')
except Exception as e:
error(f'RBAC on_user_create handler error: {e}')
def on_user_delete(self, data):
async def on_user_delete(self, data):
"""Event handler for users table delete.
Clears the specific user's permission cache.
"""
try:
userid = getattr(data, 'id', None)
if userid:
self.invalidate_user_cache(userid)
await self.invalidate_user_cache(userid)
debug(f'RBAC cache invalidated for user id={userid} (users delete)')
except Exception as e:
error(f'RBAC on_user_delete handler error: {e}')
def on_rolepermission_change(self, data):
async def on_rolepermission_change(self, data):
"""Event handler for rolepermission table C/U/D.
Clears the role-permission cache.
"""
try:
self.invalidate_rp_cache()
await self.invalidate_rp_cache()
debug('RBAC role-permission cache invalidated (rolepermission change)')
except Exception as e:
error(f'RBAC on_rolepermission_change handler error: {e}')
def on_permission_change(self, data):
async def on_permission_change(self, data):
"""Event handler for permission table update.
Clears the role-permission cache.
"""
try:
self.invalidate_rp_cache()
await self.invalidate_rp_cache()
debug('RBAC role-permission cache invalidated (permission change)')
except Exception as e:
error(f'RBAC on_permission_change handler error: {e}')
def on_role_change(self, data):
async def on_role_change(self, data):
"""Event handler for role table C/U/D.
Clears all user caches and role-permission cache,
since role changes may affect any user.
"""
try:
self.invalidate_all_user_caches()
self.invalidate_rp_cache()
await self.invalidate_all_user_caches()
await self.invalidate_rp_cache()
debug('RBAC all caches invalidated (role change)')
except Exception as e:
error(f'RBAC on_role_change handler error: {e}')
def on_userrole_change(self, data):
async def on_userrole_change(self, data):
"""Event handler for userrole table C/U/D.
Clears the specific user's permission cache based on userid.
"""
try:
userid = getattr(data, 'userid', None)
if userid:
self.invalidate_user_cache(userid)
await self.invalidate_user_cache(userid)
debug(f'RBAC cache invalidated for user id={userid} (userrole change)')
except Exception as e:
error(f'RBAC on_userrole_change handler error: {e}')
@ -180,20 +182,37 @@ class UserPermissions:
return self.ur_caches.get(userid)
return None
def invalidate_user_cache(self, userid):
async def invalidate_user_cache(self, userid):
"""Invalidate cache for a specific user.
Call this after role changes, user creation, etc.
Also broadcasts invalidation to all other processes via Redis Pub/Sub.
"""
self.ur_caches.invalidate(userid)
# Broadcast to other processes
cache_sync = get_cache_sync()
if cache_sync.is_running:
await cache_sync.invalidate(f'rbac:ur:{userid}')
def invalidate_all_user_caches(self):
"""Invalidate all user role caches."""
async def invalidate_all_user_caches(self):
"""Invalidate all user role caches.
Also broadcasts invalidation to all other processes via Redis Pub/Sub.
"""
self.ur_caches.clear()
# Broadcast to other processes
cache_sync = get_cache_sync()
if cache_sync.is_running:
await cache_sync.invalidate('rbac:ur:all')
def invalidate_rp_cache(self):
"""Invalidate role-permission cache (after permission changes)."""
async def invalidate_rp_cache(self):
"""Invalidate role-permission cache (after permission changes).
Also broadcasts invalidation to all other processes via Redis Pub/Sub.
"""
self.rp_caches = None
self.rp_cache_loaded_at = 0
# Broadcast to other processes
cache_sync = get_cache_sync()
if cache_sync.is_running:
await cache_sync.invalidate('rbac:rp')
async def load_roleperms(self, sor):
"""Load all role-permission mappings into cache.

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

@ -1,7 +1,17 @@
[
{
"name":"users",
"label":"用户管理"
"label":"用户管理",
"url":"{{entire_url('/rbac/users')}}"
},
{
"name":"list_path_roles",
"label":"查询路径权限角色",
"url":"{{entire_url('/rbac/list_path_roles.ui')}}"
},
{
"name":"find_unauth_files",
"label":"扫描未授权文件",
"url":"{{entire_url('/rbac/find_unauth_files.dspy')}}"
}
]

View File

@ -0,0 +1,8 @@
wwwroot = params_kw.get('wwwroot', '').strip()
async with get_sor_context(request._run_ns, 'rbac') as sor:
title, message, is_error = await request._run_ns.scan_unauth_files(sor, wwwroot, __file__)
if is_error:
return UiError(title=title, message=message)
return UiMessage(title=title, message=message)

212
wwwroot/index.ui Normal file
View File

@ -0,0 +1,212 @@
{% set roles = get_user_roles(get_user()) %}
{
"widgettype": "VBox",
"options": {
"width": "100%",
"height": "100%",
"padding": "0",
"bgcolor": "#0B1120"
},
"subwidgets": [
{
"widgettype": "HBox",
"options": {
"width": "100%",
"alignItems": "center",
"marginBottom": "24px"
},
"subwidgets": [
{
"widgettype": "Title2",
"options": {
"text": "用户与权限",
"color": "#F1F5F9",
"fontWeight": "700"
}
},
{
"widgettype": "Filler"
},
{
"widgettype": "Text",
"options": {
"text": "用户管理、角色权限与安全审计",
"fontSize": "14px",
"color": "#64748B"
}
}
]
},
{% if 'reseller.admin' in roles or 'owner.superuser' in roles %}
{
"widgettype": "ResponsableBox",
"options": {
"gap": "16px",
"minWidth": "250px",
"marginBottom": "24px"
},
"subwidgets": [
{
"widgettype": "VBox",
"options": {
"bgcolor": "#1E293B",
"padding": "24px",
"borderRadius": "12px",
"border": "1px solid #334155",
"cursor": "pointer"
},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "urlwidget",
"target": "app.rbac_content",
"options": {
"url": "{{entire_url('/rbac/users')}}"
},
"mode": "replace"
}
],
"subwidgets": [
{
"widgettype": "Svg",
"options": {
"svg": "<svg width=\"36\" height=\"36\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#60A5FA\" stroke-width=\"1.5\"><path d=\"M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.953 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": "36px",
"height": "36px",
"marginBottom": "16px"
}
},
{
"widgettype": "Title4",
"options": {
"text": "用户管理",
"color": "#F1F5F9",
"fontWeight": "600",
"marginBottom": "8px"
}
},
{
"widgettype": "Text",
"options": {
"text": "管理系统用户、角色分配与账户信息",
"fontSize": "14px",
"color": "#94A3B8"
}
}
]
},
{
"widgettype": "VBox",
"options": {
"bgcolor": "#1E293B",
"padding": "24px",
"borderRadius": "12px",
"border": "1px solid #334155",
"cursor": "pointer"
},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "urlwidget",
"target": "app.rbac_content",
"options": {
"url": "{{entire_url('/rbac/list_path_roles.ui')}}"
},
"mode": "replace"
}
],
"subwidgets": [
{
"widgettype": "Svg",
"options": {
"svg": "<svg width=\"36\" height=\"36\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#A78BFA\" stroke-width=\"1.5\"><path d=\"M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z\"/></svg>",
"width": "36px",
"height": "36px",
"marginBottom": "16px"
}
},
{
"widgettype": "Title4",
"options": {
"text": "路径权限角色",
"color": "#F1F5F9",
"fontWeight": "600",
"marginBottom": "8px"
}
},
{
"widgettype": "Text",
"options": {
"text": "查询各路径绑定的角色与权限配置",
"fontSize": "14px",
"color": "#94A3B8"
}
}
]
},
{
"widgettype": "VBox",
"options": {
"bgcolor": "#1E293B",
"padding": "24px",
"borderRadius": "12px",
"border": "1px solid #334155",
"cursor": "pointer"
},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "urlwidget",
"target": "app.rbac_content",
"options": {
"url": "{{entire_url('/rbac/find_unauth_files.dspy')}}"
},
"mode": "replace"
}
],
"subwidgets": [
{
"widgettype": "Svg",
"options": {
"svg": "<svg width=\"36\" height=\"36\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#EF4444\" stroke-width=\"1.5\"><path d=\"M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z\"/></svg>",
"width": "36px",
"height": "36px",
"marginBottom": "16px"
}
},
{
"widgettype": "Title4",
"options": {
"text": "扫描未授权文件",
"color": "#F1F5F9",
"fontWeight": "600",
"marginBottom": "8px"
}
},
{
"widgettype": "Text",
"options": {
"text": "检测未配置RBAC权限的页面文件",
"fontSize": "14px",
"color": "#94A3B8"
}
}
]
}
]
},
{% endif %}
{
"widgettype": "VBox",
"id": "rbac_content",
"css": "filler",
"options": {
"width": "100%",
"overflowY": "auto"
}
}
]
}

View File

@ -0,0 +1,8 @@
path = params_kw.get('path', '').strip()
async with get_sor_context(request._run_ns, 'rbac') as sor:
title, message, is_error = await request._run_ns.query_path_roles(sor, path)
if is_error:
return UiError(title=title, message=message)
return UiMessage(title=title, message=message)

View File

@ -0,0 +1,27 @@
{
"widgettype": "ModalForm",
"options": {
"cwidth": 20,
"cheight": 15,
"title": "查询路径权限角色",
"fields": [
{
"name": "path",
"label": "路径",
"uitype": "str",
"placeholder": "如 /harnessed_agent/index.ui"
}
]
},
"binds": [
{
"wid": "self",
"event": "submit",
"actiontype": "urlwidget",
"target": "root.page_center",
"options": {
"url": "{{entire_url('./list_path_roles.dspy')}}"
}
}
]
}

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"
}
}
]
}