9.0 KiB
| name | description | version |
|---|---|---|
| audit-log-module | Use when 设计/实现审计日志模块(append-only、审计独立性、owner.audit 角色)。 | 1.0.0 |
审计日志模块设计
独立设计的全局审计模块。核心诉求:谁做了什么、何时做的、结果如何,且记录不可篡改、不可自删。
核心设计决策
1. 审计独立性:audit 角色与 superuser 完全隔离
审计员(owner.audit)独立于平台管理员(owner.superuser)。superuser 也无权查看/删除审计日志——否则管理员可以删掉自己的违规记录,审计形同虚设。
# 角色定义(role 表:id / orgtypeid / name)
INSERT IGNORE INTO role (id, orgtypeid, name) VALUES ('owner.audit', 'owner', 'audit')
# 判断是否审计员(userrole 表:userid / roleid)
async def is_audit_role(sor, user_id):
recs = await sor.sqlExe(
"SELECT 1 FROM userrole WHERE userid=${u}$ AND roleid='owner.audit' LIMIT 1",
{"u": user_id})
return bool(recs)
注意 owner.audit 的 role 表 id 直接是 'owner.audit' 字符串(不是随机 ID),与 owner.superuser 同构。
2. 防自删:append-only + 删除留痕永不删除
- 日志只 INSERT 不 UPDATE(append-only)。
- 删除操作本身也写审计(
audit_delete留痕)。 audit_delete记录永不删除——否则"删除全部"会把删除证据一起删掉:
async def delete_audit_logs(sor, user_id, username, before="", client_ip=""):
# 关键:排除 audit_delete,保证删除留痕不被删
if before:
wsql = " WHERE created_at<${b}$ AND action != 'audit_delete'"
ns = {"b": before}
else:
wsql = " WHERE action != 'audit_delete'"
await audit_log(sor, user_id, username, "audit_delete", ...) # 先留痕
await sor.sqlExe("DELETE FROM sd_audit_logs" + wsql, ns)
3. 双层权限校验(RBAC + 应用层)
RBAC 层(permission 表 path 只挂 owner.audit)+ 应用层(DSPY 内 is_audit_role)。应用层校验是兜底——RBAC 对未注册路径/缓存未刷新可能放行,应用层校验才保证 superuser 也进不来。
# audit.dspy 内(所有 action 都先校验)
async with get_sor_context(request._run_ns, 'pipeline') as sor:
if not await is_audit_role(sor, user_id):
return json.dumps({'ok': False, 'error': '仅 owner.audit 角色可访问审计日志'}, ensure_ascii=False)
审计事件清单设计
按类别定义 action 白名单(VALID_ACTIONS),未识别 action 落为 unknown:
| 类别 | action |
|---|---|
| 认证 | login / login_fail / logout |
| 权限 | role_change / perm_change / user_role_change |
| 工作环境 | work_env_set / org_key_gen / remote_bwrap |
| 部署账号 | account_create / account_remove / sandbox_run |
| 用户机构 | user_create / user_disable / user_delete / org_change |
| 审计自身 | audit_delete / audit_backup |
审计写入是系统自动(各模块在关键操作时调用 audit_log),不是用户手动触发。
数据表(append-only)
CREATE TABLE IF NOT EXISTS sd_audit_logs (
id varchar(32) NOT NULL, user_id varchar(32), username varchar(100),
action varchar(50) NOT NULL, target varchar(200), detail text,
result varchar(10), client_ip varchar(64),
created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id), KEY idx_user (user_id), KEY idx_action (action), KEY idx_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
独立模块拆分(关键架构)
审计是跨模块的全局能力,必须独立成模块 + 独立 repo,不嵌入任何业务模块(否则其他模块 import 时会循环依赖,且无法独立演进)。结构:
app_audit/
├── app_audit/
│ ├── __init__.py # 导出 audit_log / is_audit_role 等
│ ├── audit_service.py # 核心逻辑(纯函数,只依赖 sqlor/appPublic)
│ └── init.py # load_app_audit:add_startup 建表 + owner.audit 角色
├── models/sd_audit_logs.json # 表定义(summary + fields)
├── wwwroot/api/audit.dspy # list/backup/delete
└── scripts/load_path.py # RBAC 权限注册
宿主应用加载(add_startup 延迟到事件循环启动后建表,不能在 init 阶段 ensure_future):
def load_app_audit():
from ahserver.configuredServer import add_startup
async def _init_audit(app):
from sqlor.dbpools import DBPools
db = DBPools()
async with db.sqlorContext("pipeline") as sor:
await sor.sqlExe("CREATE TABLE IF NOT EXISTS sd_audit_logs ...", {})
await sor.sqlExe("INSERT IGNORE INTO role ... VALUES ('owner.audit','owner','audit')", {})
add_startup(_init_audit)
return True
宿主 init() 里 from app_audit.init import load_app_audit; load_app_audit()(用 try/except ImportError 容错,模块缺失时降级为空函数)。
分配审计员用户(owner 机构 + owner.audit 角色)
审计独立性意味着必须显式创建审计员并分配 owner.audit,否则无人能看审计。
users 表字段名(pipeline 库实测,勿套用 Sage 的 passwd/status):password(不是 passwd)、user_status(不是 status)、无 orgtypeid 字段。
密码加密是 RC4 不是 bcrypt:from appPublic.rc4 import password,key 取 config.password_key(空则默认 'QRIVSRHrthhwyjy176556332')。加密正确性验证:unpassword(stored, key=...) 能还原明文。password_encode 在 ahserver.globalEnv(底层就是 rc4.password + config.password_key),standalone 脚本直接用 rc4 即可。
from appPublic.rc4 import password
from appPublic.jsonConfig import getConfig
_pk = getConfig('<app_root>').get('password_key', '') or 'QRIVSRHrthhwyjy176556332'
# 1. 建用户(owner 机构 orgid='0';user_status='0' 才是启用,'1' 是禁用!)
await sor.C('users', {'id': getID(), 'username': 'eyeon',
'password': password('初始密码', key=_pk),
'orgid': '0', 'user_status': '0', 'nick_name': '审计员'})
# 2. 分配 owner.audit(不是 owner.superuser)
await sor.C('userrole', {'id': getID(), 'userid': '<新用户id>', 'roleid': 'owner.audit'})
关键 pitfall:user_status='0' 才是启用。basic_auth 逻辑是 if user_status != '0': return None(视为禁用),设成 '1' 会导致登录永远失败(get_user() 返回 None,所有 API 报"未登录"),而密码本身是对的——排查时先看 user_status 别怀疑加密。
验证:is_audit_role(sor, '<新用户id>') 返回 True;该用户登录后访问 audit.dspy 返回日志,而 owner.superuser 用户访问被拒。
Pitfalls
INSERT IGNORE在 aiomysql 会打Duplicate entry警告(无害但每次启动噪音)。要么先SELECT 1再 INSERT,要么接受警告。- 审计写入失败不阻断主流程:
audit_log内 try/except,返回 bool。审计是旁路,不能因为审计挂了拖垮业务。 - sqlor 的
%是格式化占位符:SQL 里LIKE '%xx%'要写LIKE '%%xx%%',否则报not enough arguments for format string。 - RBAC 缓存 600s TTL:注册新权限后要重启服务(或等缓存过期),否则仍 401。
- set_role_perm.py 角色名格式:特殊角色
any/logined/anonymous直接传;其他必须orgtypeid.name(如owner.audit),否则split('.')崩。 - 审计独立性是双刃:superuser 也看不到审计 → 需要明确指定谁当审计员并分配
owner.audit,否则没人能看审计。 - 接入审计时 result 判断看接口返回结构,勿一律
r.get('ok'):run 类接口(run_in_sandbox/run_in_work_env)返回{rc, stdout, stderr, sandbox},没有ok键,用r.get('ok')会恒判 fail。正确:result='ok' if r.get('rc') == 0 else 'fail'。而ensure_account用r.get('created') is not None、remove_account用r.get('ok')。写审计前先 grep 目标函数的所有return {确认返回键。 - 审计覆盖完整性是核心质量指标:
VALID_ACTIONS白名单定义了 20 个 action,但"定义"≠"接入"。审计完模块要 grep 所有高风险操作入口(沙箱执行命令、账号删除、机构 key 生成、RBAC 变更、登录)确认真的调了audit_log——否则白名单是空头支票。 client_ip可被伪造:ahserver/real_ip.py中间件无条件信任X-Forwarded-For/X-real-ipheader 覆盖request['client_ip']。审计来源 IP 因此不可全信(user_id/username 可靠,来自 get_user())。修复要么改中间件只信任可信代理,要么靠 nginx 正确覆盖该 header。- 审计界面用 bricks DataGrid:dataurl 指向 audit.dspy 的 list,返回格式必须
{"rows":[...], "total":N}(分页参数 page/rows,loader 自动拼)。菜单项挂在宿主index.ui的 sidebar_menu,url: "{{entire_url('/app_audit')}}";/app_audit目录路径 +/app_audit/index.ui+/app_audit/api/audit.dspy三个都要注册 owner.audit。