mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-10-14 13:06:51 +00:00
docs(audit): 全栈审计报告(7 份分报告 + 总报告)
由 7 名成员并行审计 + 队长线上实测复核产出,合计约 3,600 行: - backend/audit-backend.md 后端架构与代码质量(22 条) - backend/audit-security.md 认证/授权/敏感信息(23 条) - backend/audit-testing.md 测试与可验证性(含真实命令输出) - backend/audit-requirements.md 需求覆盖对照(多租户规格错位) - frontend/audit-frontend.md 前端与移动端工程(32 条) - frontend/audit-uxui.md 交互与视觉一致性 - docker/audit-deploy.md 容器化与部署链路 - AUDIT-REPORT.md 总报告:止血项、修复路线、待决策事项、对既有结论的修正 各报告均标注级别、文件:行号、影响、建议修法,并单列未验证项与抽样边界。
This commit is contained in:
@@ -0,0 +1,352 @@
|
||||
# FastapiAdmin backend 只读审计报告
|
||||
|
||||
- 审计对象:`backend/`(v3.2.0,FastAPI 0.138 + SQLAlchemy 2.0 async + Redis + MySQL/PostgreSQL/SQLite + Alembic)
|
||||
- 审计日期:本轮审计内(工作区 `/Users/tao/workspace/FastapiAdmin`)
|
||||
- 审计方式:**只读**。除本报告外未修改/删除任何仓库文件,未启动数据库/Redis,未安装依赖,未提交 git。
|
||||
- 规模:`app/` 下 216 个 `.py`,约 28,500 行(`modules/` 16 个业务模块约 20,000 行,`core+config+utils+common` 约 7,500 行)。
|
||||
|
||||
---
|
||||
|
||||
## 0. 可复现命令与结果(务必与「未验证项」区分)
|
||||
|
||||
| # | 命令 | 退出码 | 关键输出 |
|
||||
|---|------|--------|----------|
|
||||
| 1 | `cd backend && uv run pytest -q` | **2(工具失败)** | `error: Failed to initialize cache at /Users/tao/.cache/uv ... Operation not permitted (os error 1)`。uv 缓存目录在工作区之外,被本会话的文件沙箱拒绝;未申请提权(本会话提权被自动拒绝)。 |
|
||||
| 1b | `cd backend && UV_CACHE_DIR=/tmp/uvcache-test uv run --no-sync pytest -q` | **0** | `2 passed in 2.44s` |
|
||||
| 1c | `cd backend && .venv/bin/python -m pytest -q`(等价路径) | 0 | `2 passed` |
|
||||
| 2 | `cd backend && uv run ruff check` | **2(同上,缓存失败)** | 同上 |
|
||||
| 2b | `cd backend && UV_CACHE_DIR=/tmp/uvcache-test uv run --no-sync ruff check` | **0** | `All checks passed!` |
|
||||
|
||||
工具版本:uv 0.12.10、Python 3.12.12、pytest 9.0.2、ruff 0.14.13(均为仓库自带 `.venv` 中已安装版本)。
|
||||
|
||||
**结论**:静态检查(ruff)干净;测试套件只有 **2 个测试用例**(`tests/test_main.py::test_check_health`、`tests/test_migrations.py::test_bootstrap_matches_metadata`)对约 2.85 万行代码,功能覆盖率实质为零(见 M8)。
|
||||
|
||||
### 未验证项(明确标注,未编造)
|
||||
- 本机 **3306 / 5432 / 6379 端口均不可达**(`nc -z` 探测),因此**所有需要真实 MySQL/PostgreSQL/Redis 的行为均未运行验证**:登录限流、Redis 会话续期、APScheduler 分布式锁与 jobstore、Alembic 对真实库的迁移、代码生成器对物理库的 Inspector 反射,均以**静态阅读 + 代码路径推演**为依据。
|
||||
- `uv run`(不带 `--no-sync`)在本沙箱不可用,仅能用 `UV_CACHE_DIR` 指向可写目录 + `--no-sync` 复现;这一点本身属于「环境限制」,不是仓库缺陷。
|
||||
- 测试使用的 sqlite + mock Redis 路径能通过,**不代表** MySQL/Redis 生产路径可用。
|
||||
|
||||
---
|
||||
|
||||
## 1. 安全问题(blocker / high)
|
||||
|
||||
### B1 [blocker] 代码生成器把未转义的用户输入渲染进「会被 import 的 Python 文件」→ 认证用户 RCE / 任意代码注入
|
||||
|
||||
**位置**
|
||||
- 模板渲染无转义:`backend/app/modules/generator/gencode/jinja2_template_util.py:34`(`autoescape=False`)
|
||||
- 渲染值直接嵌入 Python 字符串与标识符:
|
||||
- `backend/templates/python/model.py.jinja2:16`(`"""{{ function_name }}表"""` docstring)、`:19`、`:32`、`:34`、`:47`、`:54`、`:57`、`:59`(`comment='{{ column.column_comment }}'`)、`:61`(`'{{ table_name }}'`)
|
||||
- `backend/templates/python/schema.py.jinja2:17,22,24,26,28,30,32,34,36,44,49,51,53,55,57,65,71,76,77,79`(同理,且 `{{ column.column_name }}` 直接当字段名/标识符用)
|
||||
- `backend/templates/python/controller.py.jinja2:19,20,24,26,...`(`summary="获取{{ function_name }}详情"`、`tags=["{{ function_name }}模块"]`)
|
||||
- 落盘位置就是应用自身的插件导入目录:`backend/app/modules/generator/gencode/service.py:663-693`(`_write_templates` → `BASE_DIR.parent.joinpath("backend/app/plugin/{package_name}/{module_name}/controller.py")`,另含 `model.py`/`schema.py`/`service.py`/`crud.py`/`__init__.py`)
|
||||
- 启动时无条件 import:`backend/app/core/discover.py:52`(`glob("module_*/**/controller.py")`)+ `:74`(`importlib.import_module(module_path)`),且导入失败会**中止启动**(`:96-99`)
|
||||
- 未受校验的输入来源:
|
||||
- `backend/app/modules/generator/gencode/schema.py:27-60`:`GenTableColumnSchema.column_name` / `column_comment` / `function_name` **没有任何 pattern/长度/字符集校验**(对比 `package_name`/`module_name`/`business_name` 都做了 slug 规范化)
|
||||
- `backend/app/modules/generator/gencode/service.py:487-502`:`update_gen_table` 把请求体里「库里没有的列」直接 `GenTableColumnSchema(table_id=..., **gen_table_column.model_dump(...))` 落库
|
||||
- `backend/app/modules/generator/gencode/gen_util.py:36-48`:`function_name` 默认取自**物理表的表注释**,而表注释可被「建表 SQL」功能(`service.py:376-457`)写入
|
||||
|
||||
**现象**:`generate_code`(`POST /generator/gencode/output/{table_name}`)会把上述字段原样写进 `.py` 源码。一个 `function_name` 形如 `x\n"""\nimport os\nos.system("id")\n"""\n` 或 `column_comment` 形如 `a'); import os; os.system('id'); #` 的值,会让生成的 `model.py` / `schema.py` 语法合法且**在模块顶层执行任意语句**;文件被写入 `app/plugin/**`,应用下次启动(或 dev reload)即被 `discover.py` 导入执行。
|
||||
|
||||
**为什么有害**:这是一个听起来只需要「代码生成」权限(`module_generator:gencode:update` / `:code`)的能力,实际等价于**服务器任意代码执行**。而且它是「先写文件、后建菜单」的顺序(`service.py:662-698` 注释明确:文件先写),菜单创建失败也不会回滚已写入的代码。开发环境 `DEBUG=True` → `reload=True`(`main.py:42`),写入即触发重载执行,攻击窗口从「下次重启」缩短到「保存即生效」。
|
||||
|
||||
**建议修法**(任一都不够,建议全做)
|
||||
1. 生成模板改为**数据驱动**:`comment='...'` 用 `repr()`/`json.dumps()` 产出,标识符类字段先做 `^[a-z_][a-z0-9_]*$` 断言;`function_name` 这类自由文本只允许出现在渲染时转义的位置(或干脆不写入源码,改成常量表)。
|
||||
2. 在 `GenTableColumnSchema` / `GenTableSchema` 上补齐校验:`column_name` 必须匹配 `^[A-Za-z_][A-Za-z0-9_]*$`,`column_comment` / `function_name` 禁止换行与引号(或长度上限 + 字符集白名单)。
|
||||
3. 落盘后用 `ast.parse()` 校验生成的 `.py`,语法不合法直接拒绝并拒绝写盘(可防未来新增模板的注入面)。
|
||||
4. 生成产物写入**独立输出目录**(如 `backend/generated/`,不进 `app/plugin`),改为人工审核后移动;至少不要落在 `discover.py` 会自动 import 的路径下。
|
||||
5. 该能力单独拆分高权限(如仅超管),并在文档/权限名里写明「等价远程代码执行」。
|
||||
|
||||
---
|
||||
|
||||
### B2 [blocker] 数据权限(RBAC data_scope)在批量写路径上被绕过;多处 `set_available` 可直接改他人数据、甚至停用超管
|
||||
|
||||
**位置**
|
||||
- 根因:`backend/app/core/base_crud.py`
|
||||
- 权限条件只在**读路径**注入:`_build_conditions`(`:300-330`,其中 `:316-318` 调 `Permission(...)._permission_condition()`)
|
||||
- 写路径直接用主键集合改库、**完全不注入权限条件**:`delete`(`:225-232`)、`clear`(`:234-239`)、`set`(`:241-251`)
|
||||
- 依赖「调用方自己先查一遍」的守卫,而这些调用方漏了或只做了一半:
|
||||
- `backend/app/modules/system/notice/service.py:150-156` `NoticeService.set_available`:**零校验**直接 `set(ids=data.ids, status=...)`
|
||||
- `backend/app/modules/system/dict/service.py:221-230`(`DictTypeService`)与 `:510-519`(`DictDataService`):同样**零校验**
|
||||
- `backend/app/modules/system/user/service.py:268-273` `UserService.set_available`:只对「查出来的」用户做 `is_superuser` 拦截,**没有校验 `len(users) == len(data.ids)`**;越权 id 查不出来 → 不触发拦截 → 但 `set(ids=data.ids)` 照改 → 可用任意(含超管)id 启停
|
||||
- `backend/app/modules/system/dept/service.py:90-105` `DeptService.set_available`:`get_child_recursion` 会**无条件 `ids.append(id)`**(`backend/app/utils/common_util.py:192-196`),所以请求里的 id 即使不在可见集合中也会进入 `total_ids` → `set()` 越权
|
||||
- `backend/app/modules/task/cronjob/node/service.py:149-164` `batch_set_status`:`if not obj: continue` 后仍 `set(ids=ids, ...)`
|
||||
- 对照(说明这是「漏做」而非「设计」):`role/service.py:195-209`、`position/service.py:86-92`、`notice/service.py:135-148`(delete)、`user/service.py:145-163`(delete)都显式做了「按可见集合校验每个 id」的前置检查。
|
||||
|
||||
**现象**:一个 `data_scope=1`(仅本人)的普通管理员,只要拥有对应按钮权限(`module_system:notice:patch`、`module_system:dict_type:patch`、`module_system:user:patch`、`module_system:dept:patch` 等),就能通过 `ids` 参数修改**任意租户/部门**的公告、字典、部门状态;对 `user:patch` 还能一并**停用超级管理员账号**(`get_list` 因权限过滤看不到超管 → 绕过 `is_superuser` 拦截 → `set(status=1)` 生效)。
|
||||
|
||||
**为什么有害**:`Permission` 类的类注释自称「为业务模型提供数据权限过滤功能」,但实现只在 SELECT 上生效,导致「查不到 ≠ 改不了」。这类越权不需要任何注入技巧,普通管理员账号 + 一个正常业务接口即可完成,属可直接利用的授权缺陷;「能停用超管」还是提权/拒绝服务链的一环。
|
||||
|
||||
**建议修法**
|
||||
1. 把权限条件下沉到写路径:`delete` / `set` / `clear` 一律先经 `_build_conditions`(或构造 `WHERE pk IN (...) AND <permission_condition>`),从结构上让「越权 id 命中 0 行」;顺手用 `rowcount != len(set(ids))` 报错。这样「Service 忘记校验」最多是功能异常,不再是越权。
|
||||
2. 过渡期先把上述 6 处调用点改成统一模板:`objs = await crud.get_list(search={"id": ("in", ids)})` → `if len(objs) != len(set(ids)): raise CustomException(...)` → 再 `set/delete`(参考 `role/service.py:195-209`)。
|
||||
3. `get_child_recursion` / `get_parent_recursion` 增加 `visited` 集合(同时修 M5/H4),并**先校验入参 id 在可见集合内**再展开。
|
||||
4. 补一条集成测试:以非超管 + `data_scope=1` 登录,对越权 id 调 `set_available`/`delete`,断言 4xx 且目标行未被修改。
|
||||
|
||||
---
|
||||
|
||||
### H1 [high] `SECRET_KEY` 有硬编码默认值;`.env.example` 未模板化该键;无启动期校验 → 生产可能用公开密钥签发 JWT
|
||||
|
||||
**位置**
|
||||
- `backend/app/config/setting.py:66`:`SECRET_KEY: str = "fastapiadmin-dev-secret-key-do-not-use-in-production" # ...(必须通过环境变量 SECRET_KEY 设置,无默认值)`——注释与代码自相矛盾,实际**有**默认值。
|
||||
- `backend/env/.env.example:2` 的注释明确要求使用者修改 `SECRET_KEY`,但文件正文里**没有 `SECRET_KEY` 这一项**(也没有 `DATA_ENCRYPTION_KEY`)。
|
||||
- 该密钥还被用来派生数据加密主密钥:`backend/app/utils/crypto_util.py:52-56`(`DATA_ENCRYPTION_KEY or SECRET_KEY` → HKDF)。
|
||||
- 相关反证:`tests/conftest.py:33` 注释写「SECRET_KEY 必填项,固定注入保证测试确定性」——说明作者本意是「必填」,但 `Settings` 给了默认值,测试才需要手动注入。
|
||||
|
||||
**现象**:按仓库自带 `.env.example` 部署(不额外设置 `SECRET_KEY`)时,进程用公开已知的默认串签发/校验 JWT,并用它派生 Fernet 主密钥。
|
||||
|
||||
**为什么有害**:任何人可用默认串自行签发 `{"sub": "<任意 session_id>"}` 的 token(`app/core/security.py:99-116`,HS256);虽然还需要 Redis 里存在对应 session(`app/core/dependencies.py:114-118`),但**数据加密**(存储源密码、AI 密钥,`crypto_util.py`)可被离线解密。此外默认值被写进代码仓库,属于「默认凭据」类配置缺陷。
|
||||
|
||||
**建议修法**
|
||||
1. `SECRET_KEY: str = ""`,在 `Settings` 上加 `model_validator(mode="after")`:仅当 `ENVIRONMENT != dev` 时空值/等于默认串就直接 `raise`(fail-fast),或至少在 prod 打 `logger.critical` 并拒绝启动。
|
||||
2. `.env.example` 补 `SECRET_KEY = `(并注释生成方式 `openssl rand -hex 32`)与 `DATA_ENCRYPTION_KEY`。
|
||||
3. 把「默认串」改为不可能出现的哨兵值(如 `__CHANGE_ME__`),避免有人以为它是可用的密钥。
|
||||
4. 统一 `model_config.env_file` 与 `get_settings()` 的 `ENVIRONMENT` 默认值(`setting.py:17` 用 `os.getenv('ENVIRONMENT')`(无默认)而 `:326` 用默认 `'dev'`),避免 `.env.None` 这种隐式路径。
|
||||
|
||||
---
|
||||
|
||||
### H2 [high] 定时任务「代码块」默认允许 `exec`,且带完整 builtins
|
||||
|
||||
**位置**
|
||||
- 开关默认开:`backend/app/config/setting.py:127`:`SCHEDULER_ALLOW_CODE_EXEC: bool = True`
|
||||
- 执行点:`backend/app/core/ap_scheduler.py:544-573`,关键两行 `module.__dict__["__builtins__"] = __builtins__`(`:562`)与 `exec(code_block, module.__dict__)`(`:563`)
|
||||
- 代码块来源是业务表单字段:`backend/app/modules/task/cronjob/node/service.py:281-285`(`code_block = job_info.func`)、`:311`/`:330`(作为 job args 注册)、`:364-376`(手动执行)
|
||||
|
||||
**现象**:任何能创建/编辑「节点任务」的用户提交 `func` 即可在服务器上以应用进程权限执行任意 Python(无沙箱、无 AST 白名单、builtins 完整)。
|
||||
|
||||
**为什么有害**:与 B1 同类(认证用户 → RCE),且默认开启、只在注释里写「生产环境强烈建议设为 False」。多租户/多管理员后台里,这是「任务管理」权限被偷偷放大成「服务器控制权」。
|
||||
|
||||
**建议修法**
|
||||
1. 默认值改 `False`;生产启动时若为 `True` 打印 `logger.critical`。
|
||||
2. 改为白名单「内置处理器注册表」(node service 已有 `handlers/` 目录,如 `demo_handler.py`),`func` 只允许引用注册名;彻底删掉 `exec` 路径,或至少用 AST 校验(禁 `import`/`__`/属性访问)。
|
||||
3. 若必须保留,放到独立执行器/子进程 + 降权用户 + 资源/网络限制,并单独授予权限标识。
|
||||
|
||||
---
|
||||
|
||||
### H3 [high] 操作日志的操作用户恒为 `unknown`(审计链断裂)
|
||||
|
||||
**位置**:`backend/app/core/router_class.py:119`
|
||||
```python
|
||||
"username": str(getattr(getattr(request.state, "ctx", None), "user_username", None) or "unknown"),
|
||||
```
|
||||
全仓库检索:`request.state.ctx` **只有这一处读取,没有任何一处写入**(`grep -rn "request\.state" app` 仅命中此文件)。同时日志记录体里也没有 `created_id`(`OperationLogRecord`,`:44-56`),而导出模板却包含 `created_id` 列(`backend/app/modules/system/log/service.py:118-130`)。
|
||||
|
||||
**现象**:所有 `task_operation_log`/operation log 记录的 `username` 都是 `unknown`,「谁改了什么」无法追查。
|
||||
|
||||
**为什么有害**:对后台管理系统,操作日志是唯一的追责证据来源(也是等保/审计要求的常见项)。当前它只能证明「某个请求发生过」,不能证明「谁发起」。B2 那种越权行为发生后,日志无法定位到账号。
|
||||
|
||||
**建议修法**:在 `AuthPermission.__call__` / `get_current_user`(`app/core/dependencies.py:206-234`、`:43-49`)里把认证后的 `auth.user.username`、`user_id` 写入 `request.state`(注意 `AuthSchema.user` 带 `exclude=True`,取 `auth.user.username` 即可);`OperationLogRecord` 增加 `created_id`/`user_id` 字段并在 `_write_operation_log_async` 中落库;补一条测试断言登录用户的操作日志 `username` 非 `unknown`。
|
||||
|
||||
---
|
||||
|
||||
### H4 [high] 部门/菜单父子关系可造环 → `get_child_recursion` 无限递归(RecursionError / 500),并可被用来打爆自己的数据权限路径
|
||||
|
||||
**位置**
|
||||
- 递归无 visited 保护:`backend/app/utils/common_util.py:181-196`(`get_child_recursion`)、`:141-155`(`get_parent_recursion` 同型)
|
||||
- 可造环的写入口(都不校验 parent 是否为自己/后代):
|
||||
- `backend/app/modules/system/dept/service.py:62-72`(`update` 只查重名/编码)
|
||||
- `backend/app/modules/system/menu/service.py:98-119`(`_validate_parent_child_type` 只校验父的 type,`X.parent_id = X`(type=1)可通过)
|
||||
- 被环命中的调用点:`app/core/permission.py:119-124`(`_load_dept_children`,**每个使用 dept 数据权限的请求都会走到**)、`app/modules/system/dept/service.py:100-103`、`app/modules/system/menu/service.py:133-148`(delete/set_available)
|
||||
|
||||
**现象**:把部门 A 的 `parent_id` 设成 A(或 A↔B 互指)后,任何 `data_scope=2` 用户的下一次查询、或对该节点执行删除/启停,都会 `RecursionError: maximum recursion depth exceeded`(500)。菜单同理。
|
||||
|
||||
**为什么有害**:一个纯配置操作即可让**整个部门数据权限链路**报 500(不是单点功能坏,而是所有带 `created_id` 模型的读路径),属低成本自伤/拒绝服务;且脏数据持久化后即使重启也立刻复发。
|
||||
|
||||
**建议修法**:`update` 时校验 `data.parent_id != id` 且 `data.parent_id not in get_child_recursion(id, ...)`;两个递归函数加 `visited` 集合与深度上限(超限抛 `CustomException`);在 Alembic 迁移或启动自检里检测并告警存量环。
|
||||
|
||||
---
|
||||
|
||||
### H5 [high] 异常处理器里的 `status.HTTP_403_SERVICE_UNAVAILABLE` 不存在 → 处理器自身抛 `AttributeError`
|
||||
|
||||
**位置**:`backend/app/core/exceptions.py:119`
|
||||
```python
|
||||
if "connect" in detail or "connection" in detail:
|
||||
return ErrorResponse(msg="数据库连接失败", status_code=status.HTTP_403_SERVICE_UNAVAILABLE, data=expose_detail)
|
||||
```
|
||||
实测(`.venv/bin/python`):`starlette.status` 里 `HTTP_403_SERVICE_UNAVAILABLE` **不存在**(只有 `HTTP_403_FORBIDDEN` 与 `HTTP_503_SERVICE_UNAVAILABLE`),`'HTTP_403_SERVICE_UNAVAILABLE' in dir(status) → False`。ruff 不会发现(属性访问)。
|
||||
|
||||
**现象**:该分支一旦命中,`AttributeError` 在 ASGI 异常处理中间件里被抛出 → Starlette 兜底返回**纯文本 500**,既没有统一 JSON 信封,也没有可读原因(`logger.error` 在本行之后,也不会执行)。
|
||||
|
||||
**为什么有害**:这正是「数据库连不上」这个最需要清晰报错的场景,却退化成最难排查的 500 裸响应;同时说明这段代码从未被执行过(无覆盖)。另外该分支挂在 `isinstance(exc, IntegrityError)` 下(`:115`),而连接失败实际是 `OperationalError`/`InterfaceError`,即使名字修对了也**永远不会命中**,属双重失效。
|
||||
|
||||
**建议修法**:改成 `HTTP_503_SERVICE_UNAVAILABLE`,并把连接类错误判断从 `IntegrityError` 分支上移到 `SQLAlchemyError` 顶层(或在 `OperationalError` 分支里处理);补一个「DB 不可达 → 503 JSON 信封」的测试(可用 `pytest.raise`/mock 引擎)。
|
||||
|
||||
---
|
||||
|
||||
### H6 [high] 上传:内容校验只记日志不拦截;默认允许 `.svg` 且静态同源提供 → 存储型 XSS;`file.size` 为 None 时无大小上限
|
||||
|
||||
**位置**
|
||||
- `backend/app/utils/upload_util.py:216-234`(`validate_file_content_type`:类型不匹配只 `logger.warning`,**始终 `return True`**)
|
||||
- 默认白名单含 `.svg`、`.ico`、`.xls/.xlsx`:`backend/app/config/setting.py:213-222`
|
||||
- 上传目录通过静态路由同源暴露:`app/__init__.py:88-91`(`app.mount("/static", StaticFiles(directory=STATIC_DIR))`),落地目录 `settings.UPLOAD_FILE_PATH = Path("static/upload")`(`setting.py:211`)
|
||||
- 大小校验依赖 `file.size`:`upload_util.py:249-251`(`if file.size and file.size > MAX_FILE_SIZE`),随后 `:421` `await file.read()` **整文件读入内存**
|
||||
|
||||
**现象**:上传一个内容为 `<svg xmlns="…"><script>fetch('/api/v1/system/user/current/info',{headers:{Authorization:'Bearer '+localStorage.token}})</script></svg>` 的 `.svg`(或把 `.html` 改名成 `.png` 绕过扩展名白名单——内容校验不拦),即可从 API 同源地址访问到它。`detect_file_type`(`:170-193`)根本不识别 SVG/HTML,所以 SVG 必然通过。前端 token 存于浏览器(`Authorization` 头),同源脚本可直接读取 → 管理员打开该 URL 即被接管。若经过代理导致分片传输使 `file.size` 为 `None`,则**完全没有大小限制**,且 `await file.read()` 把整个文件读进内存。
|
||||
|
||||
**为什么有害**:文件上传是后台系统里最常见的 XSS/RCE 落地通道;同源 + 无 `Content-Disposition`(静态挂载直接内联渲染 SVG)使 XSS 可靠触发,且可窃取会话。
|
||||
|
||||
**建议修法**
|
||||
1. 内容校验改为**强制**:`detect_file_type` 无法识别或与声明扩展名不一致 → 抛 `CustomException`;补齐 SVG/HTML 魔数识别与内联白名单。
|
||||
2. 从 `ALLOWED_EXTENSIONS` 移除 `.svg`(或对 SVG 做 DOMPurify/白名单化清洗后再存)。
|
||||
3. 上传目录改为独立子域/独立静态目录,响应加 `Content-Disposition: attachment` + `X-Content-Type-Options: nosniff`。
|
||||
4. 大小限制不依赖 `file.size`:流式读取时累加字节并在超限时中止;`MAX_FILE_SIZE` 同时用 `Content-Length` 预检。
|
||||
|
||||
---
|
||||
|
||||
## 2. 中等(medium)
|
||||
|
||||
### M1 [medium] `order_by` 完全由客户端控制且无字段白名单
|
||||
- 位置:`backend/app/core/base_schema.py:141-162`(`order_by: Any`,仅承诺「JSON 数组」),解析处 `backend/app/core/base_crud.py:322-330`(`_parse_order`:`getattr(self.model, field)` → `asc/desc(...)`)
|
||||
- 现象:`order_by=[{"任意属性名":"asc"}]` 进入 `getattr`;传 `__table__`/`metadata`/关系属性等会抛 `ArgumentError`,被 `page()` 的外层 `except` 包成 `CustomException(msg="分页查询失败: ...")`(默认 HTTP 500,`exceptions.py:41-55`),对客户端表现为「服务器错误」而非 400。
|
||||
- 为什么有害:① 用户输入错误 → 500 而非 400,污染错误监控;② 可按未建索引列排序(如 `description`)造成慢查询,是低成本的接口级 DoS;③ 无白名单意味着模型新增敏感列后立刻对所有人开放排序侧信道。
|
||||
- 建议修法:在 `PaginationQueryParam.validate_order_by` 里只接受 `{field: asc|desc}` 且 `field` 匹配 `^[a-z_][a-z0-9_]*$`;在 `_parse_order` 里用 `sa_inspect(model).columns` 白名单校验,非法字段抛 400 而不是 500。
|
||||
|
||||
### M2 [medium] 数据权限判定在每个读操作上额外查库,且 `_load_dept_children` 全表加载
|
||||
- 位置:`backend/app/core/permission.py:44`(`_load_user_data_scopes`,每次 `_build_conditions` 都执行)、`:96-103`(`select(RoleModel.data_scope).join(RoleModel.users)`)、`:119-124`(`select(DeptModel)` **全表** + `get_child_recursion` 内存展开)
|
||||
- 现象:`get`/`count`/`exists`/`get_list`/`page` 各触发 1 次权限查询;`data_scope=2` 时再全表扫一次部门。`exists()` 走 `count()`(`base_crud.py:88-90`)→ 一次「存在性判断」= 权限查询 + COUNT。
|
||||
- 为什么有害:每个请求多 1–2 次 DB 往返;部门表大时每次查询都全量加载 + Python 递归,随规模线性恶化;也让 `page()` 这类本应 2 条的 SQL 变成 4 条。
|
||||
- 建议修法:在 `AuthSchema`/`Redis` 会话里缓存一次「用户可见部门集合」(会话内不变,可直接放到登录时写入的 Redis session 或 `request.state`);把部门子树改为单条递归 CTE(MySQL 8 / PostgreSQL 均支持)或按 `parent_path` 物化路径过滤。
|
||||
|
||||
### M3 [medium] 「建表 SQL」只校验顶层语句类型 → 放行 `CREATE TABLE ... AS SELECT`、`CREATE VIEW`、`CREATE INDEX`
|
||||
- 位置:`backend/app/modules/generator/gencode/service.py:391-404`(`has_create = any(isinstance(s, Create) ...)`;禁止集合只覆盖 `Delete/Drop/Insert/TruncateTable/Update` 顶层节点)、`:430-444`(只重放 `Create/Comment/Alter`)
|
||||
- 现象:`CREATE TABLE x AS SELECT * FROM sys_user`(CTAS)在 sqlglot 里是 `Create`,内层 `Select` 不会被禁止集合命中 → 直接执行,生成含敏感数据的副本表;`CREATE VIEW`/`CREATE INDEX` 同样被当作「建表」放行。
|
||||
- 为什么有害:该接口的本意是「只允许建表」,实际打开了「以数据库权限复制任意表数据」「创建视图指向任意表」的口子;配合 `db/list` 导入还能把该表变成代码生成对象。
|
||||
- 建议修法:只接受 `isinstance(s, Create)` 且 `s.kind.upper() == "TABLE"` 且 `s.expression` 为空(无 CTAS);`Comment` 只允许 `TABLE`/`COLUMN`;拒绝一切非 `TABLE` 的 `Create`;执行前对重放后的 SQL 再做一次同规则校验。
|
||||
|
||||
### M4 [medium] 代码生成/字典删除存在 N+1 写放大
|
||||
- 位置:`backend/app/modules/generator/gencode/service.py:349-368`(导入:每列 1 条 INSERT)、`:487-509`(编辑:每列 1 条 UPDATE/DELETE,含 `delete(ids=[db_id])` 逐列)、`:799-830`(每个按钮 1 次 `get` + 1 次 `create`,固定 9 个按钮 → 最多 18 条语句)
|
||||
- 另见 `backend/app/modules/system/dict/service.py:200-203`(每个 dict_type 一次 `get_list` 判空)
|
||||
- 现象:60 列表格导入 = 60 次 `flush+refresh`(`base_crud.create` 每次都 `flush()` + `refresh()` + 可能的 `joinedload` 回查,`base_crud.py:186-217`),一次「生成代码」= 十余条 INSERT。
|
||||
- 为什么有害:整个请求共用一个事务(`dependencies.py:21-28`),语句数线性放大延长锁持有时间;MySQL/PostgreSQL 下大表导入/生成代码会明显变慢并加剧锁竞争。
|
||||
- 建议修法:批量 `add_all` / `insert().values([...])`,必要时用 `executemany`;`create` 的 `refresh` 改为按需;按钮权限用 `permission in (...)` 一次查出已存在集合再差量创建;字典删除用 `exists()` 子查询或一次 `IN` 查询。
|
||||
|
||||
### M5 [medium] 时区口径混用:模型默认 UTC-aware,业务代码用 naive `datetime.now()`
|
||||
- 位置:UTC 一侧 `backend/app/core/base_model.py:104,111-112`(`created_time/updated_time` 默认 `datetime.now(UTC)`)、`backend/app/core/base_crud.py:64`(软删除 `datetime.now(UTC)`);本地时间一侧 `backend/app/modules/system/user/crud.py:40`(`last_login = datetime.now()`)、`backend/app/modules/system/log/service.py:146`(`cutoff = datetime.now() - timedelta(days=days)`)、`backend/app/modules/system/auth/service.py:372,479`(JWT exp 基线)
|
||||
- 现象:`created_time`(UTC 墙钟写入)与 `last_login`(本地墙钟写入)在同一张 `sys_user` 表里混着存;`cleanup_expired_logs` 用本地时间与 UTC 墙钟比较。
|
||||
- 为什么有害:① 日志清理边界偏移一个时区(以东八区为例多删 8 小时数据,或保留策略形同虚设);② 「最后登录时间」与「创建时间」相差 8 小时,前端展示与运维排查都会误判;③ 任何跨时区迁移/多活部署都会放大。
|
||||
- 建议修法:统一为「存 UTC、展示时按配置时区格式化」(`DateTime(timezone=True)` + `datetime.now(UTC)` 全量替换 naive 调用),前端序列化已由 `validator.py:DateTimeStr` 统一;补一条测试断言 `last_login` 与 `created_time` 同源。
|
||||
|
||||
### M6 [medium] `RedisCURD.get_keys` 走 `KEYS` 且被用于全库匹配
|
||||
- 位置:`backend/app/core/redis_crud.py:89-102`(`KEYS pattern`),调用点 `backend/app/modules/monitor/cache/service.py:35,50,57,64`(其中 `:64` 是 `get_keys()` → `KEYS *`)
|
||||
- 现象:缓存管理页面点「清除所有缓存」会对整个 Redis 实例执行 `KEYS *`。
|
||||
- 为什么有害:`KEYS` 在单线程 Redis 上 O(N) 阻塞,会拖慢/卡死所有依赖 Redis 的请求(会话、限流、调度 jobstore);生产数据量大时是明确的可用性事故点。
|
||||
- 建议修法:改 `SCAN`(类内已有 `scan_keys`,`redis_crud.py:70-87`),或分页 + 分批 `UNLINK`;`KEYS` 路径加显式弃用告警。
|
||||
|
||||
### M7 [medium] 在线用户列表 N+1 + `zip(strict=True)` 在 Redis 出错时抛未捕获异常
|
||||
- 位置:`backend/app/modules/monitor/online/service.py:23-25`(`scan_keys` → `mget` → `zip(keys, tokens, strict=True)`),循环内 `:35` 每个会话一次 `GET`,异常分支 `:59-62` 每个坏会话 3 次 `DELETE`
|
||||
- 现象:列表中每个会话 1 次 Redis 往返(外加失效会话 3 次删除);`mget` 内部异常时返回 `[]`(`redis_crud.py:59-68`),`strict=True` 使 `zip` 抛 `ValueError` → 未捕获 → 500。
|
||||
- 为什么有害:会话多时页面变慢;Redis 抖动时该接口 500 而不是降级。
|
||||
- 建议修法:`mget` 失败返回 `None` 并显式判空;用 Redis pipeline / `MGET` 一次取齐所有 `USER_SESSION`(`mget` 后逐个 `json.loads`);失效会话用一次 `DEL` 多键删除。
|
||||
|
||||
### M8 [medium] 测试覆盖实质为零,且 `assert_route` 把「异常/500」当成通过
|
||||
- 位置:`backend/tests/`(3 个文件共 324 行,2 个用例)、`backend/tests/conftest.py:243-277`(`assert_route`:`except Exception: return` 直接放过,`expected_status=None` 时只断言 `!= 404`)
|
||||
- 现象:2.85 万行代码只有「健康检查」和「迁移零漂移」两个断言的守护;`assert_route` 这个为「模块化接口测试」准备的助手**没有任何测试使用**(`grep -rn assert_route tests` 只命中定义),而它的语义是「只要不是 404 就算过」。
|
||||
- 为什么有害:本报告里 B1/B2/H1–H6 全部可以在 CI 无感地存在;任何重构(尤其是插件/生成器/权限)都没有安全网。
|
||||
- 建议修法:① 至少为「认证→权限→CRUD」主链路补 happy path + 越权 4xx 用例(`assert_route` 的 `expected_status` 必填);② 把 `except Exception: return` 改为失败;③ 加 `pytest --cov --cov-fail-under`(当前无覆盖率门禁)。
|
||||
|
||||
### M9 [medium] 用户角色/岗位「替换」语义在空列表时静默跳过
|
||||
- 位置:`backend/app/modules/system/user/service.py:180-204`(`_set_user_roles` / `_set_user_positions` 开头 `if not role_ids: return`),调用点 `:112-113`、`:140-141`(`data.role_ids or []`)
|
||||
- 现象:编辑用户时提交 `role_ids: []`(本意「清空所有角色」)不会清空;同一函数被 `delete()`(`:166-167`)复用且那里传 `role_objs=[]` 是有效的——同一语义两种行为。
|
||||
- 为什么有害:管理员以为已回收权限,实际账号仍持有旧角色(最小权限原则失效),属静默的权限残留;也是审计时「配置与预期不符」的常见来源。
|
||||
- 建议修法:区分「未提供」(`None` → 保持)与「提供空列表」(`[]` → 清空),签名改为 `role_ids: list[int] | None`;补测试固定这两种语义。
|
||||
|
||||
### M10 [medium] 配置/生命周期:引擎在 import 期创建、上传目录相对 CWD、`WORKERS>1` 未经真实多进程验证
|
||||
- 位置:`backend/app/core/database.py:87-88`(模块级 `engine = create_sync_engine()`、`async_engine, async_db_session = create_async_engine_and_session()`)、`backend/app/config/setting.py:33`(`WORKERS`)、`:211`(`UPLOAD_FILE_PATH: Path = Path("static/upload")` 相对路径)、`backend/app/modules/common/file/service.py:52-56`(用同一相对根做 `is_relative_to` 校验)
|
||||
- 现象:只要 `import app.core.database`(很多模块间接依赖)就会建立两个引擎;`uvicorn --factory app:create_app` 在 pre-fork(多 worker)场景下,若在 fork 前 import 就会把父进程的连接池句柄带进子进程(经典 fork 连接复用问题)。上传目录以进程 CWD 为基准解析。
|
||||
- 为什么有害:① 从不同 CWD 启动(systemd `WorkingDirectory=/`、容器 workdir 变更)会让上传文件落到别处、`download` 校验根也随之变化,出现「上传成功但下载 404/非法路径」;② 多 worker 下的连接池/调度器行为未验证,`WORKERS>1` 的注释(`setting.py:33`)只写了「确保 Redis 共享 jobstore」。
|
||||
- 建议修法:引擎改为 `lifespan` 内惰性创建(或用 `@lru_cache` 函数延迟到首次使用);`UPLOAD_FILE_PATH` 改为基于 `path_conf.BASE_DIR` 的绝对路径;在文档/启动日志中固化「多 worker 部署须用 `app:create_app` factory 且不要在 fork 前 import DB 模块」。
|
||||
|
||||
### M11 [medium] OAuth 回调域名白名单默认关闭;`X-Real-IP` 在私网来源下被无条件信任
|
||||
- 位置:`backend/app/config/setting.py:150`(`OAUTH_ALLOWED_HOSTS: list[str] = ["*"]`)、`backend/app/modules/system/auth/oauth_service.py:35-43`(`if allowed_hosts and allowed_hosts != ["*"]:` 才校验 Host)、`backend/app/utils/ip_local_util.py:44-50`(`TRUSTED_PROXY_HOPS > 0` 且对端是私网/回环时直接采用 `X-Real-IP`)、`setting.py:34`(`TRUSTED_PROXY_HOPS = 1` 默认)
|
||||
- 现象:默认配置下 `redirect_uri` 由 `request.base_url`(即 Host 头)拼出,Host 注入不受任何校验;只要直连对端落在私网(docker/k8s 内网、跳板机、同网段主机),客户端就能伪造 `X-Real-IP` 影响登录限流与 IP 黑白名单(`app/core/middlewares.py:43-58`、`auth/service.py:161`)。
|
||||
- 为什么有害:OAuth 授权码可能被重定向到攻击者域名;伪 IP 可直接绕过登录限流(`app/modules/system/auth/service.py:161-163` 还额外豁免 `127.0.0.1`,见 M12)与黑名单封禁。
|
||||
- 建议修法:`OAUTH_ALLOWED_HOSTS` 默认改为部署域名(或改为「必须显式配置否则拒绝启动」);私网来源不能作为信任代理的判据,应改为显式代理 IP/CIDR 白名单(`TRUSTED_PROXIES`);启动日志打印生效的信任策略。
|
||||
|
||||
### M12 [medium] 登录限流可被绕过:豁免 `127.0.0.1`、仅按 IP、成功后不复位
|
||||
- 位置:`backend/app/modules/system/auth/service.py:161-163`(`if not request_ip or request_ip in ("unknown", "127.0.0.1", "localhost"): return`)、`:164-173`(固定窗口 INCR)、`:209-242`(失败/成功分支)
|
||||
- 现象:`get_client_ip` 从不返回字面量 `"unknown"`(`ip_local_util.py:35-60`),而配合 M11 的 `X-Real-IP` 信任,攻击者可直接把来源设成 `127.0.0.1` → **完全不进入限流计数**;此外计数只按 IP、永不按账号,成功后也不清零,分布式撞库(多 IP 打同一账号)无任何约束。
|
||||
- 为什么有害:爆破/撞库防护实际上可被一行请求头绕过;`LOGIN_RATE_LIMIT_MAX_ATTEMPTS=10` 只能挡住最笨的脚本。
|
||||
- 建议修法:删除 `127.0.0.1/localhost` 豁免(排障可另设开关);增加「按账号」计数与指数退避;成功登录后清零该账号计数;把「未知来源」当作普通 IP 计数而不是豁免。
|
||||
|
||||
### M13 [medium] `execute_sql` 吞掉异常返回 False,DDL 部分成功无法感知
|
||||
- 位置:`backend/app/modules/generator/gencode/crud.py:331-346`(`except Exception: logger.error(...); return False`),调用点 `backend/app/modules/generator/gencode/service.py:443-444`
|
||||
- 现象:多条 DDL 逐条执行(`:430-444`),任一条失败只返回 `False` → 外层抛自定义异常,但**前面已成功的语句不会回滚**(MySQL DDL 隐式提交)。
|
||||
- 为什么有害:用户看到「创建表结构失败」,库里却残留半张表;下次重试因「表已存在」被拒(`service.py:421-422`),需要人工清理;服务层也丢失了真实错误类型(只剩一句 `请检查数据库`)。
|
||||
- 建议修法:`execute_sql` 抛原始异常(由 `handle_service_exception` 包装),错误信息带语句序号与方言原文;对 MySQL 显式提示「DDL 不可回滚,失败可能残留半成品」,并提供清理/重试入口。
|
||||
|
||||
---
|
||||
|
||||
## 3. 低(low)
|
||||
|
||||
### L1 [low] 错误响应/错误码在同一种语义下不一致
|
||||
- `backend/app/core/dependencies.py:228` 用 `RET.FORBIDDEN`(403),`:232` 同为 403 却用 `RET.NO_PERMISSION`(10403);`backend/app/common/response.py:70-78` 的 `ErrorResponse` 默认 `code=RET.ERROR.code=1`,于是 `HTTPException` 分支(`exceptions.py:76-85`)永远返回 `code=1` + 各自 `status_code`;`CustomException` 默认 `code=-1`(`exceptions.py:41-55`)。
|
||||
- 前端若按 `code` 分支处理会踩坑(同一语义两个码)。建议:错误码与 HTTP 状态一一映射,统一由一个映射表产出;`ErrorResponse` 默认 `code` 由 `status_code` 推导。
|
||||
|
||||
### L2 [low] `require_superadmin` 是死代码,且权限不足时返回 500 而非 403
|
||||
- `backend/app/core/exceptions.py:16-38`:全仓库无调用点(`grep -rn require_superadmin app` 只命中定义),且 `CustomException(msg="仅平台管理员可操作")` 使用默认 `status_code=500`。
|
||||
- 建议:删除或改为 `status_code=403, code=RET.FORBIDDEN.code`;若保留,补使用点与测试。
|
||||
|
||||
### L3 [low] `AuthPermission` 不识别用户侧的 `*:*:*` 通配
|
||||
- `backend/app/core/dependencies.py:219-232`:通配判断作用在**接口声明的** `self.permissions` 上;用户权限集合里即使含 `*:*:*`(RuoYi 习惯写法),`:230` 的 `any(perm in user_permissions ...)` 仍会拒绝。
|
||||
- 影响:给非超管角色授 `*:*:*` 菜单后「勾了权限却全 403」(fail-closed,安全但难排查)。建议:显式支持 `user_permissions` 中含 `*` / `*:*:*` 时放行,并在权限说明里标注通配符语义。
|
||||
|
||||
### L4 [low] 文档/常量与实际实现不一致(易误判数据权限语义)
|
||||
- `backend/app/core/base_model.py:47-56` 写「1:仅本人 2:本部门 3:本部门及以下 4:全部 5:自定义」;实际实现是 `app/core/permission.py:16-18` 的 `1/2/3 = 仅本人/本部门及以下/全部`(前端 `frontend/web/src/views/module_system/role/components/FaPermissonDrawer.vue:37-39` 与 `role/schema.py:25-30`、`role/model.py:72` 均为 1..3)。
|
||||
- `backend/app/core/validator.py:300-330`(`role_permission_request_validator`)仍按 1..5 校验并生成错误文案(因 `RolePermissionSettingSchema` 已 `le=3`,该分支为死代码)。
|
||||
- `backend/app/modules/system/role/model.py:41` 称 `sys_role_depts`「仅当 data_scope=5 时使用」,与 `role/service.py:163-165`(改数据权限时直接清空 dept 关联)矛盾。
|
||||
- `backend/app/modules/system/log/service.py:118-130` 导出映射含 `created_id`,但 `OperationLogRecord`/模型无该字段(导出为空列)。
|
||||
- 建议:统一按 1..3 修正注释与死代码;删除 `sys_role_depts` 相关残留描述(或补齐「自定义数据权限」实现)。
|
||||
|
||||
### L5 [low] `_effective_package_name` 从 `menu.route_path` 直接赋给 `package_name`,绕过 pydantic 校验器
|
||||
- `backend/app/modules/generator/gencode/service.py:88-98`(`return seg if seg.startswith("module_") else f"module_{seg}"`)与 `:649`、`:1245` 直接**赋值**给已校验过的模型字段(pydantic 的 `field_validator` 不会因赋值再跑一次)。
|
||||
- 现状:由于 `module_` 前缀约束,实测难以构造真正的路径穿越(`route_path` 首段永远是单段),但生成路径的合法性完全依赖调用方,属于脆弱点。
|
||||
- 建议:抽一个 `_safe_slug()` 在赋值后再规范化一次,并断言结果匹配 `^module_[a-z0-9_]+$`;`generate_code` 落盘前用 `Path.resolve().is_relative_to(允许根)` 复核(可复用 `upload_util.py:454` 的写法)。
|
||||
|
||||
### L6 [low] 在线用户「强制下线」无归属校验(管理员作用域内的 IDOR)
|
||||
- `backend/app/modules/monitor/online/service.py:69-78`(`delete_online(redis, session_id)` 直接删会话),session_id 来自客户端参数;受 `monitor:online:delete` 权限保护。
|
||||
- 影响:在管理员之间无隔离(A 管理员可踢掉 B 管理员的会话)。建议:普通管理员仅能踢本部门/自己创建的下线范围,或至少记录操作日志(当前操作日志 `username` 恒为 unknown,见 H3,等于无痕)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 按优先级排序的修复清单
|
||||
|
||||
| 优先级 | 编号 | 一句话动作 | 影响面 | 预估成本 |
|
||||
|--------|------|-----------|--------|---------|
|
||||
| P0 | B1 | 代码生成模板对 `function_name`/`column_comment`/`column_name` 做转义与字符集校验;生成后 `ast.parse` 校验;产物移出 `app/plugin` 自动导入路径 | 服务器 RCE | 1–2 天 |
|
||||
| P0 | B2 | 把 `Permission` 条件下沉进 `base_crud` 的 `delete/set/clear`;并修 6 处未校验的 `set_available`(notice/dict×2/user/dept/node) | 越权写、可停用超管 | 1–2 天 |
|
||||
| P0 | H1 | `SECRET_KEY` 去掉可用默认值 + prod 启动校验;`.env.example` 补模板项 | JWT 伪造 / 数据可解密 | 2–4 小时 |
|
||||
| P0 | H2 | `SCHEDULER_ALLOW_CODE_EXEC` 默认改 `False`,`exec` 换成处理器注册表 | 认证用户 RCE | 0.5–1 天 |
|
||||
| P0 | H4 | 部门/菜单父子加环检测;`get_child_recursion`/`get_parent_recursion` 加 visited + 深度上限 | 全量读路径 500 | 2–4 小时 |
|
||||
| P0 | H3 | 认证后把 `username`/`user_id` 写入 `request.state`,操作日志落库并补 `created_id` | 审计不可用 | 2–3 小时 |
|
||||
| P1 | H5 | 修 `HTTP_403_SERVICE_UNAVAILABLE` → 503,并把连接错误分支移出 `IntegrityError` | 故障期无可用报错 | 1 小时 |
|
||||
| P1 | H6 | 上传内容类型强校验 + 去掉 `.svg` 内联 + 流式大小限制 + `nosniff`/`attachment` | 存储型 XSS / OOM | 0.5–1 天 |
|
||||
| P1 | M12 | 去掉 `127.0.0.1` 限流豁免,加账号维度计数与成功后清零 | 爆破防护形同虚设 | 2–3 小时 |
|
||||
| P1 | M11 | OAuth 域名白名单默认收紧;私网来源不再等价于「可信代理」 | 重定向劫持 / IP 伪造 | 3–4 小时 |
|
||||
| P1 | M13 | `execute_sql` 抛原始异常,DDL 失败给出「不可回滚」提示与清理入口 | 半成品表残留 | 2 小时 |
|
||||
| P2 | M1 | `order_by` 白名单校验,非法字段返回 400 | 500 噪音 + 排序 DoS | 2–3 小时 |
|
||||
| P2 | M8 | CI 加主链路 + 越权用例,`assert_route` 不再吞异常,加覆盖率门禁 | 所有回归的护栏 | 1–2 天 |
|
||||
| P2 | M2 | 权限/可见部门集合按请求缓存一次,部门子树用递归 CTE | 每请求多 1–2 次查询 | 0.5–1 天 |
|
||||
| P2 | M9 | `role_ids`/`position_ids` 区分 None 与 [] | 权限残留 | 1–2 小时 |
|
||||
| P2 | M4 | 代码生成/字典删除改批量写 | 事务时长与锁竞争 | 0.5–1 天 |
|
||||
| P2 | M3 | 建表 SQL 只放行 `Create(kind=TABLE)` 且无 CTAS/VIEW/INDEX | 数据复制面 | 2–3 小时 |
|
||||
| P2 | M5 | 时间统一「存 UTC」;修 `cleanup_expired_logs` 与 `last_login` | 日志保留 & 展示错乱 | 3–4 小时 |
|
||||
| P3 | M6 | `get_keys` 换 `scan_keys`/`UNLINK` | Redis 阻塞 | 1–2 小时 |
|
||||
| P3 | M7 | 在线列表改 `MGET` + 判空,去掉 `zip(strict=True)` 崩溃点 | 页面变慢 / 500 | 2 小时 |
|
||||
| P3 | M10 | 引擎改 `lifespan` 惰性创建;`UPLOAD_FILE_PATH` 改绝对路径 | 多 worker / CWD 依赖 | 半天 |
|
||||
| P3 | L1–L6 | 错误码统一、删死代码、修过期注释、`_effective_package_name` 再规范化、在线下线归属校验 | 可维护性/排查成本 | 1 天 |
|
||||
|
||||
建议的落地顺序(最小爆炸半径):
|
||||
1. **第一批(当天可完成)**:H1、H5、H3、H4、M12 — 都是低风险小改动,立刻消掉「密钥/审计/500/环/限流」五类问题。
|
||||
2. **第二批**:B2(权限下沉,改完后 6 处 service 的临时校验可保留但不再是唯一防线)、M1、M9。
|
||||
3. **第三批**:B1 + H2 + H6(三处「权限被放大成 RCE/XSS」的能力收口,需要产品侧确认能力边界与权限命名)。
|
||||
4. **第四批**:M8 补测试护栏,其余 medium/low 按迭代消化。
|
||||
|
||||
---
|
||||
|
||||
## 5. 附:本轮确认「做得好」的部分(避免修复时误伤)
|
||||
|
||||
- **分层与依赖方向守得住**:`app/api/v1/routers.py` 只是路由总表;`app/core` 需要业务模型时一律函数内延迟导入并注明「守卫不变式 3」(`dependencies.py:172`、`permission.py:98-99,121`、`router_class.py:60`、`ap_scheduler.py:219-220,358`);`app/utils`、`app/common` 不反向依赖 `modules`;`modules` 不依赖 `app.api`。
|
||||
- **异步阻塞处理整体到位**:同步 SDK/CPU 密集操作普遍经 `asyncio.to_thread`(`excel_util.py:42,149`、`password_util.py:39,71`、`gencode/crud.py` 的 `_sync_*`、`storage/core/base.py:378-428` 统一包装各协议适配器),`monitor/server/service.py:27-28` 连 `socket.gethostbyname` 都做了下沉。
|
||||
- **事务边界清晰**:请求级单事务 `db_getter`(`dependencies.py:21-28`),CRUD 只 `flush` 不 `commit`(`base_crud.py` 类注释);后台任务/无上下文场景自建会话(`router_class.py:58-66`、`log/service.py:139-155`)。
|
||||
- **存储源凭据处理规范**:`password` 落库前加密(`task/storage/node/service.py:116-135`)、返回模型显式排除并只暴露 `has_password`(`node/schema.py:141-153`)、未传新密码时保留旧密码 —— 与 H1 的 `SECRET_KEY` 形成鲜明对比,可作为整改模板。
|
||||
- **分布式调度选主设计完整**:Redis 锁 + 续期 + 候选接管 + 退出释放(`ap_scheduler.py:76-192`),并有节点任务自愈对账(`:210-268`)。
|
||||
- **静态检查干净**:`ruff check` 全量通过。
|
||||
@@ -0,0 +1,614 @@
|
||||
# 需求覆盖审计报告:REQUIREMENTS.md vs 实现代码
|
||||
|
||||
- 审计对象:`/Users/tao/workspace/FastapiAdmin`(FastAPI 后端 + Vue3 Web 端 + uniapp 移动端)
|
||||
- 基线文档:`REQUIREMENTS.md`(3135 行,标题「SaaS 多租户平台需求文档 v3.6.0」,最后更新 2026-06-03)
|
||||
- 审计日期:本轮只读摸底,未改动任何仓库文件(仅新增本报告)
|
||||
- 审计方式:模块/路由/表结构/权限点的**代码基线盘点 + 文档映射**,非逐行通读
|
||||
|
||||
---
|
||||
|
||||
## 0. 一句话结论
|
||||
|
||||
**`REQUIREMENTS.md` 描述的是一个 SaaS 多租户平台,仓库里实际存在的是一套单租户 RBAC 后台框架(FastapiAdmin 上游形态)。多租户能力(tenant_id 行级隔离、平台/租户/用户三层权限、Tenant/Package/Plugin/Order/Email/Invoice/AuditLog/Dashboard)在三个端的代码里"零实现",不是"部分实现"。**
|
||||
|
||||
可验证的硬事实:
|
||||
|
||||
```
|
||||
$ grep -ril "tenant" backend/app | wc -l → 0
|
||||
$ grep -ril "tenant" frontend/web/src frontend/app/src | wc -l → 0
|
||||
$ find backend/app -iname "*tenant*" → (无结果)
|
||||
```
|
||||
|
||||
`REQUIREMENTS.md` 的引入方式也印证了这一点:
|
||||
|
||||
```
|
||||
$ git show --stat --no-renames 45a00334
|
||||
CHANGELOG.md | 3136 ---------
|
||||
REQUIREMENTS.md | 3136 +++++++++
|
||||
$ git log --oneline -S "SaaS 多租户平台需求文档" -- REQUIREMENTS.md
|
||||
45a00334 docs: add SaaS multi-tenant platform requirements
|
||||
```
|
||||
|
||||
需求文档是一次**纯文档提交**(内容即原 `CHANGELOG.md`,`R100` 重命名),没有任何配套的实现提交。1192 个提交中不存在租户功能实现。
|
||||
|
||||
---
|
||||
|
||||
## 1. 审计范围声明(重要,避免过度解读)
|
||||
|
||||
### 1.1 已实际核对(逐行阅读)
|
||||
|
||||
| 范围 | 文档行号 | 深度 |
|
||||
|------|---------|------|
|
||||
| Part 1 全部(概述/角色定义/架构/数据隔离模型/Mixin 体系) | 1–205 | 逐行读 |
|
||||
| §11 Notice / §12 Params / §13 LoginLog / §14 OperationLog | 616–785 | 逐行读 |
|
||||
| §15 Tenant 数据模型/创建删除流程/状态机 | 785–905 | 逐行读 |
|
||||
| §17 Ticket / §18 Plugin / §19 到期处理 | 1071–1258 | 逐行读 |
|
||||
| §20 API 接口汇总(20.1–20.25 全部表格) | 1262–1573 | 逐行读 |
|
||||
| §21 数据库表结构 / §22 安全性要求 | 1575–1682 | 逐行读 |
|
||||
| §28 未来扩展 / NFR / §29 术语表 / §30 变更记录 | 2105–2195 | 逐行读 |
|
||||
| §5.3 User 业务规则、§4 Auth 全章 | 208–364 | 逐行读 |
|
||||
|
||||
### 1.2 只做结构性核对(未逐行读正文)
|
||||
|
||||
| 范围 | 处理方式 |
|
||||
|------|---------|
|
||||
| §23 Email(1684–1770)、§24 Order(1770–1917)、§25 SelfService(1917–1980)、§26 APIUsage(1980–2033)、§27 UserInvite(2033–2109) | 读了 §20.18–§20.22 的接口表 + 标题/表名清单;正文表格未逐行读。**结论依据是"代码里完全没有对应实现文件/表/路由"这一可穷举事实**,不依赖正文细节 |
|
||||
| §31–§37(Part 4 Plugin 子模块,2197–2944) | 读了全部标题 + §32.3 业务规则 + §32.4/§33.4/§35.4 端点表;其余小节只做标题与实现映射 |
|
||||
| §38 Invoice(2948–3028)、§39 AuditLog(3028–3107)、§40 Dashboard(3107–3135) | 读标题 + §20.23–§20.25 接口表;正文数据模型未逐行读 |
|
||||
|
||||
### 1.3 未核对
|
||||
|
||||
- 前端(`frontend/web/src/**`、`frontend/app/src/**`)**逐页面**行为核对:本报告只做了目录级/接口调用级覆盖统计,未逐页读 Vue 组件。
|
||||
- 移动端(uniapp)业务流程、`subPages` 内的交互细节。
|
||||
- 运行时行为(未启动服务、未跑测试、未连数据库)。所有"表存在"结论来自 `__tablename__` 声明与 `sql/*.json` 种子,**不代表迁移脚本或线上库状态**。
|
||||
- `backend/app/alembic/versions/` 为空目录(仅 `__init__.py`),因此"声明了表"是否等于"数据库里有表"存疑(见 §8 存疑清单)。
|
||||
|
||||
**本报告不声称已逐条核对 3135 行,也不声称任何模块 100% 覆盖。**
|
||||
|
||||
---
|
||||
|
||||
## 2. 基线事实:文档清单 vs 代码清单
|
||||
|
||||
### 2.1 文档声明的模块(§1.4,行 33–58)
|
||||
|
||||
`Auth, User, Role, Dept, Position, Menu, Dict, Notice, Params, LoginLog, OperationLog, Tenant, Package, Ticket, Plugin, Cronjob, Workflow, AI Chat, CodeGen, Invoice, AuditLog, Dashboard`(22 个)
|
||||
|
||||
> §1.4 表本身**遗漏**了文档 §23–§27 已定义的 Email / Order / TenantSelfService / APIUsage / UserInvite 五个模块,以及 §36 Monitor / §37 Common(见 §8.1)。
|
||||
|
||||
### 2.2 代码实际存在的模块
|
||||
|
||||
```
|
||||
backend/app/modules/
|
||||
├── system/ auth user role menu dept position dict params notice ticket versions log
|
||||
├── monitor/ cache health online server
|
||||
├── task/ cronjob/{job,node} storage/{browse,node,transfer,workflow}
|
||||
├── ai/ chat
|
||||
├── generator/ gencode
|
||||
└── common/ file
|
||||
backend/app/plugin/
|
||||
└── module_example/demo
|
||||
```
|
||||
|
||||
模块数:`app/modules` 下 26 个业务模块目录 + 1 个插件模块;路由域 6 个(`/system /monitor /task /ai /generator /common`,见 `backend/app/api/v1/routers.py:32-64`)。
|
||||
|
||||
### 2.3 数量对照
|
||||
|
||||
| 指标 | 文档 | 代码 | 差异 |
|
||||
|------|------|------|------|
|
||||
| 章节数 | 40 章 / 5 个 Part | — | 任务简报称"3 个 Part",实际是 5 个(Part 4 = §31–37,Part 5 = §38–40) |
|
||||
| API 端点行 | 430 行 / 260 个唯一路径 | 209 个已注册处理函数(`app/modules` 200 + `app/plugin` 9) | 见 §5 |
|
||||
| 数据表 | 约 40 张(§21 全部小节) | 29 张(`__tablename__` 去重) | 见 §6 |
|
||||
| 权限标识 | 种子菜单 123 个 | 代码 121 个 | 4 个"菜单有代码无" + 2 个"代码有菜单无"(§7.1) |
|
||||
| 租户相关实现 | 贯穿全文 | **0** | — |
|
||||
|
||||
---
|
||||
|
||||
## 3. 覆盖矩阵:Part 2 核心业务模块
|
||||
|
||||
状态定义:**已实现** = 文档要求的主要端点/模型均存在;**部分实现** = 主链路可用但与文档契约有实质差异或缺项;**缺失** = 无对应代码;**存疑** = 证据不足。
|
||||
|
||||
| 模块 | 需求章节(行号) | 状态 | 证据(文件:行 / 路由) | 缺口要点 |
|
||||
|------|-----------------|------|------------------------|---------|
|
||||
| **Auth** | §4(208–286) | 部分实现 | `app/modules/system/auth/controller.py:37-172`;`/auth/login` `/auth/token/refresh` `/auth/captcha/get` `/auth/captcha/slider/complete` `/auth/logout` `/auth/oauth/{provider}/login` `/auth/oauth/{provider}/callback` `/auth/wx-login` `/auth/wx-phone-login` `/auth/wx-qrcode/generate` | 缺 `/auth/register`(自动建租户)、`/auth/select-tenant/{id}`、`/auth/tenants`、`/auth/forgot-password`、`/auth/auto-login*`;缺"多租户临时/正式 token"机制;JWT 载荷与文档不符(§7.4) |
|
||||
| **User** | §5(288–364) | 部分实现 | `app/modules/system/user/controller.py:32-193`;模型 `app/modules/system/user/model.py:62-108` | 无 `tenant_id`;无 `platform_user_tenant` 关联表;`/user/current/info/update` vs 文档 `/user/current/update`;`/user/password/reset/{id}` vs 文档 `/user/password/reset`;导入密码策略完全不符(§7.5);§5.4"菜单与租户可用菜单取交集"无实现 |
|
||||
| **Role** | §6(366–415) | 部分实现 | `app/modules/system/role/controller.py`;`RoleModel.data_scope` `app/modules/system/role/model.py:72` | 缺 `PUT /role/menus`(只有 `/role/permission`);无 `tenant_id`;`data_scope` 语义与文档冲突且实现只认 1/2/3(§7.3,**安全相关**) |
|
||||
| **Dept** | §7(415–459) | 部分实现 | `app/modules/system/dept/controller.py:18-70` | 文档 `GET /dept/list` → 代码只有 `GET /dept/tree`;无 `tenant_id` |
|
||||
| **Position** | §8(459–487) | 已实现(接口层) | `app/modules/system/position/controller.py:19-95`,含文档未列的 `/position/options` `/position/export` | 无 `tenant_id`;无配额(max_users 之类)联动 |
|
||||
| **Menu** | §9(487–550) | 部分实现 | `MenuModel` `app/modules/system/menu/model.py:14`(表名 `sys_menu`);`/menu/tree` | 表名 `sys_menu` vs 文档 `platform_menu`;文档 `GET /menu/list` → 代码 `/menu/tree`;菜单模型字段远多于文档(`scope/link/is_iframe/...`)但文档未定义 |
|
||||
| **Dict** | §10(550–616) | 部分实现 | `app/modules/system/dict/model.py:10,23`(`sys_dict_type`/`sys_dict_data`) | `__platform_data_shared__` 机制**完全不存在**(§3.2 文档核心机制);无 `tenant_id`;无 `UNIQUE(tenant_id, dict_type)` |
|
||||
| **Notice** | §11(616–666) | 部分实现 | `app/modules/system/notice/model.py:10`(仅 `sys_notice`) | **`sys_notice_read` 表不存在**;缺 `/notice/read/{id}` `/notice/read-all` `/notice/unread-count`;多设备已读同步无实现 |
|
||||
| **Params** | §12(666–702) | 部分实现 | `app/modules/system/params/controller.py:19-31` | 只有 `PUT /param/update/{id}` 和 `GET /param/info`;缺 list/detail/create/delete/status-batch;`/param/info` 走公开路径(§7.6) |
|
||||
| **LoginLog** | §13(702–733) | 部分实现 | `app/modules/system/log/model.py`(表名 `sys_login_log`);`/log/login/*` | 表名 `sys_login_log` vs 文档 `platform_login_log`;路径 `/system/log/login/*` vs 文档 `/platform/loginlog/*`;权限 `module_system:login_log:*` vs 文档 `module_platform:loginlog:*` |
|
||||
| **OperationLog** | §14(733–785) | 部分实现 | `sys_operation_log` 表;`OperationLogRoute`;`/log/operation/*` | 路径 `/system/log/operation/*` vs 文档 `/system/operationlog/*`;权限 `module_system:log:*` vs 文档 `module_system:operationlog:*`;**§14.5 日志保留/清理策略零实现**(无 retention 配置、无 cleanup 定时任务) |
|
||||
| **Tenant** | §15(785–987) | **缺失** | 无 `platform_tenant`、无 `/tenant/*`、无 `TenantMixin` | 生命周期状态机、配额、配置、用户关联、租户菜单授权全部缺失。**这是全平台最关键的缺口** |
|
||||
| **Package** | §16(987–1071) | **缺失** | 无 `platform_package`、无 `/platform/package/*` | 套餐-菜单合并逻辑、配额继承、变更预览全部缺失 |
|
||||
| **Ticket** | §17(1071–1126) | 部分实现 | `app/modules/system/ticket/model.py:17`(`sys_ticket`);状态机 `app/modules/system/ticket/service.py:48-76` | 表名 `sys_ticket` vs 文档 `platform_ticket`;**无 `tenant_id`**(文档明确要求 TenantMixin);`PUT /ticket/batch` vs 文档 `/ticket/batch/status`;缺 `close_reason/closed_time/closed_by`(v3.3.0 变更记录声称已加,代码无);代码多出 `/ticket/stats` `/ticket/export` `/ticket/{id}/comments` 与 `sys_ticket_comment` 表(文档未定义) |
|
||||
| **Plugin** | §18(1126–1194) | **缺失** | `app/plugin/module_example/plugin.toml` 仅是静态元数据;`app/core/discover.py` 只做路由自动注册 | 无 `platform_plugin` 注册表、无 `platform_tenant_plugin`、无 `/plugin/*` 端点、无安装/卸载、无 `enabled` 状态、无依赖解析。文档 §18.2 的 12 个字段无一存在 |
|
||||
| **到期处理** | §19(1196–1258) | **缺失** | `grep check_tenant_expiry / expire_after_days / archive_after_days / grace_period` → 0 命中 | 无 `check_tenant_expiry` 定时任务、无宽限期/暂停/过期/归档状态流转、无 30/7/1 天提醒 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 覆盖矩阵:Part 4 / Part 5(插件与商业运营)
|
||||
|
||||
| 模块 | 需求章节(行号) | 状态 | 证据 | 缺口要点 |
|
||||
|------|-----------------|------|------|---------|
|
||||
| **AI Chat** | §31(2201–2253) | 已实现(超出文档) | `app/modules/ai/chat/{controller,crud,service,schema}.py`;`/ai/chat/*` | 代码多出 AI 模型配置管理(`/chat/model*`,5 个端点)文档未定义;无 `tenant_id` |
|
||||
| **Cronjob** | §32(2253–2329) | 部分实现(含**安全项**) | `app/modules/task/cronjob/{job,node}/`;`task_corn_job` / `task_cornjob_node` | 表名 `task_corn_job`/`task_cornjob_node`(`corn` 拼写)vs 文档 `task_job`/`task_node`;权限 `module_task:cronjob:job:xxx` / `module_task:cronjob:node:xxx` vs 文档 `module_task:cronjob:*`;**§32.3 处理器白名单 + `platform_handler_registry` 零实现,`func` 字段就是任意代码块**(§7.2) |
|
||||
| **Workflow** | §33(2329–2397) | 部分实现(契约不同) | `app/modules/task/storage/workflow/`;表 `task_storage_workflow` / `_node` / `_edge` | 表名与文档 `task_workflow`/`task_workflow_node_type` 完全不同;端点 `/task/storage/workflow/*` vs 文档 `/workflow/flow/*`、`/workflow/nodes/*`、`/workflow/node-type/*`(文档 3 组路径均不存在) |
|
||||
| **CodeGen** | §34(2397–2479) | 部分实现 | `app/modules/generator/gencode/`;`/generator/gencode/*` | 端点路径与文档 `/gencode/*` 不一致(缺 `/generator` 前缀时无法匹配);文档端点名 `create/current/select/gen/{id}/zip/{id}/status/batch` 均不存在,代码用 `output/{table}` `preview/{id}` `batch/output` 等 |
|
||||
| **Demo** | §35(2479–2539) | 部分实现 | `app/plugin/module_example/demo/`;`/example/demo/*`(9 个端点,前缀映射正确) | 只有 `DemoModel`(`example_demo`),**文档 §35.2 的 `Demo01Model`(`example_demo01`)不存在**,对应 `/example/demo01/*` 6 个端点全部缺失 |
|
||||
| **Monitor** | §36(2539–2802) | 部分实现 | online `app/modules/monitor/online/`、cache、server,`/monitor/*` | **§36.4 资源管理(resource)整体缺失**:`/monitor/resource/*` 9 个端点无实现,代码用 `task/storage/browse/*` 代替(语义与路径都不同);health 路径不符(§7.7);无 `/metrics`;代码多出 `/online/current` `/online/stats` 文档未定义 |
|
||||
| **Common** | §37(2802–2944) | 部分实现 | `app/modules/common/file/controller.py:18,37`;`/monitor/health/*` | 文档 `/file/upload` → 代码 `/common/file/upload`;§37.4 Prometheus `/metrics` **不存在**(只在 `app/config/setting.py:200` 白名单里出现) |
|
||||
| **Invoice** | §38(2948–3028) | **缺失** | `grep -ril invoice backend/app` → 0 | 无 `platform_invoice` 表、无 `/tenant/invoice/*`、无 `/platform/invoice/*` |
|
||||
| **AuditLog** | §39(3028–3107) | **缺失** | `grep -ril audit_log backend/app` → 0 | 无 `platform_audit_log`、无 `/platform/audit/*`、无 13 类审计事件、无 3 年保留策略 |
|
||||
| **Dashboard** | §40(3107–3135) | **缺失** | 仅有 `GET /monitor/online/stats`(`app/modules/monitor/online/controller.py:62`,权限 `module_monitor:dashboard:query`) | 无 `/platform/dashboard/{overview,revenue,tenants,api-usage}`;"权限点叫 dashboard,实现的却是在线用户统计" |
|
||||
| **Email** | §23(1684–1770) | **缺失** | `grep -ril smtp backend/app` → 0;`grep EmailConfig` → 0 | 无 `platform_email_config/template/log` 三表、无 `/platform/email/*` 6 个端点、无发送服务、无降级策略;牵连注册/重置/邀请/到期提醒全部无法按文档交付 |
|
||||
| **Order / 支付 / 退款** | §24(1770–1917) | **缺失** | `grep -ril refund` → 0;`grep order` 命中均为 `order_by`/`order` 排序字段 | 无 `platform_order`/`platform_payment_record`/`platform_refund`、无 `/platform/order/*`、`/platform/payment/*`、`/platform/refund/*`。`app/common/enums.py:110` 有 `OrderTypeEnum` 但无任何使用方(悬空枚举) |
|
||||
| **TenantSelfService** | §25(1917–1980) | **缺失** | 无 `/tenant/package/*`、`/tenant/order/*` | — |
|
||||
| **APIUsage** | §26(1980–2033) | **缺失** | `grep -ril api_usage` → 0 | 无 `platform_api_usage_daily`、无 4 个统计端点、无异常检测、无限流联动 |
|
||||
| **UserInvite** | §27(2033–2109) | **缺失** | `grep -ril invite` 仅命中微信小程序 `scene="invite=xxx"` 字符串 | 无 `platform_invite_record`、无 `/tenant/invite/*`、无 `/invite/validate` 与 `/invite/accept` |
|
||||
|
||||
---
|
||||
|
||||
## 5. 覆盖矩阵:Part 1 架构与基础设施
|
||||
|
||||
| 需求条目 | 章节(行号) | 状态 | 证据 | 缺口要点 |
|
||||
|---------|-------------|------|------|---------|
|
||||
| 分层架构 Controller→Service→CRUD→Model | §2.1(64–82) | 已实现 | `app/modules/*/` 下的 `controller.py`、`service.py`、`crud.py`、`model.py`、`schema.py` | 结构一致,但 `monitor/*`、`common/file`、`system/auth` 无 `crud.py`/`model.py`(合理,无独立表) |
|
||||
| 请求链路含"租户中间件(解析 token,设置 ContextVar)" | §2.2(84–90) | **缺失** | `app/core/middlewares.py` 只有 CORS/日志/GZip/IP 黑白名单;无租户中间件、无 ContextVar | 链路中"ORM 自动注入 tenant_id → ContextVar 清理"两步不存在 |
|
||||
| 模块目录规范 `module_xxx/` | §2.3(92–104) | 部分实现 | 实际为 `app/modules/<域>/<模块>/`;`app/core/discover.py:10` 只对 `app/plugin/module_*` 生效 | 系统模块不在 `app/plugin` 下,文档目录规范与实际不符 |
|
||||
| 平台资源(无 tenant_id):platform_menu/package/plugin/tenant | §3.1(110–133) | **缺失** | 4 张表全部不存在;菜单实为 `sys_menu` | — |
|
||||
| 租户资源(含 tenant_id,ORM 自动过滤):sys_user/role/dept/position/notice/param/log、platform_ticket | §3.1(119–128) | **缺失** | `sys_user`/`sys_role`/`sys_dept`/`sys_position`/`sys_notice`/`sys_param`/`sys_operation_log` 均**无 `tenant_id` 列**;`sys_ticket` 无 `tenant_id` | 9 张表缺 tenant_id |
|
||||
| 平台共享资源(`__platform_data_shared__`,tenant_id=1 对所有租户可读) | §3.1(130–133)、§3.2(152)、§3.3(196–200) | **缺失** | `grep __platform_data_shared__` → 0 | `sys_dict_type`/`sys_dict_data` 无 tenant_id、无该标记、无 CRUD 层 `__tenant_condition` |
|
||||
| ORM 事件层 `tenant_filter.py`(`do_orm_execute` 注入 WHERE tenant_id) | §3.2(137–152) | **缺失** | 文件不存在;`grep do_orm_execute` → 0 | — |
|
||||
| CRUD 层 `__build_conditions` / `__tenant_condition` 二次确认 | §3.2(140) | **缺失** | `app/core/base_crud.py:33` `CRUDBase` 无租户条件方法 | — |
|
||||
| 权限策略层 `permission.py` 5 种策略枚举 | §3.2(154–162) | 部分实现 | `app/core/permission.py:12-124` | 只有 `data_scope` 一种策略(1/2/3);文档的 `ROLE_BASED/DEPT_BASED/USER_ROLE/SELF_ONLY/DATA_SCOPE` 五枚举不存在 |
|
||||
| `data_scope` 5 档语义 | §3.2(164–172) | **冲突** | `app/core/permission.py:16-18`(1=SELF, 2=DEPT_AND_CHILD, 3=ALL);`app/core/validator.py:314-322`(1–5);`app/modules/system/role/model.py:72` | 见 §7.3 |
|
||||
| Mixin 体系 `ModelMixin` / `TenantMixin` / `UserMixin` | §3.3(174–181) | 部分实现 | `app/core/base_model.py:42`(ModelMixin)、`:124`(UserMixin);**无 `TenantMixin`** | 缺 `TenantMixin`,连带 `platform_tenant` FK 不存在 |
|
||||
| 角色定义:超管 / 租户管理员(owner/admin) / 租户用户 | §1.3(25–31) | 部分实现 | `UserModel.is_superuser` `app/modules/system/user/model.py:73`;种子角色 `SUPER_ADMIN/ADMIN/USER` `backend/sql/sys_role.json` | 只有"超管/普通"二元;**无 owner/admin 租户角色概念**;文档"租户范围"列无对应字段 |
|
||||
| 权限分层:平台层→租户层→用户层 | §1.2(21) | **缺失** | `app/core/dependencies.py:192-234` `AuthPermission` 只做用户层 RBAC(角色→菜单 permission 集合) | 无平台层(菜单/套餐/插件)、无租户层(可见菜单/配额/配置);`is_superuser` 是唯一"平台层"体现 |
|
||||
| §22 安全性 13 条 | §22(1663–1681) | 部分实现 | 逐条见 §7.8 | 4 条与文档不符/未实现 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 覆盖矩阵:数据表(§21)
|
||||
|
||||
代码实际表名(`__tablename__` 去重,共 29 张):
|
||||
|
||||
```
|
||||
sys_user sys_role sys_menu sys_dept sys_position sys_dict_type sys_dict_data
|
||||
sys_notice sys_param sys_login_log sys_operation_log sys_ticket sys_ticket_comment
|
||||
sys_version sys_user_roles sys_user_positions sys_role_menus sys_role_depts
|
||||
task_corn_job task_cornjob_node
|
||||
task_storage_node task_storage_transfer task_storage_transfer_step
|
||||
task_storage_workflow task_storage_workflow_node task_storage_workflow_edge
|
||||
gen_table gen_table_column example_demo (+ apscheduler_jobs)
|
||||
```
|
||||
|
||||
| 文档表名(§21) | 行号 | 实现中的对应 | 状态 |
|
||||
|----------------|------|-------------|------|
|
||||
| `platform_tenant` | 1581 | — | 缺失 |
|
||||
| `platform_package` / `platform_package_menu` | 1582-1583 | — | 缺失 |
|
||||
| `platform_menu` | 1584 | `sys_menu` | **命名不符** |
|
||||
| `platform_plugin` | 1585 | — | 缺失 |
|
||||
| `platform_email_config/template/log` | 1586-1588 | — | 缺失 |
|
||||
| `platform_order` / `platform_payment_record` | 1589-1590 | — | 缺失 |
|
||||
| `platform_tenant_menu` / `platform_tenant_plugin` | 1596-1597 | — | 缺失 |
|
||||
| `platform_user_tenant` | 1598 | — | 缺失 |
|
||||
| `platform_invite_record` | 1604 | — | 缺失 |
|
||||
| `platform_api_usage_daily` | 1605 | — | 缺失 |
|
||||
| `sys_user`(UNIQUE(tenant_id,username)) | 1611 | `sys_user`(`username` 全局 UNIQUE) | 部分:唯一键缺 tenant 维度 |
|
||||
| `sys_role`(UNIQUE(tenant_id,code)) | 1612 | `sys_role`(`code` 全局 UNIQUE) | 同上 |
|
||||
| `sys_dept`(UNIQUE(tenant_id,code)) | 1613 | `sys_dept` | 同上 |
|
||||
| `sys_position` / `sys_notice` / `sys_param` / `sys_operation_log` | 1614-1617 | 同名存在 | 缺 tenant_id |
|
||||
| `platform_login_log` | 1618 | `sys_login_log` | **命名不符** |
|
||||
| `platform_ticket` | 1619 | `sys_ticket` | **命名不符** |
|
||||
| `sys_dict_type` / `sys_dict_data`(`__platform_data_shared__`) | 1625-1626 | 同名存在 | 缺 tenant_id 与共享机制 |
|
||||
| `sys_user_roles` / `sys_user_positions` / `sys_role_menus` / `sys_role_depts` | 1632-1635 | 同名存在 | 一致 |
|
||||
| `sys_notice_read` | 1636 | — | 缺失 |
|
||||
| `task_workflow` / `task_workflow_node_type` | 1642-1643 | `task_storage_workflow` / `_node` / `_edge` | **命名与结构不符** |
|
||||
| `task_node` / `task_job` | 1644-1645 | `task_cornjob_node` / `task_corn_job` | **命名不符(且 `corn` 拼写可疑)** |
|
||||
| `gen_table` / `gen_table_column` | 1646-1647 | 同名存在 | 一致 |
|
||||
| `example_demo` | 1648 | 同名存在 | 一致 |
|
||||
| `example_demo01` | 1649 | — | 缺失 |
|
||||
| `platform_invoice` / `platform_refund` / `platform_audit_log` | 1657-1659 | — | 缺失 |
|
||||
| **代码存在但文档未定义** | — | `sys_version`、`sys_ticket_comment`、`task_storage_node/transfer/transfer_step` | 4 张表无需求依据 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 需求与代码契约不一致清单
|
||||
|
||||
> 本节只列"同一件事,文档说 A,代码做 B"的地方。整块缺失的模块见 §3/§4。
|
||||
|
||||
### 7.1 权限标识与种子菜单(可自动比对,实测结果)
|
||||
|
||||
比对方法:提取全部 `AuthPermission([...])` 权限串(含装饰器参数与 `dependencies=[Security(...)]` 两种写法)与 `backend/sql/sys_menu.json` 的 `permission` 字段。
|
||||
|
||||
```
|
||||
代码权限点:121 种子菜单权限点:123
|
||||
```
|
||||
|
||||
**A. 菜单里有、代码里没有(4 个 → 授权后是"死权限")**
|
||||
|
||||
| 权限标识 | 种子菜单用途 | 代码实况 |
|
||||
|---------|-------------|---------|
|
||||
| `module_monitor:cache:detail` | 缓存监控详情按钮 | `app/modules/monitor/cache/controller.py` 无任何 `:detail` 端点 |
|
||||
| `module_swagger:docs:query` | 接口管理→Swagger文档 菜单 | 后端无 `/swagger` 域路由(`app/api/v1/routers.py:32-64` 无该域) |
|
||||
| `module_system:log:detail` | 操作日志详情按钮 | 日志详情用的是 `module_system:log:query`(`app/modules/system/log/controller.py:62`) |
|
||||
| `module_system:param:query` | 参数管理查询按钮 | `/param/info` 无权限校验;`/param/update/{id}` 用 `:update` |
|
||||
|
||||
**B. 代码里有、种子菜单没有(2 个 → 无法通过 UI 授权,只有超管能用)**
|
||||
|
||||
| 权限标识 | 位置 | 影响 |
|
||||
|---------|------|------|
|
||||
| `module_common:file:upload` | `app/modules/common/file/controller.py:18` | 非超管用户无法被授予文件上传权限 → **上传功能对普通用户不可用**(业务阻断) |
|
||||
| `module_common:file:download` | `app/modules/common/file/controller.py:37` | 同上,下载不可用 |
|
||||
|
||||
### 7.2 定时任务:任意代码执行 vs 处理器白名单(**安全**)
|
||||
|
||||
| 项 | 文档 §32.3(行 2300-2301) | 代码 |
|
||||
|----|---------------------------|------|
|
||||
| func 语义 | "须为已注册的处理器标识符",禁止任意代码 | `func: Mapped[str] = mapped_column(Text, comment="代码块")` — `app/modules/task/cronjob/node/model.py:24` |
|
||||
| 白名单 | `platform_handler_registry` 注册新处理器,仅超管可加 | 该表/机制**不存在**(`grep handler_registry` → 0) |
|
||||
| 执行 | — | `app/core/ap_scheduler.py:544-575`:`exec(code_block, module.__dict__)` |
|
||||
| 开关 | — | `app/config/setting.py:127` `SCHEDULER_ALLOW_CODE_EXEC: bool = True`(**默认开启**) |
|
||||
|
||||
代码自身注释(`ap_scheduler.py:547-549`)已承认:"exec 等同给所有能创建任务的人服务器代码执行权限"。文档要求的方向(白名单)与实现方向(默认放行任意代码)相反。
|
||||
|
||||
### 7.3 `data_scope` 三处语义冲突(**安全**)
|
||||
|
||||
| 位置 | 定义 |
|
||||
|------|------|
|
||||
| 文档 §3.2(行 166-172) | 1=仅本人 2=本部门 3=本部门及以下 **4=全部** **5=自定义(sys_role_depts)** |
|
||||
| `app/core/validator.py:314-322` | 接受 1–5,文案与文档一致 |
|
||||
| `app/core/permission.py:16-18` | 只有常量 `DATA_SCOPE_SELF=1, DATA_SCOPE_DEPT_AND_CHILD=2, DATA_SCOPE_ALL=3` |
|
||||
| `app/modules/system/role/model.py:72` | 注释"1:仅本人 **2:本部门及以下** 3:全部" |
|
||||
|
||||
后果:管理员按文档把角色设为 `data_scope=4`(期望"全部数据")→ `Permission._filter_by_data_scope()` 走到末尾兜底分支(`permission.py:77-80`)→ `WHERE created_id = 当前用户`,即**退化为"仅本人"**。这是"配置了更宽权限、实际拿到更窄权限",方向上是安全侧,但业务上会造成"管理员看不到数据"的严重误判;反过来,`data_scope=3` 在文档语义里是"本部门及以下"、在代码里是"全部",若运维按文档理解去配置,会**意外获得全量数据视野**。
|
||||
|
||||
另:`app/core/permission.py:33-34` 超管直接 `return None`(不过滤);`sys_role.json` 种子中 `SUPER_ADMIN` 与 `ADMIN` 的 `data_scope` 都是 3。
|
||||
|
||||
### 7.4 JWT 载荷(**安全**)
|
||||
|
||||
| 项 | 文档 §4.3(行 239)/§22.1(行 1665) | 代码 |
|
||||
|----|--------------------------------------|------|
|
||||
| 载荷内容 | `session_id, user_id, tenant_id, is_super_admin, 登录信息` | `app/core/base_schema.py:99-110`:只有 `sub`(session_id)、`is_refresh`、`exp` |
|
||||
| 租户上下文 | 从 Token 提取 `tenant_id` + `is_super_admin`,经 ContextVar 传递 | 无 |
|
||||
|
||||
代码把用户身份放在 Redis session(`app/core/dependencies.py:114-190`),这是合理设计,但与文档契约不符;文档要求的临时 token/正式 token 双轨机制、`/auth/select-tenant` 均不存在。
|
||||
|
||||
### 7.5 用户导入密码策略(**安全**)
|
||||
|
||||
| 项 | 文档 §5.3(行 331) | 代码 |
|
||||
|----|--------------------|------|
|
||||
| 密码列为空 | 系统生成 12 位随机密码 + 邮件发送 | 全部行统一 `settings.PASSWORD_IMPORT_DEFAULT` = `"123456"`(`app/config/setting.py:134`,使用点 `app/modules/system/user/service.py:425`) |
|
||||
| 密码列有值 | Bcrypt 加密后存储 | 模板表头无"密码"列(`service.py:331-339`),逻辑不存在 |
|
||||
| 首次登录强制修改 | 要求 | 无该标记字段与流程 |
|
||||
|
||||
即:**所有导入用户的初始密码都是 `123456`,无邮件、无强制改密**。
|
||||
|
||||
### 7.6 登录限流与密码强度(**安全**)
|
||||
|
||||
| 项 | 文档(行 1675、1677) | 代码 |
|
||||
|----|----------------------|------|
|
||||
| 限流粒度 | 同一 **IP / 账号** | 仅按 IP(`app/modules/system/auth/service.py:155-174`,key=`login_rate_limit:{ip}`) |
|
||||
| 阈值 | 连续失败 **5 次** | **10 次**(`app/config/setting.py:74` `LOGIN_RATE_LIMIT_MAX_ATTEMPTS=10`) |
|
||||
| 窗口 | **15 分钟** | **60 秒**(`app/config/setting.py:73`) |
|
||||
| 计数对象 | 连续**失败** | 递增发生在认证**之前**,成功登录也计数(`service.py:168`),且成功后不重置 |
|
||||
| 豁免 | 无 | `127.0.0.1` / `localhost` / 空 IP 直接跳过(`service.py:164-165`) |
|
||||
| 密码最短长度 | **8 位**(含字母+数字) | **6 位**(`app/config/setting.py:132` `PASSWORD_MIN_LENGTH=6`),复杂度为"字母/数字/符号任两类"(`app/utils/password_util.py:74-87`) |
|
||||
| 初始管理员密码 | 12 位随机 + 邮件一次性重置链接 | 种子用户 `super`/`admin` 使用**同一套固定 PBKDF2 哈希**(`backend/sql/sys_user.json`),无邮件通道 |
|
||||
|
||||
补充:`PwdUtil.generate_strong_password(12)` 存在(`app/utils/password_util.py:90`)且要求 ≥8 位,但未与限流/初始化流程连通。
|
||||
|
||||
### 7.7 健康检查与指标端点
|
||||
|
||||
| 文档(行 1486-1490、2912-2943) | 代码 |
|
||||
|-------------------------------|------|
|
||||
| `GET /monitor/health` | `GET /monitor/health/check`(`app/modules/monitor/health/controller.py:20`) |
|
||||
| `GET /monitor/health/live` | 不存在 |
|
||||
| `GET /monitor/health/ready` | 不存在 |
|
||||
| `GET /monitor/health/stream` | 存在(`:30`) |
|
||||
| `GET /metrics`(Prometheus) | **不存在**;仅在 `app/config/setting.py:200` 的演示模式白名单里被提及 |
|
||||
|
||||
### 7.8 §22 安全性 13 条逐条核对
|
||||
|
||||
| # | 要求 | 状态 | 证据 |
|
||||
|---|------|------|------|
|
||||
| 1 | JWT 携带 tenant_id + is_super_admin,ContextVar 传递 | 缺失 | §7.4 |
|
||||
| 2 | 白名单路径(登录/验证码/健康检查不设租户上下文) | 部分 | 无租户上下文;`WHITE_API_LIST_PATH`(`setting.py:190-200`)实为**演示模式**白名单,认证是逐路由依赖注入 |
|
||||
| 3 | 系统租户 id=1 不可删/不可禁用/编码不可改 | 缺失 | 无租户概念 |
|
||||
| 4 | 删除租户前检查关联数据 | 缺失 | — |
|
||||
| 5 | 每租户至少保留一个 owner | 缺失 | — |
|
||||
| 6 | 菜单越权防护(非超管只能在租户可用菜单内分配) | **缺失** | `app/modules/system/role/service.py:177-184` 只校验 menu_id 存在性,无租户菜单范围校验 |
|
||||
| 7 | ContextVar 请求结束清理 | 缺失 | 无 ContextVar |
|
||||
| 8 | Bcrypt 存储;普通用户 ≥8 位;初始管理员 12 位随机 | 部分 | 哈希用 PBKDF2-SHA256(`sql/sys_user.json`),符合"不存明文";长度/随机性不符(§7.6) |
|
||||
| 9 | 通知内容 `sanitize_html` 清洗 | 已实现 | `app/utils/xss_util.py:99-115`(bleach);调用点见通知服务 |
|
||||
| 10 | 登录限流 5 次/15 分钟 | 部分 | §7.6 |
|
||||
| 11 | 所有 FK 有 ON DELETE/ON UPDATE 级联 | 已实现(抽样) | `app/core/base_model.py:124-160`、各 model 的 `ForeignKey(..., ondelete=..., onupdate=...)` |
|
||||
| 12 | 文件资源管理禁止路径遍历(`..`) | 已实现 | `app/utils/upload_util.py:127-136, 343-357, 454`(`resolve().is_relative_to(...)` 兜底) |
|
||||
| 13 | CORS 白名单 | 已实现 | `app/core/middlewares.py:22-32`;`app/config/setting.py:245-247` |
|
||||
|
||||
**额外发现(文档未覆盖)**:`/api/v1/system/param/info` 在演示模式白名单中且**无认证依赖**(`app/modules/system/params/controller.py:31`),任何人可读取全部 `sys_param`(17 条,含 `demo_enable` / `ip_white_list` / `ip_black_list`)。文档 §22.2 的白名单只列了"登录、验证码、健康检查",未包含参数配置,属契约外行为。当前种子值敏感度低(品牌/备案/开关类),故定级中低。
|
||||
|
||||
### 7.9 API 路径前缀体系
|
||||
|
||||
代码统一挂载在 `/api/v1/<域>/...`(`ROOT_PATH=/api/v1`,`app/config/setting.py:47`;域见 `app/api/v1/routers.py:32-64`)。文档 §20 的路径**混用两套风格**:
|
||||
|
||||
| 文档风格 | 例 | 代码实况 | 数量 |
|
||||
|---------|-----|---------|------|
|
||||
| 无域前缀(User/Role/Dept/Position/Menu/Dict/Notice/Params/Ticket) | `/user/create` | `/api/v1/system/user/create` | 约 80 个路径 |
|
||||
| 带域前缀且与代码一致 | `/monitor/cache/info` | `/api/v1/monitor/cache/info` ✔ | 约 14 个 |
|
||||
| 带域前缀但与代码不符 | `/platform/loginlog/list`、`/system/operationlog/list`、`/file/upload`、`/chat/list`、`/gencode/*`、`/cronjob/*`、`/workflow/flow/*` | `/system/log/login/list`、`/system/log/operation/list`、`/common/file/upload`、`/ai/chat/*`、`/generator/gencode/*`、`/task/cronjob/*`、`/task/storage/workflow/*` | 约 100 个路径 |
|
||||
|
||||
自动化比对结果(路径参数名归一化后):**文档 260 个唯一路径中,248 个与代码注册路径无法直接对应;代码 200 个端点中 184 个在文档中查不到**(该数字包含"整块模块缺失"与"仅前缀不同"两类,需结合 §3/§4 阅读)。
|
||||
|
||||
> 说明:比对集合为 `app/modules` 下的 200 个端点。`app/plugin/module_example/demo` 的 9 个端点(`/api/v1/example/demo/*`)与文档 §35.4 一致,未计入差异。
|
||||
|
||||
### 7.10 操作日志接口的路径+权限双重不一致
|
||||
|
||||
| 项 | 文档 §20.11(行 1396-1398) | 代码 |
|
||||
|----|---------------------------|------|
|
||||
| 路径 | `/system/operationlog/{detail,list,delete}` | `/system/log/operation/{detail,list,delete}` |
|
||||
| 权限 | `module_system:operationlog:{query,delete}` | `module_system:log:{query,delete,export}`(`app/modules/system/log/controller.py:62,77,90,100`) |
|
||||
|
||||
登录日志同理:文档 `/platform/loginlog/*` + `module_platform:loginlog:*`,代码 `/system/log/login/*` + `module_system:login_log:*`。
|
||||
|
||||
### 7.11 接口返回结构
|
||||
|
||||
文档未定义统一返回包络;代码统一为 `ResponseSchema[T]` + `SuccessResponse/ErrorResponse`(`app/common/response.py`),列表统一 `PageResultSchema[T]`(`app/core/base_schema.py`)。**这是一处"文档缺验收标准"的问题**(见 §8.3),不宜判定为代码违规。
|
||||
|
||||
例外:`/user/export`、`/role/export`、`/position/export`、`/ticket/export`、`/log/operation/export` 返回 `StreamingResponse`(Excel 流);`/notice/stream`、`/monitor/health/stream`、`/storage/transfer/stream` 返回 SSE。文档 §20 对这些端点只写"导出/实时流",未定义格式。
|
||||
|
||||
### 7.12 其他命名/契约差异(汇总)
|
||||
|
||||
| 主题 | 文档 | 代码 |
|
||||
|------|------|------|
|
||||
| 参数模块路径 | §20.9 `/param/*`;§12.4 `/params/*`(文档内自相矛盾) | `/param/*` |
|
||||
| 工单批量状态 | §17.4 `PUT /ticket/batch/status`;§20.12 `PATCH /ticket/status/batch` | `PUT /ticket/batch` |
|
||||
| 角色菜单 | §20.3 `PUT /role/menus` | 不存在(只有 `PUT /role/permission`) |
|
||||
| 用户改密/重置 | §20.2 `/user/password/change`、`/user/password/reset/{id}`;§5.5 `/user/current/password/change`、`/user/password/reset`(文档内自相矛盾) | `/user/password/change`、`/user/password/reset/{id}` |
|
||||
| 忘记密码 | §20.1 `POST /auth/forgot-password` | `POST /user/password/forget` |
|
||||
| 注册 | §20.1/§4.5 `POST /auth/register`(自动建租户+owner 角色+全量菜单) | `POST /user/register`(`app/modules/system/user/service.py:308-327`,只建用户,无租户/角色/菜单) |
|
||||
| 数据库迁移 | §28.4 要求 Alembic 管理所有 schema 变更 | `backend/alembic.ini` 存在,但 `backend/app/alembic/versions/` **为空** |
|
||||
| 缓存键规范 | §28.4 `{namespace}:{sub_namespace}:{identifier}` | 部分符合(`login_rate_limit:{ip}`、`{USER_SESSION}:{session_id}`),但 `login_rate_limit:*` 为两段式 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 需求文档本身的问题
|
||||
|
||||
### 8.1 章节编号错误(可机械验证)
|
||||
|
||||
| 问题 | 行号 | 说明 |
|
||||
|------|------|------|
|
||||
| **§21.4 重复两次** | 1607、1621 | 1607「租户隔离业务表」与 1621「平台共享业务表」都编 21.4;后续 21.5/21.6/21.7 也未顺延 |
|
||||
| **§24.6 缺失** | 1849→1851 | §24.5 业务规则 后直接跳 §24.7 退款流程,24.6 不存在 |
|
||||
| §19 与 §15 状态机图不一致 | 865-875 vs 1211-1214 | §15 画的是 `active→grace→suspended→expired` + 独立 frozen 支线;§19 画 `active→grace→suspended→expired→archived`,未含 frozen 支线。两处对"frozen 与 expired 谁先"的描述可读性差 |
|
||||
| Part 结构 | 2197、2944 | Part 4「Plugin 子模块需求」、Part 5「商业运营模块」被排在 Part 3「附录」正文之后,层级语义混乱(附录不应夹在两个需求 Part 之间) |
|
||||
|
||||
### 8.2 §1.4 模块总览严重过期
|
||||
|
||||
§1.4(行 33-58)列 22 个模块,但**遗漏了文档自身已定义的**:Email(§23)、Order(§24)、TenantSelfService(§25)、APIUsage(§26)、UserInvite(§27)、Monitor(§36)、Common(§37)、Demo(§35)。
|
||||
|
||||
同时 §1.4 把 `Cronjob / Workflow / AI Chat / CodeGen` 标为"插件",但文档 Part 4 又把它们放在 §31-§37,且 Plugin 注册表(§18)本身缺失——"插件"既指目录约定又指注册表模块,术语未区分。
|
||||
|
||||
### 8.3 缺验收标准
|
||||
|
||||
- §20 API 汇总给出路径但**不给请求/响应示例**,无法验收返回结构(如分页字段名、时间格式、软删除是否可见)。
|
||||
- NFR(§28.4)列出 P95 ≤ 500ms、500+ 并发、99.5% 可用性、RTO/RPO,但**无验证方法、无压测脚本、无环境基线**,不可验收。
|
||||
- §22 安全性 13 条无一条给出验证用例(例如"登录失败 5 次锁定"如何观测)。
|
||||
- §15.3/§19.2 涉及定时任务(到期扫描、归档),但**未定义任务名以外的任何可观测产物**(日志格式、指标、失败重试)。
|
||||
- §28.3 API 路径规范把"日志层级问题"标为"建议保持现状",而 §20.11/§20.10 又写成 `/system/operationlog/`、`/platform/loginlog/`——**"规范"章节与附录接口表互相打架**。
|
||||
|
||||
### 8.4 与代码现实背离的表述(文档自称"与代码一致",实测不一致)
|
||||
|
||||
§30 变更记录明确声称已修复命名,但实测仍不符:
|
||||
|
||||
| 变更记录声明 | 实际代码 | 结论 |
|
||||
|-------------|---------|------|
|
||||
| v3.2.1(行 2186)"统一表名为 `platform_*` 前缀(与代码一致)" | 代码是 `sys_user/sys_role/sys_dept/sys_notice/sys_param/sys_ticket/sys_menu/sys_login_log` | **反了**:文档改成了 `platform_*`,代码一直是 `sys_*` |
|
||||
| v3.2.2(行 2187)"修复 `sys_ticket`→`platform_ticket`、`sys_login_log`→`platform_login_log` 两处表名错误" | 代码仍为 `sys_ticket`、`sys_login_log` | 该"修复"使文档进一步偏离代码 |
|
||||
| v3.3.0(行 2188)"P0-4 初始管理员密码交付改为邮件一次性重置链接" | 无邮件模块;种子管理员为固定哈希 | 未落地 |
|
||||
| v3.3.0 "P1-1 通知已读机制改为后端 `sys_notice_read` 表" | 无该表 | 未落地 |
|
||||
| v3.3.0 "P1-2 工单增加 `close_reason/closed_time/closed_by`" | `sys_ticket` 无这三个字段 | 未落地 |
|
||||
| v3.3.0 "P0-2 定时任务/工作流代码执行安全性(任意代码→预定义处理器白名单)" | `SCHEDULER_ALLOW_CODE_EXEC=True` 默认放行 `exec` | 未落地且方向相反 |
|
||||
| v3.3.0 "P1-4 用户导入密码处理策略" | 硬编码 `123456` | 未落地 |
|
||||
| v3.6.0(行 2191)"PRD 100% 完整度达标" | — | 该结论只针对文档自身完整性,与实现无关,易被误读为"已交付" |
|
||||
|
||||
### 8.5 版本/时间线异常
|
||||
|
||||
- 文档版本 `v3.6.0`、最后更新 `2026-06-03`,而 `backend/pyproject.toml` 版本 `3.2.0`,`CHANGELOG.md`(新版)最新条目为 `2026-09-06`。文档版本号与代码版本号无对应关系。
|
||||
- §30 变更记录全部集中在 `2026-06-01 ~ 2026-06-03` 三天内完成 v3.1.0→v3.6.0 五个版本的"审查修复",而仓库实际开发时间线为 2026-06 至 2026-09。**该文档像是独立生成的规划稿,未与代码仓库的迭代同步。**
|
||||
|
||||
### 8.6 需求内部矛盾清单
|
||||
|
||||
| # | 冲突点 | 位置 |
|
||||
|---|--------|------|
|
||||
| 1 | 工单批量状态:`PUT /ticket/batch/status` vs `PATCH /ticket/status/batch` | 1122 vs 1409 |
|
||||
| 2 | 参数路径:`/params/*` vs `/param/*` | 694-698 vs 1377-1382 |
|
||||
| 3 | 用户改密/重置路径两套 | 1294-1295 vs 352-353 |
|
||||
| 4 | 权限标识风格:`user:query`、`role:query`(§20.2-20.9)vs `module_system:user:query`、`module_system:login_log:query`(§20.10-20.11,也是代码实际风格) | 1284-1382 vs 1386-1398 |
|
||||
| 5 | 租户生命周期图两处不一致(frozen 支线) | 865-875 vs 1211-1214 |
|
||||
| 6 | 状态编码:§15.2 说 `status` default=0 且 0=active…5=archived;§19.1 再列一遍阶段但顺序含 archived | 810 vs 1202-1207 |
|
||||
| 7 | `expire_after_days`/`archive_after_days` 标为"全局配置"(§19.4),但 §15.2 的 `platform_tenant` 表只有 `grace_period_days`;全局配置的存储位置未定义 | 1241-1243 |
|
||||
| 8 | 文档 §1.4 把 LoginLog 标为"平台级无隔离"、OperationLog 标为"TenantMixin",但 §21.4 又把 `platform_login_log` 列在"租户隔离业务表"下的同一张表清单里(表名前缀矛盾) | 46-47 vs 1617-1618 |
|
||||
| 9 | `is_default` 套餐字段:§16.2 定义(行 1002),§4.5 依赖(行 258/269),§21 平台资源表小节无 `platform_package` 字段清单(行 1582) | 1002 / 258 / 1582 |
|
||||
| 10 | 文档称"已内置插件 module_ai/chat、module_example/demo、module_generator/gencode、module_task/cronjob、module_task/workflow",但实际 `app/plugin/` 下**只有 module_example/demo**,其余在 `app/modules/` | 1175-1180 vs 目录实况 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 缺口影响判断与建议优先级
|
||||
|
||||
### P0(阻断级 / 安全级)—— 决定"这套代码能不能称为 SaaS 多租户平台"
|
||||
|
||||
| # | 缺口 | 业务风险 | 证据 |
|
||||
|---|------|---------|------|
|
||||
| P0-1 | **多租户数据隔离整体缺失**(tenant_id / TenantMixin / ORM 事件层 / CRUD 层 / 租户中间件 / ContextVar / `__platform_data_shared__`) | 平台无法上线多租户:所有租户数据同库同表无隔离,多客户共用一个 `sys_user` 表,`username` 全局唯一,A 租户能看到 B 租户全部用户/角色/部门/公告/参数/工单/日志。这是**数据泄露级**风险,且不是"补一个字段"能修——需要贯穿 Model→CRUD→Service→Auth 的改造 | §5 Part1 全节;`grep tenant backend/app` → 0 |
|
||||
| P0-2 | **平台层/租户层权限模型缺失** | 只有"超管 / 普通用户"二元模型(`is_superuser`);无法表达"租户管理员可管本租户、不可跨租户" | §1.2/§1.3;`app/core/dependencies.py:192-234` |
|
||||
| P0-3 | **Tenant 模块 + Package 模块完全缺失** | 无法开通租户、无法售卖套餐、无法配额管控。`/tenant/*`(14 端点)、`/platform/package/*`(7 端点) 全无 → 商业闭环为零 | §3 矩阵;`find backend/app -iname "*tenant*"` → 空 |
|
||||
| P0-4 | **定时任务默认允许任意代码执行** | 任何能创建 cronjob 节点的用户可在服务器执行任意 Python(`exec`),等同 RCE。文档 v3.3.0 已把该问题列为 P0 并要求改为处理器白名单,代码未改且默认开启 | §7.2 |
|
||||
| P0-5 | **`data_scope` 语义双标 + 校验放宽到 5** | 校验层接受 4/5,权限层只认 1/2/3 且 2/3 含义与文档不同 → 按文档配置会得到与预期相反的可见范围(管理员"看不到数据"或意外获得全量视野) | §7.3 |
|
||||
|
||||
### P1(核心业务能力缺失)—— 决定"能不能跑通一个租户的完整生命周期"
|
||||
|
||||
| # | 缺口 | 业务风险 | 优先级理由 |
|
||||
|---|------|---------|-----------|
|
||||
| P1-1 | **Email 邮件服务缺失**(§23) | 密码重置、用户邀请、到期提醒、初始管理员交付**四条业务链路全部断掉**;文档 §19.5/§4.5/§27 都依赖它 | 被 4 个模块依赖,属"公共基础设施缺失" |
|
||||
| P1-2 | **Order / Payment / Refund 缺失**(§24,13 端点 + 3 表) | 无法收款、无法续费、无法升级降级。`OrderTypeEnum` 已存在但无使用方(悬空) | 商业闭环 |
|
||||
| P1-3 | **到期处理与租户生命周期状态机缺失**(§19,`check_tenant_expiry`) | 无到期扫描 → 租户永不到期;无宽限期/暂停/过期/归档 → 无续费压力也无资源回收 | 依赖 P0-3 |
|
||||
| P1-4 | **Plugin 注册表缺失**(§18,`platform_plugin` + `platform_tenant_plugin` + 安装/卸载) | 插件只有目录约定(`app/core/discover.py`)和静态 `plugin.toml`,无法按租户启用/禁用/计费 | 文档 §18 全部字段与端点均无 |
|
||||
| P1-5 | **文件上传/下载权限点不在种子菜单**(§7.1-B) | **非超管用户完全无法上传/下载文件**——这是当下立即可复现的功能阻断,与多租户无关,独立可修 | 修复成本极低、影响直接 |
|
||||
| P1-6 | **UserInvite / APIUsage / AuditLog / Dashboard / Invoice 缺失** | §26 用量统计是计费依据;§39 审计日志是合规刚需(文档要求 3 年保留、不可篡改);§40 大盘是运营入口 | 依赖 P0-3/P1-2 |
|
||||
| P1-7 | **注册链路无租户/角色/菜单初始化**(§4.5) | `POST /user/register` 公开可用但只建用户、无租户、无 owner 角色、无菜单、无邮箱验证 → 任何人在库里造孤立用户 | `app/modules/system/user/service.py:308-327` |
|
||||
| P1-8 | **用户导入密码固定 `123456`**(§7.5) | 批量导入后所有账号共享同一弱口令,且无强制改密 → 撞库即批量失陷 | `setting.py:134` |
|
||||
| P1-9 | **登录限流 10 次/60 秒 + 仅 IP + 成功也计数**(§7.6) | 10 次/60 秒 ≈ 14400 次/天/IP,对账号爆破几乎无防护;且成功登录也计数会导致正常用户被误锁 | `auth/service.py:155-174` |
|
||||
|
||||
### P2(契约对齐与工程质量)
|
||||
|
||||
| # | 缺口 | 影响 |
|
||||
|---|------|------|
|
||||
| P2-1 | 4 个"死权限点"(`cache:detail`、`swagger:docs`、`log:detail`、`param:query`) | 种子菜单授予了不存在的权限,前端按钮显隐与后端校验脱节 |
|
||||
| P2-2 | 登录/操作日志的路径 + 权限标识与文档全不一致(§7.10) | 前端、网关、权限配置、文档四方对不上 |
|
||||
| P2-3 | 表名前缀体系混乱(文档 `platform_*` vs 代码 `sys_*`,且 `task_corn_*` 拼写可疑) | 运维/DB 审计/文档读者三方误解 |
|
||||
| P2-4 | 表名缺 `tenant_id` 唯一键维度(`username`/`code` 全局唯一) | 即使补了 tenant_id,也无法支持"不同租户同名账号" |
|
||||
| P2-5 | §14.5 操作日志保留/清理策略零实现;`sys_login_log` 无清理 | 生产环境日志表无限增长 |
|
||||
| P2-6 | `/metrics` 不存在;health 端点路径不符(§7.7) | K8s liveness/readiness 探针与 Prometheus 抓取需按代码而非文档配置 |
|
||||
| P2-7 | NFR 的 i18n key 未落地 | `grep "errors\." backend/app` 命中均为 `errors.append(...)` 变量,非 i18n key;后端错误消息为硬编码中文(前端已有 `locales/{en,zh}.json`) |
|
||||
| P2-8 | `app/alembic/versions/` 为空 | §28.4 要求"所有 schema 变更通过 migration 执行",实际靠 `app/scripts/initialize.py` 建表(`initialize.py:70,167`) |
|
||||
| P2-9 | 未文档化的已实现模块:`versions`(版本管理,6 端点,含种子菜单)、`task/storage/*`(存储浏览/传输/工作流,25 端点,4 张表)、`chat/model`(AI 模型配置,5 端点) | 需求文档无法覆盖现有功能,反向缺口 |
|
||||
| P2-10 | `/api/v1/system/param/info` 契约外公开(§7.8 额外发现) | 当前敏感度低,但配置内容一旦扩展(如密钥类参数)即成泄露点 |
|
||||
|
||||
### 建议的处理顺序(若后续要"向文档对齐")
|
||||
|
||||
```
|
||||
阶段 0(当天可做,独立价值)
|
||||
→ P1-5 补种子菜单 2 个文件权限点
|
||||
→ P2-1 清理 4 个死权限点(或补齐对应端点)
|
||||
→ P0-4 把 SCHEDULER_ALLOW_CODE_EXEC 默认改为 False(最低成本消除 RCE)
|
||||
→ P1-9 / P1-8 对齐限流参数与导入密码策略
|
||||
|
||||
阶段 1(决定架构走向,需先做决策而非编码)
|
||||
→ 决策:文档 v3.6.0 是"目标规格"还是"已交付规格"?
|
||||
· 若是目标规格 → 应把 REQUIREMENTS.md 移到 docs/ 并标注"未实现",本报告即差距清单
|
||||
· 若是已交付规格 → 与实现差距过大(5 个整模块 + 跨层改造),需重写文档
|
||||
→ P0-5 data_scope 语义统一(改文档或改代码,二者取一)
|
||||
→ P0-1/P0-2/P0-3 多租户改造方案设计(不是编码任务)
|
||||
|
||||
阶段 2(多租户改造落地后)
|
||||
→ P1-1 Email → P1-3 到期处理 → P1-2 订单支付 → P1-4 Plugin → P1-6 其余
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 存疑清单与核实方法
|
||||
|
||||
| # | 存疑点 | 为什么存疑 | 核实方法 |
|
||||
|---|--------|-----------|---------|
|
||||
| 1 | 数据表是否真实存在于任何运行库 | `app/alembic/versions/` 为空;建表走 `app/scripts/initialize.py`(`initialize.py:70,167`),未启动服务验证 | 启动后连库执行 `SHOW TABLES` / `\dt`,与 `grep __tablename__` 结果比对 |
|
||||
| 2 | **审计范围未覆盖前端逐页面行为** | 只做了目录级统计(web `views` 101 个 `.vue`、app `pages+subPages` 14 个 `.vue`),未逐页读 | 指定页面逐个走查;或按 `frontend/web/src/api/**` 的实际请求路径反查后端路由是否存在 |
|
||||
| 3 | §23–§27 与 §38–§40 正文的字段级细节 | 只按"代码零实现"下结论,未逐行读正文表格 | 若这些模块要进入排期,需重读 §23.2/§24.3/§26.2/§27.2/§38.3/§39.3 的字段清单 |
|
||||
| 4 | §31–§37 的规则级符合度 | 只核对了路由前缀/表名/权限点,未逐条核对业务规则(如 §33.3 工作流规则) | 逐条读 §32.3/§33.3/§34.3/§36.3/§37.3 后与 service 层比对 |
|
||||
| 5 | `demo_enable` / IP 黑白名单的实际拦截行为 | 只读了中间件分支(`app/core/middlewares.py:50-68`),未运行验证 | 造请求实测 |
|
||||
| 6 | 前端权限点使用是否与后端一致 | 未逐条比对前端 `hasPerm`/指令与后端 `AuthPermission` | 提取 `frontend/web/src/directives/**` 与 `common_util` 中的权限串做集合比对 |
|
||||
| 7 | `sys_role_depts`(文档 `data_scope=5` 自定义数据权限)是否被实际使用 | 表存在(`app/modules/system/role/model.py:41`),但 `permission.py` 未读取该表 | 全仓搜 `RoleDeptsModel` 使用点确认 |
|
||||
| 8 | 移动端(uniapp)与文档的对应关系 | 文档几乎只写后端;`frontend/app/src/pages` 只有 index/login/mine/work | 若移动端需纳管,需另建移动端需求基线 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 证据索引(关键文件行号速查)
|
||||
|
||||
```
|
||||
多租户缺失
|
||||
backend/app/core/base_model.py:22,42,124 MappedBase / ModelMixin / UserMixin(无 TenantMixin)
|
||||
backend/app/core/permission.py:12-124 唯一的数据权限实现(data_scope 1/2/3)
|
||||
backend/app/core/validator.py:314-322 data_scope 校验接受 1-5
|
||||
backend/app/core/base_crud.py:33 CRUDBase(无租户条件)
|
||||
backend/app/api/v1/routers.py:32-64 6 个路由域,无 /tenant /platform /plugin
|
||||
backend/app/core/discover.py:1-106 仅 app/plugin/module_* 动态注册
|
||||
|
||||
安全相关
|
||||
backend/app/core/ap_scheduler.py:544-575 exec(code_block)
|
||||
backend/app/config/setting.py:127 SCHEDULER_ALLOW_CODE_EXEC = True
|
||||
backend/app/config/setting.py:73,74 限流 60s / 10 次
|
||||
backend/app/config/setting.py:132 PASSWORD_MIN_LENGTH = 6
|
||||
backend/app/config/setting.py:134 PASSWORD_IMPORT_DEFAULT = "123456"
|
||||
backend/app/modules/system/auth/service.py:155-174 限流实现(仅 IP)
|
||||
backend/app/modules/system/user/service.py:425 导入密码使用点
|
||||
backend/app/core/base_schema.py:99-110 JWTPayloadSchema(无 tenant_id/is_super_admin)
|
||||
backend/app/utils/xss_util.py:99-115 sanitize_html ✔
|
||||
backend/app/utils/upload_util.py:127,343,454 路径遍历防护 ✔
|
||||
backend/app/core/middlewares.py:22-32 CORS ✔
|
||||
backend/app/config/setting.py:190-200 白名单(含 /api/v1/system/param/info)
|
||||
|
||||
契约不一致
|
||||
backend/sql/sys_menu.json 123 个权限点(4 个 backend 无实现)
|
||||
backend/app/modules/common/file/controller.py:18,37 2 个权限点不在种子菜单
|
||||
backend/app/modules/system/ticket/model.py:17 sys_ticket(文档 platform_ticket)
|
||||
backend/app/modules/system/log/model.py sys_login_log(文档 platform_login_log)
|
||||
backend/app/modules/task/cronjob/node/model.py:11,24 task_cornjob_node / func="代码块"
|
||||
backend/app/modules/task/cronjob/job/model.py:11 task_corn_job
|
||||
backend/app/modules/system/role/model.py:72 data_scope 注释
|
||||
backend/app/modules/system/role/service.py:177-184 角色菜单分配无租户范围校验
|
||||
|
||||
种子数据
|
||||
backend/sql/*.json 11 个种子文件;无 tenant/package/plugin 种子
|
||||
backend/sql/sys_role.json SUPER_ADMIN/ADMIN data_scope=3
|
||||
backend/sql/sys_user.json super/admin 同一固定 PBKDF2 哈希
|
||||
|
||||
前端覆盖
|
||||
frontend/web/src/views/ 11 个目录,无 tenant/package/plugin/order/dashboard(platform)
|
||||
frontend/web/src/api/ 7 个模块目录
|
||||
frontend/app/src/pages/ + subPages/ 14 个页面,无租户/套餐
|
||||
frontend/web/src/locales/langs/{en,zh}.json 前端 i18n 已有骨架
|
||||
|
||||
文档基线
|
||||
REQUIREMENTS.md:1-5 标题与版本 v3.6.0 / 2026-06-03
|
||||
REQUIREMENTS.md:33-58 §1.4 模块总览(遗漏 8 个模块)
|
||||
REQUIREMENTS.md:166-172 data_scope 五档(与代码冲突)
|
||||
REQUIREMENTS.md:785-987 §15 Tenant(整章无实现)
|
||||
REQUIREMENTS.md:1262-1573 §20 API 汇总(260 唯一路径)
|
||||
REQUIREMENTS.md:1577-1659 §21 表结构(21.4 重复)
|
||||
REQUIREMENTS.md:1663-1681 §22 安全性 13 条
|
||||
REQUIREMENTS.md:1849-1851 §24.6 缺号
|
||||
REQUIREMENTS.md:2180-2191 §30 变更记录(多处声明与代码不符)
|
||||
|
||||
Git 证据
|
||||
45a00334 docs: add SaaS multi-tenant platform requirements (纯文档提交,R100 重命名自 CHANGELOG.md)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 附:机械化核对命令(可复现)
|
||||
|
||||
```bash
|
||||
cd /Users/tao/workspace/FastapiAdmin
|
||||
|
||||
# 1. 多租户实现是否存在
|
||||
grep -ril "tenant" backend/app frontend/web/src frontend/app/src | wc -l # → 0
|
||||
|
||||
# 2. 需求文档的引入方式
|
||||
git show --stat --no-renames 45a00334
|
||||
git log --oneline -S "SaaS 多租户平台需求文档" -- REQUIREMENTS.md
|
||||
|
||||
# 3. 权限点 vs 种子菜单
|
||||
grep -rn "AuthPermission(\[" backend/app --include=*.py | grep -v __pycache__ # 121 个
|
||||
python3 -c "import json;d=json.load(open('backend/sql/sys_menu.json'));p=set()
|
||||
def w(n):
|
||||
for x in n:
|
||||
p.add(x['permission']) if x.get('permission') else None; w(x.get('children') or [])
|
||||
w(d); print(len(p))" # 123 个
|
||||
|
||||
# 4. 表名清单
|
||||
grep -rn "__tablename__" backend/app --include=*.py | grep -v __pycache__
|
||||
|
||||
# 5. 到期处理是否实现
|
||||
grep -rn "check_tenant_expiry\|expire_after_days\|archive_after_days\|grace_period" backend/app # → 无
|
||||
|
||||
# 6. 商业运营模块是否实现
|
||||
for k in smtp EmailConfig refund invoice audit_log api_usage; do
|
||||
echo "$k: $(grep -ril "$k" backend/app | wc -l)"; done
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*报告结束。本报告所有结论均可由 §11 索引中的文件行号与附录命令复现;标注"存疑"的条目请按 §10 的方法核实后再进入排期。*
|
||||
@@ -0,0 +1,382 @@
|
||||
# FastapiAdmin 安全审计报告:认证 / 授权 / 敏感信息
|
||||
|
||||
- 审计任务:`t4` — 安全审计(认证 / 授权 / 敏感信息)
|
||||
- 审计对象:后端(`backend/app`、`backend/main.py`、`backend/env/*`、`backend/sql/*`、`backend/logs/*`)及必要的关联面(`docker/nginx/nginx.conf`、`frontend/web/src/utils/auth/index.ts`、`frontend/web/.env`、git 历史)
|
||||
- 工作区:`/Users/tao/workspace/FastapiAdmin`(分支工作树,含未提交改动)
|
||||
- 审计时间点:审计时刻工作区实际内容
|
||||
- 约束遵守:**只读审计**。未安装依赖、未启动服务、未提交 git、未做任何破坏性或对外发包的利用尝试;仅新增本报告文件。
|
||||
- 证据规则:`文件:行号` 为审计时刻实际内容;命令输出为在本地对工作区/仓库的只读操作结果。所有"可利用性"结论都标注了前置条件,未验证的部分进入第 6 节「待验证」。
|
||||
- 交叉引用:部署链路的安全问题已有独立报告 [`docker/audit-deploy.md`](../docker/audit-deploy.md)(任务 t6)。本报告只引用其结论,不重复展开。
|
||||
|
||||
---
|
||||
|
||||
## 1. 结论速览
|
||||
|
||||
### 1.1 上线前必须修(blocker / high)
|
||||
|
||||
| ID | 级别 | 问题 | 关键证据(文件:行号) | 修复建议 |
|
||||
|----|------|------|----------------------|---------|
|
||||
| **S1** | **blocker** | JWT 签名密钥使用源码内公开默认值,且 `env/.env.dev`、`env/.env.prod`、docker 编排均未注入 `SECRET_KEY` → 任何人可用公开密钥伪造任意用户的访问令牌;同一密钥经 HKDF 派生数据加密主密钥,落库/落缓存的 AI `api_key`、存储源口令可被解密 | `backend/app/config/setting.py:66`(默认值)、`backend/env/.env.prod`(无 `SECRET_KEY` 键)、`grep -n "SECRET_KEY" docker/docker-compose.yaml deploy.sh` 无命中(编排亦未注入)、`backend/app/core/security.py:112-116,139`、`backend/app/utils/crypto_util.py:35-55`;与 `docker/audit-deploy.md` B1 同一根因 | 1) 删除 `SECRET_KEY` 的默认值,改为必填(缺省即启动失败);2) 部署侧注入 ≥32 字节随机值(`openssl rand -hex 32`)并轮换现值;3) 同时显式配置 `DATA_ENCRYPTION_KEY` 并轮换已加密数据 |
|
||||
| **S2** | **blocker** | 种子账号 `super` / `admin` / `user` 三者的口令哈希完全相同,实测对应口令 `123456`;该种子在任何环境的**空库启动时自动写入**,`super`/`admin` 还是 `is_superuser=true`;README 直接公开该口令 | `backend/sql/sys_user.json:3-4,23-24,43-44`(同一 pbkdf2 哈希)、`backend/app/scripts/initialize.py:50-58,163-200`(无环境判断)、`README.md:66-67,86`;口令校验命令见 §7.1 | 1) 首次启动随机生成超管口令并一次性输出/落盘(或强制首次登录改密);2) 删除 `sql/sys_user.json` 中的固定哈希,改为按环境注入;3) README 改为"首次初始化口令见控制台" |
|
||||
| **S3** | **high** | 停用/删除用户、重置口令、撤销角色后**既有会话不被撤销**:认证只看 Redis 会话里的 `user_status`,每请求查库只校验"用户存在且未删除",不校验 DB 的 `status`;会话滑动续期最长 7 天 | `backend/app/core/dependencies.py:162-182`(用会话里的 `user_status`,DB 查询未含 `status` 条件)、`backend/app/modules/system/user/service.py:145-168`(删除用户不清理会话)、`288-295`(重置口令不清理会话)、`backend/app/modules/system/auth/service.py:531-533`(仅登出自身会话);权限取自会话快照 `dependencies.py:185-189` | 1) `_authenticate` 增加 `user_obj.status != 0 → 401` 并即时清理会话;2) 停用/删除/改密/角色变更后按 `user_id` 扫描并删除 `user_session:{sid}`;3) 会话内权限改为每请求/短 TTL 重算 |
|
||||
| **S4** | **high** | 第三方 OAuth 的 `redirect_uri` 完全由请求方指定、无任何域名白名单校验;登录成功后按该地址 302 重定向并把 `access_token`/`refresh_token` 拼进 **URL query** → 钓鱼链接可让受害者把自己的令牌交到攻击者域 | `backend/app/modules/system/auth/oauth_service.py:410-439`(原样保存 `redirect_uri`)、`474-479` + `51-60`(成功重定向携带令牌)、`backend/app/modules/system/auth/controller.py:93-107`;`OAUTH_ALLOWED_HOSTS` 只校验回调域不校验前端回跳域(`setting.py:150`、`oauth_service.py:35-43`) | 1) `redirect_uri` 必须命中配置白名单,否则拒绝(缺失时才回落 `OAUTH_FRONTEND_FALLBACK`);2) 令牌改为短时一次性 code,由前端换 token,禁止出现在 URL;3) 该端点对 `redirect_uri` 做同源/相对路径限制 |
|
||||
| **S5** | **high** | `/api/v1/system/user/register` 无鉴权、无验证码、无开关,任何人可无限注册账号;同时登录验证码是"假滑块"(服务端只查 key 状态,无任何人机证据),`/captcha/get` + `/captcha/slider/complete` 可脚本化 → 账号爆破与刷号成本极低 | `backend/app/modules/system/user/controller.py:84-92`(无依赖)、`backend/app/core/dependencies.py:192-234`(授权类与注册无关)、`backend/app/modules/system/auth/service.py:540-549`(作者自述"只能提高滥用成本")、`196-204`、`155-173`(限流仅按 IP,10 次/分钟) | 1) 注册接口加开关(默认关)+ 验证码 + 邮箱/短信校验;2) 换服务端出题的图形/行为验证码;3) 登录限流改为 账号+IP 双维度并收紧窗口 |
|
||||
| **S6** | **high** | DEBUG 级别下数据库驱动把**含实参值的完整 SQL** 写入日志,口令哈希明文落盘;`backend/logs/fastapiadmin.log` 中已有 24 行包含种子口令哈希 | `backend/app/core/logger.py:57,71-94`(`aiosqlite` 未列入降噪名单)、实测 `backend/logs/fastapiadmin.log:8407`(见 §7.2);`backend/.gitignore` 未忽略日志本身,但 `backend/logs` 已在根 `.gitignore` 中 | 1) 将 `aiosqlite`/`aioboto3` 等驱动 logger 一并置为 WARNING;2) DEBUG 级别禁止写文件 sink,或对 SQL 值做脱敏;3) 清理现存日志并按需轮换密钥(哈希已在本地明文出现) |
|
||||
|
||||
### 1.2 可排期修复(medium)
|
||||
|
||||
| ID | 级别 | 问题 | 关键证据 | 修复建议 |
|
||||
|----|------|------|---------|---------|
|
||||
| **S7** | medium | 生产环境**无条件暴露** API 文档与 OpenAPI 规范(`register_docs` 无环境判断),且 `/api/v1/openapi.json` 经 nginx `/api/v1` 代理可直接访问 → 完整接口/参数/字段结构外泄 | `backend/app/__init__.py:95-123`、`backend/app/config/setting.py:302-321`(只关 `docs_url`,`openapi_url` 仍默认)、`docker/nginx/nginx.conf:116-135` | 生产环境不注册 `/docs`、`/redoc`,并设置 `openapi_url=None` 或用管理员鉴权包裹 |
|
||||
| **S8** | medium | 未鉴权接口暴露系统配置与业务数据:`/api/v1/system/param/info`(全部系统参数,含 IP 黑白名单、演示开关)、`/dict/data/info/{type}`、`/versions/published`、`/monitor/health/check`(版本+环境+DB/Redis 状态)、`/monitor/health/stream` | `backend/app/modules/system/params/controller.py:31-36`、`backend/app/modules/system/params/service.py:101-118`、`backend/app/modules/system/dict/controller.py:180-186`、`backend/app/modules/system/versions/controller.py:36-44`、`backend/app/modules/monitor/health/controller.py:20-45` | 除健康探针外全部补 `get_current_user`;健康接口只返回 `ok/not ok`,版本与环境写入内部监控 |
|
||||
| **S9** | medium | CORS 默认"任意来源"且 `allow_credentials=True`(`PROD_CORS_ORIGINS` 为空即回落 `["*"]`),生产亦是 | `backend/app/config/setting.py:57,60,243-248`、`backend/app/core/middlewares.py:22-33`;实测 starlette 0.52.1 行为:简单请求回 `Access-Control-Allow-Origin: *`(`.venv/.../starlette/middleware/cors.py:40-46`) | 生产强制配置显式域名清单并在启动期校验非空(空即启动失败);不使用时关掉 `ALLOW_CREDENTIALS` |
|
||||
| **S10** | medium | 代码生成器可执行任意 SQL(`POST /generator/gencode/create` 的 `body.sql` 直送 `execute_sql`);定时任务默认允许执行用户提交代码块(`exec`,等价 RCE) | `backend/app/modules/generator/gencode/controller.py:76-83`、`backend/app/modules/generator/gencode/crud.py:333-342`、`backend/app/config/setting.py:127`、`backend/app/core/ap_scheduler.py:548-563`、`backend/app/modules/task/cronjob/node/service.py:284,371` | 生产 `SCHEDULER_ALLOW_CODE_EXEC=False`(并写入 `.env.example`/部署模板);代码生成器移除"执行任意 SQL"入口,或降级为仅白名单 DDL 模板 |
|
||||
| **S11** | medium | 存储源支持 `local` 协议且 `host` 即根目录,配合浏览/下载/删除/重命名接口 → 具备存储权限者可读写服务器任意目录;FTP/SFTP/S3 等协议可作为 SSRF 探测内网 | `backend/app/modules/task/storage/core/local_adapter.py:15-26,43-59`、`backend/app/modules/task/storage/node/controller.py:81-89`(仅看 permission)、`backend/app/modules/task/storage/browse/controller.py:43-75` | local 协议根目录限定在 `static/upload` 且做 `resolve()+is_relative_to` 校验;远端协议加内网网段黑名单与出站策略;该类权限收敛到超管 |
|
||||
| **S12** | medium | 缓存监控接口能按 `{cache_name}:{cache_key}` 组合读取**任意 Redis 键**(含 `user_session:*` 会话 JSON:手机号/邮箱/权限);`/cache/clear` 清空整个 DB(会话、调度器 jobstore 一并丢失) | `backend/app/modules/monitor/cache/service.py:34-40,43-57`、`backend/app/modules/monitor/cache/controller.py:37-59`(仅 `module_monitor:cache:*`) | 仅允许白名单前缀(如 `system_config:*`);键名做严格格式校验;`clear` 改为按前缀清理并二次确认 |
|
||||
| **S13** | medium | 口令策略被"合法绕过":批量导入用户与导入更新会把口令重置为固定 `123456`(`PASSWORD_IMPORT_DEFAULT`),且无首次登录强制改密;导入仅校验用户名唯一、不校验手机号唯一 | `backend/app/config/setting.py:132-134`、`backend/app/modules/system/user/service.py:370-445`(`425` 固定口令、`434` 更新既有用户)、`backend/app/modules/system/user/model.py:68-71`(`mobile` 非唯一)、`backend/app/modules/system/auth/wx_mini_service.py:302-318`(按手机号登录取 `.first()`) | 导入时随机口令并要求首登改密;`mobile` 加唯一约束(含软删除场景);手机号登录命中多条时报错 |
|
||||
| **S14** | medium | 文件上传允许 `.svg`,上传目录 `static/upload` 以 `/static` 同源直出 → 具备上传权限者可构造同源存储型 XSS,窃取 localStorage 中的令牌;声明 MIME 与文件真实内容不一致时只记 warning 不拦截 | `backend/app/config/setting.py:213-222`、`backend/app/__init__.py:89-92`、`backend/app/utils/upload_util.py:215-234`(`return True` 恒定)、前端令牌在 localStorage:`frontend/web/src/utils/auth/index.ts:24-50`。缓解现状:nginx 已全局下发 `X-Content-Type-Options: nosniff` + `Referrer-Policy`(`docker/nginx/nginx.conf:59-63`),可挡住"改扩展名伪装 HTML"的嗅探变体,但**不阻止** `image/svg+xml` 内嵌脚本被执行,且无 CSP;直连后端 8001(绕过 nginx)时全部安全头缺失 | 移除 `.svg`(或强制 `Content-Disposition: attachment` + 独立域);补充 CSP;在应用层也下发 nosniff(覆盖直连场景);MIME 不匹配即拒绝 |
|
||||
| **S15** | medium | 工单富文本在**服务端未做任何 HTML 清洗**,仅 Web 端渲染时用 DOMPurify 兜底;公告内容有服务端清洗但工单没有 → 其它客户端(移动端/导出/富文本回显)存在存储型 XSS | `backend/app/modules/system/notice/schema.py:10,30-33`(公告有)、`backend/app/modules/system/ticket/*.py`(grep `sanitize` 无命中)、`frontend/web/src/views/module_system/ticket/index.vue:187,198,240,403,865` | 与公告一致,在 ticket `schema.py` 用 `sanitize_html` 入库清洗;导出/其它渲染端同样清洗 |
|
||||
|
||||
### 1.3 加固项(low)
|
||||
|
||||
| ID | 级别 | 问题 | 关键证据 | 修复建议 |
|
||||
|----|------|------|---------|---------|
|
||||
| **S16** | low | `AuthPermission()` 传空权限列表时等于"仅需登录",`/task/storage/transfer/stream` 即用此写法(当前只推送本人任务,影响有限) | `backend/app/core/dependencies.py:219-220`、`backend/app/modules/task/storage/transfer/controller.py:107` | 空权限列表直接 `raise`,避免"忘记填权限 = 放行" |
|
||||
| **S17** | low | `WHITE_API_LIST_PATH` 的注释与命名("无需认证即可访问")与实际用途(仅演示模式写保护放行)不一致;`/user/current/info` 等被列入却仍需认证 → 误导后续维护者 | `backend/app/config/setting.py:188-201`、验证只用在一处:`backend/app/core/middlewares.py:55` | 改名为 `DEMO_WRITE_ALLOWLIST` 并修正注释 |
|
||||
| **S18** | low | 无全局鉴权中间件,鉴权完全依赖每个 endpoint 自觉声明依赖 → **默认 fail-open**,本轮即发现 6 个接口漏加(S5/S8) | `backend/app/__init__.py:69-74`(`MIDDLEWARE_LIST` 无鉴权件)、`backend/app/api/v1/routers.py:66-71` | 增加"默认拒绝"的全局依赖/启动期自检:扫描所有路由必须在白名单内或包含 `get_current_user` |
|
||||
| **S19** | low | 数据权限(data_scope)实现依赖模型有 `created_id`,无该字段的模型完全不参与过滤(登录/操作日志、字典、参数、定时任务等) | `backend/app/core/permission.py:29-40`、`backend/app/core/base_crud.py:321-325`、`backend/app/modules/system/log/model.py:23,40`、`backend/app/modules/task/cronjob/job/model.py:7` | 明确这些表的可见范围并写进权限矩阵;日志类接口保持管理员专属 |
|
||||
| **S20** | low | 前端"锁屏加密密钥"`VITE_LOCK_ENCRYPT_KEY = s3cur3k3y4adpro` 硬编码,且该文件(`frontend/web/.env`)**当前仍被 git 跟踪**(`.gitignore` 后来才加入该路径,已跟踪文件不会被自动忽略) | `frontend/web/.env:25`(`git ls-files --error-unmatch frontend/web/.env` 命中)、`frontend/web/src/layouts/fa-screen-lock/index.vue:168` | 客户端固定密钥无法真正保密,锁屏只作"防窥"用途,不得复用为任何数据加密密钥;如需保留该文件,请 `git rm --cached frontend/web/.env` 并改用 `.example` |
|
||||
| **S21** | low | `.env.prod` 中的 `DEMO_ENABLE=True` 是**无效配置**(`Settings` 无该字段且 `extra="ignore"`),演示模式实际由 Redis 系统参数控制 → 运维误以为"演示写保护已开启" | `backend/env/.env.prod:13`、`backend/app/config/setting.py:16-21`(model_config)、`backend/app/core/middlewares.py:48-58`;已被 `docker/audit-deploy.md` 记为 medium | 删除该变量或实现为真实开关,并在文档中说明演示模式配置位置 |
|
||||
| **S22** | low | 本地 `env/.env.dev`、`env/.env.prod` 含真实凭据(生产 DB/Redis 口令、可用 LLM API Key),且**历史上曾被提交进 git**(后续 `xxxx` 脱敏并删除文件,但旧提交仍在历史中) | `backend/env/.env.prod`(工作区文件)、`git log -S "FastApi123abc"` 命中 `2ba5b321`、`16054f6c`、`3b45ca2b` 等(见 §7.3) | 视为已泄露:轮换全部相关口令与 API Key;历史清理(`git filter-repo`)或直接废弃该仓库副本 |
|
||||
| **S23** | low | 令牌过期语义失效:滑动续期开启时 JWT 的 `exp` **完全不校验**,访问令牌在会话存活期内(最长 7 天)永远有效;且 `_authenticate` 不比对 Redis 中存的活动令牌,无法撤销单个令牌(只能整体删会话) | `backend/app/config/setting.py:71`、`backend/app/core/dependencies.py:105-106`、`backend/app/core/security.py:119-139`、`backend/app/modules/system/auth/service.py:531-533` | 滑动续期改为"服务端 TTL 决定、但 JWT exp 仍校验一个较短上限";`_authenticate` 比对 `access_token:{sid}` 的值,实现单令牌吊销 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 详细结论(可利用性判断)
|
||||
|
||||
### S1 [blocker] JWT 签名密钥为公开默认值(可伪造令牌 + 可解密落库密文)
|
||||
|
||||
**事实**
|
||||
|
||||
- `SECRET_KEY` 的声明带默认值,且注释声称"必须通过环境变量设置,无默认值",但代码并未强制:
|
||||
`backend/app/config/setting.py:66`
|
||||
```python
|
||||
SECRET_KEY: str = "fastapiadmin-dev-secret-key-do-not-use-in-production"
|
||||
```
|
||||
- `backend/env/.env.dev`、`backend/env/.env.prod` **都不包含 `SECRET_KEY`**(见 §7.4 的 `grep` 结果),docker 编排同样未注入(`docker/audit-deploy.md` B1)。配置源为 `.env.{ENVIRONMENT}`(`setting.py:16-21,324-329`),因此所有环境实际使用上述公开字符串。
|
||||
- 令牌签发/校验都使用该密钥与 `HS256`:`backend/app/core/security.py:112-116`、`139`。`algorithms` 固定为配置算法(无 `alg=none` 风险)。
|
||||
- 该密钥同时是 `CryptoUtil` 数据加密主密钥的派生源(`DATA_ENCRYPTION_KEY` 默认 `None`):`backend/app/utils/crypto_util.py:35-55`。
|
||||
|
||||
**可利用性(分两条链,均有前置条件,已按现实性排序)**
|
||||
|
||||
1. **链 A(有明确前置,可完整冒充超管)**:`/api/v1/monitor/online/list` 返回全部在线会话的完整 JSON,**包含 `session_id`**(`backend/app/modules/monitor/online/service.py:20-52`;`OnlineOutSchema` 继承 `SessionInfoSchema.session_id`:`backend/app/modules/monitor/online/schema.py:7-8`、`backend/app/core/base_schema.py:78`)。任一持有 `module_monitor:online:query` 的账号即可拿到超管的 `session_id`,随后用公开密钥离线签发
|
||||
`{"sub": "<超管 session_id>", "is_refresh": false, "exp": <任意未来时间>}`
|
||||
即可通过 `_authenticate`(它只要求 Redis 中存在 `user_session:{sub}`,并信任会话内的 `permissions`/`is_superuser`),无需口令即获得超管权限。
|
||||
2. **链 B(无额外权限,危害较小)**:任意已认证用户(含 S5 自助注册的零权限账号)都能签发自己 `session_id` 的令牌并随意设置 `exp`,绕过令牌过期(因 S23 的滑动模式跳过 `exp` 校验)。
|
||||
|
||||
**为什么不能只依赖 Redis 会话**:`_authenticate`(`backend/app/core/dependencies.py:110-189`)只用 `sub` 去 Redis 取会话,**从不比较请求令牌与 `access_token:{sid}` 中存的值**,因此"签名正确 + 会话存在"即放行;密钥公开就等于"任意会话可被重新签发"。
|
||||
|
||||
**修复**:见 §1.1 S1;另建议把权限/超管标记从会话快照改为每请求查库,并让 `_authenticate` 比对 Redis 中存的活动令牌(实现真正可撤销)。
|
||||
|
||||
### S2 [blocker] 种子账号固定弱口令 `123456`(超管可被直接登录)
|
||||
|
||||
**事实**
|
||||
|
||||
- 三个种子用户口令哈希完全相同:`backend/sql/sys_user.json:4,24,44` 均为
|
||||
`$pbkdf2-sha256$600000$XX20aO1v73xS0JnoewXNtw==$PEaVHV1N5L7PfYQw2lCAQOc4hAEyCiwsGR48/jgVBjU=`
|
||||
其中 `super`、`admin` 为 `is_superuser: true`。
|
||||
- 该哈希在本地按实现(PBKDF2-HMAC-SHA256、60 万次迭代、salt 为 `base64.b64decode(salt_b64)`,见 `backend/app/utils/password_util.py:42-59`)复算,命中口令 `123456`(命令与输出见 §7.1)。
|
||||
- 种子数据在**任何环境的空库启动时**写入,无环境判断:`backend/app/scripts/initialize.py:50-58`(`init_db` → `__init_data`)、`163-200`(表为空即灌入);`lifespan` 在应用启动时调用(`backend/app/__init__.py:30`)。
|
||||
- README 公开该口令:`README.md:66-67,86`(`admin` / `123456`)。
|
||||
|
||||
**可利用性**:对任何"用本项目默认方式部署、未改口令"的实例,攻击者只需 `POST /api/v1/system/auth/login`(默认 `CAPTCHA_ENABLE=True`,但见 S5:滑块可脚本化)即可获得超管权限。**无需任何前置知识**,是该仓库最高可利用性的问题。
|
||||
|
||||
**修复**:见 §1.1 S2。注意口令策略(最少 2 类字符,`password_util.py:73-87`)对种子数据无效——`123456` 是纯数字。
|
||||
|
||||
### S3 [high] 停用/删除/改密/撤权后会话不失效(最长 7 天)
|
||||
|
||||
**事实**
|
||||
|
||||
- 认证读取的"用户状态"来自**登录时写入 Redis 的会话快照**,而不是数据库当前值:
|
||||
`backend/app/core/dependencies.py:162-166`
|
||||
```python
|
||||
user_status = user_info.get("user_status", 0)
|
||||
...
|
||||
if user_status == 1:
|
||||
raise CustomException(msg="用户已被停用", ...)
|
||||
```
|
||||
- 每请求的数据库校验只判断"存在且未软删",**没有 `status` 条件**:`dependencies.py:171-182`。
|
||||
- `set_available`(停用/启用)只写库不清理会话:`backend/app/modules/system/user/service.py:269-273`;删除用户(`145-168`)、重置口令(`288-295`)、角色授权变更(`backend/app/modules/system/role/service.py` 的授权接口)同样不清理会话。
|
||||
- 会话存活上限:`SESSION_MAX_LIFETIME_SECONDS = 7 天`,且滑动续期在每次请求时刷新 TTL(`setting.py:71-72`、`dependencies.py:124-156`)。
|
||||
|
||||
**可利用性**:管理员发现异常账号后"停用/改密/撤角色",被处置账号在会话到期前(活跃时被不断续期,最长 7 天)仍可调用全部原权限接口;权限列表也是会话快照(`dependencies.py:185-189`),撤权不生效。属于"处置无效"类风险,需与 S1(会话可被重新签发)一起看。
|
||||
|
||||
**修复**:见 §1.1 S3。
|
||||
|
||||
### S4 [high] OAuth `redirect_uri` 无白名单 → 令牌经 302 外泄(未启用第三方登录时为纯开放重定向)
|
||||
|
||||
**事实**
|
||||
|
||||
- `/api/v1/system/auth/oauth/{provider}/login?redirect_uri=...`:`redirect_uri` 由请求方任意指定,直接写入 state 缓存,无任何校验:
|
||||
`backend/app/modules/system/auth/oauth_service.py:410-439`
|
||||
- 回调成功后按该地址重定向,并把令牌拼进 query:
|
||||
`oauth_service.py:474-479` + `51-60`
|
||||
```python
|
||||
def _frontend_success_redirect(frontend_base, access_token, refresh_token, token_type):
|
||||
q = urlencode({"access_token": ..., "refresh_token": ..., "token_type": ...})
|
||||
return f"{frontend_base}{sep}{q}"
|
||||
```
|
||||
- `OAUTH_ALLOWED_HOSTS`(默认 `["*"]`,`setting.py:150`)只用于**回调域**校验(`oauth_service.py:35-43`),不约束前端回跳域。
|
||||
- 异常分支同样会重定向到调用方给的地址:`oauth_service.py:439`(`_frontend_error_redirect(redirect_uri or fallback, e.msg)`)。
|
||||
|
||||
**可利用性**
|
||||
|
||||
- 前置:目标站点启用了 OAuth(`OAUTH_*_CLIENT_ID/SECRET` 非空,默认全空)。攻击者构造
|
||||
`https://victim/api/v1/system/auth/oauth/github/login?redirect_uri=https://evil.tld/cb`,诱导受害者点击并完成第三方登录 → 受害者浏览器被重定向到 `https://evil.tld/cb?access_token=...&refresh_token=...`,攻击者拿到可用令牌(凭证交换与用户绑定流程本身是正确的:state 一次性消费、provider 比对,见 `oauth_service.py:331-345`)。
|
||||
- 前置不满足时(默认未配置渠道密钥):仍是**开放重定向**(302 到任意域并带 `oauth_error`),可用于钓鱼跳板。
|
||||
- 附带问题:令牌出现在 URL query,会进入浏览器历史、Referer 与网关访问日志。
|
||||
|
||||
**修复**:见 §1.1 S4。
|
||||
|
||||
### S5 [high] 开放注册 + 验证码无实质人机校验
|
||||
|
||||
**事实**
|
||||
|
||||
- `POST /api/v1/system/user/register` 无鉴权依赖、无验证码、无配置开关:`backend/app/modules/system/user/controller.py:84-92`。注册会创建 `status=0`、无角色用户(`backend/app/modules/system/user/service.py:308-327`),因此不构成直接的权限提升,但可用于刷号、占用用户名(含 `201-...` 类业务标识)、以及拿到一个"合法登录态"配合 S1/S16。
|
||||
- 验证码:`CaptchaService` 自述"滑块是纯前端交互,服务端拿不到任何可信的人机证据(拖动轨迹可以伪造)",实现只需 `captcha_key` + 指纹(IP+UA)+ ≥0.2s 间隔即可通过:`backend/app/modules/system/auth/service.py:540-549,610-648`。
|
||||
- 登录限流按 IP 固定窗口 10 次/分钟,且对 `127.0.0.1`/`unknown` 直接放行:`auth/service.py:155-173`、`setting.py:73-74`。IP 来源依赖 `X-Real-IP`/`X-Forwarded-For` 且只在"直连对端是可信代理"时信任(`backend/app/utils/ip_local_util.py:24-60`)——但部署侧存在 `FORWARDED_ALLOW_IPS=*` + 8001 直连(`docker/audit-deploy.md` H2),一旦成立即可伪造 IP 绕过限流(见 §6 待验证)。
|
||||
- `POST /api/v1/system/user/password/forget` 也未鉴权,但实现是**安全的**:不返回账号是否存在、不改密(`controller.py:74-81`、`service.py:297-306`),此项不作为问题。
|
||||
|
||||
**可利用性**:对已知用户名做口令爆破,自动化成本被限流(10 次/分钟/IP)压低但不为 0(约 14400 次/天/IP,配合代理池或伪造头可放大);配合 S2 的弱口令几乎无意义——弱口令本身已被直接命中。
|
||||
|
||||
**修复**:见 §1.1 S5。
|
||||
|
||||
### S6 [high] 敏感数据(含口令哈希)写入日志文件
|
||||
|
||||
**事实**
|
||||
|
||||
- `setup_logger` 把根 logger 与所有既有 logger 接到 `InterceptHandler`,并把 `LOGGER_LEVEL`(dev 默认 `DEBUG`)作为级别;降噪白名单**不含 `aiosqlite`**:`backend/app/core/logger.py:49-94`(`aiomysql` 在列,`aiosqlite` 不在)。
|
||||
- 实测日志中 24 行包含种子口令哈希明文,例如 `backend/logs/fastapiadmin.log:8407`:
|
||||
```
|
||||
2026-10-04 03:57:52.396 | DEBUG | aiosqlite.core:_connection_worker_thread:62 - executing functools.partial(..., 'INSERT INTO sys_user (username, password, name, ...' ... pbkdf2-sha256$600000$XX20aO1v73xS0JnoewXNtw==$PEaVHV1N5L7PfYQw2lCAQOc4h
|
||||
```
|
||||
(完整命令与输出见 §7.2)
|
||||
- 生产 `LOGGER_LEVEL=INFO`(`backend/env/.env.prod`)时不触发;但 DEBUG 在 dev/调试环境是默认值,日志文件按天轮转保留 30 天(`logger.py:65-66`)。
|
||||
|
||||
**可利用性**:任何能读到 `backend/logs/*.log`(开发机共享、容器挂载、日志采集平台、备份)的人,直接获得口令哈希(PBKDF2 600k,离线爆破成本较高,但 S2 的种子哈希等于明文口令)以及 SQL 中的其它业务数据。
|
||||
|
||||
**修复**:见 §1.1 S6。
|
||||
|
||||
### S7 [medium] 生产无条件暴露 `/docs`、`/redoc`、`/openapi.json`
|
||||
|
||||
- `FASTAPI_CONFIG` 只把 `docs_url`/`redoc_url` 设为 `None`(`setting.py:308-310`),但 `register_docs` 又**无条件**注册 `/docs`、`/redoc`(`app/__init__.py:95-123`),无 `ENVIRONMENT` 判断;`openapi_url` 保持默认(`/openapi.json`)。
|
||||
- `ROOT_PATH="/api/v1"` 经 Starlette 的 `get_route_path` 生效(`.venv/.../starlette/_utils.py:90-102`、`fastapi/applications.py:1160-1163`),故实际外部路径为 `/api/v1/docs`、`/api/v1/redoc`、`/api/v1/openapi.json`,全部落在 nginx 的 `location /api/v1` 代理范围内(`docker/nginx/nginx.conf:116-135`)。
|
||||
- 可利用性:无需认证即可下载完整 API 结构(接口清单、请求/响应 schema、字段名),显著降低后续攻击成本。修复见速览表 S7。
|
||||
|
||||
### S8 [medium] 未鉴权接口暴露系统配置与业务数据
|
||||
|
||||
已确认"无任何鉴权依赖"的路由(解析全部 `controller.py` 的 decorator→签名块,见 §7.5):
|
||||
|
||||
| 路由 | 说明 | 证据 |
|
||||
|------|------|------|
|
||||
| `GET /api/v1/system/param/info` | 返回 Redis 中全部系统参数(`sys_name`、`demo_enable`、`ip_white_list`、`ip_black_list`、`ip_location_enable` …) | `params/controller.py:31-36`、`params/service.py:101-118`、种子 `backend/sql/sys_param.json` |
|
||||
| `GET /api/v1/system/dict/data/info/{dict_type}` | 任意字典类型的数据 | `dict/controller.py:180-186` |
|
||||
| `GET /api/v1/system/versions/published` | 已发布版本列表(`auth = AuthSchema()` 空上下文) | `versions/controller.py:36-44` |
|
||||
| `GET /api/v1/monitor/health/check`、`/health/stream` | 应用名/版本/环境 + DB/Redis 连通状态 | `monitor/health/controller.py:20-45`、`health/service.py:42-54` |
|
||||
| `POST /api/v1/system/user/register`、`/password/forget` | 见 S5(`forget` 实现安全,仅列举) | `user/controller.py:74-92` |
|
||||
|
||||
说明:`setting.py:188-201` 把其中若干路径列进 `WHITE_API_LIST_PATH` 并注释为"无需认证即可访问",但该列表**仅**用于演示模式写保护判断(`middlewares.py:55`,全仓唯一引用)。因此这些接口的公开性是"漏加依赖"而非"有意白名单",详见 S17/S18。
|
||||
|
||||
### S9 [medium] CORS 任意来源 + 允许携带凭证
|
||||
|
||||
- `ALLOW_ORIGINS` 在非 PROD 或 `PROD_CORS_ORIGINS` 为空时返回 `["*"]`(`setting.py:243-248`),`ALLOW_CREDENTIALS=True`(`setting.py:60`)。
|
||||
- 实测安装版本 starlette 0.52.1 的行为:`allow_all_origins=True` 时简单响应返回 `Access-Control-Allow-Origin: *` 且同时带 `Access-Control-Allow-Credentials: true`(`.venv/lib/python3.12/site-packages/starlette/middleware/cors.py:38-47`);预检因 `allow_credentials` 会回显 Origin。
|
||||
- 可利用性评估(不夸大):本项目认证走 `Authorization` 头而非 Cookie(全仓 `grep set_cookie` 无命中),浏览器不会自动附带凭证,因此**当前不构成"任意站点读取已登录用户数据"**;但任意站点可读取所有未鉴权接口(S8)的响应,且一旦将来引入 Cookie 会话即变为严重问题。修复见速览表 S9。
|
||||
|
||||
### S10 [medium] 任意 SQL 执行与用户代码执行(默认开启)
|
||||
|
||||
- `POST /api/v1/generator/gencode/create` 把请求体 `sql` 直接送 `execute_sql(text(sql))`:`generator/gencode/controller.py:76-83`、`generator/gencode/crud.py:333-342`。权限 `module_generator:gencode:create`。
|
||||
- 定时任务节点保存 `code_block` 并在调度器中 `exec(code_block, module.__dict__)`,仅由 `SCHEDULER_ALLOW_CODE_EXEC` 控制,**默认 True**(`setting.py:127`),env/部署模板均未覆盖(`backend/env/.env.prod` 无该键);相关校验见 `ap_scheduler.py:548-563`、`task/cronjob/node/service.py:284,371`。权限 `module_task:cronjob:job:task`。
|
||||
- 可利用性:拥有上述权限的账号可执行任意系统命令/任意 SQL(MySQL 下还可 `INTO OUTFILE` 写文件),属"设计内的高危能力"。若把这些权限授给非超管角色,实际后果等同服务器失陷。修复见速览表 S10。
|
||||
|
||||
### S11 [medium] 存储源 `local` 协议可读写服务器任意目录 / 远端协议 SSRF
|
||||
|
||||
- `LocalStorageAdapter.__init__` 把 `host` 当根目录,`_abs_path` 仅拒绝 `..`(`local_adapter.py:15-26`),因此 `host="/"` 或 `/etc` 即可浏览、下载、删除、重命名宿主机文件(`_sync_list/_sync_download/_sync_delete/_sync_rename`,`43-120`);路径以 `/` 开头会被 `lstrip("/")` 归到 root 下,但 root 本身可被配置为任意目录。
|
||||
- 节点创建/更新只需 `module_task:storage:node:create/update`(`storage/node/controller.py:81-101`);浏览接口 `storage/browse/controller.py:28-135` 用同一套配置。
|
||||
- FTP/SFTP/S3/OSS/COS/OBS 适配器接受用户填写的 `host:port`(`storage/node/model.py:14-16`),具备内网探测(SSRF)能力。
|
||||
- 缓解:存储源口令以 Fernet 加密存储且不回显(`storage/node/schema.py:138-149`),但该密钥派生自 S1 的公开 `SECRET_KEY` → 加密对读代码者无效。
|
||||
- 修复见速览表 S11。
|
||||
|
||||
### S12 [medium] 缓存监控可读任意 Redis 键 / 一键清库
|
||||
|
||||
- `get_monitor_cache_value` 拼接 `f"{cache_name}:{cache_key}"` 并直接 `get`(`monitor/cache/service.py:34-40`);`cache_name` 填 `user_session`、`cache_key` 填某 `session_id` 即可读出该用户会话 JSON(手机号/邮箱/权限/IP);也可读 `ai_model_config:items:{uid}`(`api_key` 密文,但 S1 使其可解)。
|
||||
- `clear_monitor_cache_all` 清空整个 DB(`service.py:52-57`),会一并删除全部会话与调度器 jobstore,等于强制全员下线 + 任务状态丢失。
|
||||
- 权限仅 `module_monitor:cache:query/delete`(`monitor/cache/controller.py:37-61`)。
|
||||
- 修复见速览表 S12。
|
||||
|
||||
### S13 [medium] 固定默认口令的导入路径 + 手机号登录的账号匹配风险
|
||||
|
||||
- 批量导入:新用户口令固定为 `PASSWORD_IMPORT_DEFAULT="123456"`(`setting.py:134`、`user/service.py:425`);`update_support=True` 时对已存在用户执行 `UserUpdateSchema(**user_data)` 更新,**同样把口令重置为 123456**(`user/service.py:428-437`)。权限 `module_system:user:import`。
|
||||
- 无"首次登录必须改密"机制(全仓无 `must_change_password` 类字段)。
|
||||
- `mobile` 仅有普通索引、非唯一(`user/model.py:71`),而导入路径不校验手机号唯一(只校验用户名,`user/service.py:428`)→ 可能出现两账号同手机号,微信手机号登录取 `.first()`(`wx_mini_service.py:302-318`),存在账号串号隐患。
|
||||
- 修复见速览表 S13。
|
||||
|
||||
### S14 [medium] 上传 SVG + 同源静态直出 → 存储型 XSS 面
|
||||
|
||||
- 允许扩展名含 `.svg`(`setting.py:213-222`);上传校验只对"危险扩展名"和允许清单做判断,内容类型不符仅 `logger.warning` 后 `return True`(`upload_util.py:215-234`);文件路径穿越有校验(`upload_util.py:410-411,437-456`),下载有 `is_relative_to` 校验(`common/file/service.py:46-58`)——**这两处实现是好的**。
|
||||
- 上传目录 `static/upload` 通过 `app.mount(settings.STATIC_URL, StaticFiles(...))` 同源直出(`app/__init__.py:89-92`)。
|
||||
- 浏览器直接打开 `/static/upload/.../*.svg` 会以 `image/svg+xml` 渲染并执行内嵌脚本。缓解现状:nginx 在 http 级下发 `X-Content-Type-Options: nosniff`、`Referrer-Policy: strict-origin-when-cross-origin`(`docker/nginx/nginx.conf:59-63`),可挡住"改成 `.png` 伪装 HTML 被嗅探"的变体,但 `image/svg+xml` 是声明类型本身,nosniff 不阻止其脚本执行;仓库内 `grep Content-Security-Policy` 无命中,且应用层(直连 8001 或开发环境)没有任何安全响应头(`grep -rn "X-Content-Type-Options" backend/app` 无命中)。前端令牌存 localStorage(`frontend/web/src/utils/auth/index.ts:24-50`),一旦同源 XSS 成立即可窃取令牌(影响 S1 链 B 的令牌豁免)。
|
||||
- 前置:攻击者需具备 `module_common:file:upload` 权限并诱导受害者打开链接。
|
||||
- 修复见速览表 S14。
|
||||
|
||||
### S15 [medium] 工单富文本服务端未清洗
|
||||
|
||||
- 公告在入库前清洗(`notice/schema.py:10,30-33` 调 `sanitize_html`,白名单见 `utils/xss_util.py:99-118`)。
|
||||
- 工单模块全目录 `grep sanitize` 无命中(`app/modules/system/ticket/`),仅前端渲染时用 DOMPurify(`frontend/web/src/views/module_system/ticket/index.vue:403,865`)。服务端不设防意味着任何非 Web 渲染方(移动端、导出、日志回显、第三方集成)会直接执行内容中的脚本。
|
||||
- 修复见速览表 S15。
|
||||
|
||||
### S16–S23(low)
|
||||
|
||||
见 §1.3 速览表;其中 S18(默认 fail-open 的鉴权模型)是 S5/S8 的根因,建议优先以"启动期路由自检"的方式固化,避免后续新增接口再次漏加。
|
||||
|
||||
补充说明 S23(令牌过期语义):认证时按 `decode_access_token(token, verify_exp=not settings.TOKEN_SLIDING_EXPIRE)` 调用(`dependencies.py:105-106`),默认 `TOKEN_SLIDING_EXPIRE=True`(`setting.py:71`)→ PyJWT 跳过 `exp` 校验,访问令牌只要会话仍在 Redis 中就有效;而会话在每次请求时被续期(`dependencies.py:124-156`),仅由 `SESSION_MAX_LIFETIME_SECONDS`(7 天)封顶。因此"12 小时的 access_token 有效期"这一配置(`ACCESS_TOKEN_EXPIRE_SECONDS`)在默认配置下**不产生实际约束**,且无法单独吊销某个已签发令牌(`logout` 直接删除整个会话,`auth/service.py:531-533`)。<br>可利用性:拿到任一份令牌(日志、代理缓存、浏览器历史、S4 的 URL 泄漏)后,其可用窗口从"12 小时"变为"受害者会话剩余寿命",并可直接用公开密钥重签(S1)延长至任意时长。
|
||||
|
||||
---
|
||||
|
||||
## 3. 已核查、未发现问题的项(避免误判)
|
||||
|
||||
| 项 | 结论 | 证据 |
|
||||
|----|------|------|
|
||||
| SQL 注入(业务查询) | 未发现。查询条件全部经 SQLAlchemy 表达式构造;`search` 键来自 Pydantic schema 声明(`json_schema_extra={"q": ...}`),`order_by` 走 `getattr(model, field)` 属性访问 | `backend/app/core/base_crud.py:303-387`、`backend/app/core/base_schema.py:136-162` |
|
||||
| 代码生成器列表查询的 f-string SQL | **无注入**:`where_sql` 只拼接固定片段,变量一律用绑定参数(`:name_kw` 等) | `backend/app/modules/generator/gencode/crud.py:176-226` |
|
||||
| JWT 算法混淆(`alg=none`、RS/HS 混淆) | 未发现:`decode` 固定白名单算法为配置值 `HS256` | `backend/app/core/security.py:139` |
|
||||
| 刷新令牌重放 | 有防护:refresh token 轮换 + 不一致即撤销整个会话 | `backend/app/modules/system/auth/service.py:449-463` |
|
||||
| 登出 | 会话/双令牌均被删除(`logout` 需先通过认证) | `auth/service.py:522-537`、`auth/controller.py:82-90` |
|
||||
| 越权混用"用户端/管理端令牌" | 未发现双端令牌体系;权限完全由 RBAC 会话决定,令牌本身不含角色信息 | `backend/app/core/base_schema.py:99-121`、`dependencies.py:185-189` |
|
||||
| 垂直越权(普通用户访问管理接口) | 除 S8 所列公开接口与 S16 外,所有管理接口均带 `AuthPermission([...])`;`AuthPermission` 对无权限用户返回 403,`is_superuser` 才短路放行 | `backend/app/core/dependencies.py:206-234` |
|
||||
| `AuthPermission` 通配符 | `*` / `*:*:*` 才短路,未发现空串短路 | `dependencies.py:222-223` |
|
||||
| 水平越权(数据权限) | 已在 `base_crud._build_conditions` 统一注入 `Permission` 过滤,且 `created_id` 为空集合时降级为 `created_id == self.id` | `base_crud.py:321-325`、`permission.py:29-80` |
|
||||
| 模板注入(Jinja2 SSTI) | 未发现:Jinja 模板来自仓库内固定文件,渲染上下文是 DB 元数据(值不参与模板语法) | `backend/app/modules/generator/gencode/jinja2_template_util.py:150-264` |
|
||||
| 反序列化 | 仅 `pickle.loads` 用于读取 APScheduler 自身 jobstore 状态字节,非外部输入解码路径(不排除"能写 Redis 者"这一常规信任边界) | `backend/app/core/ap_scheduler.py:578,591` |
|
||||
| 上传路径穿越 / 任意文件下载 | 有防护:文件名清洗 + `resolve().is_relative_to(UPLOAD_FILE_PATH)` 双重校验;下载同样校验 | `backend/app/utils/upload_util.py:107-124,410-456`、`backend/app/modules/common/file/service.py:46-58` |
|
||||
| 操作日志敏感字段 | 有脱敏:请求体/响应体按敏感键名递归打码(含 `access_token`/`refresh_token`/`password`/`api_key` 等),文件对象不入库 | `backend/app/core/router_class.py:15-41,86-116` |
|
||||
| 异常响应信息泄漏 | 生产环境不外泄 `data`/DB 细节(`ENVIRONMENT == PROD` 判断) | `backend/app/core/exceptions.py:72-74,115-130` |
|
||||
| 存储源口令回显 | 不回显(`password` 字段 `exclude=True`,只给 `has_password`) | `backend/app/modules/task/storage/node/schema.py:138-149` |
|
||||
| 用户列表/详情泄漏口令哈希 | 输出模型不含 `password` | `backend/app/modules/system/user/schema.py:218-237` |
|
||||
| 注册/自助改资料的字段越权(mass assignment) | 未发现:`UserRegisterSchema` / `CurrentUserUpdateSchema` 均不含 `is_superuser`/`role_ids`/`status`,Pydantic 默认忽略多余字段 | `user/schema.py:25-33,159-183`、`user/controller.py:42-49,84-92` |
|
||||
| 忘记密码接口 | 安全:不区分账号是否存在、不直接改密 | `user/controller.py:74-81`、`user/service.py:297-306` |
|
||||
| 客户端 IP 伪造 | 实现是收敛的(仅在直连对端为可信代理时采信 `X-Real-IP`/右起取 XFF);风险在部署侧(见 §6) | `backend/app/utils/ip_local_util.py:24-60` |
|
||||
| 本地日志中的令牌 | 未发现 `Bearer`/JWT 落日志(`grep -c "Bearer " logs/*.log` = 0);但存在 S6 的口令哈希问题 | §7.2 |
|
||||
| 接口文档/静态资源鉴权 | 属于设计取舍(S7)而非"漏鉴权" | `app/__init__.py:95-123` |
|
||||
| 安全响应头(nginx 层) | 已下发 `X-Frame-Options: SAMEORIGIN`、`X-Content-Type-Options: nosniff`、`X-XSS-Protection`、`Referrer-Policy: strict-origin-when-cross-origin`;缺 HSTS/CSP(与 t6 的 M7 一致),应用层直连时无任何安全头 | `docker/nginx/nginx.conf:59-63`;`grep -rn "X-Content-Type-Options" backend/app` 无命中 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 最小修复路径(按投入产出排序)
|
||||
|
||||
**P0(当天可完成,阻断最高可利用性风险)**
|
||||
|
||||
1. `setting.py:66` 去掉 `SECRET_KEY` 默认值 → 改为必填并在启动期校验长度 ≥32;`env/.env.example` 增加 `SECRET_KEY=` 必填说明;现网轮换密钥并重新登录全体用户。
|
||||
2. 处理种子口令:删除 `sql/sys_user.json` 的固定哈希或改为启动时随机生成 + 一次性输出;README 去除明文口令(S2)。
|
||||
3. 部署侧落实 `docker/audit-deploy.md` 的 B1/B3(不要在镜像层烤入 `.env.prod`)与 P0 同步执行。
|
||||
4. 关闭可达的公网调试面:生产不注册 `/docs`/`/redoc`,`openapi_url=None`(S7);`SCHEDULER_ALLOW_CODE_EXEC=False`(S10)。
|
||||
|
||||
**P1(1–2 天,收敛授权与会话语义)**
|
||||
|
||||
5. `_authenticate` 增加 DB `status` 校验 + 会话比对(S3),并提供"按 user_id 清理会话"的服务方法,在停用/删除/改密/改角色处调用。
|
||||
6. S8 的 6 个公开接口按需补 `get_current_user`;`AuthPermission` 空列表直接拒绝(S16)。
|
||||
7. 新增启动期路由自检:遍历 `app.routes`,凡未含 `get_current_user`/白名单声明的路由打 warning 或直接失败(S18)。
|
||||
8. OAuth `redirect_uri` 白名单(S4),令牌改为一次性 code 换取(参考 S4 修复项 2)。
|
||||
|
||||
**P2(排期,纵深防御)**
|
||||
|
||||
9. 口令与会话策略:注册开关默认关 + 真实验证码(S5);导入随机口令 + 首登改密 + `mobile` 唯一约束(S13)。
|
||||
10. 敏感面收敛:缓存监控键名白名单(S12)、存储源 local 根目录与远端协议出站限制(S11)、上传去掉 `.svg` 并加 `nosniff`/CSP(S14)、工单入库清洗(S15)。
|
||||
11. 日志与配置卫生:`aiosqlite` 降噪、DEBUG 不落盘(S6);强制生产 CORS 显式域名(S9);清理无效的 `DEMO_ENABLE`、重命名 `WHITE_API_LIST_PATH`(S21/S17);轮换所有历史泄露凭据(S22)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 结论一致性说明
|
||||
|
||||
- 本报告与 `docker/audit-deploy.md` 的 S1/B1、S10/H2、S21(其 medium 列表中的 `DEMO_ENABLE`)指向同一批根因,视角不同(应用代码 vs 部署链路),修复时需要同时落地,避免"只改代码没改编排"(例如 `SECRET_KEY` 必须由编排注入)。
|
||||
- 本报告未包含对前端 Web 应用与 uniapp 移动端的完整审计(前端仅核查了令牌存储与锁屏密钥两处与认证/敏感信息直接相关的点,S14/S20)。若需要覆盖 XSS/CSRF/依赖漏洞等前端面,应由前端审计任务单独产出。
|
||||
|
||||
---
|
||||
|
||||
## 6. 待验证清单(附验证方法)
|
||||
|
||||
| 编号 | 待验证问题 | 验证方法(只读、不改代码、不对外发包) |
|
||||
|------|-----------|----------------------------------------|
|
||||
| V1 | `X-Forwarded-For` 伪造是否真能绕过登录限流(依赖部署编排 `FORWARDED_ALLOW_IPS=*`、后端 8001 是否对外) | 在测试环境按 `docker/docker-compose.yaml` 启动后,用 `curl -H 'X-Forwarded-For: 1.2.3.4'` 连续请求 `/api/v1/system/auth/login`,观察是否出现"登录尝试过于频繁";同时确认 8001 未被 nginx 屏蔽(t6 H2 已给静态证据,此条为动态确认) |
|
||||
| V2 | 默认部署下 `GET /api/v1/param/info`、`/api/v1/openapi.json`、`/api/v1/docs` 是否真能匿名访问 | 启动后 `curl -i http://127.0.0.1:8001/api/v1/openapi.json`(不带 Authorization);再经 nginx `curl -i https://<host>/api/v1/openapi.json` |
|
||||
| V3 | OAuth 重定向链在"已配置渠道密钥"时的实际行为(本地默认未配置渠道密钥,无法端到端复现) | 在测试环境填入测试用 GitHub OAuth App(`OAUTH_GITHUB_CLIENT_ID/SECRET`),用 `redirect_uri=https://example.com/cb` 走完授权,检查 302 Location 是否携带 `access_token` |
|
||||
| V4 | 停用用户后旧会话是否仍可用(S3 的动态确认) | 用 A 账号登录取 token → 管理员停用 A → 用同一 token 请求 `/api/v1/system/user/current/info`,观察是否仍 200 |
|
||||
| V5 | `mobile` 是否存在重复数据(影响手机号登录串号) | 只读 SQL:`SELECT mobile, COUNT(*) FROM sys_user WHERE mobile IS NOT NULL GROUP BY mobile HAVING COUNT(*)>1;` |
|
||||
| V6 | `backend/env/.env.dev|.env.prod` 中的真实凭据是否曾被推送到远端(本地 git 历史命中 `FastApi123abc`,但未确认远端是否可达) | `git log --all -S "FastApi123abc" --oneline`(已确认本地历史命中)、`git remote -v` 与远端仓库可见性核查;无论结果如何,按 S22 直接轮换 |
|
||||
| V7 | 生产环境 `PROD_CORS_ORIGINS` 是否实际配置(代码默认回落到 `["*"]`) | 在目标环境执行 `python -c` 加载 `settings` 打印 `ALLOW_ORIGINS`;或查看部署用的 `.env`(`docker/.env` 未入库,需在服务器上核对) |
|
||||
| V8 | 定时任务 `exec` 能力在生产是否真的开放 | 在目标环境读取 `SCHEDULER_ALLOW_CODE_EXEC` 生效值(`python -c "from app.config.setting import settings; print(settings.SCHEDULER_ALLOW_CODE_EXEC)"`) |
|
||||
| V9 | 缓存监控接口能否读到他人会话(需一个具备 `module_monitor:cache:query` 的测试账号) | 测试环境:先 `GET /api/v1/monitor/online/list` 取 `session_id`,再 `GET /api/v1/monitor/cache/get/value/user_session/<sid>`,观察是否返回会话 JSON |
|
||||
|
||||
---
|
||||
|
||||
## 7. 附录:关键命令与输出
|
||||
|
||||
### 7.1 种子口令哈希 → 明文 `123456`(只读本地复算)
|
||||
|
||||
```
|
||||
$ python3 - <<'EOF'
|
||||
import hashlib, base64, hmac
|
||||
salt = base64.b64decode("XX20aO1v73xS0JnoewXNtw==")
|
||||
expected = base64.b64decode("PEaVHV1N5L7PfYQw2lCAQOc4hAEyCiwsGR48/jgVBjU=")
|
||||
for c in ["123456","admin123","Admin123", ...]:
|
||||
if hmac.compare_digest(hashlib.pbkdf2_hmac('sha256', c.encode(), salt, 600000), expected):
|
||||
print("MATCH:", c)
|
||||
EOF
|
||||
MATCH: 123456
|
||||
```
|
||||
|
||||
迭代次数/编码方式与实现一致:`backend/app/utils/password_util.py:12-14,25-29,42-59`。
|
||||
|
||||
### 7.2 日志中的口令哈希(本机只读 grep)
|
||||
|
||||
```
|
||||
$ grep -rc "pbkdf2-sha256" backend/logs/fastapiadmin.log
|
||||
24
|
||||
$ sed -n '8407p' backend/logs/fastapiadmin.log | grep -o "pbkdf2-sha256\$[0-9]*\$[^']*"
|
||||
pbkdf2-sha256$600000$XX20aO1v73xS0JnoewXNtw==$PEaVHV1N5L7PfYQw2lCAQOc4h
|
||||
$ grep -rc "Bearer " backend/logs/*.log
|
||||
backend/logs/fastapiadmin.log:0 # 未发现令牌落日志
|
||||
```
|
||||
|
||||
### 7.3 git 历史中的真实凭据(只读)
|
||||
|
||||
```
|
||||
$ git log --all --oneline -S "FastApi123abc"
|
||||
2ba5b321 fix: 统一数据库和Redis的密码为小写格式
|
||||
37d4dade feat(nginx): 更新SSL证书路径配置
|
||||
c178b996 chore: 删除前后端环境配置文件
|
||||
...
|
||||
$ git show 2ba5b321 | grep -n "PASSWORD"
|
||||
- MYSQL_ROOT_PASSWORD: "FastApi123abc"
|
||||
- MYSQL_PASSWORD: "FastApi123abc"
|
||||
- DATABASE_PASSWORD: "FastApi123abc"
|
||||
- REDIS_PASSWORD: "FastApi123abc"
|
||||
```
|
||||
|
||||
`git ls-files` 显示:`backend/env/.env.dev`、`backend/env/.env.prod` 已从索引移除(当前只保留 `.env.example`),但历史提交仍可检出(S22);另需注意 `frontend/web/.env` **仍在跟踪中**(S20),即便 `.gitignore` 已列入该路径。
|
||||
|
||||
### 7.4 环境配置文件未注入 `SECRET_KEY`
|
||||
|
||||
```
|
||||
$ grep -n "SECRET_KEY" backend/env/.env.dev backend/env/.env.prod backend/env/.env.example
|
||||
(backend/env/.env.dev、.env.prod 无输出;.env.example 的注释里提到需修改 SECRET_KEY,但无该行)
|
||||
```
|
||||
`Settings.model_config.env_file = ENV_DIR / f".env.{ENVIRONMENT}"`(`backend/app/config/setting.py:16-21`)→ 因此实际生效值就是 `setting.py:66` 的默认字符串。
|
||||
|
||||
### 7.5 未鉴权路由的提取方式(只读静态解析)
|
||||
|
||||
对 `backend/app/**/controller.py` 的每个 `@XRouter.get|post|put|delete|patch` 装饰器块(装饰器 + 函数签名)做文本扫描,凡块内不含 `AuthPermission` 与 `get_current_user` 的即判为"无鉴权依赖",得到 §1.1 S5 / §3 S8 的清单;同时用 `grep -rn "Security(AuthPermission" ` 交叉核对(189 处命中,覆盖 24 个 controller)。该方法只做静态判断,不含运行时行为(动态确认见 §6 V2)。
|
||||
|
||||
---
|
||||
|
||||
*报告结束。本报告为只读审计产出,未修改任何业务代码;仓库唯一新增文件即本报告。*
|
||||
@@ -0,0 +1,515 @@
|
||||
# FastapiAdmin 测试与可验证性审计(t3 / 只读审计)
|
||||
|
||||
- 审计人:测试工程师(team `fastapiadmin-audit`,task `t3`)
|
||||
- 工作区:`/Users/tao/workspace/FastapiAdmin`
|
||||
- 审计范围:`backend/tests`、`backend/pyproject.toml` 的 pytest 配置、`frontend/web` 的 vitest 与用例、`frontend/app`(uniapp)测试能力、仓库级 CI 配置
|
||||
- 本轮约束:只读,除本报告外未修改/删除任何仓库文件;未安装依赖、未启服务、未提交 git
|
||||
|
||||
---
|
||||
|
||||
## 0. 结论摘要(TL;DR)
|
||||
|
||||
| 维度 | 现状 | 结论 |
|
||||
| --- | --- | --- |
|
||||
| 后端 pytest 用例数 | **2 个**(收集 2 / 通过 2 / 失败 0 / 跳过 0 / 退出码 0) | 能跑通,但等于没有回归网 |
|
||||
| 后端被测模块 | 216 个 `.py`、28519 行 → 仅 `/monitor/health/check/` 1 个接口被断言 | 209 个 OpenAPI 操作里 1 个有断言 |
|
||||
| 历史测试规模 | 2026-07-17(commit `158c14a4`)前仓库内 9 个测试文件共 **238 个 test 函数**;`.pytest_cache` 记录过 38 个文件 / **761 个 node id** | 测试能力被"重构"清空,无替代物 |
|
||||
| 断言有效性 | 唯一的行为测试断言的是恒为 `True/0` 的字段 | 该用例无法失败 |
|
||||
| 夹具质量 | 手写 AsyncMock Redis(未用已声明的 fakeredis)+ 模块级 `patch().start()` + 无逐用例隔离 | 夹具本身会掩盖缺陷 |
|
||||
| 迁移测试 | 只覆盖 SQLite 下 `create_all ≡ metadata`;`app/alembic/versions/` **没有任何迁移文件** | `alembic upgrade` 路径零覆盖 |
|
||||
| 前端 web vitest | **有真实可运行用例**:2 文件 / 10 用例 / 全通过 / 退出码 0 | 能力存在,但未进门禁 |
|
||||
| uniapp | 无 vitest/jest、无 `test` script、0 个 spec | 测试能力为零 |
|
||||
| CI | 仓库根 `.github/` 为空且未跟踪(`git ls-files .github` = 0);`frontend/web/.github`、`frontend/app/.github` 不在仓库根 → **GitHub 不会运行任何 workflow** | 零门禁 |
|
||||
|
||||
一句话:**后端"可运行但无验证力",前端 web 有少量真用例却无一环节强制运行,uniapp 与 CI 是空白。当前任何回归都只能靠人工点测发现。**
|
||||
|
||||
---
|
||||
|
||||
## 1. 命令与真实输出
|
||||
|
||||
### 1.1 后端:`cd backend && uv run pytest -q`
|
||||
|
||||
环境:`uv 0.12.10`(/opt/homebrew/bin/uv),Python 3.12.12,pytest 9.0.2,pytest-asyncio 1.4.0,anyio 4.12.1,`backend/.venv` 已存在。
|
||||
|
||||
**第一次(原样执行,未改任何环境变量)**:
|
||||
|
||||
```text
|
||||
$ cd backend && uv run pytest -q
|
||||
error: Failed to initialize cache at `/Users/tao/.cache/uv`
|
||||
Caused by: failed to open file `/Users/tao/.cache/uv/sdists-v9/.git`: Operation not permitted (os error 1)
|
||||
EXIT=2
|
||||
```
|
||||
|
||||
→ 失败原因是 `uv` 需要写 home 下的缓存目录(`~/.cache/uv`),在该沙箱内不可写,**与项目代码无关**。
|
||||
|
||||
**第二次(把 uv 缓存重定向到 /tmp,同一命令语义)**:
|
||||
|
||||
```text
|
||||
$ cd backend && UV_CACHE_DIR=/tmp/uv-cache-test uv run pytest -q
|
||||
.. [100%]
|
||||
2 passed in 1.80s
|
||||
EXIT=0
|
||||
```
|
||||
|
||||
**详细模式(收集清单 + 结果)**:
|
||||
|
||||
```text
|
||||
$ cd backend && UV_CACHE_DIR=/tmp/uv-cache-test uv run pytest -v
|
||||
============================= test session starts ==============================
|
||||
platform darwin -- Python 3.12.12, pytest-9.0.2, pluggy-1.6.0 -- .../backend/.venv/bin/python3
|
||||
cachedir: .pytest_cache
|
||||
rootdir: /Users/tao/workspace/FastapiAdmin/backend
|
||||
configfile: pyproject.toml
|
||||
plugins: anyio-4.12.1, asyncio-1.4.0
|
||||
asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=function, ...
|
||||
collecting ... collected 2 items
|
||||
|
||||
tests/test_main.py::test_check_health PASSED [ 50%]
|
||||
tests/test_migrations.py::test_bootstrap_matches_metadata PASSED [100%]
|
||||
|
||||
============================== 2 passed in 2.02s ===============================
|
||||
EXIT=0
|
||||
```
|
||||
|
||||
**真实结果汇总**:收集 2、通过 2、失败 0、跳过 0、错误 0、退出码 0、耗时 ≈1.8–2.0s。
|
||||
|
||||
> 后续所有复现(探针脚本)改用 `backend/.venv/bin/python` 与带 `--no-sync` 的形式,避免 `uv` 再次触碰锁文件(见 §7 环境说明)。
|
||||
|
||||
### 1.2 前端 web:vitest
|
||||
|
||||
`frontend/web` 的 `node_modules` 已就绪(vitest 4.1.7)。
|
||||
|
||||
```text
|
||||
$ cd frontend/web && ./node_modules/.bin/vitest run
|
||||
|
||||
RUN v4.1.7 /Users/tao/workspace/FastapiAdmin/frontend/web
|
||||
|
||||
✓ src/__tests__/smoke.spec.ts (3 tests) 24ms
|
||||
stdout | src/__tests__/route-invariants.spec.ts > 静态路由 — 中间层不挂组件(深度跳级的前提) > ...
|
||||
[i18n] 使用默认语言: zh
|
||||
|
||||
✓ src/__tests__/route-invariants.spec.ts (7 tests) 111ms
|
||||
|
||||
Test Files 2 passed (2)
|
||||
Tests 10 passed (10)
|
||||
Duration 1.05s (transform 126ms, setup 0ms, import 91ms, tests 135ms, environment 1.36s)
|
||||
EXIT=0
|
||||
```
|
||||
|
||||
**真实结果汇总**:2 个测试文件、10 个用例、全部通过、退出码 0。
|
||||
|
||||
### 1.3 未执行的项目(说明原因,避免"看起来跑过了")
|
||||
|
||||
| 项目 | 原因 |
|
||||
| --- | --- |
|
||||
| `pnpm test`(web) | `pnpm` 走 npm 源/网络,本轮禁止安装依赖;已直接用 `node_modules/.bin/vitest` |
|
||||
| uniapp 任何测试 | 无测试脚本、无测试依赖、无用例,无可跑对象 |
|
||||
| `pytest --cov` | `pytest-cov` / `coverage` 未安装(`import pytest_cov` → `ModuleNotFoundError`),且本轮不装依赖 → **无法给出行覆盖率数字**,报告中的"覆盖缺口"由路由表、模块清单与代码路径证据推导 |
|
||||
| 集成/端到端、压测 | 本轮不启服务、不连真实 MySQL/Redis |
|
||||
|
||||
---
|
||||
|
||||
## 2. 测试资产现状与夹具审计
|
||||
|
||||
### 2.1 分层:名义三层,实际一层
|
||||
|
||||
| 层 | 目录/文件 | 实际内容 |
|
||||
| --- | --- | --- |
|
||||
| 单元测试 | — | **不存在**(历史上有 `tests/test_unit.py` 47 个用例,已不在仓库) |
|
||||
| 集成/API 测试 | `tests/test_main.py` | 1 个健康检查断言 |
|
||||
| 迁移守卫 | `tests/test_migrations.py` | 1 个 `create_all ≡ metadata`(SQLite)断言 |
|
||||
| 共享夹具 | `tests/conftest.py`(277 行) | session 级 TestClient + 手写 Redis mock + 自建 lifespan |
|
||||
|
||||
`conftest.py` 自己声明的能力(第 1–5 行)是"模块化 API 接口测试共享 fixture",但仓库里已经**没有任何调用 `assert_route` 的用例**(`grep -rn assert_route backend/tests` 只命中定义处),也就是这套夹具的消费者全部被删除了。
|
||||
|
||||
### 2.2 `conftest.py` 逐项问题(带行号)
|
||||
|
||||
1. **导入期副作用**(L26–L46):在 import 时创建临时 SQLite 文件并改写 `os.environ` 与全局 `settings`;甚至用 `sys.path.insert` 兜底(L17)。任何新测试文件只要 import `conftest`,全进程配置即被改写。
|
||||
2. **临时库即业务库、用完不删**(L26):`tempfile.NamedTemporaryFile(suffix=".db", delete=False).name`,每次 session 生成新文件、从不清理;`/tmp` 会持续积累。
|
||||
3. **无逐用例隔离**:`_api_client` 是 session 级 TestClient(L204–208),`auth_headers` 是 session 级(L223–232),全程共享同一个 app、同一个 SQLite 库、同一个 token。没有任何 per-test 事务回滚/清库 → **用例顺序会互相影响**,且无法并行(`-n`)。
|
||||
4. **测试专用 lifespan 覆盖真实 lifespan**(L173–197):只跑 `init_db()` + 注入 mock redis,并用 `UPDATE` 把 admin 密码重置成 `admin123`。真实启动路径中的 `init_agno_tables`、`redis_connect`、`ParamsService.init_cache`、`DictDataService.init_cache`、`SchedulerUtil.init_scheduler` 全部被绕过 → 启动期回归完全无覆盖;同时"改业务表数据"这个动作本身是测试污染。
|
||||
5. **用手写 AsyncMock 顶替 Redis,而 `fakeredis` 才是已声明依赖**(L53–161):`pyproject.toml` dev 组声明了 `fakeredis>=2.21.0,<3`,但全仓库除该行外**没有任何 `import fakeredis`**。手写 mock 的语义与真实 Redis 不一致:
|
||||
- `_redis_ttl` 恒返回 3600 或 -2(L85–86)→ TTL/续期逻辑无法验证;
|
||||
- `_redis_expire` 恒返回"键是否存在"且不改 TTL(L89–90)→ 过期语义假通过;
|
||||
- `_redis_keys` 只做前缀切割,不支持 `user:*:x` 等通配(L75–78);
|
||||
- **缺失被业务代码使用的方法**:`incr`、`eval`、`mget`、`scan_iter`、`publish`、`pubsub`、`call`(`grep -rhoE '\bredis\.[a-z_]+\(' app` 的结果与 mock 白名单对比)。
|
||||
后果 1:`LoginService._check_login_rate_limit`(`auth/service.py:155–173`)用 `redis.incr` → 拿到 `MagicMock`,`count > settings.LOGIN_RATE_LIMIT_MAX_ATTEMPTS` 抛 `TypeError` → 被 `except Exception` 吞掉并仅 warning → **登录限流在测试环境静默失效**。
|
||||
后果 2:`RedisCURD.compare_and_set` / `unlock` 依赖 `redis.eval`(`redis_crud.py:170、223`)→ `result == 1` 恒 False → **会话续期的 CAS 路径在测试里永远走失败分支**。
|
||||
6. **模块级 `patch(...).start()` 永不 stop**(L161–164):`redis.asyncio.Redis.from_url`、`SchedulerUtil.init_scheduler/shutdown` 被进程级替换,跨测试文件持续生效,且失败时不留痕迹。
|
||||
7. **`assert_route` 把异常当通过**(L268–277):
|
||||
|
||||
```python
|
||||
try:
|
||||
response = test_client.request(method, path, **kwargs)
|
||||
except Exception:
|
||||
# 后端代码异常(500 等),路由存在即不计为测试失败
|
||||
return
|
||||
...
|
||||
assert response.status_code != 404, ...
|
||||
```
|
||||
|
||||
→ 5xx/未捕获异常被计为"路由存在",**接口挂掉也能绿**;默认断言只查 `!= 404`。当前无调用者,但它是后续补测试时最容易误用的"绿色陷阱"。
|
||||
8. **完全没有异步夹具**:`pyproject.toml` 配了 `asyncio_mode = "auto"` + `asyncio_default_fixture_loop_scope = "function"`,但 `tests/` 内 `async def test_` / `async def fixture` 数量为 **0**。新增异步 DB 用例时若直接复用应用全局 `async_engine`(session 级 TestClient 在独立线程/事件循环中运行),会踩"连接绑定到另一个事件循环"的坑——现有夹具没有为这件事提供任何支撑(缺 async session/事务夹具)。
|
||||
9. **pytest 配置过薄**(`pyproject.toml:155–157`):无 `markers`、无 `--strict-markers`、无 `filterwarnings = error`、无 `timeout`、无覆盖率插件与阈值、无 `-p no:randomly`(无随机序,掩盖顺序依赖)。
|
||||
|
||||
### 2.3 是否依赖真实 MySQL / Redis
|
||||
|
||||
- **不依赖**:`conftest` 强制 `DATABASE_TYPE=sqlite` + 临时库文件,Redis 用 AsyncMock 顶替(L28–32、L141–178)。
|
||||
- **代价**:
|
||||
- 生产主用 MySQL/Postgres,但测试只在 SQLite 上跑 → 方言相关代码(`information_schema` / `pg_catalog` 查询、`ON UPDATE CURRENT_TIMESTAMP`、`UNSIGNED`、`COLLATE`、JSON 列、`server_default` 渲染)**零覆盖**;
|
||||
- `app/modules/generator/gencode/crud.py:178–243` 的 MySQL/Postgres 物理表分页 SQL 分支只在真实方言下成立,测试永不会执行;
|
||||
- 真实 Redis 的 Lua 脚本(`eval`)、`incr`、SCAN 语义无覆盖(见 §2.2.5)。
|
||||
|
||||
### 2.4 迁移测试的有效性
|
||||
|
||||
`tests/test_migrations.py` 两个守卫确实有意义(守卫一:布局阶段 `app/alembic/versions/` 不得新增文件;守卫二:显式 `autogenerate` 必须零产出),但存在三个边界:
|
||||
|
||||
1. **`app/alembic/versions/` 里只有 `__init__.py`,没有任何迁移文件**。也就是说项目声明的"双轨制"(新装走 `create_all` + `stamp head`,存量走 `alembic upgrade`)目前只剩单轨:`alembic upgrade head` 无版本可升。**迁移链本身的正确性(upgrade/downgrade、数据回填、`stamp` 基线)100% 无测试**,而 `app/scripts/initialize.py:61–123` 里对空 `alembic_version` 的处理(清空、提示、跳过 stamp)也只是被间接启动过一次。
|
||||
2. **只覆盖 SQLite 方言**:注释里已承认"MySQL 方言的 DDL 渲染差异由 dev 启动时的自动迁移承担"——而 dev 启动只发生在开发者本机,CI/无本地启动场景下无人守。
|
||||
3. **守卫二有写仓库副作用**:`command.revision(cfg, autogenerate=True)` 会把产物写进 `app/alembic/versions/` 再删除(L31–34)。这是本次唯一会写仓库的测试路径;若未来在只读检出或并行执行(`pytest -n`)中运行,会互相打架或留下残留文件。
|
||||
|
||||
### 2.5 前端 web 的 vitest:**真有可运行用例** ✅
|
||||
|
||||
- 配置:`frontend/web/vitest.config.ts`,`environment: jsdom`、`globals: true`、`include: ["src/**/*.{test,spec}.{ts,js}"]`,并同步了 `__APP_INFO__` 与 `@/` 等别名(配置质量不错)。
|
||||
- 用例 1 `src/__tests__/smoke.spec.ts`(3 例):断言 `MenuTypeEnum` 四个取值、代码生成枚举模块可导入。属于**冒烟级别**,价值低但零成本。
|
||||
- 用例 2 `src/__tests__/route-invariants.spec.ts`(7 例):把"有 children 的路由记录不得挂 component"(Vue Router 深度跳级 + 单层 KeepAlive 的前提)固化成断言,并校验后端 `backend/sql/sys_menu.json` 中目录节点不得配 `component_path`。**这是全仓库质量最高的一组测试**(带历史回归说明、有明确失败信息)。
|
||||
- 缺口:
|
||||
- `package.json` 有 `"test": "vitest run"`、`SKILL.md` 也写了 `pnpm test`,但**没有任何环节会执行它**(CI 不跑、husky 的 `pre-commit` 只跑 `lint-staged`);
|
||||
- **无覆盖率能力**:未安装 `@vitest/coverage-v8` 等 provider(`node_modules/@vitest/` 不存在),`vitest --coverage` 无法产出数据;
|
||||
- `@vue/test-utils`、`jsdom` 已安装,但 `src/` 中**没有任何组件挂载测试**(`grep -rn "@vue/test-utils" src/` 无命中)→ 组件/交互层零覆盖;
|
||||
- `route-invariants.spec.ts` 依赖仓库相对路径读取后端 `sql/sys_menu.json`(三个候选路径兜底)→ 前端用例与后端数据文件形成隐式耦合,脱离仓库根目录单独签出 web 会失败(首例带清晰报错,属可接受的取舍,但需要写进交接文档)。
|
||||
|
||||
### 2.6 uniapp(`frontend/app`):测试能力为零
|
||||
|
||||
- `package.json` 无 `test` script,无 vitest/jest/@vue/test-utils(只有 `type-check`、`lint`)。
|
||||
- 全目录 0 个 `*.spec.*` / `*.test.*` 文件。
|
||||
- 自带的 `.github/workflows/ci.yml` 里的三步"测试"是:
|
||||
|
||||
```yaml
|
||||
- name: Test H5 dev mode
|
||||
shell: bash
|
||||
run: |
|
||||
timeout 30s pnpm dev:h5 || true
|
||||
echo "✅ H5 dev mode test completed"
|
||||
```
|
||||
|
||||
`|| true` + 仅打印 ✅ → **无论开发服务器是否启动成功都返回成功**,不构成任何验证(且 `windows-latest` + `shell: bash` 下 `timeout` 行为同样可疑)。
|
||||
|
||||
### 2.7 CI 覆盖:仓库级 **0**
|
||||
|
||||
- 仓库根 `.github/workflows/` **是空目录**,且 `git ls-files .github` 返回 0 → 未被跟踪。GitHub Actions **不会在本仓库运行任何 workflow**。
|
||||
- `frontend/web/.github/workflows/ci.yml`:内容认真(`pnpm install --frozen-lockfile` → `type-check` → `lint` → `build:dev`),但 (a) 目录不在仓库根,不会被触发;(b) 即使被触发,也**只跑 type-check/lint/build,不跑 `vitest`**。
|
||||
- `frontend/app/.github/workflows/ci.yml`:同上,且"测试"步骤是 `|| true` 空跑。
|
||||
- 后端:**没有任何 workflow**,`pytest` 全靠人手敲。
|
||||
- `deploy.sh` / `deploy-artifacts.sh` 只做打包与发布,不跑测试(仅在排除列表里提到 `.pytest_cache`)。
|
||||
- 仓库的成文约定与实现不符:`SKILL.md:57/67/121` 要求"后端 `uv run pytest`;前端 `pnpm ts:check`、`pnpm lint`、`pnpm test`",实际无任何自动执行点。
|
||||
|
||||
---
|
||||
|
||||
## 3. 覆盖缺口清单(含证据)
|
||||
|
||||
### 3.1 宏观证据
|
||||
|
||||
| 证据 | 数值 |
|
||||
| --- | --- |
|
||||
| 后端 Python 文件 / 行数(`backend/app`) | 216 个 / 28519 行 |
|
||||
| OpenAPI 路径 / 操作 | 205 / 209(`app.openapi()` 实测;GET 79、POST 63、PUT 26、DELETE 30、PATCH 11) |
|
||||
| `AuthPermission(...)` 调用 | 187 处(另有类定义等共 212 行出现该标识符) |
|
||||
| 现有 pytest 用例 | 2 |
|
||||
| 有断言的接口 | 1(`/monitor/health/check/`) |
|
||||
| 历史用例数(`158c14a4^`,2026-07-17) | 238(`test_api_module_system.py` 92、`platform` 54、`task` 39、`monitor` 19、`generator` 12、`example` 9、`ai` 6、`common` 5、`test_main` 2) |
|
||||
| 历史 node id(`.pytest_cache/v/cache/nodeids`) | 761 个 / 38 个文件(`test_codegen.py` 99、`test_api_module_system.py` 95、`test_api_module_platform.py` 90、`test_api_system.py` 72、`test_api_module_plugin.py` 58、`test_unit.py` 47、`test_permission_logic.py` 28、`test_integration.py` 20、`test_data_scope.py` 5 …) |
|
||||
| `.pytest_cache/v/cache/lastfailed`(2026-09-06) | 120 个失败 id(含 `test_permission_logic.py` 21、`test_api_module_system.py` 21、`test_integration.py` 20) |
|
||||
| 删除轨迹 | `158c14a4`(2026-07-17) 删 7 个模块化 API 测试文件;`d470c7eb`(2026-09-06) 删 `test_api_module_system.py`;`tests/__pycache__` 还残留 `test_sse_once/test_api_system/test_routes/test_architecture` 的 `.pyc` |
|
||||
|
||||
> 结论:**测试不是"从未有",而是被两次重构清空且没有替代物**;`.pytest_cache` 显示本地曾存在 `test_codegen.py`(99)、`test_permission_logic.py`(28)、`test_data_scope.py`(5) 等成套用例,且当时已有 120 条失败——这些用例本身也需要重建而非"恢复"。
|
||||
|
||||
### 3.2 按模块的缺口(附只读探针实测)
|
||||
|
||||
探针脚本:`/tmp/probe_tests.py`(临时目录,不落仓库;`SECRET_KEY`/SQLite 环境变量隔离,不连真实 MySQL/Redis)。实测输出节选:
|
||||
|
||||
```text
|
||||
resolve_condition('like','a') : OK -> ['probe_tbl.id LIKE :id_1']
|
||||
resolve_condition('in', []) : OK -> ['false'] # 空集合 → 恒假,合理
|
||||
resolve_condition('between',[1]) : OK -> [] # 长度≠2 → 静默丢弃条件
|
||||
resolve_condition('date','2026-10-04') : OK -> ['probe_tbl.id >= :id_1', 'probe_tbl.id < :id_1']
|
||||
resolve_condition('month','2026-12') : OK -> ['probe_tbl.id >= :id_1', 'probe_tbl.id < :id_1']
|
||||
resolve_condition('date','not-a-date') : ValueError: time data 'not-a-date' does not match format '%Y-%m-%d'
|
||||
resolve_condition('unknown_op','x') : OK -> [] # 未知算子 → 无 WHERE,结果被静默放大
|
||||
build_conditions unknown key : AttributeError: type object 'ProbeModel' has no attribute 'no_such_col'
|
||||
JWT tampered 'a.b.c' : CustomException: 无效认证,请重新登录
|
||||
JWT empty : CustomException: 认证不存在,请重新登录
|
||||
PwdUtil.hash/verify : OK -> True / verify wrong -> False
|
||||
camel/snake 转换、bytes2human : OK -> 'UserName' / 'user_name' / '1.0GB'
|
||||
get_child_recursion(1, {1:[2],2:[1]}) : RecursionError: maximum recursion depth exceeded # 无环检测
|
||||
```
|
||||
|
||||
| 模块 | 关键代码 | 缺口与风险(有据) |
|
||||
| --- | --- | --- |
|
||||
| 认证 entry | `app/core/dependencies.py:43–189`(`get_current_user` / `_authenticate`) | **11 条 401 抛出点(`grep -c "status_code=401"` = 11)全部无测试**:空 token、`Bearer` 后为空、`is_refresh` 令牌、缺 `session_id`、session 不存在、session 数据不完整、超最大存活时长、缺 `user_name`、`user_status==1` 停用、缺 `user_id`、每请求查库确认用户存在且未删除;滑动续期(TTL==-1 兜底、`SESSION_MAX_LIFETIME_SECONDS` 绝对上限、双键同时续期)同样无测试 |
|
||||
| RBAC 接口权限 | `app/core/dependencies.py:192–234`(`AuthPermission`) | 超管旁路(L216)、`permissions` 为空即放行(L219–220)、`*` / `*:*:*` 通配放行(L222)三条"放行"逻辑与"无权限 403"分支均无测试。187 处调用 ↔ 0 个用例;任何一处语义翻转都会静默放开或锁死全部接口 |
|
||||
| 数据权限 | `app/core/permission.py` 全文;`app/utils/common_util.py:162–196` | 3 种 `data_scope`(SELF/DEPT_AND_CHILD/ALL)、`created_by.dept_id` 关系判定、`UserModel` 特例、`hasattr(model,"created_id")` 缺失模型、部门树异常时"降级为本部门"(L109–117)全部无测试;`get_child_recursion` 对 `parent_id` 成环会 `RecursionError`(探针实测),当前被 `except Exception` 兜成降级+warning → **权限范围静默收窄且只留一条 warning** |
|
||||
| 登录/防爆破 | `auth/service.py:122–270`(`LoginService`)、`:540–649`(`CaptchaService`) | 用户不存在 / 密码错 / 停用 三类失败与登录日志写入、限流窗口、验证码开关、`ENVIRONMENT != PROD` 时的 docs Referer 豁免均无测试。两处已可见问题:① 限流对 `127.0.0.1/unknown/localhost` 直接 return(L161–162)→ 反代/本机部署形同不限流;② "用户不存在"与"账号或密码错误"文案不同(L217/L231)→ 用户名枚举 oracle |
|
||||
| 会话与令牌 | `auth/service.py:349–539`(`create_token`/`refresh_token`/`logout`)、`core/redis_crud.py:147–245` | refresh 轮换、`compare_and_set` CAS 语义、`lock/unlock` Lua 原子性、`logout` 需同时清 `access_token:{sid}` / `refresh_token:{sid}` / `user_session:{sid}` 三键(`service.py:531–533`),全部无测试;且现有 Redis mock 缺失 `eval` → CAS 路径在测试中恒失败(§2.2.5) |
|
||||
| 分页/查询封装 | `core/base_crud.py:104–182`(`count/get_list/page`)、`:303–387`(`_build_conditions/_resolve_condition/_parse_order`)、`utils/common_util.py:87–126`(`search_to_dict`) | `page()` 的 `total/has_next/page_no(=offset//limit+1)/limit=0`、软删 `is_deleted` 自动过滤与 `include_deleted`、`load_columns`/`preload` 与 COUNT 拆分的等价性,全部无测试;异常一律被包装成 `CustomException("分页查询失败: ...")`(L181–182)→ 非法 `date`(ValueError)、未知排序字段(AttributeError)、未知 search key(AttributeError)都退化成同一条"分页失败",**无法区分 4xx 与 500**;未知算子静默返回空条件列表 → 查询范围被放大却无报错。`search_to_dict` 的 `_start/_end` 合并、`_time` 列表、`json_schema_extra={"q": ...}` 打包是所有列表接口过滤语义的唯一入口,无测试 |
|
||||
| 代码生成器 | `gencode/service.py:375–449`(`create_table`)、`gencode/crud.py:148–350`(系统表分页 / `execute_sql`)、`gencode/gen_util.py`(309 行纯函数)、`jinja2_template_util.py`(759 行模板) | ① DDL 白名单:`has_create = any(isinstance(s, Create))` + 禁用 `(Delete, Drop, Insert, TruncateTable, Update)`;sqlglot 的 `Create` 同时涵盖 `CREATE VIEW/INDEX/DATABASE`,**会被放行并执行**;`Alter` 用字符串包含判定(`"SET " not in upper` 等)脆弱(合法的 `ON DELETE SET NULL` 反被拒)。② 物理表列表查询按方言走 `information_schema`/`pg_catalog`(L178–243),SQLite 测试永不触达。③ `GenUtils.get_db_type/get_column_length/split_column_type/arrays_contains/replace_first/convert_class_name` 等纯函数边界(`tinyint(1)`→boolean、`int UNSIGNED`、`COLLATE`、`integer[]`、`timestamp without time zone`、`decimal(10,2)`、空括号)无测试。④ 模板渲染产物语法正确性无测试 |
|
||||
| 操作日志与脱敏 | `core/router_class.py:33–40`(`_redact_sensitive`)、`:59–134`(`OperationLogRoute`) | 敏感键递归脱敏(含请求体表单、响应体 → token 不得明文入库)、>2000 字符截断为"请求参数过长"、非 JSON 体兜底、后台写库失败仅告警,均无测试。这是"日志泄露 token/密码"的直接防线 |
|
||||
| 中间件/异常 | `core/middlewares.py`、`core/exceptions.py`、`common/response.py` | 限流、XSS、请求 ID、`CustomException → ResponseSchema` 映射(`success/code/msg/status_code` 契约)、`ErrorResponse` 语义无测试 |
|
||||
| SSE / WebSocket | `core/sse_manager.py`、`dependencies.py:52–87`(`get_websocket_token` 子协议解析、`websocket_authenticate`) | 无测试(`tests/__pycache__/test_sse_once.*.pyc` 说明曾经有过);WS 鉴权、子协议回显、`?token=` 兜底都属于易回归点 |
|
||||
| AI 会话 / 存储 / 任务 | `modules/ai/chat`(agno)、`modules/task/storage/*`(S3/OBS/OSS/COS/SFTP)、`modules/task/cronjob`、`core/ap_scheduler.py`(609 行) | 0 测试;外部 SDK(`boto3`/`esdk-obs-python`/`alibabacloud-oss-v2`/`cos-python-sdk-v5`/`paramiko`)全部无 mock 边界测试 |
|
||||
| OAuth | `auth/oauth_service.py`(479 行,github/gitee/wechat/qq 换 token 与取 profile) | 0 测试(需 HTTP mock);state 校验与前端跳转兜底无测试 |
|
||||
| 迁移/schema | `tests/test_migrations.py`、`app/scripts/initialize.py:50–123` | 见 §2.4:无迁移文件、仅 SQLite、无 upgrade/downgrade 链路测试 |
|
||||
| 前端 web | `frontend/web/src/**` | 覆盖仅限枚举与路由结构;`store`(pinia)、`utils`(request/axios 拦截器、storage、crypto)、`router` 动态路由加载(`route-loader.ts`)、权限指令、组件均无测试;无组件挂载测试 |
|
||||
| uniapp | `frontend/app/src/**` | 0 测试、0 测试基建 |
|
||||
|
||||
### 3.3 断言有效性问题(最容易被忽略的一条)
|
||||
|
||||
唯一的行为测试 `tests/test_main.py`:
|
||||
|
||||
```python
|
||||
response = test_client.get("/monitor/health/check/")
|
||||
assert response.status_code == 200
|
||||
assert body["success"] is True
|
||||
assert body["code"] == 0
|
||||
```
|
||||
|
||||
而 `app/modules/monitor/health/controller.py`:
|
||||
|
||||
```python
|
||||
info = await HealthService.collect(redis)
|
||||
ok = info.db_status == 1 and info.redis_status == 1
|
||||
return SuccessResponse(data=info, msg=f"系统健康-{settings.VERSION}" if ok else f"服务异常-{settings.VERSION}")
|
||||
```
|
||||
|
||||
`SuccessResponse` 的 `success`/`code` 恒为 `True`/`0`(`app/common/response.py:29–42`)。也就是说该用例**即使 DB 与 Redis 全部探活失败也会通过**——它只验证了路由存在 + JSON 形状,没有验证 `data.db_status/redis_status`,也没验证 `msg`。全仓唯一的行为断言是**不可失败断言**。
|
||||
|
||||
---
|
||||
|
||||
## 4. 最该先补的 10 个测试点(含验证方式)
|
||||
|
||||
排序原则:安全相关(越权/凭据)> 影响全部接口的公共封装 > 不可逆操作(DDL/迁移)> 业务模块。
|
||||
|
||||
---
|
||||
|
||||
### T1. 接口权限矩阵(`AuthPermission`)与认证失败矩阵 ✅最高优先
|
||||
|
||||
- **为什么**:187 处 `AuthPermission(...)` 调用、0 个用例;三条放行分支(超管 / 空权限 / 通配符)中任何一条回归都等于全站越权。
|
||||
- **验证方式**
|
||||
- 新增 `backend/tests/test_auth_permission.py`(纯单元,无 DB):
|
||||
```python
|
||||
@pytest.mark.parametrize(("is_superuser", "user_perms", "required", "expect"), [
|
||||
(True, [], ["system:user:create"], "allow"), # 超管旁路
|
||||
(False, [], ["system:user:create"], "allow"), # self.permissions 为空 → 放行
|
||||
(False, ["*"], ["system:user:create"], "allow"),
|
||||
(False, ["*:*:*"], ["system:user:create"], "allow"),
|
||||
(False, ["a:b:c"], ["system:user:create"], "403-errcode"), # 无权限分支
|
||||
(False, [], ["x:y:z", "a:b:c"], "allow"),
|
||||
])
|
||||
async def test_matrix(...):
|
||||
with_403 = pytest.raises(CustomException)
|
||||
```
|
||||
- 组装 `AuthSchema(user=CoreUserSchema(id=1, is_superuser=..., ...), permissions=user_perms)` 直接 `await AuthPermission(required)(auth=...)`;对 403 分支断言 `code == RET.NO_PERMISSION.code`(而非只有 HTTP 状态码)。
|
||||
- 接口层(用现有 `test_client` 夹具)对任一受保护接口断言四类 401:无 `Authorization`、`Bearer` 空、随机串、签名正确但 session 已删(先 `logout` 再请求)。
|
||||
- **验收**:T1 里"无权限 → 403"与"超管 → 200"两类用例必须同时存在,防止只测放行不测拦截。
|
||||
|
||||
### T2. 数据权限 `Permission` 的数据范围矩阵
|
||||
|
||||
- **为什么**:这是"用户 A 看到用户 B 数据"的唯一闸门,当前零覆盖;`created_id`/`dept_id`/`created_by.dept_id` 三条取值路径 + 降级逻辑都很绕。
|
||||
- **验证方式**
|
||||
- 新增 `backend/tests/test_data_scope.py`,用内存 SQLite(`create_async_engine("sqlite+aiosqlite:///:memory:")`,独立于 conftest 的全局库)建 3 部门(1 → 2 → 3)+ 3 用户 + 若干带 `created_id` 的示例行。
|
||||
- 对 `Permission(model, auth, db)._permission_condition()` 断言生成的条件类别,而不是断言数据量:
|
||||
```python
|
||||
cond = await Permission(UserModel, auth, db)._permission_condition()
|
||||
assert cond is None # is_superuser 或 DATA_SCOPE_ALL
|
||||
sql = str(cond.compile(compile_kwargs={"literal_binds": True}))
|
||||
assert "dept_id IN (1, 2, 3)" in sql or "created_id = 2" in sql
|
||||
```
|
||||
- 必测分支:`data_scope=ALL → None`、`DEPT_AND_CHILD → dept 子树包含自身`、`SELF → created_id = 当前用户`、模型无 `created_id` → `None`、`_load_dept_children` 抛异常 → 降级为"仅本部门"且捕获到 `logger.warning`(用 `caplog`)。
|
||||
- 顺带固化 §3.2 的环检测缺陷:构造 `parent_id` 自引用(`{1: [2], 2: [1]}`),断言**期望行为**是"不抛 `RecursionError`、返回有限集合"——当前会失败,正好作为修复工单的验收依据。
|
||||
- **验收**:每条 `data_scope` 至少 1 个正例 + 1 个反例;环用例先标 `xfail(strict=True)` 并附 issue 说明,修复后转正。
|
||||
|
||||
### T3. 会话令牌全生命周期(create / refresh / logout / 滑动续期)
|
||||
|
||||
- **为什么**:令牌是唯一凭据;`compare_and_set`(CAS)+ 双键续期 + 绝对存活上限都是"并发下悄悄失效"的高危逻辑,且现有 Redis mock 缺失 `eval` 使它**不可能被测到**。
|
||||
- **验证方式**
|
||||
- 先改夹具(这是本轮报告给出的必要前置):把 `conftest` 的手写 `AsyncMock` 换成 `fakeredis.asyncio.FakeRedis`(依赖已在 dev 组声明),或至少补齐 `incr/eval/mget/scan_iter/publish/pubsub/call` 的语义实现。
|
||||
- 新增 `backend/tests/test_session_lifecycle.py`:
|
||||
- `create_token` 后断言 Redis 中同时存在 `access_token:{sid}` / `refresh_token:{sid}` / `user_session:{sid}` 三键且 TTL ≈ 配置值;
|
||||
- `refresh_token` 后断言旧 session 失效(返回 401)、`is_refresh` 令牌不能直接访问业务接口;
|
||||
- `logout` 后断言三个键都被删除、原 token 请求返回 401(`service.py:531–533` 少删一个键即为漏登出);
|
||||
- CAS:写入 key→A 后并发/串行地先改成 B,再调 `compare_and_set(key, expected="A", ...)`,断言返回 `False` 且值仍为 B;
|
||||
- 滑动续期:`TOKEN_SLIDING_EXPIRE=True` 时把 session 值里的 `created_at` 设到 `SESSION_MAX_LIFETIME_SECONDS` 之前,断言请求被拒且键被删除(覆盖 `dependencies.py:125–156` 的三条分支)。
|
||||
- **验收**:CAS 的成功与失败两条路径都必须有断言(现在测试环境恒为失败)。
|
||||
|
||||
### T4. 登录认证与防爆破(`LoginService.authenticate_user`)
|
||||
|
||||
- **为什么**:最直接的攻击面;且现有夹具**必然测不到限流**(`incr` 缺失 + 127.0.0.1 跳过)。
|
||||
- **验证方式**
|
||||
- 新增 `backend/tests/test_login.py`(沿用 `test_client`):
|
||||
- 三类失败:不存在用户、错误密码、`status=1` 停用 → 断言 HTTP 200 + 业务错误码 + `msg` 文案;同时断言 `LoginLogModel` 落库(`status=2` 与对应 `msg`)——这同时覆盖了 `_write_login_log`。
|
||||
- 成功登录:断言返回 `access_token`、Redis 有 session、`last_login` 被更新。
|
||||
- 限流:用 fakeredis + `X-Forwarded-For`(或 monkeypatch `get_client_ip` 返回 `10.0.0.9`)连续请求 `LOGIN_RATE_LIMIT_MAX_ATTEMPTS + 1` 次,断言第 N+1 次被拒;**并单独写一条用例固化"`127.0.0.1/unknown` 跳过限流"的既有行为**,让这个安全取舍显式可见。
|
||||
- 验证码:`CAPTCHA_ENABLE=True` 时缺 `captcha_key` → 明确报错;`ENVIRONMENT=PROD` 时 `Referer: .../docs` 不再豁免。
|
||||
- **验收**:限流用例必须在"非本机 IP"下运行,否则等于没测。
|
||||
|
||||
### T5. `CRUDBase.page()` 的分页/软删语义
|
||||
|
||||
- **为什么**:所有列表接口都走它;`total/has_next/page_no` 算错会导致前端分页器错乱,软删过滤算错会导致数据泄漏。
|
||||
- **验证方式**
|
||||
- 新增 `backend/tests/test_crud_page.py`:独立内存 SQLite + 一个测试模型(同时含 `is_deleted`、`created_id`),插入 25 行(其中 3 行 `is_deleted=True`)。
|
||||
- 断言矩阵:
|
||||
| 入参 | 期望 |
|
||||
| --- | --- |
|
||||
| `offset=0, limit=10` | `total=22, page_no=1, page_size=10, has_next=True, len(items)=10` |
|
||||
| `offset=20, limit=10` | `page_no=3, has_next=False, len(items)=2` |
|
||||
| `offset=25, limit=10` | `items=[]`, `has_next=False` |
|
||||
| `limit=0` | 不抛 `ZeroDivisionError`,`page_size` 回落到 10 |
|
||||
| `include_deleted=True` | `total=25` |
|
||||
| `page()` 与 `get_list()` 同参 | 行集合一致(COUNT/数据两趟查询的条件等价) |
|
||||
| `order_by=[{"id":"desc"}]` / 多字段 | 顺序与预期一致 |
|
||||
- 断言 `page()` 抛出的异常类型是 `CustomException` 且 `msg` 可区分(不要只断言"抛异常")。
|
||||
- **验收**:`limit=0` 与多字段排序必须入用例(前者是边界除零,后者是 `_parse_order` 的循环语义)。
|
||||
|
||||
### T6. 查询算子与查询参数封装(含非法输入的错误语义)
|
||||
|
||||
- **为什么**:决定了所有列表接口的过滤语义与错误码;探针已证实**未知算子静默丢弃条件**、非法日期抛裸 `ValueError` 后被包装成统一的"分页查询失败"。
|
||||
- **验证方式**
|
||||
- 新增 `backend/tests/test_query_operators.py`,参数化 `CRUDBase._resolve_condition`(纯静态方法,零依赖):
|
||||
| 输入 | 期望 |
|
||||
| --- | --- |
|
||||
| `("None", None)` / `("not None", None)` | `IS NULL` / `IS NOT NULL`,不依赖 val |
|
||||
| `("like", "a")` | `LIKE '%a%'` |
|
||||
| `("in", [])` | `false()`(恒假,不得退化为"无条件") |
|
||||
| `("between", [1])` | 长度非法 → 明确行为(当前 `[]`,建议改为拒绝) |
|
||||
| `("date", "2026-10-04")` | `>= 00:00` 且 `< 次日 00:00` |
|
||||
| `("month", "2026-12")` | 跨年到次年 1 月 1 日 |
|
||||
| `("date", "not-a-date")` | **期望** `CustomException`(清晰 4xx),当前 `ValueError` |
|
||||
| `("unknown_op", "x")` | **期望** 抛错或忽略但记录告警,当前静默返回 `[]` |
|
||||
| `_OPERATOR_MAP` 8 个比较符 | `attr.__eq__/__ne__/__gt__/...` 映射正确 |
|
||||
- 同一文件覆盖 `search_to_dict`:`xxx_start`/`xxx_end` 合并为 `("between", [s, e])`、`xxx_time` 列表合并、`json_schema_extra={"q": "like"}` 打包、`None` 值被排除。
|
||||
- **验收**:非法日期这条用例的断言应指向"统一 `CustomException`",把"500 类错误伪装成业务错误"的问题钉住。
|
||||
|
||||
### T7. 代码生成器 `create_table` 的 DDL 白名单(安全)
|
||||
|
||||
- **为什么**:这是"用户提交 SQL → 服务端执行 DDL"的路径,白名单一旦漏判就是任意建表/建视图/建索引;当前 0 测试。
|
||||
- **验证方式**
|
||||
- 新增 `backend/tests/test_codegen_create_table.py`:不连库,`monkeypatch` 掉 `GenTableCRUD.execute_sql`(记录被执行的语句)、`check_table_exists`(恒 False)、`get_gen_table_by_name`(恒 None),直接 `await GenCodeService(auth, db).create_table(sql)`。
|
||||
| SQL 输入 | 期望 |
|
||||
| --- | --- |
|
||||
| `CREATE TABLE t1 (id INT PRIMARY KEY)` | 通过,且执行语句集合 == {该 DDL} |
|
||||
| `DROP TABLE t1` / `INSERT` / `UPDATE` / `DELETE` / `TRUNCATE` | 拒绝,`msg` 命中"禁止的关键操作" |
|
||||
| `CREATE TABLE t1 (...); DROP TABLE t1;` | 整体拒绝(破坏性语句检测在 has_create 之后仍生效) |
|
||||
| `CREATE TABLE t1 (...); ALTER TABLE t1 ADD CONSTRAINT fk FOREIGN KEY (a) REFERENCES b(id);` | 通过 |
|
||||
| `ALTER TABLE t1 DROP COLUMN c;` | 拒绝 |
|
||||
| `ALTER TABLE t1 ADD CONSTRAINT fk FOREIGN KEY (a) REFERENCES b(id) ON DELETE SET NULL;` | **当前会误拒**(含 `SET `)→ 用例固化预期行为,暴露字符串判定缺陷 |
|
||||
| `CREATE VIEW v AS SELECT 1` / `CREATE INDEX i ON t1(id)` | **当前会被放行** → 用例断言必须先确定安全预期(建议归为拒绝),作为加固依据 |
|
||||
| 表已存在 / 已在代码生成模块存在 | 拒绝,`msg` 区分两种原因 |
|
||||
- 断言"未执行的语句不被执行"(例如含 DROP 的多语句输入下,`execute_sql` 调用次数为 0)。
|
||||
- **验收**:`CREATE VIEW`/`CREATE INDEX` 与 `ON DELETE SET NULL` 三条必须显式入用例,否则等于没有白名单。
|
||||
|
||||
### T8. 代码生成元数据纯函数 + 模板渲染
|
||||
|
||||
- **为什么**:`gen_util.py`(309 行)+ `jinja2_template_util.py`(759 行)是"生成代码给用户跑"的核心,边界输入容易产出语法错误的 Python;纯函数测试成本极低(毫秒级、零依赖)。
|
||||
- **验证方式**
|
||||
- 新增 `backend/tests/test_gen_util.py`,参数化:
|
||||
- `get_db_type`:`tinyint(1)`→`boolean`、`tinyint`→`tinyint`、`int UNSIGNED`→`int`、`varchar(64) COLLATE utf8mb4_bin`→`varchar`、`integer[]`/`ARRAY[INTEGER]`→`array`、`timestamp without time zone`→`timestamp`、`""`→`""`;
|
||||
- `get_column_length`:`varchar(255)`→255、`decimal(10,2)`→10、`numeric(20, 0)`→20、无括号→0、`varchar()`→0、`text`→0;
|
||||
- `split_column_type`:`decimal(10,2)`→`["10","2"]`、无括号→`[]`;
|
||||
- `arrays_contains`:大小写不敏感、`COLLATE`/`UNSIGNED` 修饰剥离、`TINYINT(1)` 与 `tinyint` 的基类型比较;
|
||||
- `replace_first` / `convert_class_name`(`sys_user`→`SysUser`);
|
||||
- `StringUtil`(`app/utils/string_util.py`)的同族转换。
|
||||
- 模板:对 `jinja2_template_util` 用最小 `GenTableSchema` 渲染一轮,断言 `compile(rendered, "<gen>", "exec")` 不抛 `SyntaxError`(比逐字快照更稳,能挡住"生成的代码是坏的"这类真实事故);再对 1–2 个关键模板做字符级快照防意外改动。
|
||||
- **验收**:模板用例的断言必须是"能编译"而非"包含某段字符串"。
|
||||
|
||||
### T9. 操作日志脱敏与响应契约
|
||||
|
||||
- **为什么**:这是阻止"密码/token 明文入库"的唯一防线;`_redact_sensitive` 是纯函数,测试成本极低。
|
||||
- **验证方式**
|
||||
- 新增 `backend/tests/test_operation_log.py`:
|
||||
- 纯单元(无 DB):`_redact_sensitive({"Password": "x", "nested": {"access_token": "y"}, "list": [{"client_secret": "z"}]})` → 三个值均为 `"******"`,非敏感键原样保留;大小写混写(`Authorization`、`SECRET_KEY`)命中断言。
|
||||
- 路由层:`test_client` 打 `/system/auth/login`(含密码表单)与一个返回 token 的接口,断言 `OperationLogModel.request_payload` 不含明文密码、`response_json` 不含 `access_token` 明文;
|
||||
- 长度:构造 > 2000 字符的请求体,断言 `request_payload == "请求参数过长"`;
|
||||
- 非 JSON:`Content-Type: text/plain` 请求,断言记录走 `errors="ignore"` 兜底且不抛异常;
|
||||
- 写库失败:`monkeypatch` 使 `async_db_session` 抛异常,断言响应仍为 200(日志失败不得影响主流程)。
|
||||
- **验收**:`Authorization`/`access_token` 两个键必须各有一条"明文不得出现"的负向断言。
|
||||
|
||||
### T10. 迁移与 schema 一致性守卫升级
|
||||
|
||||
- **为什么**:迁移漂移是"本地好、线上崩"的典型来源;现有守卫只覆盖 SQLite,且 `versions/` 为空使 upgrade 路径完全没被验证。
|
||||
- **验证方式**(扩展现有 `backend/tests/test_migrations.py`,不新建平行文件)
|
||||
- 守卫 1/2 保留,新增守卫 3:断言 `ALEMBIC_VERSION_DIR` 的实际状态与启动路径一致——当目录为空时,断言 `init_db` 走的确实是"create_all(不 stamp)"分支(可断言 `alembic_version` 表不存在或为空 + 捕获对应日志),避免未来悄悄变成"空库被 stamp head"。
|
||||
- 守卫 4(关键):把一致性检查从 SQLite 扩到 MySQL/Postgres。**注意:`MigrationContext.configure(dialect_name="mysql")` + `compare_metadata` 无法离线使用**(本次实测三种方言均直接 `AssertionError`,因为 `compare_metadata` 需要真实 `Connection` 做反射),所以分两条腿:
|
||||
1. **离线多方言 DDL 渲染**(无需数据库,可进常规 CI):先 `from app import create_app; create_app()` 把全部模型 import 进来(否则 `UserModel.__table__` 会因 `sys_user_roles → sys_role` 外键找不到表而抛 `NoReferencedTableError`),然后逐方言编译:
|
||||
```python
|
||||
from sqlalchemy.dialects import mysql, postgresql
|
||||
from sqlalchemy.schema import CreateTable
|
||||
from app.core.base_model import MappedBase # 实测 29 张表
|
||||
|
||||
for name, dialect in (("mysql", mysql.dialect()), ("postgresql", postgresql.dialect())):
|
||||
for table_name, table in MappedBase.metadata.tables.items():
|
||||
ddl = str(CreateTable(table).compile(dialect=dialect)) # 实测:sys_user → 1586/1199 字符
|
||||
assert ddl.strip(), f"{name}.{table_name} 渲染为空"
|
||||
# 断言口径二选一:① 关键表 DDL 字符级快照;② 规则断言(如禁止 "VARCHAR" 无长度、
|
||||
# 禁止 MySQL 专有修饰出现在 Postgres 渲染结果里),避免快照噪音过大。
|
||||
```
|
||||
用途:挡住"某字段在 MySQL 下渲染出非法/退化 DDL(如 `UNSIGNED`、`ON UPDATE`、`COLLATE`、无长度 `VARCHAR`)"这类只在方言上暴露的问题。
|
||||
2. **真连接等价检查**(`@pytest.mark.integration`,按需开启,仓库已有 `docker/mysql` compose):连上真实 MySQL/Postgres 后
|
||||
```python
|
||||
with engine.connect() as conn:
|
||||
diffs = compare_metadata(MigrationContext.configure(conn), MappedBase.metadata)
|
||||
assert diffs == [], diffs # 实测 SQLite 真连接下为 0 diff
|
||||
```
|
||||
- 守卫 5:让迁移用例在**只读检出**下也能跑——把 `command.revision(autogenerate=True)` 的写法改为在临时目录(`tmp_path` + 覆盖 `script_location`)中执行,消除测试写仓库的副作用(当前 `test_migrations.py:31–34` 会向 `app/alembic/versions/` 写文件再删)。
|
||||
- 若后续补回迁移文件:新增一条"`alembic upgrade head` 从空库升到 head 后,`compare_metadata` 零 diff"的端到端用例(这是当前最大盲区)。
|
||||
- **验收**:多方言断言必须真编译一次 DDL(不得只 mock `CreateTable`),且**不要**使用 `MigrationContext.configure(dialect_name=...)` 冒充等价检查;真连接等价检查须放在有 DB 的 job / 本地显式开启;迁移用例执行后 `git status` 必须保持干净。
|
||||
|
||||
---
|
||||
|
||||
## 5. 落地顺序建议(3 个批次)
|
||||
|
||||
| 批次 | 内容 | 目的 | 门禁 |
|
||||
| --- | --- | --- | --- |
|
||||
| **第 1 批(≤2 天)** | T1、T6、T8、T9(纯函数/纯单元为主,不需要新夹具)+ 重建 `pytest-cov` 与覆盖率基线 | 用最低成本拿到"安全 + 公共封装"的回归网 | 后端 `uv run pytest` 纳入 CI,覆盖率设"不回退"而非固定阈值 |
|
||||
| **第 2 批(3–5 天)** | 夹具改造(fakeredis 替换手写 mock、`assert_route` 改为对 5xx 失败、增加 async session/事务夹具、逐用例隔离)+ T3、T4、T5、T2 | 让"测试能失败",覆盖凭据与数据权限 | 新增 async 夹具后开启 `filterwarnings = error`;`pytest -q --strict-markers` |
|
||||
| **第 3 批(1–2 周)** | T7、T10 + 启动路径(真实 lifespan)冒烟 + 前端 vitest 扩展(request 拦截器、storage、route-loader)+ uniapp 最小 vitest 接入 | 覆盖不可逆操作与真实启动链 | 仓库根新建 `.github/workflows/ci.yml`:backend(ruff + pytest)+ web(type-check + lint + **vitest run**)+ app(type-check + lint) |
|
||||
|
||||
配套的"制度性"修复(与测试点同等重要,均属零成本配置):
|
||||
|
||||
1. **在仓库根建 CI**(当前为空目录,`git ls-files .github` = 0):把 `frontend/web/.github/workflows/ci.yml` 的内容上提并补 `pnpm test`;后端加 `uv sync --group dev && uv run pytest -q`。
|
||||
2. **保留 `frontend/web` 的 vitest 并扩面**:它已有 10 个真用例,只要进 CI 就能立刻防住路由结构回归。
|
||||
3. **修正 `assert_route`**(`backend/tests/conftest.py:240–277`):`except Exception` 应转为失败(或改为显式允许 5xx 白名单),默认断言从"`!= 404`"升级为"status < 500"。
|
||||
4. **把 `fakeredis` 用起来或从 dev 组删掉**:声明了却不用,是明确的误导信号。
|
||||
5. **`SKILL.md` 与实现对齐**:文档要求 `pnpm test` / `uv run pytest`,实际无执行点;要么加门禁,要么改文档。
|
||||
|
||||
---
|
||||
|
||||
## 6. 附:本次审计用到的只读命令
|
||||
|
||||
```bash
|
||||
# 1) 后端测试(uv 缓存需重定向;详见 §1.1)
|
||||
cd backend && uv run pytest -q # 原样执行:uv 缓存权限失败,exit 2
|
||||
cd backend && UV_CACHE_DIR=/tmp/uv-cache-test uv run pytest -q # exit 0, 2 passed
|
||||
cd backend && UV_CACHE_DIR=/tmp/uv-cache-test uv run pytest -v # 收集清单
|
||||
cd backend && .venv/bin/python -m pytest -q # 等价路径(不再触碰 uv.lock)
|
||||
|
||||
# 2) 前端测试
|
||||
cd frontend/web && ./node_modules/.bin/vitest run # exit 0, 2 files / 10 tests
|
||||
|
||||
# 3) 规模与路由统计(只读探针,见 /tmp/probe_tests.py)
|
||||
cd backend && .venv/bin/python -c "from app import create_app; print(len(create_app().openapi()['paths']))" # 205
|
||||
find backend/app -name '*.py' | wc -l ; find backend/app -name '*.py' -exec cat {} + | wc -l # 216 / 28519
|
||||
|
||||
# 4) 测试资产溯源
|
||||
git ls-files .github # 0(根 CI 为空)
|
||||
git log --diff-filter=D --name-only -- backend/tests # 删除轨迹
|
||||
python3 -c "import json;print(len(json.load(open('backend/.pytest_cache/v/cache/nodeids'))))" # 761
|
||||
```
|
||||
|
||||
## 7. 环境与合规说明(重要)
|
||||
|
||||
1. **工作区在本次审计开始前即为脏**(其他 teammate 的并行产物):`git status --porcelain` 显示已修改 `backend/app/config/setting.py`、`backend/pyproject.toml`、`backend/requirements.txt`、`backend/uv.lock`、`docker/.env.example`,未跟踪 `backend/requirements/`、`backend/audit-requirements.md`、`docker/audit-deploy.md`、`deploy-artifacts.sh`、`.agent-teams/`。其中 `pyproject.toml` 的 mtime 为 03:29(早于本会话),改动内容是把 mysql/pg 驱动从核心依赖迁到 `optional-dependencies`。
|
||||
2. **`backend/uv.lock` 的 mtime 被本次 `uv run pytest -q`(未加 `--no-sync`)刷新为 03:58**。该文件的 diff 内容与上述 03:29 的 `pyproject.toml` 改动严格对应(`aiomysql`/`asyncpg`/`pymysql`/`psycopg` 从核心组迁入 `optional-dependencies`,并新增 `all-db` 组),未发现与之无关的内容变化;后续所有命令已改为 `backend/.venv/bin/python` 或 `--no-sync`,不再触碰锁文件。**此为本轮唯一可能非预期的写操作,特此披露。**
|
||||
3. 除本报告文件 `backend/audit-testing.md` 外,**未新建、修改或删除仓库内任何文件**;未安装依赖、未启服务、未执行 git 提交。
|
||||
4. `backend/app/alembic/versions/`、`backend/tests/` 在审计前后内容一致(分别只有 `__init__.py` / 3 个 `.py`)。
|
||||
5. 本报告中的"探针实测"结果来自 `/tmp/probe_tests.py`(临时目录),仅使用内存/SQLite 与纯函数路径,未连接真实 MySQL、PostgreSQL 或 Redis。
|
||||
6. 未取得**行覆盖率数字**:`pytest-cov` 与 `@vitest/coverage-*` 均未安装,且本轮不安装依赖。所有"缺口"结论基于:OpenAPI 路由清单、模块清单、`AuthPermission` 引用计数、代码路径静态审读与上述探针行为实测。
|
||||
Reference in New Issue
Block a user