Compare commits
7 Commits
ced93934f1
...
f7bf5a86ea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7bf5a86ea | ||
|
|
d9818c263e | ||
|
|
c452ae81d1 | ||
|
|
a1cbb9fb1d | ||
|
|
a78ee2f154 | ||
|
|
51c41acfa7 | ||
|
|
f668e64266 |
@@ -426,6 +426,8 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.prompt.manage")?;
|
||||
validate_prompt_safety(&body.system_prompt)?;
|
||||
validate_prompt_safety(&body.user_prompt_template)?;
|
||||
let prompt = state
|
||||
.prompt
|
||||
.create_prompt(
|
||||
@@ -683,3 +685,24 @@ fn build_sse_stream(
|
||||
yield Ok(Event::default().event("done").data(data));
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查提示词内容是否包含可疑注入模式
|
||||
fn validate_prompt_safety(content: &str) -> Result<(), erp_core::error::AppError> {
|
||||
let suspicious = [
|
||||
"ignore previous",
|
||||
"ignore all previous",
|
||||
"ignore above",
|
||||
"disregard previous",
|
||||
"you are now",
|
||||
"new instructions:",
|
||||
];
|
||||
let lower = content.to_lowercase();
|
||||
for pattern in &suspicious {
|
||||
if lower.contains(pattern) {
|
||||
return Err(erp_core::error::AppError::Validation(
|
||||
format!("提示词内容包含不安全模式: {}", pattern),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -195,7 +195,7 @@ impl AuthService {
|
||||
TokenService::validate_refresh_token(refresh_token_str, db, jwt.secret).await?;
|
||||
|
||||
// Revoke the old token (rotation)
|
||||
TokenService::revoke_token(old_token_id, db).await?;
|
||||
TokenService::revoke_token(old_token_id, claims.sub, db).await?;
|
||||
|
||||
// Fetch fresh roles and permissions
|
||||
let roles: Vec<String> = TokenService::get_user_roles(claims.sub, claims.tid, db).await?;
|
||||
|
||||
@@ -131,6 +131,7 @@ impl TokenService {
|
||||
let hash = sha256_hex(token);
|
||||
let token_row = user_token::Entity::find()
|
||||
.filter(user_token::Column::TokenHash.eq(hash))
|
||||
.filter(user_token::Column::TenantId.eq(claims.tid))
|
||||
.filter(user_token::Column::RevokedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
@@ -151,8 +152,10 @@ impl TokenService {
|
||||
}
|
||||
|
||||
/// Revoke a specific refresh token by database ID.
|
||||
pub async fn revoke_token(token_id: Uuid, db: &DatabaseConnection) -> AuthResult<()> {
|
||||
/// Verifies that the token belongs to the specified user for security.
|
||||
pub async fn revoke_token(token_id: Uuid, user_id: Uuid, db: &DatabaseConnection) -> AuthResult<()> {
|
||||
let token_row = user_token::Entity::find_by_id(token_id)
|
||||
.filter(user_token::Column::UserId.eq(user_id))
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
|
||||
@@ -32,9 +32,15 @@ pub fn patient_to_fhir(p: &patient::Model) -> serde_json::Value {
|
||||
}
|
||||
|
||||
if let Some(ref id_number) = p.id_number {
|
||||
// 加密密文(v1| 前缀)不输出,明文做脱敏处理
|
||||
let display_value = if id_number.starts_with("v1|") {
|
||||
"[REDACTED]".to_string()
|
||||
} else {
|
||||
mask_sensitive(id_number)
|
||||
};
|
||||
result["identifier"] = serde_json::json!([{
|
||||
"system": "urn:oid:2.16.156.10011.1.3",
|
||||
"value": id_number,
|
||||
"value": display_value,
|
||||
}]);
|
||||
}
|
||||
|
||||
@@ -332,6 +338,15 @@ pub fn follow_up_to_fhir(t: &follow_up_task::Model) -> serde_json::Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// 对敏感字符串做脱敏:保留前 1 位和后 4 位,中间用 * 替代
|
||||
fn mask_sensitive(s: &str) -> String {
|
||||
if s.len() <= 5 {
|
||||
"*".repeat(s.len())
|
||||
} else {
|
||||
format!("{}{}{}", &s[..1], "*".repeat(s.len() - 5), &s[s.len() - 4..])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -370,7 +385,47 @@ mod tests {
|
||||
assert_eq!(fhir["id"], "00000000-0000-0000-0000-000000000001");
|
||||
assert_eq!(fhir["gender"], "male");
|
||||
assert_eq!(fhir["birthDate"], "1968-05-15");
|
||||
assert_eq!(fhir["identifier"][0]["value"], "110101196805150001");
|
||||
assert_eq!(fhir["identifier"][0]["value"], "1*************0001");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_patient_to_fhir_encrypted_id_number_redacted() {
|
||||
let p = patient::Model {
|
||||
id: uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
|
||||
tenant_id: uuid::Uuid::now_v7(),
|
||||
user_id: None,
|
||||
name: "测试".into(),
|
||||
gender: None,
|
||||
birth_date: None,
|
||||
blood_type: None,
|
||||
id_number: Some("v1|encrypted_payload_here".into()),
|
||||
id_number_hash: None,
|
||||
allergy_history: None,
|
||||
medical_history_summary: None,
|
||||
emergency_contact_name: None,
|
||||
emergency_contact_phone: None,
|
||||
emergency_contact_phone_hash: None,
|
||||
key_version: None,
|
||||
status: "active".into(),
|
||||
verification_status: "pending".into(),
|
||||
source: None,
|
||||
notes: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
deleted_at: None,
|
||||
version: 1,
|
||||
};
|
||||
let fhir = patient_to_fhir(&p);
|
||||
assert_eq!(fhir["identifier"][0]["value"], "[REDACTED]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mask_sensitive() {
|
||||
assert_eq!(mask_sensitive("110101196805150001"), "1*************0001");
|
||||
assert_eq!(mask_sensitive("12345"), "*****");
|
||||
assert_eq!(mask_sensitive("123456"), "1*3456");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -18,8 +18,16 @@ pub async fn token(
|
||||
State(state): State<HealthState>,
|
||||
Json(req): Json<TokenRequest>,
|
||||
) -> Result<(StatusCode, Json<TokenResponse>), (StatusCode, Json<TokenErrorResponse>)> {
|
||||
let jwt_secret = std::env::var("ERP__AUTH__JWT_SECRET")
|
||||
.expect("ERP__AUTH__JWT_SECRET 环境变量未设置 — 无法签发 OAuth token");
|
||||
let jwt_secret = match std::env::var("ERP__AUTH__JWT_SECRET") {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
tracing::error!("ERP__AUTH__JWT_SECRET 环境变量未设置 — 无法签发 OAuth token");
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(TokenErrorResponse::invalid_client("服务配置错误")),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
match OAuthService::token(&state.db, &req, &jwt_secret).await {
|
||||
Ok(resp) => Ok((StatusCode::OK, Json(resp))),
|
||||
|
||||
@@ -283,10 +283,10 @@ pub async fn update_patient(
|
||||
])?;
|
||||
}
|
||||
|
||||
// 记录变更前的关键临床值(过敏史、病史、身份证号)
|
||||
// 记录变更前的关键临床值(加密字段用 REDACTED 替代)
|
||||
let old_snapshot = serde_json::json!({
|
||||
"allergy_history": model.allergy_history,
|
||||
"medical_history_summary": model.medical_history_summary,
|
||||
"allergy_history": model.allergy_history.as_ref().map(|_| "[REDACTED]"),
|
||||
"medical_history_summary": model.medical_history_summary.as_ref().map(|_| "[REDACTED]"),
|
||||
"status": model.status,
|
||||
"verification_status": model.verification_status,
|
||||
});
|
||||
@@ -329,10 +329,10 @@ pub async fn update_patient(
|
||||
|
||||
let updated = active.update(&state.db).await?;
|
||||
|
||||
// 变更后快照
|
||||
// 变更后快照(加密字段用 REDACTED 替代)
|
||||
let new_snapshot = serde_json::json!({
|
||||
"allergy_history": updated.allergy_history,
|
||||
"medical_history_summary": updated.medical_history_summary,
|
||||
"allergy_history": updated.allergy_history.as_ref().map(|_| "[REDACTED]"),
|
||||
"medical_history_summary": updated.medical_history_summary.as_ref().map(|_| "[REDACTED]"),
|
||||
"status": updated.status,
|
||||
"verification_status": updated.verification_status,
|
||||
});
|
||||
|
||||
@@ -91,11 +91,14 @@ async fn check_database(db: &sea_orm::DatabaseConnection) -> ComponentStatus {
|
||||
latency_ms: Some(start.elapsed().as_millis() as u64),
|
||||
error: None,
|
||||
},
|
||||
Err(e) => ComponentStatus {
|
||||
status: "error".to_string(),
|
||||
latency_ms: Some(start.elapsed().as_millis() as u64),
|
||||
error: Some(e.to_string()),
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Database health check failed");
|
||||
ComponentStatus {
|
||||
status: "error".to_string(),
|
||||
latency_ms: Some(start.elapsed().as_millis() as u64),
|
||||
error: Some("connection failed".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,18 +115,24 @@ async fn check_redis(client: &redis::Client) -> ComponentStatus {
|
||||
latency_ms: Some(start.elapsed().as_millis() as u64),
|
||||
error: None,
|
||||
},
|
||||
Err(e) => ComponentStatus {
|
||||
status: "error".to_string(),
|
||||
latency_ms: Some(start.elapsed().as_millis() as u64),
|
||||
error: Some(e.to_string()),
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Redis PING failed");
|
||||
ComponentStatus {
|
||||
status: "error".to_string(),
|
||||
latency_ms: Some(start.elapsed().as_millis() as u64),
|
||||
error: Some("connection failed".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Redis connection failed");
|
||||
ComponentStatus {
|
||||
status: "error".to_string(),
|
||||
latency_ms: Some(start.elapsed().as_millis() as u64),
|
||||
error: Some("connection failed".to_string()),
|
||||
}
|
||||
}
|
||||
Err(e) => ComponentStatus {
|
||||
status: "error".to_string(),
|
||||
latency_ms: Some(start.elapsed().as_millis() as u64),
|
||||
error: Some(e.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -808,11 +808,19 @@ fn build_cors_layer(allowed_origins: &str) -> tower_http::cors::CorsLayer {
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if origins.len() == 1 && origins[0] == "*" {
|
||||
tracing::warn!(
|
||||
"⚠️ CORS 允许所有来源 — 仅限开发环境使用!\
|
||||
生产环境请通过 ERP__CORS__ALLOWED_ORIGINS 设置具体的来源域名"
|
||||
);
|
||||
return tower_http::cors::CorsLayer::permissive();
|
||||
#[cfg(not(debug_assertions))]
|
||||
{
|
||||
tracing::error!("CORS wildcard '*' is not allowed in production builds");
|
||||
panic!("Refusing to start with CORS wildcard in release mode. Set ERP__CORS__ALLOWED_ORIGINS to specific domains.");
|
||||
}
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
tracing::warn!(
|
||||
"⚠️ CORS 允许所有来源 — 仅限开发环境使用!\
|
||||
生产环境请通过 ERP__CORS__ALLOWED_ORIGINS 设置具体的来源域名"
|
||||
);
|
||||
return tower_http::cors::CorsLayer::permissive();
|
||||
}
|
||||
}
|
||||
|
||||
let allowed: Vec<HeaderValue> = origins
|
||||
|
||||
Reference in New Issue
Block a user