diff --git a/crates/erp-health/src/fhir/handler.rs b/crates/erp-health/src/fhir/handler.rs index 13bcffb..aad91b5 100644 --- a/crates/erp-health/src/fhir/handler.rs +++ b/crates/erp-health/src/fhir/handler.rs @@ -12,6 +12,7 @@ use erp_core::types::TenantContext; use crate::fhir::converter; use crate::fhir::types::{category_to_device_types, loinc_to_device_type}; +use crate::oauth::middleware::FhirAuthContext; use crate::state::HealthState; /// GET /fhir/R4/metadata — FHIR CapabilityStatement @@ -65,11 +66,38 @@ pub struct FhirSearchParams { pub status: Option, } +// ── 患者访问范围辅助 ────────────────────────────────────────────────── + +/// 检查单个 patient_id 是否在 OAuth 客户端的允许范围内 +fn enforce_patient_scope(fhir_ctx: &FhirAuthContext, patient_id: Uuid) -> Result<(), AppError> { + if let Some(ref allowed) = fhir_ctx.allowed_patient_ids { + if !allowed.iter().any(|id| id == &patient_id.to_string()) { + tracing::warn!( + client_id = %fhir_ctx.client_id, + requested_patient = %patient_id, + "FHIR 客户端尝试访问授权范围外的患者" + ); + return Err(AppError::Forbidden("Access denied: patient not in allowed scope".into())); + } + } + Ok(()) +} + +/// 解析 allowed_patient_ids 为 Uuid 列表 +fn resolve_allowed_patient_uuids(fhir_ctx: &FhirAuthContext) -> Option> { + fhir_ctx.allowed_patient_ids.as_ref().map(|ids| { + ids.iter() + .filter_map(|id| Uuid::parse_str(id).ok()) + .collect() + }) +} + // ── Patient ──────────────────────────────────────────────────────────── pub async fn search_patients( State(state): State, Extension(ctx): Extension, + Extension(fhir_ctx): Extension, Query(params): Query, ) -> Result { require_permission(&ctx, "health.patient.list")?; @@ -89,6 +117,13 @@ pub async fn search_patients( query = query.filter(crate::entity::patient::Column::IdNumber.contains(identifier)); } + // 强制执行 allowed_patient_ids 范围 + if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx) { + if !uuids.is_empty() { + query = query.filter(crate::entity::patient::Column::Id.is_in(uuids)); + } + } + let limit = params.count.unwrap_or(20).min(100); let offset = params.offset.unwrap_or(0); let patients = query @@ -112,9 +147,11 @@ pub async fn search_patients( pub async fn get_patient( State(state): State, Extension(ctx): Extension, + Extension(fhir_ctx): Extension, Path(id): Path, ) -> Result { require_permission(&ctx, "health.patient.list")?; + enforce_patient_scope(&fhir_ctx, id)?; let patient = crate::entity::patient::Entity::find_by_id(id) .one(&state.db) @@ -133,6 +170,7 @@ pub async fn get_patient( pub async fn search_observations( State(state): State, Extension(ctx): Extension, + Extension(fhir_ctx): Extension, Query(params): Query, ) -> Result { require_permission(&ctx, "health.device-readings.list")?; @@ -181,6 +219,13 @@ pub async fn search_observations( } } + // 强制执行 allowed_patient_ids 范围 + if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx) { + if !uuids.is_empty() { + query = query.filter(crate::entity::device_readings::Column::PatientId.is_in(uuids)); + } + } + let limit = params.count.unwrap_or(50).min(200); let readings = query .order_by_desc(crate::entity::device_readings::Column::MeasuredAt) @@ -208,6 +253,7 @@ pub async fn search_observations( pub async fn search_devices( State(state): State, Extension(ctx): Extension, + Extension(fhir_ctx): Extension, Query(params): Query, ) -> Result { require_permission(&ctx, "health.devices.list")?; @@ -222,6 +268,13 @@ pub async fn search_devices( query = query.filter(crate::entity::patient_devices::Column::PatientId.eq(uid)); } + // 强制执行 allowed_patient_ids 范围 + if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx) { + if !uuids.is_empty() { + query = query.filter(crate::entity::patient_devices::Column::PatientId.is_in(uuids)); + } + } + let limit = params.count.unwrap_or(50).min(200); let devices = query.limit(limit).all(&state.db).await?; @@ -240,6 +293,7 @@ pub async fn search_devices( pub async fn get_device( State(state): State, Extension(ctx): Extension, + Extension(fhir_ctx): Extension, Path(id): Path, ) -> Result { require_permission(&ctx, "health.devices.list")?; @@ -253,6 +307,8 @@ pub async fn get_device( return Err(AppError::Forbidden("Access denied".into())); } + enforce_patient_scope(&fhir_ctx, device.patient_id)?; + Ok(Json(converter::patient_device_to_fhir(&device))) } @@ -312,6 +368,7 @@ pub async fn get_practitioner( pub async fn search_appointments( State(state): State, Extension(ctx): Extension, + Extension(fhir_ctx): Extension, Query(params): Query, ) -> Result { require_permission(&ctx, "health.appointment.list")?; @@ -329,6 +386,13 @@ pub async fn search_appointments( query = query.filter(crate::entity::appointment::Column::Status.eq(status)); } + // 强制执行 allowed_patient_ids 范围 + if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx) { + if !uuids.is_empty() { + query = query.filter(crate::entity::appointment::Column::PatientId.is_in(uuids)); + } + } + let limit = params.count.unwrap_or(50).min(200); let appointments = query .order_by_desc(crate::entity::appointment::Column::AppointmentDate) @@ -351,6 +415,7 @@ pub async fn search_appointments( pub async fn get_appointment( State(state): State, Extension(ctx): Extension, + Extension(fhir_ctx): Extension, Path(id): Path, ) -> Result { require_permission(&ctx, "health.appointment.list")?; @@ -364,6 +429,8 @@ pub async fn get_appointment( return Err(AppError::Forbidden("Access denied".into())); } + enforce_patient_scope(&fhir_ctx, appointment.patient_id)?; + Ok(Json(converter::appointment_to_fhir(&appointment))) } @@ -372,6 +439,7 @@ pub async fn get_appointment( pub async fn search_diagnostic_reports( State(state): State, Extension(ctx): Extension, + Extension(fhir_ctx): Extension, Query(params): Query, ) -> Result { require_permission(&ctx, "health.health-data.list")?; @@ -392,6 +460,13 @@ pub async fn search_diagnostic_reports( query = query.filter(crate::entity::lab_report::Column::Status.eq(status)); } + // 强制执行 allowed_patient_ids 范围 + if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx) { + if !uuids.is_empty() { + query = query.filter(crate::entity::lab_report::Column::PatientId.is_in(uuids)); + } + } + let limit = params.count.unwrap_or(50).min(200); let reports = query .order_by_desc(crate::entity::lab_report::Column::ReportDate) @@ -414,6 +489,7 @@ pub async fn search_diagnostic_reports( pub async fn get_diagnostic_report( State(state): State, Extension(ctx): Extension, + Extension(fhir_ctx): Extension, Path(id): Path, ) -> Result { require_permission(&ctx, "health.health-data.list")?; @@ -427,6 +503,8 @@ pub async fn get_diagnostic_report( return Err(AppError::Forbidden("Access denied".into())); } + enforce_patient_scope(&fhir_ctx, report.patient_id)?; + Ok(Json(converter::lab_report_to_fhir(&report))) } @@ -435,6 +513,7 @@ pub async fn get_diagnostic_report( pub async fn search_encounters( State(state): State, Extension(ctx): Extension, + Extension(fhir_ctx): Extension, Query(params): Query, ) -> Result { require_permission(&ctx, "health.consultation.list")?; @@ -452,6 +531,13 @@ pub async fn search_encounters( query = query.filter(crate::entity::consultation_session::Column::Status.eq(status)); } + // 强制执行 allowed_patient_ids 范围 + if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx) { + if !uuids.is_empty() { + query = query.filter(crate::entity::consultation_session::Column::PatientId.is_in(uuids)); + } + } + let limit = params.count.unwrap_or(50).min(200); let sessions = query .order_by_desc(crate::entity::consultation_session::Column::CreatedAt) @@ -474,6 +560,7 @@ pub async fn search_encounters( pub async fn get_encounter( State(state): State, Extension(ctx): Extension, + Extension(fhir_ctx): Extension, Path(id): Path, ) -> Result { require_permission(&ctx, "health.consultation.list")?; @@ -487,6 +574,8 @@ pub async fn get_encounter( return Err(AppError::Forbidden("Access denied".into())); } + enforce_patient_scope(&fhir_ctx, session.patient_id)?; + Ok(Json(converter::consultation_to_fhir(&session))) } @@ -495,6 +584,7 @@ pub async fn get_encounter( pub async fn search_tasks( State(state): State, Extension(ctx): Extension, + Extension(fhir_ctx): Extension, Query(params): Query, ) -> Result { require_permission(&ctx, "health.follow-up.list")?; @@ -512,6 +602,13 @@ pub async fn search_tasks( query = query.filter(crate::entity::follow_up_task::Column::Status.eq(status)); } + // 强制执行 allowed_patient_ids 范围 + if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx) { + if !uuids.is_empty() { + query = query.filter(crate::entity::follow_up_task::Column::PatientId.is_in(uuids)); + } + } + let limit = params.count.unwrap_or(50).min(200); let tasks = query .order_by_desc(crate::entity::follow_up_task::Column::PlannedDate) @@ -534,6 +631,7 @@ pub async fn search_tasks( pub async fn get_task( State(state): State, Extension(ctx): Extension, + Extension(fhir_ctx): Extension, Path(id): Path, ) -> Result { require_permission(&ctx, "health.follow-up.list")?; @@ -547,6 +645,8 @@ pub async fn get_task( return Err(AppError::Forbidden("Access denied".into())); } + enforce_patient_scope(&fhir_ctx, task.patient_id)?; + Ok(Json(converter::follow_up_to_fhir(&task))) } @@ -556,9 +656,11 @@ pub async fn get_task( pub async fn patient_everything( State(state): State, Extension(ctx): Extension, + Extension(fhir_ctx): Extension, Path(id): Path, ) -> Result { require_permission(&ctx, "health.patient.list")?; + enforce_patient_scope(&fhir_ctx, id)?; let patient = crate::entity::patient::Entity::find_by_id(id) .one(&state.db) diff --git a/crates/erp-health/src/oauth/handler.rs b/crates/erp-health/src/oauth/handler.rs index d510e8b..c0b216d 100644 --- a/crates/erp-health/src/oauth/handler.rs +++ b/crates/erp-health/src/oauth/handler.rs @@ -4,6 +4,7 @@ use axum::{ Extension, Json, }; use erp_core::error::AppError; +use erp_core::rbac::require_permission; use erp_core::types::TenantContext; use uuid::Uuid; @@ -18,7 +19,7 @@ pub async fn token( Json(req): Json, ) -> Result<(StatusCode, Json), (StatusCode, Json)> { let jwt_secret = std::env::var("ERP__AUTH__JWT_SECRET") - .unwrap_or_else(|_| "dev-secret-key".to_string()); + .expect("ERP__AUTH__JWT_SECRET 环境变量未设置 — 无法签发 OAuth token"); match OAuthService::token(&state.db, &req, &jwt_secret).await { Ok(resp) => Ok((StatusCode::OK, Json(resp))), @@ -54,6 +55,7 @@ pub async fn create_client( Extension(tenant_ctx): Extension, Json(req): Json, ) -> Result, AppError> { + require_permission(&tenant_ctx, "health.oauth.manage")?; OAuthService::create_client(&state.db, tenant_ctx.tenant_id, &req, tenant_ctx.user_id) .await .map_err(AppError::from) @@ -65,6 +67,7 @@ pub async fn list_clients( State(state): State, Extension(tenant_ctx): Extension, ) -> Result>, AppError> { + require_permission(&tenant_ctx, "health.oauth.list")?; OAuthService::list_clients(&state.db, tenant_ctx.tenant_id) .await .map_err(AppError::from) @@ -78,6 +81,7 @@ pub async fn update_client( Path(id): Path, Json(req): Json, ) -> Result, AppError> { + require_permission(&tenant_ctx, "health.oauth.manage")?; OAuthService::update_client(&state.db, tenant_ctx.tenant_id, id, &req, tenant_ctx.user_id) .await .map_err(AppError::from) @@ -90,6 +94,7 @@ pub async fn delete_client( Extension(tenant_ctx): Extension, Path(id): Path, ) -> Result { + require_permission(&tenant_ctx, "health.oauth.manage")?; OAuthService::delete_client(&state.db, tenant_ctx.tenant_id, id) .await .map_err(AppError::from)?; @@ -102,6 +107,7 @@ pub async fn regenerate_secret( Extension(tenant_ctx): Extension, Path(id): Path, ) -> Result, AppError> { + require_permission(&tenant_ctx, "health.oauth.manage")?; let (client_id, plain) = OAuthService::regenerate_secret(&state.db, tenant_ctx.tenant_id, id) .await diff --git a/crates/erp-health/src/oauth/middleware.rs b/crates/erp-health/src/oauth/middleware.rs index ba64d0d..d232cd9 100644 --- a/crates/erp-health/src/oauth/middleware.rs +++ b/crates/erp-health/src/oauth/middleware.rs @@ -64,8 +64,24 @@ pub async fn oauth_auth_middleware(request: Request, next: Next) -> Response { } }; - let jwt_secret = std::env::var("ERP__AUTH__JWT_SECRET") - .unwrap_or_else(|_| "dev-secret-key".to_string()); + let jwt_secret = match std::env::var("ERP__AUTH__JWT_SECRET") { + Ok(secret) => secret, + Err(_) => { + tracing::error!("ERP__AUTH__JWT_SECRET 环境变量未设置 — 无法验证 OAuth token"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "resourceType": "OperationOutcome", + "issue": [{ + "severity": "error", + "code": "exception", + "diagnostics": "Server configuration error" + }] + })), + ) + .into_response(); + } + }; let claims = match jsonwebtoken::decode::( token, diff --git a/crates/erp-health/src/service/action_inbox_service.rs b/crates/erp-health/src/service/action_inbox_service.rs index 5bc66af..e092fce 100644 --- a/crates/erp-health/src/service/action_inbox_service.rs +++ b/crates/erp-health/src/service/action_inbox_service.rs @@ -268,12 +268,19 @@ pub async fn list_action_items( let mut segments: Vec = Vec::new(); - // patient_id 过滤条件(所有段共用) + // patient_id 参数化过滤($2 绑定 patient_id 值,始终传递,条件仅在有值时追加) let patient_filter = match &query.patient_id { - Some(pid) => format!("AND patient_id = '{}'", pid), + Some(_) => "AND patient_id = $2".to_string(), None => String::new(), }; + // assigned_to 参数化过滤($3 绑定 user_id 值) + let assigned_filter = if filter_by_user { + "AND f.assigned_to = $3".to_string() + } else { + String::new() + }; + if include_sug { segments.push(format!( r#" @@ -302,11 +309,6 @@ pub async fn list_action_items( } if include_fu { - let assigned_filter = if filter_by_user { - format!("AND f.assigned_to = '{}'", user_id.unwrap()) - } else { - String::new() - }; segments.push(format!( r#" SELECT f.id, 'followup' AS action_type, 'medium' AS priority_raw, @@ -331,12 +333,19 @@ pub async fn list_action_items( let union_sql = segments.join("\n UNION ALL\n"); + // $1=tenant_id, $2=patient_id, $3=assigned_to (union 内部) + // $4=LIMIT, $5=OFFSET (外层分页) + let patient_val: sea_orm::Value = query.patient_id + .map_or(sea_orm::Value::Uuid(None), |pid| pid.into()); + let assigned_val: sea_orm::Value = user_id + .map_or(sea_orm::Value::Uuid(None), |uid| uid.into()); + let data_sql = format!( r#"SELECT * FROM ({union_sql}) sub ORDER BY CASE priority_raw WHEN 'high' THEN 1 WHEN 'urgent' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END, created_at DESC - LIMIT $2 OFFSET $3"# + LIMIT $4 OFFSET $5"# ); let count_sql = format!("SELECT COUNT(*) AS cnt FROM ({union_sql}) sub"); @@ -347,6 +356,8 @@ pub async fn list_action_items( data_sql, [ tenant_id.into(), + patient_val.clone(), + assigned_val.clone(), (page_size as i64).into(), (offset as i64).into(), ], @@ -360,7 +371,11 @@ pub async fn list_action_items( })?; let count_row: Option = FromQueryResult::find_by_statement( - Statement::from_sql_and_values(DatabaseBackend::Postgres, count_sql, [tenant_id.into()]), + Statement::from_sql_and_values( + DatabaseBackend::Postgres, + count_sql, + [tenant_id.into(), patient_val, assigned_val], + ), ) .one(db) .await