fix(health): P0 安全修复 — SQL注入 + FHIR越权 + OAuth权限 + JWT硬编码
C1: action_inbox_service.rs 中 patient_id/user_id 的 format! 拼接改为
参数化查询 ($2/$3/$4/$5 绑定),消除 SQL 注入风险
C2: fhir/handler.rs 所有患者相关端点强制执行 allowed_patient_ids 范围
过滤,search 端点用 is_in 过滤,get 端点用 enforce_patient_scope 校验
H5: oauth/handler.rs 5 个管理端点添加 require_permission 校验
M3: oauth/handler.rs 和 middleware.rs 移除 "dev-secret-key" fallback,
缺少环境变量时启动失败(token)/返回 500(middleware)
This commit is contained in:
@@ -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<String>,
|
||||
}
|
||||
|
||||
// ── 患者访问范围辅助 ──────────────────────────────────────────────────
|
||||
|
||||
/// 检查单个 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<Vec<Uuid>> {
|
||||
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<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Extension(fhir_ctx): Extension<FhirAuthContext>,
|
||||
Query(params): Query<FhirSearchParams>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Extension(fhir_ctx): Extension<FhirAuthContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Extension(fhir_ctx): Extension<FhirAuthContext>,
|
||||
Query(params): Query<FhirSearchParams>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Extension(fhir_ctx): Extension<FhirAuthContext>,
|
||||
Query(params): Query<FhirSearchParams>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Extension(fhir_ctx): Extension<FhirAuthContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Extension(fhir_ctx): Extension<FhirAuthContext>,
|
||||
Query(params): Query<FhirSearchParams>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Extension(fhir_ctx): Extension<FhirAuthContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Extension(fhir_ctx): Extension<FhirAuthContext>,
|
||||
Query(params): Query<FhirSearchParams>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Extension(fhir_ctx): Extension<FhirAuthContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Extension(fhir_ctx): Extension<FhirAuthContext>,
|
||||
Query(params): Query<FhirSearchParams>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Extension(fhir_ctx): Extension<FhirAuthContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Extension(fhir_ctx): Extension<FhirAuthContext>,
|
||||
Query(params): Query<FhirSearchParams>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Extension(fhir_ctx): Extension<FhirAuthContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Extension(fhir_ctx): Extension<FhirAuthContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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)
|
||||
|
||||
@@ -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<TokenRequest>,
|
||||
) -> Result<(StatusCode, Json<TokenResponse>), (StatusCode, Json<TokenErrorResponse>)> {
|
||||
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<TenantContext>,
|
||||
Json(req): Json<CreateApiClientReq>,
|
||||
) -> Result<Json<ApiClientResp>, 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<HealthState>,
|
||||
Extension(tenant_ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<Vec<ApiClientListItem>>, 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<Uuid>,
|
||||
Json(req): Json<UpdateApiClientReq>,
|
||||
) -> Result<Json<ApiClientListItem>, 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<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
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<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
require_permission(&tenant_ctx, "health.oauth.manage")?;
|
||||
let (client_id, plain) =
|
||||
OAuthService::regenerate_secret(&state.db, tenant_ctx.tenant_id, id)
|
||||
.await
|
||||
|
||||
@@ -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::<ClientCredentialsClaims>(
|
||||
token,
|
||||
|
||||
@@ -268,12 +268,19 @@ pub async fn list_action_items(
|
||||
|
||||
let mut segments: Vec<String> = 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<CountRow> = 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
|
||||
|
||||
Reference in New Issue
Block a user