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
+222 -66
View File
@@ -10,6 +10,12 @@ use diesel::PgConnection;
use reqwest::header::{HeaderValue, CONTENT_TYPE};
use tracing::{debug, error};
fn current_org_id_from_token_str(token_str: &String) -> Option<String> {
jwt_decode_token(token_str)
.ok()
.and_then(|decoded| decoded.current_org_id)
}
use htycommons::common::{
current_local_datetime, get_page_and_page_size, get_some_from_query_params, strip_result_vec,
HtyErr, HtyErrCode, HtyResponse,
@@ -2159,14 +2165,14 @@ pub async fn raw_update_course_section_by_id_with_tx(
pub async fn find_teacher_students_by_ids(
Query(page_params): Query<HashMap<String, String>>,
_sudoer: HtySudoerTokenHeader,
sudoer: HtySudoerTokenHeader,
State(db_pool): State<Arc<DbState>>,
Json(params): Json<ReqTeacherStudentsQuery>,
) -> Json<HtyResponse<(Vec<ReqTeacherStudent>, i64, i64)>> {
debug!("find_teacher_students_by_ids -> start here");
let (page, page_size) = get_page_and_page_size(&page_params);
match raw_find_teacher_students_by_ids(&page, &page_size, params, db_pool) {
match raw_find_teacher_students_by_ids(&page, &page_size, params, &sudoer, db_pool) {
Ok(all) => {
debug!("raw_find_teacher_students_by_ids -> out: {:?}", all);
wrap_json_ok_resp(all)
@@ -2182,17 +2188,29 @@ pub fn raw_find_teacher_students_by_ids(
page: &Option<i64>,
page_size: &Option<i64>,
params: ReqTeacherStudentsQuery,
sudoer: &HtySudoerTokenHeader,
db_pool: Arc<DbState>,
) -> anyhow::Result<(Vec<ReqTeacherStudent>, i64, i64)> {
let query_ids = params
.hty_ids
.ok_or_else(|| anyhow!("hty_ids is required"))?;
let (records, total_page, total) = TeacherStudent::find_with_page_by_ids(
page,
page_size,
&query_ids,
extract_conn(fetch_db_conn(&db_pool)?).deref_mut(),
)?;
let current_org_id = current_org_id_from_token_str(&sudoer.0);
let (records, total_page, total) = if let Some(org_id) = &current_org_id {
TeacherStudent::find_with_page_by_ids_in_org(
page,
page_size,
&query_ids,
org_id,
extract_conn(fetch_db_conn(&db_pool)?).deref_mut(),
)?
} else {
TeacherStudent::find_with_page_by_ids(
page,
page_size,
&query_ids,
extract_conn(fetch_db_conn(&db_pool)?).deref_mut(),
)?
};
let req_records = TeacherStudent::to_req_vec(&records);
debug!(
"raw_find_teacher_students_by_ids -> req_records: {:?}",
@@ -2203,14 +2221,14 @@ pub fn raw_find_teacher_students_by_ids(
pub async fn find_all_teacher_students(
Query(params): Query<HashMap<String, String>>,
_root: HtySudoerTokenHeader,
root: HtySudoerTokenHeader,
State(db_pool): State<Arc<DbState>>,
) -> Json<HtyResponse<(Vec<ReqTeacherStudent>, i64, i64)>> {
debug!("find_all_teacher_students -> start here");
let (page, page_size) = get_page_and_page_size(&params);
match raw_find_all_teacher_students(&page, &page_size, db_pool) {
match raw_find_all_teacher_students(&page, &page_size, &root, db_pool) {
Ok(all) => {
debug!("find_all_teacher_students -> out: {:?}", all);
wrap_json_ok_resp(all)
@@ -2225,13 +2243,24 @@ pub async fn find_all_teacher_students(
pub fn raw_find_all_teacher_students(
page: &Option<i64>,
page_size: &Option<i64>,
root: &HtySudoerTokenHeader,
db_pool: Arc<DbState>,
) -> anyhow::Result<(Vec<ReqTeacherStudent>, i64, i64)> {
let (records, total_page, total) = TeacherStudent::find_all_with_page(
page,
page_size,
extract_conn(fetch_db_conn(&db_pool)?).deref_mut(),
)?;
let current_org_id = current_org_id_from_token_str(&root.0);
let (records, total_page, total) = if let Some(org_id) = &current_org_id {
TeacherStudent::find_all_with_page_in_org(
page,
page_size,
org_id,
extract_conn(fetch_db_conn(&db_pool)?).deref_mut(),
)?
} else {
TeacherStudent::find_all_with_page(
page,
page_size,
extract_conn(fetch_db_conn(&db_pool)?).deref_mut(),
)?
};
let req_records = TeacherStudent::to_req_vec(&records);
Ok((req_records, total_page, total))
}
@@ -3167,7 +3196,7 @@ pub async fn claim_student(
}
pub async fn raw_claim_student(
_token: AuthorizationHeader,
token: AuthorizationHeader,
some_id_teacher: Option<String>,
some_id_student: Option<String>,
db_pool: Arc<DbState>,
@@ -3188,11 +3217,21 @@ pub async fn raw_claim_student(
.clone()
.ok_or_else(|| anyhow!("id_student is required"))?;
let res_exist = TeacherStudent::verify_exist_by_teacher_id_and_student_id(
&id_teacher,
&id_student,
extract_conn(fetch_db_conn(&db_pool)?).deref_mut(),
)?;
let current_org_id = current_org_id_from_token_str(&(*token).clone());
let res_exist = if let Some(org_id) = &current_org_id {
TeacherStudent::verify_exist_by_teacher_id_and_student_id_in_org(
&id_teacher,
&id_student,
org_id,
extract_conn(fetch_db_conn(&db_pool)?).deref_mut(),
)?
} else {
TeacherStudent::verify_exist_by_teacher_id_and_student_id(
&id_teacher,
&id_student,
extract_conn(fetch_db_conn(&db_pool)?).deref_mut(),
)?
};
if res_exist {
return Err(anyhow!(HtyErr {
code: HtyErrCode::WebErr,
@@ -3205,6 +3244,7 @@ pub async fn raw_claim_student(
id: uuid(),
status: String::from("Approved"),
created_at: Some(current_local_datetime()),
org_id: current_org_id,
};
let res = TeacherStudent::create(
&in_record,
@@ -3214,12 +3254,13 @@ pub async fn raw_claim_student(
}
pub async fn link_teacher_student(
auth: AuthorizationHeader,
State(db_pool): State<Arc<DbState>>,
link_data: Json<ReqTeacherStudent>,
) -> Json<HtyResponse<String>> {
debug!("link_teacher_student -> starts");
let input = link_data.0;
match raw_link_teacher_student(input, db_pool).await {
match raw_link_teacher_student(auth, input, db_pool).await {
Ok(result) => wrap_json_ok_resp(result),
Err(e) => {
error!(
@@ -3232,9 +3273,12 @@ pub async fn link_teacher_student(
}
pub async fn raw_link_teacher_student(
auth: AuthorizationHeader,
link_data: ReqTeacherStudent,
db_pool: Arc<DbState>,
) -> anyhow::Result<String> {
let current_org_id = current_org_id_from_token_str(&(*auth).clone())
.ok_or_else(|| anyhow!("current_org_id is required"))?;
let teacher_student = TeacherStudent {
teacher_id: link_data
.teacher_id
@@ -3245,6 +3289,7 @@ pub async fn raw_link_teacher_student(
id: uuid(),
status: link_data.status.unwrap_or(String::from("Waiting")),
created_at: Some(current_local_datetime()),
org_id: Some(current_org_id),
};
let res = TeacherStudent::create(
&teacher_student,
@@ -3274,7 +3319,7 @@ pub async fn disclaim_student(
}
pub async fn raw_disclaim_student(
_auth: AuthorizationHeader,
auth: AuthorizationHeader,
some_id_teacher: Option<String>,
some_id_student: Option<String>,
db_pool: Arc<DbState>,
@@ -3295,26 +3340,42 @@ pub async fn raw_disclaim_student(
.clone()
.ok_or_else(|| anyhow!("id_student is required"))?;
let res_exist = TeacherStudent::verify_exist_by_teacher_id_and_student_id(
&id_teacher,
&id_student,
extract_conn(fetch_db_conn(&db_pool)?).deref_mut(),
)?;
let current_org_id = current_org_id_from_token_str(&(*auth).clone());
let res_exist = if let Some(org_id) = &current_org_id {
TeacherStudent::verify_exist_by_teacher_id_and_student_id_in_org(
&id_teacher,
&id_student,
org_id,
extract_conn(fetch_db_conn(&db_pool)?).deref_mut(),
)?
} else {
TeacherStudent::verify_exist_by_teacher_id_and_student_id(
&id_teacher,
&id_student,
extract_conn(fetch_db_conn(&db_pool)?).deref_mut(),
)?
};
if !res_exist {
return Err(anyhow!(HtyErr {
code: HtyErrCode::WebErr,
reason: Some("no existing record for this teacher and student".into()),
}));
}
let record = TeacherStudent::find_by_teacher_id_and_student_id(
&id_teacher,
&id_student,
extract_conn(fetch_db_conn(&db_pool)?).deref_mut(),
)?;
let res = TeacherStudent::delete_by_id(
&record.id,
extract_conn(fetch_db_conn(&db_pool)?).deref_mut(),
)?;
let res = if let Some(org_id) = &current_org_id {
TeacherStudent::del_by_teacher_id_and_student_id_in_org(
&id_teacher,
&id_student,
org_id,
extract_conn(fetch_db_conn(&db_pool)?).deref_mut(),
)?
} else {
let record = TeacherStudent::find_by_teacher_id_and_student_id(
&id_teacher,
&id_student,
extract_conn(fetch_db_conn(&db_pool)?).deref_mut(),
)?;
TeacherStudent::delete_by_id(&record.id, extract_conn(fetch_db_conn(&db_pool)?).deref_mut())?
};
Ok(res)
}
@@ -3518,10 +3579,19 @@ pub async fn raw_find_all_teachers_by_student_id(
student_id: String,
db_pool: Arc<DbState>,
) -> anyhow::Result<Vec<ReqHtyUserWithInfos>> {
let teacher_ids = TeacherStudent::get_all_teachers_by_student_id(
&student_id,
extract_conn(fetch_db_conn(&db_pool)?).deref_mut(),
)?;
let current_org_id = current_org_id_from_token_str(&root.0);
let teacher_ids = if let Some(org_id) = &current_org_id {
TeacherStudent::get_all_teachers_by_student_id_in_org(
&student_id,
org_id,
extract_conn(fetch_db_conn(&db_pool)?).deref_mut(),
)?
} else {
TeacherStudent::get_all_teachers_by_student_id(
&student_id,
extract_conn(fetch_db_conn(&db_pool)?).deref_mut(),
)?
};
debug!(
"raw_find_all_teachers_by_student_id -> teacher_ids {:?}",
&teacher_ids
@@ -3582,11 +3652,21 @@ pub async fn raw_delete_teacher_student(
let _teacher = find_hty_user_with_info_by_id(&teacher_id, &root).await?;
let _student = find_hty_user_with_info_by_id(&student_id, &root).await?;
let is_exist = TeacherStudent::verify_exist_by_teacher_id_and_student_id(
&teacher_id,
&student_id,
extract_conn(fetch_db_conn(&db_pool)?).deref_mut(),
)?;
let current_org_id = current_org_id_from_token_str(&root.0);
let is_exist = if let Some(org_id) = &current_org_id {
TeacherStudent::verify_exist_by_teacher_id_and_student_id_in_org(
&teacher_id,
&student_id,
org_id,
extract_conn(fetch_db_conn(&db_pool)?).deref_mut(),
)?
} else {
TeacherStudent::verify_exist_by_teacher_id_and_student_id(
&teacher_id,
&student_id,
extract_conn(fetch_db_conn(&db_pool)?).deref_mut(),
)?
};
if !is_exist {
return Err(anyhow!(HtyErr {
@@ -3602,11 +3682,20 @@ pub async fn raw_delete_teacher_student(
// )?;
// record.status = status;
let _ = TeacherStudent::del_by_teacher_id_and_student_id(
&teacher_id,
&student_id,
extract_conn(fetch_db_conn(&db_pool)?).deref_mut(),
)?;
if let Some(org_id) = &current_org_id {
let _ = TeacherStudent::del_by_teacher_id_and_student_id_in_org(
&teacher_id,
&student_id,
org_id,
extract_conn(fetch_db_conn(&db_pool)?).deref_mut(),
)?;
} else {
let _ = TeacherStudent::del_by_teacher_id_and_student_id(
&teacher_id,
&student_id,
extract_conn(fetch_db_conn(&db_pool)?).deref_mut(),
)?;
}
Ok(())
}
@@ -3664,22 +3753,41 @@ pub async fn raw_update_teacher_student(
let _teacher = find_hty_user_with_info_by_id(&teacher_id, &root).await?;
let _student = find_hty_user_with_info_by_id(&student_id, &root).await?;
let is_exist = TeacherStudent::verify_exist_by_teacher_id_and_student_id(
&teacher_id,
&student_id,
extract_conn(fetch_db_conn(&db_pool)?).deref_mut(),
)?;
let current_org_id = current_org_id_from_token_str(&root.0);
let is_exist = if let Some(org_id) = &current_org_id {
TeacherStudent::verify_exist_by_teacher_id_and_student_id_in_org(
&teacher_id,
&student_id,
org_id,
extract_conn(fetch_db_conn(&db_pool)?).deref_mut(),
)?
} else {
TeacherStudent::verify_exist_by_teacher_id_and_student_id(
&teacher_id,
&student_id,
extract_conn(fetch_db_conn(&db_pool)?).deref_mut(),
)?
};
if !is_exist {
return Err(anyhow!(HtyErr {
code: HtyErrCode::NullErr,
reason: Some("no record for this teacher and student".to_string()),
}));
}
let mut record = TeacherStudent::find_by_teacher_id_and_student_id(
&teacher_id,
&student_id,
extract_conn(fetch_db_conn(&db_pool)?).deref_mut(),
)?;
let mut record = if let Some(org_id) = &current_org_id {
TeacherStudent::find_by_teacher_id_and_student_id_in_org(
&teacher_id,
&student_id,
org_id,
extract_conn(fetch_db_conn(&db_pool)?).deref_mut(),
)?
} else {
TeacherStudent::find_by_teacher_id_and_student_id(
&teacher_id,
&student_id,
extract_conn(fetch_db_conn(&db_pool)?).deref_mut(),
)?
};
record.status = status;
let _ = TeacherStudent::update(&record, extract_conn(fetch_db_conn(&db_pool)?).deref_mut())?;
Ok(())
@@ -3726,11 +3834,21 @@ pub async fn raw_find_all_students_by_teacher_id_and_status(
let status = req_teacher_student
.status
.ok_or_else(|| anyhow!("status is required"))?;
let student_ids = TeacherStudent::get_all_students_by_teacher_id_and_status(
&teacher_id,
&status,
extract_conn(fetch_db_conn(&db_pool)?).deref_mut(),
)?;
let current_org_id = current_org_id_from_token_str(&root.0);
let student_ids = if let Some(org_id) = &current_org_id {
TeacherStudent::get_all_students_by_teacher_id_and_status_in_org(
&teacher_id,
&status,
org_id,
extract_conn(fetch_db_conn(&db_pool)?).deref_mut(),
)?
} else {
TeacherStudent::get_all_students_by_teacher_id_and_status(
&teacher_id,
&status,
extract_conn(fetch_db_conn(&db_pool)?).deref_mut(),
)?
};
debug!(
"raw_find_all_students_by_teacher_id_and_status -> student_ids {:?}",
&student_ids
@@ -4173,3 +4291,41 @@ pub async fn raw_find_all_course_sections_by_created_by_with_page(
Ok((result, total_page, total))
}
#[cfg(test)]
mod tests {
use htycommons::common::current_local_datetime;
use htycommons::jwt::jwt_encode_token;
use htycommons::web::HtyToken;
use super::current_org_id_from_token_str;
fn build_test_token(current_org_id: Option<&str>) -> String {
std::env::set_var("JWT_KEY", "htyws-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_valid_token() {
let raw_token = build_test_token(Some("org-123"));
let parsed_org_id = current_org_id_from_token_str(&raw_token);
assert_eq!(parsed_org_id, Some("org-123".to_string()));
}
#[test]
fn should_return_none_when_token_has_no_current_org_id() {
let raw_token = build_test_token(None);
let parsed_org_id = current_org_id_from_token_str(&raw_token);
assert_eq!(parsed_org_id, None);
}
}