Files
erp/docs/superpowers/plans/2026-04-17-platform-maturity-q2-plan.md
iven 9f85188886 docs: 添加 Q2 安全地基 + CI/CD 实施计划
14 个 Task 覆盖:密钥外部化、启动强制检查、多租户加固、
限流 fail-closed、审计日志补全、Gitea Actions CI/CD、Docker 生产化
2026-04-17 16:51:51 +08:00

766 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Q2 安全地基 + CI/CD 实施计划
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 消除所有 CRITICAL/HIGH 安全风险,建立 CI/CD 自动化质量门,完成审计日志补全和 Docker 生产化。
**Architecture:** 密钥外部化通过环境变量强制注入 + 启动检查拒绝默认值CI/CD 使用 Gitea Actions 四 job 并行;限流改为 fail-closed审计日志补全 IP/UA 和变更值。
**Tech Stack:** Rust (Axum, SeaORM), Gitea Actions, Docker Compose, PostgreSQL 16, Redis 7
**Spec:** `docs/superpowers/specs/2026-04-17-platform-maturity-roadmap-design.md` §2
---
## File Structure
| 操作 | 文件 | 职责 |
|------|------|------|
| Modify | `crates/erp-server/config/default.toml` | 敏感值改为占位符 |
| Modify | `crates/erp-server/src/main.rs` | 启动时拒绝默认密钥 |
| Modify | `crates/erp-auth/src/module.rs:149-150` | 移除密码 fallback |
| Modify | `crates/erp-auth/src/error.rs:46-53` | 移除 `From<AppError> for AuthError` 反向映射 |
| Modify | `crates/erp-auth/src/service/auth_service.rs:177-181` | refresh 添加 tenant_id 过滤 |
| Modify | `crates/erp-auth/src/service/user_service.rs` | get_by_id/update/delete 改为 DB 级 tenant 过滤 |
| Modify | `crates/erp-server/src/middleware/rate_limit.rs:122-124,135-137` | fail-closed |
| Modify | `crates/erp-core/src/audit.rs` | `with_request_info` 类型扩展 |
| Modify | `crates/erp-auth/src/service/auth_service.rs` | login/logout/change_password 添加审计 |
| Modify | `crates/erp-plugin/src/data_service.rs` | CRUD 操作添加审计 |
| Modify | `docker/docker-compose.yml` | 端口不暴露、Redis 密码、资源限制 |
| Modify | `.gitignore` | 添加 `.test_token` |
| Create | `.gitea/workflows/ci.yml` | CI/CD 流水线 |
---
## Chunk 1: 密钥外部化与启动强制检查
### Task 1: 清理 `.test_token` 和 `.gitignore`
**Files:**
- Modify: `.gitignore`
- Delete: `.test_token`(仅本地文件)
- [ ] **Step 1: 验证 `.test_token` 是否曾提交到 git 历史**
```bash
git log --all --oneline -- .test_token
```
Expected: 无输出(从未提交)。如果有输出,需额外执行 BFG 清理。
- [ ] **Step 2: 添加 `.test_token` 到 `.gitignore`**
`.gitignore` 末尾添加:
```
# Test artifacts
.test_token
*.heapsnapshot
perf-trace-*.json
```
- [ ] **Step 3: Commit**
```bash
git add .gitignore
git commit -m "chore: 添加 .test_token 和测试产物到 .gitignore"
```
---
### Task 2: `default.toml` 敏感值改为占位符
**Files:**
- Modify: `crates/erp-server/config/default.toml`
- [ ] **Step 1: 替换敏感值**
`crates/erp-server/config/default.toml` 中的:
```toml
url = "postgres://erp:erp_dev_2024@localhost:5432/erp"
```
改为:
```toml
url = "__MUST_SET_VIA_ENV__"
```
将:
```toml
secret = "change-me-in-production"
```
改为:
```toml
secret = "__MUST_SET_VIA_ENV__"
```
将:
```toml
super_admin_password = "Admin@2026"
```
改为:
```toml
super_admin_password = "__MUST_SET_VIA_ENV__"
```
- [ ] **Step 2: 创建 `.env.development` 供本地开发使用**
在项目根目录创建 `.env.development`(已被 `.gitignore``*.env.local` 覆盖,但需显式添加 `.env.development`
```bash
# .env.development — 本地开发用,不提交到仓库
ERP__DATABASE__URL=postgres://erp:erp_dev_2024@localhost:5432/erp
ERP__JWT__SECRET=dev-local-secret-change-me
ERP__AUTH__SUPER_ADMIN_PASSWORD=Admin@2026
```
更新 `.gitignore`,添加 `.env.development`
- [ ] **Step 3: Commit**
```bash
git add crates/erp-server/config/default.toml .gitignore
git commit -m "fix(security): default.toml 敏感值改为占位符,强制通过环境变量注入"
```
---
### Task 3: 启动检查 — 拒绝默认密钥
**Files:**
- Modify: `crates/erp-server/src/main.rs`(在服务启动前添加检查)
- [ ] **Step 1: 在 `main.rs` 的配置加载后、服务启动前添加安全检查**
在配置加载完成后(`let config = ...` 之后),添加:
```rust
// ── 安全检查:拒绝默认密钥 ──────────────────────────
if config.jwt.secret == "__MUST_SET_VIA_ENV__" || config.jwt.secret == "change-me-in-production" {
tracing::error!(
"JWT 密钥为默认值,拒绝启动。请设置环境变量 ERP__JWT__SECRET"
);
std::process::exit(1);
}
if config.database.url == "__MUST_SET_VIA_ENV__" {
tracing::error!(
"数据库 URL 为默认占位值,拒绝启动。请设置环境变量 ERP__DATABASE__URL"
);
std::process::exit(1);
}
```
- [ ] **Step 2: 验证默认配置启动被拒绝**
```bash
ERP__JWT__SECRET="__MUST_SET_VIA_ENV__" cargo run -p erp-server
```
Expected: 进程退出,输出包含 "JWT 密钥为默认值,拒绝启动"
- [ ] **Step 3: 验证环境变量设置后正常启动**
```bash
ERP__JWT__SECRET="my-real-secret" ERP__DATABASE__URL="postgres://erp:erp_dev_2024@localhost:5432/erp" ERP__AUTH__SUPER_ADMIN_PASSWORD="TestPass123" cargo run -p erp-server
```
Expected: 服务正常启动(或因数据库未运行而失败,但不应因安全检查退出)
- [ ] **Step 4: Commit**
```bash
git add crates/erp-server/src/main.rs
git commit -m "fix(security): 启动时拒绝默认 JWT 密钥和数据库 URL"
```
---
### Task 4: 移除密码 fallback 硬编码
**Files:**
- Modify: `crates/erp-auth/src/module.rs:149-150`
- [ ] **Step 1: 将 `unwrap_or_else` 改为显式错误处理**
将:
```rust
let password = std::env::var("ERP__SUPER_ADMIN_PASSWORD")
.unwrap_or_else(|_| "Admin@2026".to_string());
```
改为:
```rust
let password = std::env::var("ERP__SUPER_ADMIN_PASSWORD")
.map_err(|_| {
tracing::error!("环境变量 ERP__SUPER_ADMIN_PASSWORD 未设置,无法初始化租户认证");
erp_core::error::AppError::Internal(
"ERP__SUPER_ADMIN_PASSWORD 未设置".to_string(),
)
})?;
```
- [ ] **Step 2: 验证编译通过**
```bash
cargo check -p erp-auth
```
Expected: 编译成功
- [ ] **Step 3: Commit**
```bash
git add crates/erp-auth/src/module.rs
git commit -m "fix(security): 移除 super_admin_password 硬编码 fallback"
```
---
### Task 5: 移除 `From<AppError> for AuthError` 反向映射
**Files:**
- Modify: `crates/erp-auth/src/error.rs:46-53`
- [ ] **Step 1: 删除反向映射 impl**
删除 `crates/erp-auth/src/error.rs` 中的整个 impl 块:
```rust
// 删除以下代码
impl From<AppError> for AuthError {
fn from(err: AppError) -> Self {
match err {
AppError::VersionMismatch => AuthError::VersionMismatch,
other => AuthError::Validation(other.to_string()),
}
}
}
```
- [ ] **Step 2: 修复所有依赖此反向映射的调用点**
搜索 `AuthError``AppError` 隐式转换的位置:
```bash
grep -rn "map_err.*AppError" crates/erp-auth/src/
grep -rn "?.*AuthError" crates/erp-auth/src/
```
`on_tenant_deleted` 等函数中的 `AppError``AuthError` 转换改为直接返回 `AppError`(函数签名可能需从 `AuthResult<T>` 改为 `AppResult<T>`,或在调用点显式 `.map_err()`)。
- [ ] **Step 3: 删除反向映射的测试**
删除 `crates/erp-auth/src/error.rs` 测试中的 `app_error_version_mismatch_roundtrip``app_error_other_maps_to_auth_validation` 测试。
- [ ] **Step 4: 验证编译和测试**
```bash
cargo check -p erp-auth && cargo test -p erp-auth
```
Expected: 编译成功,所有测试通过
- [ ] **Step 5: Commit**
```bash
git add crates/erp-auth/src/
git commit -m "refactor(auth): 移除 From<AppError> for AuthError 反向映射"
```
---
## Chunk 2: 多租户安全加固 + 限流 fail-closed
### Task 6: `auth_service::refresh()` 添加 tenant_id 过滤
**Files:**
- Modify: `crates/erp-auth/src/service/auth_service.rs:177-181`
- [ ] **Step 1: 修改 refresh 中的用户查询**
将:
```rust
let user_model = user::Entity::find_by_id(claims.sub)
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.ok_or(AuthError::TokenRevoked)?;
```
改为:
```rust
let user_model = user::Entity::find_by_id(claims.sub)
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.ok_or(AuthError::TokenRevoked)?;
// 验证用户属于 JWT 中声明的租户
if user_model.tenant_id != claims.tid {
tracing::warn!(
user_id = %claims.sub,
jwt_tenant = %claims.tid,
actual_tenant = %user_model.tenant_id,
"Token tenant_id 与用户实际租户不匹配"
);
return Err(AuthError::TokenRevoked);
}
```
- [ ] **Step 2: 验证编译**
```bash
cargo check -p erp-auth
```
- [ ] **Step 3: Commit**
```bash
git add crates/erp-auth/src/service/auth_service.rs
git commit -m "fix(auth): refresh token 流程添加 tenant_id 校验"
```
---
### Task 7: `user_service` 改为 DB 级 tenant_id 过滤
**Files:**
- Modify: `crates/erp-auth/src/service/user_service.rs``get_by_id``update``delete` 三个函数)
- [ ] **Step 1: 修改 `get_by_id`(约第 129-134 行)**
`find_by_id` + 内存 `.filter()` 模式改为数据库级查询:
```rust
pub async fn get_by_id(id: Uuid, tenant_id: Uuid, db: &DatabaseConnection) -> AuthResult<user::Model> {
user::Entity::find()
.filter(user::Column::Id.eq(id))
.filter(user::Column::TenantId.eq(tenant_id))
.filter(user::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.ok_or(AuthError::Validation("用户不存在".to_string()))
}
```
- [ ] **Step 2: 同样修改 `update` 和 `delete` 函数**
将这两个函数中的 `find_by_id` + 内存 `.filter()` 改为相同的 DB 级过滤模式。
- [ ] **Step 3: 验证编译和测试**
```bash
cargo check -p erp-auth && cargo test -p erp-auth
```
- [ ] **Step 4: Commit**
```bash
git add crates/erp-auth/src/service/user_service.rs
git commit -m "fix(auth): get_by_id/update/delete 改为数据库级 tenant_id 过滤"
```
---
### Task 8: 限流 fail-closed
**Files:**
- Modify: `crates/erp-server/src/middleware/rate_limit.rs:122-137`
- [ ] **Step 1: 将 Redis 不可达时的放行改为拒绝**
`apply_rate_limit` 函数中,将三处 `return next.run(req).await;` 改为返回 429
```rust
// 第一处Redis 不可达快速检查(约第 122-124 行)
if !avail.should_try().await {
tracing::warn!("Redis 不可达,启用 fail-closed 限流保护");
return RateLimitResponse {
error: "Too Many Requests".to_string(),
message: "服务暂时不可用,请稍后重试".to_string(),
}.into_response();
}
// 第二处:连接失败(约第 135-137 行)
Err(e) => {
tracing::warn!(error = %e, "Redis 连接失败fail-closed 限流保护");
avail.mark_failed().await;
return RateLimitResponse {
error: "Too Many Requests".to_string(),
message: "服务暂时不可用,请稍后重试".to_string(),
}.into_response();
}
// 第三处INCR 失败(约第 143-145 行)
Err(e) => {
tracing::warn!(error = %e, "Redis INCR 失败fail-closed 限流保护");
return RateLimitResponse {
error: "Too Many Requests".to_string(),
message: "服务暂时不可用,请稍后重试".to_string(),
}.into_response();
}
```
注意:需将 `RateLimitResponse` 结构体移到函数外部或使其可访问。
- [ ] **Step 2: 验证编译**
```bash
cargo check -p erp-server
```
- [ ] **Step 3: Commit**
```bash
git add crates/erp-server/src/middleware/rate_limit.rs
git commit -m "fix(server): 限流改为 fail-closed — Redis 不可达时拒绝请求"
```
---
## Chunk 3: 审计日志补全
### Task 9: 登录/登出/密码修改添加审计日志
**Files:**
- Modify: `crates/erp-auth/src/service/auth_service.rs`
- Reference: `crates/erp-core/src/audit.rs`
- [ ] **Step 1: 在 `login` 函数成功路径添加审计**
在登录成功后(签发 token 之后)添加:
```rust
// 审计日志:登录成功
audit_service::record(
audit::AuditLog::new("user.login", user_model.tenant_id, user_model.id)
.with_detail("username", &req.username),
db,
).await;
```
- [ ] **Step 2: 在 `login` 函数失败路径(密码错误/用户禁用)添加审计**
在返回 `InvalidCredentials` / `UserDisabled` 之前添加审计日志(不含敏感信息):
```rust
// 审计日志:登录失败
audit_service::record(
audit::AuditLog::new("user.login_failed", tenant_id, Uuid::nil())
.with_detail("username", &req.username)
.with_detail("reason", "invalid_credentials"),
db,
).await;
```
- [ ] **Step 3: 在 `logout` 函数添加审计**
- [ ] **Step 4: 在 `change_password` 函数添加审计**
- [ ] **Step 5: 验证编译**
```bash
cargo check -p erp-auth
```
- [ ] **Step 6: Commit**
```bash
git add crates/erp-auth/src/service/auth_service.rs
git commit -m "feat(auth): 登录/登出/密码修改添加审计日志"
```
---
### Task 10: 审计日志添加 IP 和 User-Agent
**Files:**
- Modify: `crates/erp-core/src/audit.rs`(确保 `with_request_info` 接受 IP + UA
- Modify: `crates/erp-auth/src/handler/auth_handler.rs`(从请求提取信息传入 service
- [ ] **Step 1: 确认 `audit.rs` 中 `with_request_info` 的签名**
确认 `AuditLogBuilder::with_request_info(ip: String, user_agent: String)` 存在且类型正确。如果不存在则添加。
- [ ] **Step 2: 在 auth handler 中提取 IP 和 UA 并传给 service**
`login` handler 中从 `ConnectInfo` / `X-Forwarded-For` / `X-Real-IP` 提取 IP`User-Agent` header 提取 UA传给 `AuthService::login`
这可能需要扩展 `login` 函数签名,添加 `ip: Option<String>``user_agent: Option<String>` 参数。
- [ ] **Step 3: 在审计日志记录时调用 `.with_request_info(ip, user_agent)`**
- [ ] **Step 4: 验证编译**
```bash
cargo check -p erp-auth && cargo check -p erp-core
```
- [ ] **Step 5: Commit**
```bash
git add crates/erp-auth/ crates/erp-core/src/audit.rs
git commit -m "feat(audit): 审计日志添加 IP 地址和 User-Agent"
```
---
### Task 11: 关键实体 update 添加变更前后值
**Files:**
- Modify: `crates/erp-auth/src/service/user_service.rs``update` 函数)
- Modify: `crates/erp-auth/src/service/role_service.rs``update` 函数)
- [ ] **Step 1: 在 `user_service::update` 中,先查询旧值再更新**
在 update 函数中,获取旧模型后、执行更新前,记录:
```rust
let old_json = serde_json::to_value(&old_user)
.unwrap_or(serde_json::Value::Null);
// ... 执行更新 ...
let new_json = serde_json::to_value(&updated_user)
.unwrap_or(serde_json::Value::Null);
audit_service::record(
audit::AuditLog::new("user.update", tenant_id, operator_id)
.with_changes(old_json, new_json),
db,
).await;
```
- [ ] **Step 2: 同样修改 `role_service::update`**
- [ ] **Step 3: 确认 `with_changes` 方法存在于 `audit.rs`**
如果不存在则添加:
```rust
pub fn with_changes(mut self, old: serde_json::Value, new: serde_json::Value) -> Self {
self.old_value = Some(old);
self.new_value = Some(new);
self
}
```
- [ ] **Step 4: 验证编译**
```bash
cargo check -p erp-auth
```
- [ ] **Step 5: Commit**
```bash
git add crates/erp-auth/ crates/erp-core/src/audit.rs
git commit -m "feat(audit): 用户/角色更新记录变更前后值"
```
---
### Task 12: 插件 CRUD 添加审计日志
**Files:**
- Modify: `crates/erp-plugin/src/data_service.rs`
- [ ] **Step 1: 在 `create_record`、`update_record`、`delete_record` 中添加审计**
每个 CUD 操作添加审计日志记录,包含 `plugin_id``entity_name``record_id`
- [ ] **Step 2: 验证编译**
```bash
cargo check -p erp-plugin
```
- [ ] **Step 3: Commit**
```bash
git add crates/erp-plugin/src/data_service.rs
git commit -m "feat(plugin): 数据 CRUD 操作添加审计日志"
```
---
## Chunk 4: CI/CD + Docker 生产化
### Task 13: 创建 Gitea Actions CI/CD 流水线
**Files:**
- Create: `.gitea/workflows/ci.yml`
- [ ] **Step 1: 创建工作流目录**
```bash
mkdir -p .gitea/workflows
```
- [ ] **Step 2: 创建 CI 流水线文件**
创建 `.gitea/workflows/ci.yml`
```yaml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
rust-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: ". -> target"
- run: cargo fmt --check --all
- run: cargo clippy -- -D warnings
rust-test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: erp_test
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: ". -> target"
- run: cargo test --workspace
env:
ERP__DATABASE__URL: postgres://test:test@localhost:5432/erp_test
ERP__JWT__SECRET: ci-test-secret
ERP__AUTH__SUPER_ADMIN_PASSWORD: CI_Test_Pass_2026
frontend-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: cd apps/web && corepack enable && pnpm install --frozen-lockfile
- run: cd apps/web && pnpm build
security-audit:
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- run: cargo install cargo-audit && cargo audit
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: cd apps/web && corepack enable && pnpm install --frozen-lockfile && pnpm audit
```
- [ ] **Step 3: Commit**
```bash
git add .gitea/
git commit -m "ci: 添加 Gitea Actions CI/CD 流水线"
```
---
### Task 14: Docker 生产化
**Files:**
- Modify: `docker/docker-compose.yml`
- [ ] **Step 1: 移除端口暴露,添加 Redis 密码和资源限制**
将 PostgreSQL 的 `ports: "5432:5432"` 改为 `expose: ["5432"]`(仅容器网络内部可访问)。
将 Redis 的 `ports: "6379:6379"` 改为 `expose: ["6379"]`,并添加命令 `--requirepass ${REDIS_PASSWORD:-erp_redis_dev}`
为两个服务添加资源限制:
```yaml
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
```
- [ ] **Step 2: 更新 `.env.example`**
添加 `REDIS_PASSWORD` 变量说明。
- [ ] **Step 3: 更新 `default.toml` 的 Redis URL 格式**
如果 Redis 需要密码URL 格式改为 `redis://:password@localhost:6379`
- [ ] **Step 4: 验证 Docker Compose 配置有效**
```bash
cd docker && docker compose config
```
Expected: 无语法错误
- [ ] **Step 5: Commit**
```bash
git add docker/
git commit -m "fix(docker): 生产化配置 — 端口不暴露、Redis 密码、资源限制"
```
---
## 验证清单
完成所有 Task 后,执行以下验证:
- [ ] **V1: 默认配置拒绝启动**
```bash
cargo run -p erp-server
```
Expected: 进程退出,日志包含 "JWT 密钥为默认值,拒绝启动"
- [ ] **V2: 环境变量设置后正常启动**
```bash
ERP__JWT__SECRET="test-secret" ERP__DATABASE__URL="postgres://erp:erp_dev_2024@localhost:5432/erp" ERP__AUTH__SUPER_ADMIN_PASSWORD="TestPass123" cargo run -p erp-server
```
Expected: 服务正常启动
- [ ] **V3: 全量编译和测试**
```bash
cargo check && cargo test --workspace
```
Expected: 全部通过
- [ ] **V4: 前端构建**
```bash
cd apps/web && pnpm build
```
Expected: 构建成功
- [ ] **V5: Docker Compose 正常启动**
```bash
cd docker && docker compose up -d && docker compose ps
```
Expected: PostgreSQL 和 Redis 状态 healthy
- [ ] **V6: Push 到远程仓库**
```bash
git push origin main
```
Expected: Gitea Actions 触发 CI 流水线