9.0 KiB
Raw Blame History

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' 字符串(不是随机 IDowner.superuser 同构。

2. 防自删append-only + 删除留痕永不删除

  • 日志只 INSERT 不 UPDATEappend-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_auditadd_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/statuspassword(不是 passwduser_status(不是 status、无 orgtypeid 字段。

密码加密是 RC4 不是 bcryptfrom appPublic.rc4 import passwordkey 取 config.password_key(空则默认 'QRIVSRHrthhwyjy176556332')。加密正确性验证:unpassword(stored, key=...) 能还原明文。password_encodeahserver.globalEnv(底层就是 rc4.password + config.password_keystandalone 脚本直接用 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'})

关键 pitfalluser_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_accountr.get('created') is not Noneremove_accountr.get('ok')。写审计前先 grep 目标函数的所有 return { 确认返回键。
  • 审计覆盖完整性是核心质量指标VALID_ACTIONS 白名单定义了 20 个 action但"定义"≠"接入"。审计完模块要 grep 所有高风险操作入口(沙箱执行命令、账号删除、机构 key 生成、RBAC 变更、登录)确认真的调了 audit_log——否则白名单是空头支票。
  • client_ip 可被伪造ahserver/real_ip.py 中间件无条件信任 X-Forwarded-For/X-real-ip header 覆盖 request['client_ip']。审计来源 IP 因此不可全信user_id/username 可靠,来自 get_user())。修复要么改中间件只信任可信代理,要么靠 nginx 正确覆盖该 header。
  • 审计界面用 bricks DataGriddataurl 指向 audit.dspy 的 list返回格式必须 {"rows":[...], "total":N}(分页参数 page/rowsloader 自动拼)。菜单项挂在宿主 index.ui 的 sidebar_menuurl: "{{entire_url('/app_audit')}}"/app_audit 目录路径 + /app_audit/index.ui + /app_audit/api/audit.dspy 三个都要注册 owner.audit。