From 6bcc1469ebc788eac26050ce355c1949affbeae8 Mon Sep 17 00:00:00 2001 From: yumoqing Date: Fri, 14 Aug 2026 12:19:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20app=5Faudit=20=E5=AE=A1=E8=AE=A1?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E5=85=A8=E5=B1=80=E6=A8=A1=E5=9D=97=EF=BC=88?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=E4=BB=93=E5=BA=93=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - audit_service.py: audit_log 写入 + is_audit_role + list/backup/delete - init.py: load_app_audit 建 sd_audit_logs 表 + owner.audit 角色 - audit.dspy: list/backup/delete 仅 owner.audit(审计独立性) - models + load_path.py + README --- .gitignore | 5 ++ README.md | 50 ++++++++++++ app_audit/__init__.py | 31 +++++++ app_audit/audit_service.py | 160 +++++++++++++++++++++++++++++++++++++ app_audit/init.py | 42 ++++++++++ models/sd_audit_logs.json | 75 +++++++++++++++++ pyproject.toml | 13 +++ scripts/load_path.py | 49 ++++++++++++ wwwroot/api/audit.dspy | 71 ++++++++++++++++ 9 files changed, 496 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app_audit/__init__.py create mode 100644 app_audit/audit_service.py create mode 100644 app_audit/init.py create mode 100644 models/sd_audit_logs.json create mode 100644 pyproject.toml create mode 100644 scripts/load_path.py create mode 100644 wwwroot/api/audit.dspy diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b9b6e2e --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.pyc +__pycache__/ +build/ +*.egg-info/ +dist/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..39f25ae --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# app_audit - 审计日志全局模块 + +独立设计的全局审计模块。审计日志 append-only,查看/备份/删除权限仅赋予 +`owner.audit` 角色(审计独立性:`owner.superuser` 也无权访问,防管理员删自己的记录)。 + +## 结构 + +``` +app_audit/ +├── app_audit/ +│ ├── __init__.py # 导出 audit_log / is_audit_role 等 +│ ├── audit_service.py # 核心逻辑 +│ └── init.py # load_app_audit(建表 + owner.audit 角色) +├── models/ +│ └── sd_audit_logs.json +├── wwwroot/ +│ └── api/audit.dspy # list/backup/delete(仅 owner.audit) +└── scripts/load_path.py # RBAC 权限注册 +``` + +## 审计事件清单(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` + +## 接入 + +宿主应用在 `init()` 里调用: + +```python +from app_audit.init import load_app_audit +load_app_audit() +``` + +其他模块在关键操作时写入审计: + +```python +from app_audit import audit_log +await audit_log(sor, user_id, username, "work_env_set", target=..., detail=..., result="ok") +``` + +## 权限隔离 + +- `owner.audit` 角色:查看/备份/删除审计日志的唯一角色 +- `owner.superuser` 也无权访问(审计独立性) +- `audit_delete` 留痕记录永不删除(防自删) diff --git a/app_audit/__init__.py b/app_audit/__init__.py new file mode 100644 index 0000000..ae39c84 --- /dev/null +++ b/app_audit/__init__.py @@ -0,0 +1,31 @@ +""" +app_audit - 审计日志全局模块(独立仓库)。 + +审计日志 append-only,查看/备份/删除权限仅赋予 owner.audit 角色。 +其他模块通过 `from app_audit import audit_log` 在关键操作时写入审计。 +""" + +from .audit_service import ( + audit_log, + is_audit_role, + list_audit_logs, + backup_audit_logs, + delete_audit_logs, + VALID_ACTIONS, +) + +from .init import load_app_audit, MODULE_NAME, MODULE_VERSION + +__version__ = "1.0.0" + +__all__ = [ + "audit_log", + "is_audit_role", + "list_audit_logs", + "backup_audit_logs", + "delete_audit_logs", + "VALID_ACTIONS", + "load_app_audit", + "MODULE_NAME", + "MODULE_VERSION", +] diff --git a/app_audit/audit_service.py b/app_audit/audit_service.py new file mode 100644 index 0000000..d8b7292 --- /dev/null +++ b/app_audit/audit_service.py @@ -0,0 +1,160 @@ +""" +app_audit/audit_service.py - 审计日志全局模块 + +独立设计的全局审计模块(独立仓库 app_audit)。审计日志 append-only, +查看/备份/删除权限仅赋予 `owner.audit` 角色(审计独立性:owner.superuser 也不能看, +防管理员删自己的记录)。 + +- audit_log: 各模块在关键操作时写入审计(系统自动,非用户触发) +- is_audit_role: 判断用户是否 owner.audit(API 层权限校验用) +- list_audit_logs / backup_audit_logs / delete_audit_logs: 仅 owner.audit + +审计事件清单(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 +""" + +import logging + +logger = logging.getLogger("app_audit") + +# 合法审计动作白名单 +VALID_ACTIONS = { + "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", +} + + +async def audit_log(sor, user_id: str, username: str, action: str, + target: str = "", detail: str = "", result: str = "ok", + client_ip: str = "") -> bool: + """写入一条审计日志(append-only,不 update)。 + + 审计写入失败不应阻断主流程,返回 False 由调用方决定是否处理。 + """ + if action not in VALID_ACTIONS: + action = "unknown" + try: + from appPublic.uniqueID import getID + data = { + "id": getID(), + "user_id": user_id or "", + "username": username or "", + "action": action, + "target": (target or "")[:200], + "detail": (detail or "")[:2000], + "result": result or "ok", + "client_ip": (client_ip or "")[:64], + } + await sor.C("sd_audit_logs", data) + return True + except Exception as e: + logger.exception("audit_log failed: %s", e) + return False + + +async def is_audit_role(sor, user_id: str) -> bool: + """判断用户是否 owner.audit 角色。""" + if not user_id: + return False + try: + recs = await sor.sqlExe( + "SELECT 1 FROM userrole WHERE userid=${u}$ AND roleid='owner.audit' LIMIT 1", + {"u": user_id}) + return bool(recs) + except Exception as e: + logger.exception("is_audit_role failed: %s", e) + return False + + +def _row_to_dict(row) -> dict: + """DB 行 → dict(created_at 等 datetime 转字符串)。""" + d = {} + for k in ("id", "user_id", "username", "action", "target", "detail", + "result", "client_ip", "created_at"): + v = getattr(row, k, None) + if hasattr(v, "isoformat"): + v = v.isoformat() + d[k] = v + return d + + +async def list_audit_logs(sor, filters: dict = None, limit: int = 100, offset: int = 0): + """查询审计日志(仅 owner.audit)。""" + filters = filters or {} + where = [] + ns = {} + if filters.get("user_id"): + where.append("user_id=${u}$") + ns["u"] = filters["user_id"] + if filters.get("username"): + where.append("username=${un}$") + ns["un"] = filters["username"] + if filters.get("action"): + where.append("action=${a}$") + ns["a"] = filters["action"] + if filters.get("from"): + where.append("created_at>=${f}$") + ns["f"] = filters["from"] + if filters.get("to"): + where.append("created_at<=${t}$") + ns["t"] = filters["to"] + wsql = (" WHERE " + " AND ".join(where)) if where else "" + try: + limit = max(1, min(int(limit or 100), 1000)) + except (ValueError, TypeError): + limit = 100 + try: + offset = max(0, int(offset or 0)) + except (ValueError, TypeError): + offset = 0 + recs = await sor.sqlExe( + "SELECT * FROM sd_audit_logs" + wsql + " ORDER BY created_at DESC LIMIT " + + str(limit) + " OFFSET " + str(offset), ns) + return [_row_to_dict(r) for r in recs] + + +async def backup_audit_logs(sor, user_id: str, username: str, filters: dict = None, + client_ip: str = "") -> dict: + """导出审计日志(仅 owner.audit),返回 JSON 内容供下载。""" + # 查最近 10000 条(备份上限,避免一次拉全表拖垮内存) + recs = await sor.sqlExe( + "SELECT * FROM sd_audit_logs ORDER BY created_at DESC LIMIT 10000", {}) + rows = [_row_to_dict(r) for r in recs] + # 备份操作本身也审计 + await audit_log(sor, user_id, username, "audit_backup", + target="sd_audit_logs", detail="备份 " + str(len(rows)) + " 条", + result="ok", client_ip=client_ip) + return {"ok": True, "count": len(rows), "data": rows} + + +async def delete_audit_logs(sor, user_id: str, username: str, before: str = "", + client_ip: str = "") -> dict: + """删除审计日志(仅 owner.audit)。before 为时间戳,删除早于该时间的日志。 + + 删除操作本身也会审计(audit_delete 留痕),且 audit_delete 记录**永不删除**, + 保证删除操作的证据永久保留(防自删)。 + """ + ns = {} + # 排除 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", + target="sd_audit_logs", detail="删除 before=" + (before or "全部"), + result="ok", client_ip=client_ip) + recs = await sor.sqlExe("SELECT COUNT(*) AS c FROM sd_audit_logs" + wsql, ns) + count = getattr(recs[0], "c", 0) if recs else 0 + await sor.sqlExe("DELETE FROM sd_audit_logs" + wsql, ns) + return {"ok": True, "deleted": count} diff --git a/app_audit/init.py b/app_audit/init.py new file mode 100644 index 0000000..eac85d7 --- /dev/null +++ b/app_audit/init.py @@ -0,0 +1,42 @@ +""" +app_audit/init.py - 审计模块加载入口。 + +独立审计模块:建 sd_audit_logs 表 + owner.audit 角色(审计独立性, +owner.superuser 也无权访问审计日志)。宿主应用调用 load_app_audit() 即可。 +""" + +MODULE_NAME = "app_audit" +MODULE_VERSION = "1.0.0" + + +def load_app_audit(): + """注册审计模块的建表 + owner.audit 角色到启动流程。""" + from ahserver.configuredServer import add_startup + + async def _init_audit(app): + from sqlor.dbpools import DBPools + from appPublic.log import debug + try: + db = DBPools() + async with db.sqlorContext("pipeline") as sor: + # 审计日志表(append-only,全局) + await sor.sqlExe( + "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", {}) + debug("app_audit: sd_audit_logs table ready") + # owner.audit 角色(审计独立角色) + await sor.sqlExe( + "INSERT IGNORE INTO role (id, orgtypeid, name) " + "VALUES ('owner.audit', 'owner', 'audit')", {}) + debug("app_audit: owner.audit role ready") + except Exception as e: + debug("app_audit init: " + str(e)) + + add_startup(_init_audit) + return True diff --git a/models/sd_audit_logs.json b/models/sd_audit_logs.json new file mode 100644 index 0000000..518899e --- /dev/null +++ b/models/sd_audit_logs.json @@ -0,0 +1,75 @@ +{ + "summary": [ + { + "name": "sd_audit_logs", + "title": "审计日志表", + "primary": [ + "id" + ], + "catelog": "entity" + } + ], + "fields": [ + { + "name": "id", + "title": "主键ID", + "type": "str", + "length": 32, + "nullable": "no" + }, + { + "name": "user_id", + "title": "操作者ID", + "type": "str", + "length": 32, + "nullable": "yes" + }, + { + "name": "username", + "title": "操作者用户名", + "type": "str", + "length": 100, + "nullable": "yes" + }, + { + "name": "action", + "title": "审计动作", + "type": "str", + "length": 50, + "nullable": "no" + }, + { + "name": "target", + "title": "操作对象", + "type": "str", + "length": 200, + "nullable": "yes" + }, + { + "name": "detail", + "title": "详情", + "type": "text", + "nullable": "yes" + }, + { + "name": "result", + "title": "结果", + "type": "str", + "length": 10, + "nullable": "yes" + }, + { + "name": "client_ip", + "title": "来源IP", + "type": "str", + "length": 64, + "nullable": "yes" + }, + { + "name": "created_at", + "title": "创建时间", + "type": "datetime", + "nullable": "yes" + } + ] +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1705b71 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,13 @@ +[build-system] +requires = ["setuptools>=45", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "app_audit" +version = "1.0.0" +description = "Audit log global module — append-only audit trail with owner.audit role isolation" +requires-python = ">=3.8" + +[tool.setuptools.packages.find] +where = ["."] +include = ["app_audit*"] diff --git a/scripts/load_path.py b/scripts/load_path.py new file mode 100644 index 0000000..bdec1f9 --- /dev/null +++ b/scripts/load_path.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""RBAC path registration for app_audit module. + +审计 API 仅 owner.audit 角色(审计独立性:superuser 也不给)。 +""" +import os, sys, subprocess + +MOD = "app_audit" + +# 审计日志 API:仅 owner.audit 角色 +PATHS_AUDIT = [ + f"/{MOD}/api/audit.dspy", +] + + +def find_app_root(): + # 从 scripts/ 向上逐层查找 set_role_perm.py(位于宿主 app 根目录) + d = os.path.dirname(os.path.abspath(__file__)) + for _ in range(6): + if os.path.isfile(os.path.join(d, "set_role_perm.py")): + return d + parent = os.path.dirname(d) + if parent == d: + break + d = parent + return None + + +def main(): + root = find_app_root() + if not root: + print("ERROR: app root not found") + sys.exit(1) + set_perm = os.path.join(root, "set_role_perm.py") + py = sys.executable + count = 0 + for role, paths in [("owner.audit", PATHS_AUDIT)]: + for path in paths: + cmd = [py, set_perm, role, path] + r = subprocess.run(cmd, capture_output=True, text=True, cwd=root) + if r.returncode == 0: + count += 1 + else: + print(f" WARN: {path} -> {r.stderr.strip()[-80:]}") + print(f"Registered {count}/{len(PATHS_AUDIT)} paths for {MOD}") + + +if __name__ == "__main__": + main() diff --git a/wwwroot/api/audit.dspy b/wwwroot/api/audit.dspy new file mode 100644 index 0000000..c6af60f --- /dev/null +++ b/wwwroot/api/audit.dspy @@ -0,0 +1,71 @@ +# app_audit audit.dspy - 审计日志 API +# 查看/备份/删除 仅 owner.audit 角色(审计独立性:superuser 也无权访问) +# action: list / backup / delete + +user_id = await get_user() +if not user_id: + return json.dumps({'ok': False, 'error': '未登录'}, ensure_ascii=False) + +action = (params_kw or {}).get('action', 'list') + +from app_audit.audit_service import ( + is_audit_role, list_audit_logs, backup_audit_logs, delete_audit_logs, +) + +# 查 username 和 client_ip +username = user_id +try: + async with get_sor_context(request._run_ns, 'pipeline') as _sor: + _u = await _sor.sqlExe("SELECT username FROM users WHERE id=${u}$", {'u': user_id}) + if _u: + username = getattr(_u[0], 'username', user_id) or user_id +except Exception: + pass + +client_ip = '' +try: + client_ip = request.get('client_ip', '') or '' +except Exception: + pass + +# 权限校验:所有 action 仅 owner.audit(superuser 也不行) +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) + +if action == 'list': + filters = { + 'user_id': (params_kw or {}).get('user_id', ''), + 'username': (params_kw or {}).get('username', ''), + 'action': (params_kw or {}).get('act', ''), + 'from': (params_kw or {}).get('from', ''), + 'to': (params_kw or {}).get('to', ''), + } + limit = (params_kw or {}).get('limit', 100) + offset = (params_kw or {}).get('offset', 0) + try: + async with get_sor_context(request._run_ns, 'pipeline') as sor: + rows = await list_audit_logs(sor, filters, limit, offset) + return json.dumps({'ok': True, 'logs': rows}, ensure_ascii=False) + except Exception as e: + return json.dumps({'ok': False, 'error': str(e)}, ensure_ascii=False) + +elif action == 'backup': + try: + async with get_sor_context(request._run_ns, 'pipeline') as sor: + r = await backup_audit_logs(sor, user_id, username, None, client_ip) + return json.dumps(r, ensure_ascii=False) + except Exception as e: + return json.dumps({'ok': False, 'error': str(e)}, ensure_ascii=False) + +elif action == 'delete': + before = (params_kw or {}).get('before', '') + try: + async with get_sor_context(request._run_ns, 'pipeline') as sor: + r = await delete_audit_logs(sor, user_id, username, before, client_ip) + return json.dumps(r, ensure_ascii=False) + except Exception as e: + return json.dumps({'ok': False, 'error': str(e)}, ensure_ascii=False) + +else: + return json.dumps({'ok': False, 'error': '未知 action: ' + str(action)}, ensure_ascii=False)