20 KiB
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 历史
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
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 中的:
url = "postgres://erp:erp_dev_2024@localhost:5432/erp"
改为:
url = "__MUST_SET_VIA_ENV__"
将:
secret = "change-me-in-production"
改为:
secret = "__MUST_SET_VIA_ENV__"
将:
super_admin_password = "Admin@2026"
改为:
super_admin_password = "__MUST_SET_VIA_ENV__"
- Step 2: 创建
.env.development供本地开发使用
在项目根目录创建 .env.development(已被 .gitignore 中 *.env.local 覆盖,但需显式添加 .env.development):
# .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
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 = ... 之后),添加:
// ── 安全检查:拒绝默认密钥 ──────────────────────────
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: 验证默认配置启动被拒绝
ERP__JWT__SECRET="__MUST_SET_VIA_ENV__" cargo run -p erp-server
Expected: 进程退出,输出包含 "JWT 密钥为默认值,拒绝启动"
- Step 3: 验证环境变量设置后正常启动
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
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改为显式错误处理
将:
let password = std::env::var("ERP__SUPER_ADMIN_PASSWORD")
.unwrap_or_else(|_| "Admin@2026".to_string());
改为:
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: 验证编译通过
cargo check -p erp-auth
Expected: 编译成功
- Step 3: Commit
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 块:
// 删除以下代码
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 隐式转换的位置:
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: 验证编译和测试
cargo check -p erp-auth && cargo test -p erp-auth
Expected: 编译成功,所有测试通过
- Step 5: Commit
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 中的用户查询
将:
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)?;
改为:
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: 验证编译
cargo check -p erp-auth
- Step 3: Commit
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() 模式改为数据库级查询:
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: 验证编译和测试
cargo check -p erp-auth && cargo test -p erp-auth
- Step 4: Commit
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:
// 第一处: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: 验证编译
cargo check -p erp-server
- Step 3: Commit
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 之后)添加:
// 审计日志:登录成功
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 之前添加审计日志(不含敏感信息):
// 审计日志:登录失败
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: 验证编译
cargo check -p erp-auth
- Step 6: Commit
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: 验证编译
cargo check -p erp-auth && cargo check -p erp-core
- Step 5: Commit
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 函数中,获取旧模型后、执行更新前,记录:
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
如果不存在则添加:
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: 验证编译
cargo check -p erp-auth
- Step 5: Commit
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: 验证编译
cargo check -p erp-plugin
- Step 3: Commit
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: 创建工作流目录
mkdir -p .gitea/workflows
- Step 2: 创建 CI 流水线文件
创建 .gitea/workflows/ci.yml:
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
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}。
为两个服务添加资源限制:
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 配置有效
cd docker && docker compose config
Expected: 无语法错误
- Step 5: Commit
git add docker/
git commit -m "fix(docker): 生产化配置 — 端口不暴露、Redis 密码、资源限制"
验证清单
完成所有 Task 后,执行以下验证:
- V1: 默认配置拒绝启动
cargo run -p erp-server
Expected: 进程退出,日志包含 "JWT 密钥为默认值,拒绝启动"
- V2: 环境变量设置后正常启动
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: 全量编译和测试
cargo check && cargo test --workspace
Expected: 全部通过
- V4: 前端构建
cd apps/web && pnpm build
Expected: 构建成功
- V5: Docker Compose 正常启动
cd docker && docker compose up -d && docker compose ps
Expected: PostgreSQL 和 Redis 状态 healthy
- V6: Push 到远程仓库
git push origin main
Expected: Gitea Actions 触发 CI 流水线