fix: P0/P1 安全与质量缺陷修复 — 10 项 QA 审查问题解决
Some checks failed
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled

P0 安全修复:
- tenant_rls: SQL 拼接改为参数化查询防止注入
- follow_up_service: UUID SQL 拼接改为参数化原生查询
- RLS 策略: 新迁移移除空字符串绕过条件
- SSE 消息推送: token 键名 'token' → 'access_token' 修复
- rate_limit: 登录端点 Redis 不可达时 fail-close

P1 质量修复:
- 小程序缓存清理: preservedKeys 补全认证键名
- 小程序 token 刷新: 失败时清除所有认证数据
- 小程序 401: redirectTo → reLaunch 兼容 tabBar
- 集成测试: 信号量限制并行数据库创建(4个)
- change_password: 乐观锁 version 硬编码 → 动态递增

测试: 516 全部通过 (含 153 集成测试)
This commit is contained in:
iven
2026-04-28 00:57:41 +08:00
parent 3d34e021a9
commit 9dd6095e77
11 changed files with 391 additions and 21 deletions

View File

@@ -321,10 +321,11 @@ impl AuthService {
// 3. Hash new password and update credential
let new_hash = password::hash_password(new_password)?;
let current_version = cred.version;
let mut cred_active: user_credential::ActiveModel = cred.into();
cred_active.credential_data = Set(Some(serde_json::json!({ "hash": new_hash })));
cred_active.updated_at = Set(Utc::now());
cred_active.version = Set(2);
cred_active.version = Set(current_version + 1);
cred_active
.update(db)
.await

View File

@@ -5,7 +5,7 @@ use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::events::DomainEvent;
use sea_orm::entity::prelude::*;
use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect, TransactionTrait};
use sea_orm::{ActiveValue::Set, DatabaseBackend, QueryOrder, QuerySelect, Statement, TransactionTrait};
use uuid::Uuid;
use erp_core::error::check_version;
@@ -71,13 +71,16 @@ pub async fn list_tasks(
// 批量查询 assigned_to_name从 users 表)
let assigned_ids: HashSet<Uuid> = models.iter().filter_map(|m| m.assigned_to).collect();
let assigned_names: HashMap<Uuid, String> = if !assigned_ids.is_empty() {
let ids_csv = assigned_ids.iter().map(|id| format!("'{}'", id)).collect::<Vec<_>>().join(",");
let params: Vec<sea_orm::Value> = assigned_ids.iter().map(|id| (*id).into()).collect();
let placeholders: Vec<String> = (1..=params.len()).map(|i| format!("${}", i)).collect();
let mut values = params;
values.push(tenant_id.into());
let sql = format!(
"SELECT id, COALESCE(display_name, username) AS name FROM users WHERE id IN ({}) AND tenant_id = '{}'",
ids_csv, tenant_id
"SELECT id, COALESCE(display_name, username) AS name FROM users WHERE id IN ({}) AND tenant_id = ${}",
placeholders.join(","), values.len()
);
let rows = state.db.query_all(sea_orm::Statement::from_string(
sea_orm::DatabaseBackend::Postgres, sql,
let rows = state.db.query_all(Statement::from_sql_and_values(
DatabaseBackend::Postgres, sql, values,
)).await?;
rows.into_iter()
.filter_map(|row| {

View File

@@ -87,6 +87,7 @@ mod m20260427_000084_domain_events_cleanup;
mod m20260427_000085_processed_events;
mod m20260427_000086_enable_rls_all_tables;
mod m20260427_000087_audit_logs_hash_chain;
mod m20260428_000088_rls_policy_strict;
pub struct Migrator;
@@ -181,6 +182,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260427_000085_processed_events::Migration),
Box::new(m20260427_000086_enable_rls_all_tables::Migration),
Box::new(m20260427_000087_audit_logs_hash_chain::Migration),
Box::new(m20260428_000088_rls_policy_strict::Migration),
]
}
}

View File

@@ -0,0 +1,80 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let conn = manager.get_connection();
// 替换所有表的 RLS 策略:移除空字符串绕过条件
// 原策略允许 current_setting(...) = '' 时通过(绕过 RLS现在要求变量已设置且匹配
conn.execute_unprepared(
r#"
DO $$
DECLARE
tbl TEXT;
BEGIN
FOR tbl IN
SELECT c.table_name FROM information_schema.columns c
JOIN information_schema.tables t
ON c.table_name = t.table_name AND c.table_schema = t.table_schema
WHERE c.column_name = 'tenant_id'
AND c.table_schema = 'public'
AND t.table_type = 'BASE TABLE'
ORDER BY c.table_name
LOOP
EXECUTE format('DROP POLICY IF EXISTS tenant_isolation ON %I', tbl);
EXECUTE format(
'CREATE POLICY tenant_isolation ON %I USING (
current_setting(''app.current_tenant_id'', true) != ''''
AND tenant_id = current_setting(''app.current_tenant_id'', true)::uuid
)',
tbl
);
END LOOP;
END;
$$;
"#,
).await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let conn = manager.get_connection();
// 回滚:恢复允许空字符串绕过的原策略
conn.execute_unprepared(
r#"
DO $$
DECLARE
tbl TEXT;
BEGIN
FOR tbl IN
SELECT c.table_name FROM information_schema.columns c
JOIN information_schema.tables t
ON c.table_name = t.table_name AND c.table_schema = t.table_schema
WHERE c.column_name = 'tenant_id'
AND c.table_schema = 'public'
AND t.table_type = 'BASE TABLE'
ORDER BY c.table_name
LOOP
EXECUTE format('DROP POLICY IF EXISTS tenant_isolation ON %I', tbl);
EXECUTE format(
'CREATE POLICY tenant_isolation ON %I USING (
current_setting(''app.current_tenant_id'', true) = ''''
OR tenant_id = current_setting(''app.current_tenant_id'', true)::uuid
)',
tbl
);
END LOOP;
END;
$$;
"#,
).await?;
Ok(())
}
}

View File

@@ -180,10 +180,13 @@ pub async fn account_lockout_middleware(
) -> Response {
let avail = redis_avail();
// Redis 不可达时 fail-open放行请求
// Redis 不可达时 fail-close拒绝登录请求安全优先
if !avail.should_try().await {
tracing::warn!("Redis 不可达fail-open 账户锁定检查放行");
return next.run(req).await;
tracing::error!("Redis 不可达fail-close 拒绝登录请求");
return (StatusCode::SERVICE_UNAVAILABLE, axum::Json(RateLimitResponse {
error: "service_unavailable".to_string(),
message: "安全服务暂不可用,请稍后重试".to_string(),
})).into_response();
}
// 获取 Redis 连接
@@ -193,9 +196,12 @@ pub async fn account_lockout_middleware(
c
}
Err(e) => {
tracing::warn!(error = %e, "Redis 连接失败fail-open 账户锁定检查放行");
tracing::error!(error = %e, "Redis 连接失败fail-close 拒绝登录请求");
avail.mark_failed().await;
return next.run(req).await;
return (StatusCode::SERVICE_UNAVAILABLE, axum::Json(RateLimitResponse {
error: "service_unavailable".to_string(),
message: "安全服务暂不可用,请稍后重试".to_string(),
})).into_response();
}
};

View File

@@ -3,7 +3,7 @@ use axum::http::Request;
use axum::middleware::Next;
use axum::response::Response;
use erp_core::types::TenantContext;
use sea_orm::ConnectionTrait;
use sea_orm::{ConnectionTrait, DatabaseBackend, Statement};
/// Tenant RLS 中间件。
///
@@ -21,11 +21,12 @@ pub async fn tenant_rls_middleware(
let tenant_id = req.extensions().get::<TenantContext>().map(|ctx| ctx.tenant_id);
if let Some(tid) = tenant_id {
// SET app.current_tenant_id — RLS 策略读取此值
// SET app.current_tenant_id — RLS 策略读取此值(参数化查询防止注入)
if let Err(e) = db
.execute_unprepared(&format!(
"SET app.current_tenant_id = '{}'",
tid
.execute(Statement::from_sql_and_values(
DatabaseBackend::Postgres,
"SET app.current_tenant_id = $1",
[tid.into()],
))
.await
{

View File

@@ -1,7 +1,15 @@
use sea_orm::{Database, ConnectionTrait, Statement, DatabaseBackend};
use std::sync::Arc;
use erp_server_migration::MigratorTrait;
/// 全局信号量:限制同时创建数据库的测试数量,避免 PostgreSQL 连接耗尽
static DB_SEMAPHORE: std::sync::OnceLock<Arc<tokio::sync::Semaphore>> = std::sync::OnceLock::new();
fn db_semaphore() -> &'static Arc<tokio::sync::Semaphore> {
DB_SEMAPHORE.get_or_init(|| Arc::new(tokio::sync::Semaphore::new(4)))
}
/// 测试数据库 — 使用本地 PostgreSQL 创建隔离测试库
///
/// 连接本地 PostgreSQLwiki/infrastructure.md 配置),为每个测试创建独立的测试数据库。
@@ -9,10 +17,13 @@ use erp_server_migration::MigratorTrait;
pub struct TestDb {
db: Option<sea_orm::DatabaseConnection>,
db_name: String,
_permit: Option<tokio::sync::OwnedSemaphorePermit>,
}
impl TestDb {
pub async fn new() -> Self {
let permit = db_semaphore().clone().acquire_owned().await.expect("信号量获取失败");
let db_name = format!("erp_test_{}", uuid::Uuid::now_v7().simple());
let admin_url = std::env::var("TEST_DB_URL")
@@ -47,7 +58,7 @@ impl TestDb {
.await
.expect("执行数据库迁移失败");
Self { db: Some(db), db_name }
Self { db: Some(db), db_name, _permit: Some(permit) }
}
/// 获取数据库连接引用