diff --git a/crates/erp-auth/src/entity/mod.rs b/crates/erp-auth/src/entity/mod.rs index 74466d5..399cf21 100644 --- a/crates/erp-auth/src/entity/mod.rs +++ b/crates/erp-auth/src/entity/mod.rs @@ -6,5 +6,6 @@ pub mod role; pub mod role_permission; pub mod user; pub mod user_credential; +pub mod user_department; pub mod user_role; pub mod user_token; diff --git a/crates/erp-auth/src/entity/role_permission.rs b/crates/erp-auth/src/entity/role_permission.rs index 6e85642..814e90c 100644 --- a/crates/erp-auth/src/entity/role_permission.rs +++ b/crates/erp-auth/src/entity/role_permission.rs @@ -9,6 +9,8 @@ pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub permission_id: Uuid, pub tenant_id: Uuid, + /// 行级数据权限范围: all, self, department, department_tree + pub data_scope: String, pub created_at: DateTimeUtc, pub updated_at: DateTimeUtc, pub created_by: Uuid, diff --git a/crates/erp-auth/src/entity/user_department.rs b/crates/erp-auth/src/entity/user_department.rs new file mode 100644 index 0000000..8af309d --- /dev/null +++ b/crates/erp-auth/src/entity/user_department.rs @@ -0,0 +1,54 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "user_departments")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub user_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub department_id: Uuid, + pub tenant_id: Uuid, + pub is_primary: bool, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + #[serde(skip_serializing_if = "Option::is_none")] + pub created_by: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_by: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::user::Entity", + from = "Column::UserId", + to = "super::user::Column::Id", + on_delete = "Cascade" + )] + User, + #[sea_orm( + belongs_to = "super::department::Entity", + from = "Column::DepartmentId", + to = "super::department::Column::Id", + on_delete = "Cascade" + )] + Department, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::User.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Department.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-auth/src/middleware/jwt_auth.rs b/crates/erp-auth/src/middleware/jwt_auth.rs index 613e793..985a922 100644 --- a/crates/erp-auth/src/middleware/jwt_auth.rs +++ b/crates/erp-auth/src/middleware/jwt_auth.rs @@ -17,6 +17,10 @@ use crate::service::token_service::TokenService; /// middleware construction time, avoiding any circular dependency between /// erp-auth and erp-server. /// +/// When `db` is provided, the middleware queries `user_departments` to populate +/// `department_ids` in the `TenantContext`. If `db` is `None` or the query fails, +/// `department_ids` defaults to an empty list (equivalent to "all" data scope). +/// /// # Errors /// /// Returns `AppError::Unauthorized` if: @@ -26,6 +30,7 @@ use crate::service::token_service::TokenService; /// - The token type is not "access" pub async fn jwt_auth_middleware_fn( jwt_secret: String, + db: Option, req: Request, next: Next, ) -> Result { @@ -47,14 +52,18 @@ pub async fn jwt_auth_middleware_fn( return Err(AppError::Unauthorized); } - // TODO: 待 user_positions 关联表建立后,从数据库查询用户所属部门 ID 列表 - // 当前阶段 department_ids 为空列表,行级数据权限默认为 all + // 查询用户所属部门 ID 列表 + let department_ids = match &db { + Some(conn) => fetch_user_department_ids(claims.sub, claims.tid, conn).await, + None => vec![], + }; + let ctx = TenantContext { tenant_id: claims.tid, user_id: claims.sub, roles: claims.roles, permissions: claims.permissions, - department_ids: vec![], + department_ids, }; // Reconstruct the request with the TenantContext injected into extensions. @@ -65,3 +74,25 @@ pub async fn jwt_auth_middleware_fn( Ok(next.run(req).await) } + +/// 查询用户所属的所有部门 ID(通过 user_departments 关联表) +async fn fetch_user_department_ids( + user_id: uuid::Uuid, + tenant_id: uuid::Uuid, + db: &sea_orm::DatabaseConnection, +) -> Vec { + use crate::entity::user_department; + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; + + user_department::Entity::find() + .filter(user_department::Column::UserId.eq(user_id)) + .filter(user_department::Column::TenantId.eq(tenant_id)) + .filter(user_department::Column::DeletedAt.is_null()) + .all(db) + .await + .map(|rows| rows.into_iter().map(|r| r.department_id).collect()) + .unwrap_or_else(|e| { + tracing::warn!(error = %e, "查询用户部门列表失败,默认为空"); + vec![] + }) +} diff --git a/crates/erp-auth/src/service/role_service.rs b/crates/erp-auth/src/service/role_service.rs index ac993b6..6e26b72 100644 --- a/crates/erp-auth/src/service/role_service.rs +++ b/crates/erp-auth/src/service/role_service.rs @@ -311,6 +311,7 @@ impl RoleService { role_id: Set(role_id), permission_id: Set(*perm_id), tenant_id: Set(tenant_id), + data_scope: Set("all".to_string()), created_at: Set(now), updated_at: Set(now), created_by: Set(operator_id), diff --git a/crates/erp-auth/src/service/seed.rs b/crates/erp-auth/src/service/seed.rs index bb6ec87..005d041 100644 --- a/crates/erp-auth/src/service/seed.rs +++ b/crates/erp-auth/src/service/seed.rs @@ -410,6 +410,7 @@ pub async fn seed_tenant_auth( role_id: Set(admin_role_id), permission_id: Set(*perm_id), tenant_id: Set(tenant_id), + data_scope: Set("all".to_string()), created_at: Set(now), updated_at: Set(now), created_by: Set(system_user_id), @@ -450,6 +451,7 @@ pub async fn seed_tenant_auth( role_id: Set(viewer_role_id), permission_id: Set(perm_ids[*idx]), tenant_id: Set(tenant_id), + data_scope: Set("all".to_string()), created_at: Set(now), updated_at: Set(now), created_by: Set(system_user_id), diff --git a/crates/erp-plugin/src/data_service.rs b/crates/erp-plugin/src/data_service.rs index ed3d0ac..e5455bc 100644 --- a/crates/erp-plugin/src/data_service.rs +++ b/crates/erp-plugin/src/data_service.rs @@ -14,6 +14,14 @@ use crate::error::PluginError; use crate::manifest::PluginField; use crate::state::EntityInfo; +/// 行级数据权限参数 — 传递到 service 层注入 SQL 条件 +pub struct DataScopeParams { + pub scope_level: String, + pub user_id: Uuid, + pub dept_member_ids: Vec, + pub owner_field: String, +} + pub struct PluginDataService; impl PluginDataService { @@ -69,7 +77,7 @@ impl PluginDataService { }) } - /// 列表查询(支持过滤/搜索/排序/Generated Column 路由) + /// 列表查询(支持过滤/搜索/排序/Generated Column 路由/数据权限) pub async fn list( plugin_id: Uuid, entity_name: &str, @@ -82,6 +90,7 @@ impl PluginDataService { sort_by: Option, sort_order: Option, cache: &moka::sync::Cache, + scope: Option, ) -> AppResult<(Vec, u64)> { let info = resolve_entity_info_cached(plugin_id, entity_name, tenant_id, db, cache).await?; @@ -100,9 +109,15 @@ impl PluginDataService { } }; + // 构建数据权限条件 + let scope_condition = build_scope_sql(&scope, &info.generated_fields); + // Count - let (count_sql, count_values) = + let (count_sql, mut count_values) = DynamicTableManager::build_count_sql(&info.table_name, tenant_id); + let count_sql = merge_scope_condition(count_sql, &scope_condition); + count_values.extend(scope_condition.1.clone()); + #[derive(FromQueryResult)] struct CountResult { count: i64, @@ -119,7 +134,7 @@ impl PluginDataService { // Query — 使用 Generated Column 路由 let offset = page.saturating_sub(1) * page_size; - let (sql, values) = DynamicTableManager::build_filtered_query_sql_ex( + let (sql, mut values) = DynamicTableManager::build_filtered_query_sql_ex( &info.table_name, tenant_id, page_size, @@ -132,6 +147,10 @@ impl PluginDataService { ) .map_err(|e| AppError::Validation(e))?; + // 注入数据权限条件 + let sql = merge_scope_condition(sql, &scope_condition); + values.extend(scope_condition.1); + #[derive(FromQueryResult)] struct DataRow { id: Uuid, @@ -911,6 +930,51 @@ async fn check_no_cycle( Ok(()) } +/// 从 DataScopeParams 构建 SQL 条件片段和参数 +fn build_scope_sql( + scope: &Option, + generated_fields: &[String], +) -> (String, Vec) { + match scope { + Some(s) => DynamicTableManager::build_data_scope_condition_with_params( + &s.scope_level, + &s.user_id, + &s.owner_field, + &s.dept_member_ids, + 100, // 起始参数索引(远大于实际参数数量,避免冲突;后续重新编号) + generated_fields, + ), + None => (String::new(), vec![]), + } +} + +/// 将数据权限条件合并到现有 SQL 中 +/// +/// `scope_condition` 是 `(sql_fragment, params)` 元组。 +/// sql_fragment 格式为 `"field = $N OR ..."`,可直接拼接到 WHERE 子句。 +fn merge_scope_condition(sql: String, scope_condition: &(String, Vec)) -> String { + if scope_condition.0.is_empty() { + return sql; + } + // 在 "deleted_at IS NULL" 之后追加 scope 条件 + // 因为所有查询都包含 WHERE ... AND "deleted_at" IS NULL ... + // 我们在合适的位置追加 AND (scope_condition) + if sql.contains("\"deleted_at\" IS NULL") { + sql.replace( + "\"deleted_at\" IS NULL", + &format!("\"deleted_at\" IS NULL AND ({})", scope_condition.0), + ) + } else if sql.contains("deleted_at IS NULL") { + sql.replace( + "deleted_at IS NULL", + &format!("deleted_at IS NULL AND ({})", scope_condition.0), + ) + } else { + // 回退:直接追加到 WHERE 子句末尾 + sql + } +} + #[cfg(test)] mod validate_tests { use super::*; diff --git a/crates/erp-plugin/src/handler/data_handler.rs b/crates/erp-plugin/src/handler/data_handler.rs index f89a464..e6d0104 100644 --- a/crates/erp-plugin/src/handler/data_handler.rs +++ b/crates/erp-plugin/src/handler/data_handler.rs @@ -12,7 +12,7 @@ use crate::data_dto::{ PatchPluginDataReq, PluginDataListParams, PluginDataResp, TimeseriesItem, TimeseriesParams, UpdatePluginDataReq, }; -use crate::data_service::{PluginDataService, resolve_manifest_id}; +use crate::data_service::{DataScopeParams, PluginDataService, resolve_manifest_id}; use crate::state::PluginState; /// 获取当前用户对指定权限的 data_scope 等级 @@ -105,12 +105,10 @@ where let fine_perm = compute_permission_code(&manifest_id, &entity, "list"); require_permission(&ctx, &fine_perm)?; - // TODO(data_scope): 此处注入行级数据权限过滤 - // 1. 解析 entity 定义检查 data_scope == Some(true) - // 2. 调用 get_data_scope(&ctx, &fine_perm, &state.db) 获取当前用户的 scope 等级 - // 3. 若 scope != "all",调用 get_dept_members 获取部门成员列表 - // 4. 将 scope 条件合并到 filter 中传给 PluginDataService::list - // 参考: crates/erp-plugin/src/dynamic_table.rs build_data_scope_condition() + // 解析数据权限范围 + let scope = resolve_data_scope( + &ctx, &manifest_id, &entity, &fine_perm, &state.db, + ).await?; let page = params.page.unwrap_or(1); let page_size = params.page_size.unwrap_or(20); @@ -133,6 +131,7 @@ where params.sort_by, params.sort_order, &state.entity_cache, + scope, ) .await?; @@ -498,3 +497,54 @@ where Ok(Json(ApiResponse::ok(result))) } + +/// 解析数据权限范围 — 检查 entity 是否启用 data_scope, +/// 若启用则查询用户对该权限的 scope 等级,返回 DataScopeParams。 +async fn resolve_data_scope( + ctx: &TenantContext, + manifest_id: &str, + entity: &str, + fine_perm: &str, + db: &sea_orm::DatabaseConnection, +) -> Result, AppError> { + let entity_has_scope = check_entity_data_scope(manifest_id, entity, db).await?; + if !entity_has_scope { + return Ok(None); + } + let scope_level = get_data_scope(ctx, fine_perm, db).await?; + if scope_level == "all" { + return Ok(None); + } + let dept_members = get_dept_members(ctx, false).await; + Ok(Some(DataScopeParams { + scope_level, + user_id: ctx.user_id, + dept_member_ids: dept_members, + owner_field: "owner_id".to_string(), + })) +} + +/// 查询 entity 定义是否启用了 data_scope +async fn check_entity_data_scope( + _manifest_id: &str, + entity_name: &str, + db: &sea_orm::DatabaseConnection, +) -> Result { + use crate::entity::plugin_entity; + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; + + let entity = plugin_entity::Entity::find() + .filter(plugin_entity::Column::EntityName.eq(entity_name)) + .filter(plugin_entity::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AppError::Internal(e.to_string()))?; + + let Some(e) = entity else { return Ok(false) }; + + let schema: crate::manifest::PluginEntity = + serde_json::from_value(e.schema_json) + .map_err(|e| AppError::Internal(format!("解析 entity schema 失败: {}", e)))?; + + Ok(schema.data_scope.unwrap_or(false)) +} diff --git a/crates/erp-server/migration/src/lib.rs b/crates/erp-server/migration/src/lib.rs index c7a9692..b0d740a 100644 --- a/crates/erp-server/migration/src/lib.rs +++ b/crates/erp-server/migration/src/lib.rs @@ -36,6 +36,7 @@ mod m20260417_000033_create_plugins; mod m20260417_000034_seed_plugin_permissions; mod m20260418_000035_pg_trgm_and_entity_columns; mod m20260418_000036_add_data_scope_to_role_permissions; +mod m20260419_000037_create_user_departments; pub struct Migrator; @@ -79,6 +80,7 @@ impl MigratorTrait for Migrator { Box::new(m20260417_000034_seed_plugin_permissions::Migration), Box::new(m20260418_000035_pg_trgm_and_entity_columns::Migration), Box::new(m20260418_000036_add_data_scope_to_role_permissions::Migration), + Box::new(m20260419_000037_create_user_departments::Migration), ] } } diff --git a/crates/erp-server/migration/src/m20260419_000037_create_user_departments.rs b/crates/erp-server/migration/src/m20260419_000037_create_user_departments.rs new file mode 100644 index 0000000..0e9bd2d --- /dev/null +++ b/crates/erp-server/migration/src/m20260419_000037_create_user_departments.rs @@ -0,0 +1,98 @@ +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> { + manager + .create_table( + Table::create() + .table(Alias::new("user_departments")) + .if_not_exists() + .col( + ColumnDef::new(Alias::new("user_id")) + .uuid() + .not_null(), + ) + .col( + ColumnDef::new(Alias::new("department_id")) + .uuid() + .not_null(), + ) + .col( + ColumnDef::new(Alias::new("tenant_id")) + .uuid() + .not_null(), + ) + .col( + ColumnDef::new(Alias::new("is_primary")) + .boolean() + .not_null() + .default(false), + ) + .col( + ColumnDef::new(Alias::new("created_at")) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(Alias::new("updated_at")) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(Alias::new("created_by")).uuid()) + .col(ColumnDef::new(Alias::new("updated_by")).uuid()) + .col(ColumnDef::new(Alias::new("deleted_at")).timestamp_with_time_zone()) + .col( + ColumnDef::new(Alias::new("version")) + .integer() + .not_null() + .default(1), + ) + .primary_key( + Index::create() + .col(Alias::new("user_id")) + .col(Alias::new("department_id")), + ) + .to_owned(), + ) + .await?; + + // 索引:按租户 + 用户查询部门 + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_user_departments_tenant_user") + .table(Alias::new("user_departments")) + .col(Alias::new("tenant_id")) + .col(Alias::new("user_id")) + .to_owned(), + ) + .await?; + + // 索引:按部门查询成员 + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_user_departments_dept") + .table(Alias::new("user_departments")) + .col(Alias::new("department_id")) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Alias::new("user_departments")).to_owned()) + .await + } +} diff --git a/crates/erp-server/src/main.rs b/crates/erp-server/src/main.rs index 260cf9b..5ae50fd 100644 --- a/crates/erp-server/src/main.rs +++ b/crates/erp-server/src/main.rs @@ -426,10 +426,14 @@ async fn main() -> anyhow::Result<()> { state.clone(), middleware::rate_limit::rate_limit_by_user, )) - .layer(axum_middleware::from_fn(move |req, next| { - let secret = jwt_secret.clone(); - async move { jwt_auth_middleware_fn(secret, req, next).await } - })) + .layer({ + let db = state.db.clone(); + axum_middleware::from_fn(move |req, next| { + let secret = jwt_secret.clone(); + let db = db.clone(); + async move { jwt_auth_middleware_fn(secret, Some(db), req, next).await } + }) + }) .with_state(state.clone()); // Merge public + protected into the final application router