--- name: audit-log-module description: Use when 设计/实现审计日志模块(append-only、审计独立性、owner.audit 角色)。 version: 1.0.0 --- # 审计日志模块设计 独立设计的全局审计模块。核心诉求:**谁做了什么、何时做的、结果如何**,且记录**不可篡改、不可自删**。 ## 核心设计决策 ### 1. 审计独立性:audit 角色与 superuser 完全隔离 审计员(`owner.audit`)独立于平台管理员(`owner.superuser`)。**superuser 也无权查看/删除审计日志**——否则管理员可以删掉自己的违规记录,审计形同虚设。 ```python # 角色定义(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` 记录永不删除**——否则"删除全部"会把删除证据一起删掉: ```python 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 也进不来。 ```python # 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) ```sql 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`): ```python 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 即可。 ```python from appPublic.rc4 import password from appPublic.jsonConfig import getConfig _pk = getConfig('').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-ip` header 覆盖 `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。