From b3c7f76b7f50430ddffadb5de4589db22c32cadb Mon Sep 17 00:00:00 2001 From: iven Date: Sat, 11 Apr 2026 12:49:45 +0800 Subject: [PATCH] fix(security): resolve audit findings and compilation errors (Phase 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security fixes: - Add startup warning for default JWT secret in config - Add enum validation for priority, recipient_type, channel fields - Add pagination size cap (max 100) via safe_page_size() - Return generic "权限不足" instead of specific permission names Compilation fixes: - Fix missing standard fields in ActiveModel for tokens/process_variables - Fix migration imports for Statement/DatabaseBackend/Uuid - Add version_field to process_definition ActiveModel Co-Authored-By: Claude Opus 4.6 --- crates/erp-core/src/rbac.rs | 9 +- crates/erp-message/src/dto.rs | 31 +++++++ .../src/service/message_service.rs | 2 +- crates/erp-server/migration/src/lib.rs | 6 ++ ...4_000027_fix_unique_indexes_soft_delete.rs | 92 +++++++++++++++++++ ...14_000028_add_standard_fields_to_tokens.rs | 74 +++++++++++++++ ...dd_standard_fields_to_process_variables.rs | 82 +++++++++++++++++ crates/erp-server/src/config.rs | 9 +- crates/erp-workflow/src/engine/executor.rs | 7 ++ .../src/service/definition_service.rs | 1 + .../src/service/instance_service.rs | 19 ++++ 11 files changed, 324 insertions(+), 8 deletions(-) create mode 100644 crates/erp-server/migration/src/m20260414_000027_fix_unique_indexes_soft_delete.rs create mode 100644 crates/erp-server/migration/src/m20260414_000028_add_standard_fields_to_tokens.rs create mode 100644 crates/erp-server/migration/src/m20260414_000029_add_standard_fields_to_process_variables.rs diff --git a/crates/erp-core/src/rbac.rs b/crates/erp-core/src/rbac.rs index 40124b9..a147812 100644 --- a/crates/erp-core/src/rbac.rs +++ b/crates/erp-core/src/rbac.rs @@ -8,7 +8,7 @@ pub fn require_permission(ctx: &TenantContext, permission: &str) -> Result<(), A if ctx.permissions.iter().any(|p| p == permission) { Ok(()) } else { - Err(AppError::Forbidden(format!("需要权限: {}", permission))) + Err(AppError::Forbidden("权限不足".to_string())) } } @@ -26,10 +26,7 @@ pub fn require_any_permission( if has_any { Ok(()) } else { - Err(AppError::Forbidden(format!( - "需要以下权限之一: {}", - permissions.join(", ") - ))) + Err(AppError::Forbidden("权限不足".to_string())) } } @@ -40,7 +37,7 @@ pub fn require_role(ctx: &TenantContext, role: &str) -> Result<(), AppError> { if ctx.roles.iter().any(|r| r == role) { Ok(()) } else { - Err(AppError::Forbidden(format!("需要角色: {}", role))) + Err(AppError::Forbidden("权限不足".to_string())) } } diff --git a/crates/erp-message/src/dto.rs b/crates/erp-message/src/dto.rs index 0c9d819..7df7ca5 100644 --- a/crates/erp-message/src/dto.rs +++ b/crates/erp-message/src/dto.rs @@ -41,14 +41,30 @@ pub struct SendMessageReq { pub body: String, pub recipient_id: Uuid, #[serde(default = "default_recipient_type")] + #[validate(custom(function = "validate_recipient_type"))] pub recipient_type: String, #[serde(default = "default_priority")] + #[validate(custom(function = "validate_priority"))] pub priority: String, pub template_id: Option, pub business_type: Option, pub business_id: Option, } +fn validate_recipient_type(value: &str) -> Result<(), validator::ValidationError> { + match value { + "user" | "role" | "department" | "all" => Ok(()), + _ => Err(validator::ValidationError::new("invalid_recipient_type")), + } +} + +fn validate_priority(value: &str) -> Result<(), validator::ValidationError> { + match value { + "normal" | "important" | "urgent" => Ok(()), + _ => Err(validator::ValidationError::new("invalid_priority")), + } +} + fn default_recipient_type() -> String { "user".to_string() } @@ -68,6 +84,13 @@ pub struct MessageQuery { pub status: Option, } +impl MessageQuery { + /// 获取安全的分页大小(上限 100)。 + pub fn safe_page_size(&self) -> u64 { + self.page_size.unwrap_or(20).min(100) + } +} + /// 未读消息计数响应 #[derive(Debug, Serialize, ToSchema)] pub struct UnreadCountResp { @@ -99,6 +122,7 @@ pub struct CreateTemplateReq { #[validate(length(min = 1, max = 50, message = "编码不能为空且不超过50字符"))] pub code: String, #[serde(default = "default_channel")] + #[validate(custom(function = "validate_channel"))] pub channel: String, #[validate(length(min = 1, max = 200, message = "标题模板不能为空"))] pub title_template: String, @@ -112,6 +136,13 @@ fn default_channel() -> String { "in_app".to_string() } +fn validate_channel(value: &str) -> Result<(), validator::ValidationError> { + match value { + "in_app" | "email" | "sms" | "wechat" => Ok(()), + _ => Err(validator::ValidationError::new("invalid_channel")), + } +} + fn default_language() -> String { "zh-CN".to_string() } diff --git a/crates/erp-message/src/service/message_service.rs b/crates/erp-message/src/service/message_service.rs index 8de5a8e..0ab4146 100644 --- a/crates/erp-message/src/service/message_service.rs +++ b/crates/erp-message/src/service/message_service.rs @@ -20,7 +20,7 @@ impl MessageService { query: &MessageQuery, db: &sea_orm::DatabaseConnection, ) -> MessageResult<(Vec, u64)> { - let page_size = query.page_size.unwrap_or(20); + let page_size = query.safe_page_size(); let mut q = message::Entity::find() .filter(message::Column::TenantId.eq(tenant_id)) .filter(message::Column::RecipientId.eq(recipient_id)) diff --git a/crates/erp-server/migration/src/lib.rs b/crates/erp-server/migration/src/lib.rs index 3237272..a996065 100644 --- a/crates/erp-server/migration/src/lib.rs +++ b/crates/erp-server/migration/src/lib.rs @@ -26,6 +26,9 @@ mod m20260413_000023_create_message_templates; mod m20260413_000024_create_messages; mod m20260413_000025_create_message_subscriptions; mod m20260413_000026_create_audit_logs; +mod m20260414_000027_fix_unique_indexes_soft_delete; +mod m20260414_000028_add_standard_fields_to_tokens; +mod m20260414_000029_add_standard_fields_to_process_variables; pub struct Migrator; @@ -59,6 +62,9 @@ impl MigratorTrait for Migrator { Box::new(m20260413_000024_create_messages::Migration), Box::new(m20260413_000025_create_message_subscriptions::Migration), Box::new(m20260413_000026_create_audit_logs::Migration), + Box::new(m20260414_000027_fix_unique_indexes_soft_delete::Migration), + Box::new(m20260414_000028_add_standard_fields_to_tokens::Migration), + Box::new(m20260414_000029_add_standard_fields_to_process_variables::Migration), ] } } diff --git a/crates/erp-server/migration/src/m20260414_000027_fix_unique_indexes_soft_delete.rs b/crates/erp-server/migration/src/m20260414_000027_fix_unique_indexes_soft_delete.rs new file mode 100644 index 0000000..fd052b3 --- /dev/null +++ b/crates/erp-server/migration/src/m20260414_000027_fix_unique_indexes_soft_delete.rs @@ -0,0 +1,92 @@ +use sea_orm_migration::prelude::*; +use sea_orm::Statement; +use sea_orm::DatabaseBackend; + +/// Recreate unique indexes on roles and permissions to include soft-delete awareness. +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute(Statement::from_string( + DatabaseBackend::Postgres, + "DROP INDEX IF EXISTS idx_roles_tenant_code".to_string(), + )) + .await?; + + manager + .get_connection() + .execute(Statement::from_string( + DatabaseBackend::Postgres, + "DROP INDEX IF EXISTS idx_permissions_tenant_code".to_string(), + )) + .await?; + + manager + .get_connection() + .execute(Statement::from_string( + DatabaseBackend::Postgres, + "CREATE UNIQUE INDEX idx_roles_tenant_code \ + ON roles (tenant_id, code) \ + WHERE deleted_at IS NULL" + .to_string(), + )) + .await?; + + manager + .get_connection() + .execute(Statement::from_string( + DatabaseBackend::Postgres, + "CREATE UNIQUE INDEX idx_permissions_tenant_code \ + ON permissions (tenant_id, code) \ + WHERE deleted_at IS NULL" + .to_string(), + )) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute(Statement::from_string( + DatabaseBackend::Postgres, + "DROP INDEX IF EXISTS idx_roles_tenant_code".to_string(), + )) + .await?; + + manager + .get_connection() + .execute(Statement::from_string( + DatabaseBackend::Postgres, + "DROP INDEX IF EXISTS idx_permissions_tenant_code".to_string(), + )) + .await?; + + manager + .get_connection() + .execute(Statement::from_string( + DatabaseBackend::Postgres, + "CREATE UNIQUE INDEX idx_roles_tenant_code \ + ON roles (tenant_id, code)" + .to_string(), + )) + .await?; + + manager + .get_connection() + .execute(Statement::from_string( + DatabaseBackend::Postgres, + "CREATE UNIQUE INDEX idx_permissions_tenant_code \ + ON permissions (tenant_id, code)" + .to_string(), + )) + .await?; + + Ok(()) + } +} diff --git a/crates/erp-server/migration/src/m20260414_000028_add_standard_fields_to_tokens.rs b/crates/erp-server/migration/src/m20260414_000028_add_standard_fields_to_tokens.rs new file mode 100644 index 0000000..a25f4e9 --- /dev/null +++ b/crates/erp-server/migration/src/m20260414_000028_add_standard_fields_to_tokens.rs @@ -0,0 +1,74 @@ +use sea_orm_migration::prelude::*; + +/// 为 tokens 表添加缺失的标准字段: updated_at, created_by, updated_by, deleted_at, version。 +/// +/// tokens 表原始迁移缺少 ERP 标准要求的审计和乐观锁字段。 +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Tokens::Table) + .add_column( + ColumnDef::new(Tokens::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .add_column( + ColumnDef::new(Tokens::CreatedBy) + .uuid() + .not_null() + .default(Expr::val("00000000-0000-0000-0000-000000000000")), + ) + .add_column( + ColumnDef::new(Tokens::UpdatedBy) + .uuid() + .not_null() + .default(Expr::val("00000000-0000-0000-0000-000000000000")), + ) + .add_column( + ColumnDef::new(Tokens::DeletedAt) + .timestamp_with_time_zone() + .null(), + ) + .add_column( + ColumnDef::new(Tokens::Version) + .integer() + .not_null() + .default(1), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Tokens::Table) + .drop_column(Tokens::UpdatedAt) + .drop_column(Tokens::CreatedBy) + .drop_column(Tokens::UpdatedBy) + .drop_column(Tokens::DeletedAt) + .drop_column(Tokens::Version) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum Tokens { + Table, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + Version, +} diff --git a/crates/erp-server/migration/src/m20260414_000029_add_standard_fields_to_process_variables.rs b/crates/erp-server/migration/src/m20260414_000029_add_standard_fields_to_process_variables.rs new file mode 100644 index 0000000..f0f2ecc --- /dev/null +++ b/crates/erp-server/migration/src/m20260414_000029_add_standard_fields_to_process_variables.rs @@ -0,0 +1,82 @@ +use sea_orm_migration::prelude::*; + +/// 为 process_variables 表添加缺失的标准字段: created_at, updated_at, created_by, updated_by, deleted_at, version。 +/// +/// process_variables 表原始迁移缺少 ERP 标准要求的审计和乐观锁字段。 +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(ProcessVariables::Table) + .add_column( + ColumnDef::new(ProcessVariables::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .add_column( + ColumnDef::new(ProcessVariables::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .add_column( + ColumnDef::new(ProcessVariables::CreatedBy) + .uuid() + .not_null() + .default(Expr::val("00000000-0000-0000-0000-000000000000")), + ) + .add_column( + ColumnDef::new(ProcessVariables::UpdatedBy) + .uuid() + .not_null() + .default(Expr::val("00000000-0000-0000-0000-000000000000")), + ) + .add_column( + ColumnDef::new(ProcessVariables::DeletedAt) + .timestamp_with_time_zone() + .null(), + ) + .add_column( + ColumnDef::new(ProcessVariables::Version) + .integer() + .not_null() + .default(1), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(ProcessVariables::Table) + .drop_column(ProcessVariables::CreatedAt) + .drop_column(ProcessVariables::UpdatedAt) + .drop_column(ProcessVariables::CreatedBy) + .drop_column(ProcessVariables::UpdatedBy) + .drop_column(ProcessVariables::DeletedAt) + .drop_column(ProcessVariables::Version) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum ProcessVariables { + Table, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + Version, +} diff --git a/crates/erp-server/src/config.rs b/crates/erp-server/src/config.rs index 7f801e9..926d16e 100644 --- a/crates/erp-server/src/config.rs +++ b/crates/erp-server/src/config.rs @@ -59,6 +59,13 @@ impl AppConfig { .add_source(config::File::with_name("config/default")) .add_source(config::Environment::with_prefix("ERP").separator("__")) .build()?; - Ok(config.try_deserialize()?) + let app_config: Self = config.try_deserialize()?; + + // 安全检查:禁止在生产使用默认 JWT 密钥 + if app_config.jwt.secret == "change-me-in-production" { + tracing::warn!("⚠️ JWT 密钥使用默认值,请通过 ERP__JWT__SECRET 环境变量设置安全密钥"); + } + + Ok(app_config) } } diff --git a/crates/erp-workflow/src/engine/executor.rs b/crates/erp-workflow/src/engine/executor.rs index a09441a..3fe4f88 100644 --- a/crates/erp-workflow/src/engine/executor.rs +++ b/crates/erp-workflow/src/engine/executor.rs @@ -250,6 +250,8 @@ impl FlowExecutor { let new_token_id = Uuid::now_v7(); let now = Utc::now(); + let system_user = uuid::Uuid::nil(); + let token_model = token::ActiveModel { id: Set(new_token_id), tenant_id: Set(tenant_id), @@ -257,6 +259,11 @@ impl FlowExecutor { node_id: Set(node_id.to_string()), status: Set("active".to_string()), created_at: Set(now), + updated_at: Set(now), + created_by: Set(system_user), + updated_by: Set(system_user), + deleted_at: Set(None), + version: Set(1), consumed_at: Set(None), }; token_model diff --git a/crates/erp-workflow/src/service/definition_service.rs b/crates/erp-workflow/src/service/definition_service.rs index 78838a9..4948a54 100644 --- a/crates/erp-workflow/src/service/definition_service.rs +++ b/crates/erp-workflow/src/service/definition_service.rs @@ -93,6 +93,7 @@ impl DefinitionService { created_by: Set(operator_id), updated_by: Set(operator_id), deleted_at: Set(None), + version_field: Set(1), }; model .insert(db) diff --git a/crates/erp-workflow/src/service/instance_service.rs b/crates/erp-workflow/src/service/instance_service.rs index 4eb1964..07c22c7 100644 --- a/crates/erp-workflow/src/service/instance_service.rs +++ b/crates/erp-workflow/src/service/instance_service.rs @@ -257,6 +257,16 @@ impl InstanceService { Self::change_status(id, tenant_id, operator_id, "running", "terminated", db).await } + /// 恢复已挂起的流程实例。 + pub async fn resume( + id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> WorkflowResult<()> { + Self::change_status(id, tenant_id, operator_id, "suspended", "running", db).await + } + async fn change_status( id: Uuid, tenant_id: Uuid, @@ -332,6 +342,9 @@ impl InstanceService { _ => (Some(value.to_string()), None, None, None), }; + let now = chrono::Utc::now(); + let system_user = uuid::Uuid::nil(); + let model = process_variable::ActiveModel { id: Set(id), tenant_id: Set(tenant_id), @@ -342,6 +355,12 @@ impl InstanceService { value_number: Set(value_number), value_boolean: Set(value_boolean), value_date: Set(None), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(system_user), + updated_by: Set(system_user), + deleted_at: Set(None), + version: Set(1), }; model .insert(txn)