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:
+128
-14
@@ -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) = ¤t_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) = ¤t_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) = ¤t_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) = ¤t_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) = ¤t_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) = ¤t_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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user