feat(org): enforce tenant isolation in ws/kc with leave and stats APIs

Add org_id schema migrations and service-level filtering for teacher-student and class workflows, then cover org-context behavior with focused unit/e2e tests for leave and hour statistics.

Made-with: Cursor
This commit is contained in:
2026-04-27 20:12:02 +08:00
parent a15b5dbf58
commit c5134c9356
19 changed files with 1226 additions and 87 deletions
+128 -14
View File
@@ -21,6 +21,12 @@ use std::ops::DerefMut;
use std::sync::Arc;
use tracing::{debug, error};
fn current_org_id_from_auth(token: &AuthorizationHeader) -> Option<String> {
jwt_decode_token(&(*token).clone())
.ok()
.and_then(|decoded| decoded.current_org_id)
}
pub async fn find_all_non_repeatable_within_date_range(
sudoer: HtySudoerTokenHeader,
host: HtyHostHeader,
@@ -48,7 +54,7 @@ pub async fn find_all_non_repeatable_within_date_range(
}
fn raw_find_all_non_repeatable_within_date_range(
_token: AuthorizationHeader,
token: AuthorizationHeader,
_sudoer: HtySudoerTokenHeader,
_host: HtyHostHeader,
db_pool: Arc<DbState>,
@@ -89,7 +95,18 @@ fn raw_find_all_non_repeatable_within_date_range(
);
if let Some(kechengs) = some_kechengs {
let res: Vec<ReqClazz> = kechengs.iter().map(|kc| kc.to_req()).collect();
let current_org_id = current_org_id_from_auth(&token);
let res: Vec<ReqClazz> = kechengs
.iter()
.filter(|clazz| {
if let Some(org_id) = &current_org_id {
clazz.org_id.as_ref() == Some(org_id)
} else {
true
}
})
.map(|kc| kc.to_req())
.collect();
Ok(res)
} else {
Ok(vec![])
@@ -123,7 +140,7 @@ pub async fn find_all_non_repeatable_within_date_range_by_hty_id(
}
fn raw_find_all_non_repeatable_within_date_range_by_hty_id(
_token: AuthorizationHeader,
token: AuthorizationHeader,
_sudoer: HtySudoerTokenHeader,
_host: HtyHostHeader,
db_pool: Arc<DbState>,
@@ -177,7 +194,18 @@ fn raw_find_all_non_repeatable_within_date_range_by_hty_id(
);
if let Some(kechengs) = some_kechengs {
let res: Vec<ReqClazz> = kechengs.iter().map(|kc| kc.to_req()).collect();
let current_org_id = current_org_id_from_auth(&token);
let res: Vec<ReqClazz> = kechengs
.iter()
.filter(|clazz| {
if let Some(org_id) = &current_org_id {
clazz.org_id.as_ref() == Some(org_id)
} else {
true
}
})
.map(|kc| kc.to_req())
.collect();
Ok(res)
} else {
Ok(vec![])
@@ -209,7 +237,7 @@ pub async fn find_all_repeatable_within_date_range(
}
fn raw_find_all_repeatable_within_date_range(
_token: AuthorizationHeader,
token: AuthorizationHeader,
_sudoer: HtySudoerTokenHeader,
_host: HtyHostHeader,
db_pool: Arc<DbState>,
@@ -250,11 +278,22 @@ fn raw_find_all_repeatable_within_date_range(
);
if let Some(kechengs) = some_kechengs {
let current_org_id = current_org_id_from_auth(&token);
let filtered_kechengs: Vec<ReqClazzWithRepeat> = kechengs
.into_iter()
.filter(|clazz| {
if let Some(org_id) = &current_org_id {
clazz.clz_org_id.as_ref() == Some(org_id)
} else {
true
}
})
.collect();
debug!(
"raw_find_all_repeatable_within_date_range -> kechengs.len(): {:?}",
kechengs.len()
filtered_kechengs.len()
);
Ok(kechengs)
Ok(filtered_kechengs)
} else {
Ok(vec![])
}
@@ -299,7 +338,7 @@ pub async fn find_all_repeatable_within_date_range_by_hty_id(
* b. 如果此条kecheng的`start_from`和`end_by`落在本周,则说明是本周数据
*/
fn raw_find_all_repeatable_within_date_range_by_hty_id(
_token: AuthorizationHeader,
token: AuthorizationHeader,
_sudoer: HtySudoerTokenHeader,
_host: HtyHostHeader,
db_pool: Arc<DbState>,
@@ -353,11 +392,22 @@ fn raw_find_all_repeatable_within_date_range_by_hty_id(
);
if let Some(kechengs) = some_kechengs {
let current_org_id = current_org_id_from_auth(&token);
let filtered_kechengs: Vec<ReqClazzWithRepeat> = kechengs
.into_iter()
.filter(|clazz| {
if let Some(org_id) = &current_org_id {
clazz.clz_org_id.as_ref() == Some(org_id)
} else {
true
}
})
.collect();
debug!(
"raw_find_all_repeatable_within_date_range_by_hty_id -> kechengs.len(): {:?}",
kechengs.len()
filtered_kechengs.len()
);
Ok(kechengs)
Ok(filtered_kechengs)
} else {
Ok(vec![])
}
@@ -473,7 +523,7 @@ pub async fn find_clazz_by_hty_id(
}
fn raw_find_clazz_by_hty_id(
_auth: AuthorizationHeader,
auth: AuthorizationHeader,
_sudoer: HtySudoerTokenHeader,
_host: HtyHostHeader,
db_pool: Arc<DbState>,
@@ -504,6 +554,7 @@ fn raw_find_clazz_by_hty_id(
}));
}
let current_org_id = current_org_id_from_auth(&auth);
let kechengs = Clazz::find_by_user_id(
id_user
.as_ref()
@@ -513,7 +564,17 @@ fn raw_find_clazz_by_hty_id(
extract_conn(fetch_db_conn(&db_pool)?).deref_mut(),
)?;
let res = kechengs.into_iter().map(|item| item.to_req()).collect();
let res = kechengs
.into_iter()
.filter(|clazz| {
if let Some(org_id) = &current_org_id {
clazz.org_id.as_ref() == Some(org_id)
} else {
true
}
})
.map(|item| item.to_req())
.collect();
debug!("raw_find_clazz_by_hty_id -> {:?}", res);
Ok(res)
}
@@ -536,7 +597,7 @@ pub async fn update_clazz(
}
async fn raw_update_clazz(
_token: AuthorizationHeader,
token: AuthorizationHeader,
_sudoer: HtySudoerTokenHeader,
_host: HtyHostHeader,
db_pool: Arc<DbState>,
@@ -557,6 +618,15 @@ async fn raw_update_clazz(
&id_kecheng,
extract_conn(fetch_db_conn(&db_pool)?).deref_mut(),
)?;
let current_org_id = current_org_id_from_auth(&token);
if let Some(org_id) = &current_org_id {
if db_kecheng.org_id.as_ref() != Some(org_id) {
return Err(anyhow!(HtyErr {
code: HtyErrCode::AuthenticationFailed,
reason: Some("clazz does not belong to current organization".to_string()),
}));
}
}
if let Some(clazz_name) = &in_kecheng.clazz_name {
db_kecheng.clazz_name = clazz_name.clone();
@@ -691,6 +761,7 @@ async fn raw_create_clazz_with_repeat(
let id_user = jwt_decode_token(&(*token).clone())?
.hty_id
.ok_or_else(|| anyhow!("hty_id is required"))?;
let current_org_id = jwt_decode_token(&(*token).clone())?.current_org_id;
debug!(
"raw_create_clazz_with_repeat -> id_user: {:?}",
id_user
@@ -708,7 +779,7 @@ async fn raw_create_clazz_with_repeat(
.clone()
.unwrap_or_else(|| new_clazz_id.clone());
let to_create_kecheng = Clazz {
let mut to_create_kecheng = Clazz {
id: new_clazz_id.clone(),
clazz_name: in_kecheng
.clazz_name
@@ -742,7 +813,11 @@ async fn raw_create_clazz_with_repeat(
course_sections: in_kecheng.course_sections.clone(),
is_notified: in_kecheng.is_notified.clone(),
completed_at: None,
org_id: in_kecheng.org_id.clone(),
};
if to_create_kecheng.org_id.is_none() {
to_create_kecheng.org_id = current_org_id.clone();
}
debug!(
"raw_create_clazz_with_repeat -> to_create_kecheng: {:?}",
@@ -763,6 +838,7 @@ async fn raw_create_clazz_with_repeat(
repeat_end: kecheng_repeat_copy.repeat_end.clone(),
repeat_status: kecheng_repeat_copy.repeat_status.clone(),
latest_clazz_created_at: kecheng_repeat_copy.latest_clazz_created_at.clone(),
org_id: kecheng_repeat_copy.org_id.clone(),
})
}
@@ -785,3 +861,41 @@ async fn raw_create_clazz_with_repeat(
})),
}
}
#[cfg(test)]
mod tests {
use htycommons::common::current_local_datetime;
use htycommons::jwt::jwt_encode_token;
use htycommons::web::{AuthorizationHeader, HtyToken};
use super::current_org_id_from_auth;
fn build_test_auth_header(current_org_id: Option<&str>) -> String {
std::env::set_var("JWT_KEY", "htykc-org-test-key");
let token = HtyToken {
token_id: "test-token-id".to_string(),
hty_id: Some("teacher-1".to_string()),
app_id: Some("app-1".to_string()),
ts: current_local_datetime(),
roles: None,
tags: None,
current_org_id: current_org_id.map(|value| value.to_string()),
current_org_role_keys: Some(vec!["TEACHER".to_string()]),
};
jwt_encode_token(token).expect("encode test token")
}
#[test]
fn should_extract_current_org_id_from_auth_header() {
let auth_header = build_test_auth_header(Some("org-456"));
let parsed_org_id = current_org_id_from_auth(&AuthorizationHeader(auth_header));
assert_eq!(parsed_org_id, Some("org-456".to_string()));
}
#[test]
fn should_return_none_when_auth_header_has_no_org_context() {
let auth_header = build_test_auth_header(None);
let parsed_org_id = current_org_id_from_auth(&AuthorizationHeader(auth_header));
assert_eq!(parsed_org_id, None);
}
}