diff --git a/ocg-server/src/handlers/auth.rs b/ocg-server/src/handlers/auth.rs index d3e30e68..9d350998 100644 --- a/ocg-server/src/handlers/auth.rs +++ b/ocg-server/src/handlers/auth.rs @@ -7,7 +7,7 @@ use async_trait::async_trait; use axum::{ Form, extract::{Path, Query, Request, State}, - http::StatusCode, + http::{Method, StatusCode}, middleware::Next, response::{Html, IntoResponse, Redirect, Response}, }; @@ -35,9 +35,13 @@ use crate::{ templates::{ self, PageId, auth::{User, UserDetails}, + dashboard::group::home::UserGroupsByCommunity, notifications::EmailVerification, }, - types::permissions::{CommunityPermission, GroupPermission}, + types::{ + community::CommunitySummary, + permissions::{CommunityPermission, GroupPermission}, + }, validation::{MAX_LEN_S, trimmed_non_empty}, }; @@ -873,12 +877,50 @@ pub(crate) async fn user_has_selected_community_permission( }; // Check required permission in the selected community - let Ok(has_permission) = db + let Ok(mut has_permission) = db .user_has_community_permission(&community_id, &user.user_id, permission) .await else { return StatusCode::INTERNAL_SERVER_ERROR.into_response(); }; + + // A failed permission check is ambiguous: the selected community might be + // stale, or the user might simply lack this specific permission. For GET + // requests, check whether the selected community is still accessible before + // repairing the session; if it is still accessible, keep the denial tied to + // the user's current selection. + let community_id = if !has_permission && request.method() == Method::GET { + let Ok(communities) = db.list_user_communities(&user.user_id).await else { + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + }; + if communities + .iter() + .any(|community| community.community_id == community_id) + { + community_id + } else { + let community_id = + match select_first_accessible_community_from_list(&db, &session, &user.user_id, &communities) + .await + { + Ok(Some(community_id)) => community_id, + Ok(None) => return StatusCode::FORBIDDEN.into_response(), + Err(error) => return error.into_response(), + }; + let Ok(repaired_has_permission) = db + .user_has_community_permission(&community_id, &user.user_id, permission) + .await + else { + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + }; + has_permission = repaired_has_permission; + community_id + } + } else { + community_id + }; + + // Deny when neither the selected nor repaired community grants the requested permission. if !has_permission { return StatusCode::FORBIDDEN.into_response(); } @@ -929,12 +971,54 @@ pub(crate) async fn user_has_selected_group_permission( }; // Check required permission in the selected group - let Ok(has_permission) = db + let Ok(mut has_permission) = db .user_has_group_permission(&community_id, &group_id, &user.user_id, permission) .await else { return StatusCode::INTERNAL_SERVER_ERROR.into_response(); }; + + // A failed permission check is ambiguous: the selected group might be + // stale, or the user might simply lack this specific permission. For GET + // requests, check whether the selected group is still accessible before + // repairing the session; if it is still accessible, keep the denial tied to + // the user's current selection. + let (community_id, group_id) = if !has_permission && request.method() == Method::GET { + let Ok(groups_by_community) = db.list_user_groups(&user.user_id).await else { + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + }; + let selected_group_is_available = groups_by_community.iter().any(|community| { + community.community.community_id == community_id + && community.groups.iter().any(|group| group.group_id == group_id) + }); + if selected_group_is_available { + (community_id, group_id) + } else { + let (community_id, group_id) = match select_first_accessible_group_from_list( + &session, + &groups_by_community, + Some(community_id), + ) + .await + { + Ok(Some(ids)) => ids, + Ok(None) => return Redirect::to(USER_DASHBOARD_INVITATIONS_URL).into_response(), + Err(error) => return error.into_response(), + }; + let Ok(repaired_has_permission) = db + .user_has_group_permission(&community_id, &group_id, &user.user_id, permission) + .await + else { + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + }; + has_permission = repaired_has_permission; + (community_id, group_id) + } + } else { + (community_id, group_id) + }; + + // Deny when neither the selected nor repaired group grants the requested permission. if !has_permission { return StatusCode::FORBIDDEN.into_response(); } @@ -994,6 +1078,18 @@ async fn select_first_accessible_community_for_dashboard( ) -> Result, HandlerError> { // Load all community dashboards available to the user let communities = db.list_user_communities(user_id).await?; + + select_first_accessible_community_from_list(db, session, user_id, &communities).await +} + +/// Selects the first community dashboard from a preloaded accessible list. +async fn select_first_accessible_community_from_list( + db: &DynDB, + session: &Session, + user_id: &Uuid, + communities: &[CommunitySummary], +) -> Result, HandlerError> { + // Get first community from the list provided let Some(first_community) = communities.first() else { return Ok(None); }; @@ -1015,6 +1111,15 @@ async fn select_first_accessible_group_for_dashboard( // Load all group dashboards available to the user let groups_by_community = db.list_user_groups(user_id).await?; + select_first_accessible_group_from_list(session, &groups_by_community, selected_community_id).await +} + +/// Selects the first group dashboard from a preloaded accessible list. +async fn select_first_accessible_group_from_list( + session: &Session, + groups_by_community: &[UserGroupsByCommunity], + selected_community_id: Option, +) -> Result, HandlerError> { // Prefer the selected community when it has at least one group let selected_community = selected_community_id .and_then(|community_id| { diff --git a/ocg-server/src/handlers/auth/tests.rs b/ocg-server/src/handlers/auth/tests.rs index 0e21c6a0..6302320a 100644 --- a/ocg-server/src/handlers/auth/tests.rs +++ b/ocg-server/src/handlers/auth/tests.rs @@ -11,7 +11,7 @@ use axum::{ }, middleware, response::IntoResponse, - routing::get, + routing::{get, post}, }; use axum_login::tower_sessions::session; use oauth2::{AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl, basic::BasicClient}; @@ -3584,6 +3584,74 @@ async fn test_user_has_selected_community_permission_forbidden_without_permissio }) .returning(|_, _, _| Ok(false)); + // Setup router + let server_cfg = HttpServerConfig::default(); + let db: DynDB = Arc::new(db); + let nm = Arc::new(MockNotificationsManager::new()); + let state = test_state_with_server_cfg( + db.clone(), + Arc::new(MockImageStorage::new()), + nm.clone(), + &server_cfg, + ); + let auth_layer = crate::auth::setup_layer(&server_cfg, db.clone()).await.unwrap(); + let router = Router::new() + .route("/protected", post(|| async { StatusCode::OK })) + .layer(middleware::from_fn_with_state( + (db.clone(), CommunityPermission::Read), + user_has_selected_community_permission, + )) + .layer(auth_layer) + .with_state(state); + + // Execute request + let request = Request::builder() + .method("POST") + .uri("/protected") + .header(COOKIE, format!("id={session_id}")) + .body(Body::empty()) + .unwrap(); + let response = router.oneshot(request).await.unwrap(); + let (parts, body) = response.into_parts(); + let bytes = to_bytes(body, usize::MAX).await.unwrap(); + + // Check response matches expectations + assert_eq!(parts.status, StatusCode::FORBIDDEN); + assert!(bytes.is_empty()); +} + +#[tokio::test] +async fn test_user_has_selected_community_permission_forbidden_when_safe_request_lacks_permission() { + // Setup identifiers and data structures + let session_id = session::Id::default(); + let user_id = Uuid::new_v4(); + let community_id = Uuid::new_v4(); + let auth_hash = "hash".to_string(); + let session_record = sample_session_record(session_id, user_id, &auth_hash, Some(community_id), None); + + // Setup database mock + let mut db = MockDB::new(); + db.expect_get_session() + .times(1) + .withf(move |id| *id == session_id) + .returning(move |_| Ok(Some(session_record.clone()))); + db.expect_get_user_by_id() + .times(1) + .withf(move |id| *id == user_id) + .returning(move |_| Ok(Some(sample_auth_user(user_id, &auth_hash)))); + db.expect_user_has_community_permission() + .times(1) + .withf(move |cid, uid, permission| { + *cid == community_id && *uid == user_id && permission == CommunityPermission::Read + }) + .returning(|_, _, _| Ok(false)); + db.expect_list_user_communities() + .times(1) + .withf(move |uid| *uid == user_id) + .returning(move |_| Ok(vec![sample_community_summary(community_id)])); + db.expect_list_user_groups().times(0); + db.expect_update_session().times(0); + // Setup router let server_cfg = HttpServerConfig::default(); let db: DynDB = Arc::new(db); @@ -3620,6 +3688,171 @@ async fn test_user_has_selected_community_permission_forbidden_without_permissio assert!(bytes.is_empty()); } +#[tokio::test] +async fn test_user_has_selected_community_permission_repairs_stale_context_for_safe_request() { + // Setup identifiers and data structures + let inaccessible_community_id = Uuid::new_v4(); + let accessible_community_id = Uuid::new_v4(); + let accessible_group_id = Uuid::new_v4(); + let stale_group_id = Uuid::new_v4(); + let session_id = session::Id::default(); + let user_id = Uuid::new_v4(); + let auth_hash = "hash".to_string(); + let session_record = sample_session_record( + session_id, + user_id, + &auth_hash, + Some(inaccessible_community_id), + Some(stale_group_id), + ); + let groups = sample_user_groups_by_community(accessible_community_id, accessible_group_id); + + // Setup database mock + let mut db = MockDB::new(); + db.expect_get_session() + .times(1) + .withf(move |id| *id == session_id) + .returning(move |_| Ok(Some(session_record.clone()))); + db.expect_get_user_by_id() + .times(1) + .withf(move |id| *id == user_id) + .returning(move |_| Ok(Some(sample_auth_user(user_id, &auth_hash)))); + db.expect_user_has_community_permission() + .times(1) + .withf(move |cid, uid, permission| { + *cid == inaccessible_community_id && *uid == user_id && permission == CommunityPermission::Read + }) + .returning(|_, _, _| Ok(false)); + db.expect_list_user_communities() + .times(1) + .withf(move |uid| *uid == user_id) + .returning(move |_| Ok(vec![sample_community_summary(accessible_community_id)])); + db.expect_list_user_groups() + .times(1) + .withf(move |uid| *uid == user_id) + .returning(move |_| Ok(groups.clone())); + db.expect_user_has_community_permission() + .times(1) + .withf(move |cid, uid, permission| { + *cid == accessible_community_id && *uid == user_id && permission == CommunityPermission::Read + }) + .returning(|_, _, _| Ok(true)); + db.expect_update_session() + .times(1) + .withf(move |record| { + record.id == session_id + && record + .data + .get(SELECTED_COMMUNITY_ID_KEY) + .is_some_and(|value| value == &json!(accessible_community_id)) + && record + .data + .get(SELECTED_GROUP_ID_KEY) + .is_some_and(|value| value == &json!(accessible_group_id)) + }) + .returning(|_| Ok(())); + + // Setup router + let server_cfg = HttpServerConfig::default(); + let db: DynDB = Arc::new(db); + let nm = Arc::new(MockNotificationsManager::new()); + let state = test_state_with_server_cfg( + db.clone(), + Arc::new(MockImageStorage::new()), + nm.clone(), + &server_cfg, + ); + let auth_layer = crate::auth::setup_layer(&server_cfg, db.clone()).await.unwrap(); + let router = Router::new() + .route("/protected", get(|| async { StatusCode::OK })) + .layer(middleware::from_fn_with_state( + (db.clone(), CommunityPermission::Read), + user_has_selected_community_permission, + )) + .layer(auth_layer) + .with_state(state); + + // Execute request + let request = Request::builder() + .method("GET") + .uri("/protected") + .header(COOKIE, format!("id={session_id}")) + .body(Body::empty()) + .unwrap(); + let response = router.oneshot(request).await.unwrap(); + let (parts, body) = response.into_parts(); + let bytes = to_bytes(body, usize::MAX).await.unwrap(); + + // Check response matches expectations + assert_eq!(parts.status, StatusCode::OK); + assert!(bytes.is_empty()); +} + +#[tokio::test] +async fn test_user_has_selected_community_permission_keeps_stale_context_for_mutating_request() { + // Setup identifiers and data structures + let community_id = Uuid::new_v4(); + let session_id = session::Id::default(); + let user_id = Uuid::new_v4(); + let auth_hash = "hash".to_string(); + let session_record = sample_session_record(session_id, user_id, &auth_hash, Some(community_id), None); + + // Setup database mock + let mut db = MockDB::new(); + db.expect_get_session() + .times(1) + .withf(move |id| *id == session_id) + .returning(move |_| Ok(Some(session_record.clone()))); + db.expect_get_user_by_id() + .times(1) + .withf(move |id| *id == user_id) + .returning(move |_| Ok(Some(sample_auth_user(user_id, &auth_hash)))); + db.expect_user_has_community_permission() + .times(1) + .withf(move |cid, uid, permission| { + *cid == community_id && *uid == user_id && permission == CommunityPermission::Read + }) + .returning(|_, _, _| Ok(false)); + db.expect_list_user_communities().times(0); + db.expect_list_user_groups().times(0); + db.expect_update_session().times(0); + + // Setup router + let server_cfg = HttpServerConfig::default(); + let db: DynDB = Arc::new(db); + let nm = Arc::new(MockNotificationsManager::new()); + let state = test_state_with_server_cfg( + db.clone(), + Arc::new(MockImageStorage::new()), + nm.clone(), + &server_cfg, + ); + let auth_layer = crate::auth::setup_layer(&server_cfg, db.clone()).await.unwrap(); + let router = Router::new() + .route("/protected", post(|| async { StatusCode::OK })) + .layer(middleware::from_fn_with_state( + (db.clone(), CommunityPermission::Read), + user_has_selected_community_permission, + )) + .layer(auth_layer) + .with_state(state); + + // Execute request + let request = Request::builder() + .method("POST") + .uri("/protected") + .header(COOKIE, format!("id={session_id}")) + .body(Body::empty()) + .unwrap(); + let response = router.oneshot(request).await.unwrap(); + let (parts, body) = response.into_parts(); + let bytes = to_bytes(body, usize::MAX).await.unwrap(); + + // Check response matches expectations + assert_eq!(parts.status, StatusCode::FORBIDDEN); + assert!(bytes.is_empty()); +} + #[tokio::test] async fn test_user_has_selected_community_permission_returns_error_on_db_failure() { // Setup identifiers and data structures @@ -3987,6 +4220,81 @@ async fn test_user_has_selected_group_permission_forbidden_without_permission() .withf(move |cid, gid, uid, _permission| *cid == community_id && *gid == group_id && *uid == user_id) .returning(|_, _, _, _| Ok(false)); + // Setup router + let server_cfg = HttpServerConfig::default(); + let db: DynDB = Arc::new(db); + let nm = Arc::new(MockNotificationsManager::new()); + let state = test_state_with_server_cfg( + db.clone(), + Arc::new(MockImageStorage::new()), + nm.clone(), + &server_cfg, + ); + let auth_layer = crate::auth::setup_layer(&server_cfg, db.clone()).await.unwrap(); + let router = Router::new() + .route("/protected", post(|| async { StatusCode::OK })) + .layer(middleware::from_fn_with_state( + (db.clone(), GroupPermission::Read), + user_has_selected_group_permission, + )) + .layer(auth_layer) + .with_state(state); + + // Execute request + let request = Request::builder() + .method("POST") + .uri("/protected") + .header(COOKIE, format!("id={session_id}")) + .body(Body::empty()) + .unwrap(); + let response = router.oneshot(request).await.unwrap(); + let (parts, body) = response.into_parts(); + let bytes = to_bytes(body, usize::MAX).await.unwrap(); + + // Check response matches expectations + assert_eq!(parts.status, StatusCode::FORBIDDEN); + assert!(bytes.is_empty()); +} + +#[tokio::test] +async fn test_user_has_selected_group_permission_forbidden_when_safe_request_lacks_permission() { + // Setup identifiers and data structures + let session_id = session::Id::default(); + let user_id = Uuid::new_v4(); + let community_id = Uuid::new_v4(); + let group_id = Uuid::new_v4(); + let auth_hash = "hash".to_string(); + let session_record = sample_session_record( + session_id, + user_id, + &auth_hash, + Some(community_id), + Some(group_id), + ); + let groups = sample_user_groups_by_community(community_id, group_id); + + // Setup database mock + let mut db = MockDB::new(); + db.expect_get_session() + .times(1) + .withf(move |id| *id == session_id) + .returning(move |_| Ok(Some(session_record.clone()))); + db.expect_get_user_by_id() + .times(1) + .withf(move |id| *id == user_id) + .returning(move |_| Ok(Some(sample_auth_user(user_id, &auth_hash)))); + db.expect_user_has_group_permission() + .times(1) + .withf(move |cid, gid, uid, permission| { + *cid == community_id && *gid == group_id && *uid == user_id && permission == GroupPermission::Read + }) + .returning(|_, _, _, _| Ok(false)); + db.expect_list_user_groups() + .times(1) + .withf(move |uid| *uid == user_id) + .returning(move |_| Ok(groups.clone())); + db.expect_update_session().times(0); + // Setup router let server_cfg = HttpServerConfig::default(); let db: DynDB = Arc::new(db); @@ -4023,6 +4331,181 @@ async fn test_user_has_selected_group_permission_forbidden_without_permission() assert!(bytes.is_empty()); } +#[tokio::test] +async fn test_user_has_selected_group_permission_repairs_stale_context_for_safe_request() { + // Setup identifiers and data structures + let session_id = session::Id::default(); + let user_id = Uuid::new_v4(); + let community_id = Uuid::new_v4(); + let stale_group_id = Uuid::new_v4(); + let repaired_group_id = Uuid::new_v4(); + let auth_hash = "hash".to_string(); + let session_record = sample_session_record( + session_id, + user_id, + &auth_hash, + Some(community_id), + Some(stale_group_id), + ); + let groups = sample_user_groups_by_community(community_id, repaired_group_id); + + // Setup database mock + let mut db = MockDB::new(); + db.expect_get_session() + .times(1) + .withf(move |id| *id == session_id) + .returning(move |_| Ok(Some(session_record.clone()))); + db.expect_get_user_by_id() + .times(1) + .withf(move |id| *id == user_id) + .returning(move |_| Ok(Some(sample_auth_user(user_id, &auth_hash)))); + db.expect_user_has_group_permission() + .times(1) + .withf(move |cid, gid, uid, permission| { + *cid == community_id + && *gid == stale_group_id + && *uid == user_id + && permission == GroupPermission::Read + }) + .returning(|_, _, _, _| Ok(false)); + db.expect_list_user_groups() + .times(1) + .withf(move |uid| *uid == user_id) + .returning(move |_| Ok(groups.clone())); + db.expect_user_has_group_permission() + .times(1) + .withf(move |cid, gid, uid, permission| { + *cid == community_id + && *gid == repaired_group_id + && *uid == user_id + && permission == GroupPermission::Read + }) + .returning(|_, _, _, _| Ok(true)); + db.expect_update_session() + .times(1) + .withf(move |record| { + record.id == session_id + && record + .data + .get(SELECTED_COMMUNITY_ID_KEY) + .is_some_and(|value| value == &json!(community_id)) + && record + .data + .get(SELECTED_GROUP_ID_KEY) + .is_some_and(|value| value == &json!(repaired_group_id)) + }) + .returning(|_| Ok(())); + + // Setup router + let server_cfg = HttpServerConfig::default(); + let db: DynDB = Arc::new(db); + let nm = Arc::new(MockNotificationsManager::new()); + let state = test_state_with_server_cfg( + db.clone(), + Arc::new(MockImageStorage::new()), + nm.clone(), + &server_cfg, + ); + let auth_layer = crate::auth::setup_layer(&server_cfg, db.clone()).await.unwrap(); + let router = Router::new() + .route("/protected", get(|| async { StatusCode::OK })) + .layer(middleware::from_fn_with_state( + (db.clone(), GroupPermission::Read), + user_has_selected_group_permission, + )) + .layer(auth_layer) + .with_state(state); + + // Execute request + let request = Request::builder() + .method("GET") + .uri("/protected") + .header(COOKIE, format!("id={session_id}")) + .body(Body::empty()) + .unwrap(); + let response = router.oneshot(request).await.unwrap(); + let (parts, body) = response.into_parts(); + let bytes = to_bytes(body, usize::MAX).await.unwrap(); + + // Check response matches expectations + assert_eq!(parts.status, StatusCode::OK); + assert!(bytes.is_empty()); +} + +#[tokio::test] +async fn test_user_has_selected_group_permission_keeps_stale_context_for_mutating_request() { + // Setup identifiers and data structures + let session_id = session::Id::default(); + let user_id = Uuid::new_v4(); + let community_id = Uuid::new_v4(); + let stale_group_id = Uuid::new_v4(); + let auth_hash = "hash".to_string(); + let session_record = sample_session_record( + session_id, + user_id, + &auth_hash, + Some(community_id), + Some(stale_group_id), + ); + + // Setup database mock + let mut db = MockDB::new(); + db.expect_get_session() + .times(1) + .withf(move |id| *id == session_id) + .returning(move |_| Ok(Some(session_record.clone()))); + db.expect_get_user_by_id() + .times(1) + .withf(move |id| *id == user_id) + .returning(move |_| Ok(Some(sample_auth_user(user_id, &auth_hash)))); + db.expect_user_has_group_permission() + .times(1) + .withf(move |cid, gid, uid, permission| { + *cid == community_id + && *gid == stale_group_id + && *uid == user_id + && permission == GroupPermission::Read + }) + .returning(|_, _, _, _| Ok(false)); + db.expect_list_user_groups().times(0); + db.expect_update_session().times(0); + + // Setup router + let server_cfg = HttpServerConfig::default(); + let db: DynDB = Arc::new(db); + let nm = Arc::new(MockNotificationsManager::new()); + let state = test_state_with_server_cfg( + db.clone(), + Arc::new(MockImageStorage::new()), + nm.clone(), + &server_cfg, + ); + let auth_layer = crate::auth::setup_layer(&server_cfg, db.clone()).await.unwrap(); + let router = Router::new() + .route("/protected", post(|| async { StatusCode::OK })) + .layer(middleware::from_fn_with_state( + (db.clone(), GroupPermission::Read), + user_has_selected_group_permission, + )) + .layer(auth_layer) + .with_state(state); + + // Execute request + let request = Request::builder() + .method("POST") + .uri("/protected") + .header(COOKIE, format!("id={session_id}")) + .body(Body::empty()) + .unwrap(); + let response = router.oneshot(request).await.unwrap(); + let (parts, body) = response.into_parts(); + let bytes = to_bytes(body, usize::MAX).await.unwrap(); + + // Check response matches expectations + assert_eq!(parts.status, StatusCode::FORBIDDEN); + assert!(bytes.is_empty()); +} + #[tokio::test] async fn test_user_has_selected_group_permission_returns_error_on_db_failure() { // Setup identifiers and data structures