diff --git a/editoast/authz/src/model.rs b/editoast/authz/src/model.rs index 183fd92b2e3..9de60f6b4c8 100644 --- a/editoast/authz/src/model.rs +++ b/editoast/authz/src/model.rs @@ -140,6 +140,7 @@ pub enum InfraGrant { Eq, Hash, )] +#[cfg_attr(test, derive(PartialOrd, Ord))] #[fga(name = "rolling_stock")] pub struct RollingStock(pub i64); @@ -177,7 +178,18 @@ pub enum RollingStockPrivilege { } #[derive( - Debug, Display, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, + Debug, + Display, + Clone, + Copy, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Serialize, + Deserialize, + EnumIter, )] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] #[strum(serialize_all = "SCREAMING_SNAKE_CASE")] diff --git a/editoast/authz/src/v2.rs b/editoast/authz/src/v2.rs index 9d4b419bdc4..2a6ca1e578d 100644 --- a/editoast/authz/src/v2.rs +++ b/editoast/authz/src/v2.rs @@ -210,6 +210,12 @@ impl Protected { } } +impl Protected<()> { + pub fn check(check: Check) -> Self { + Protected::<()>::default().with_check_iter([check]) + } +} + impl Protected { /// A [Protected] value that always succeeds with the provided value pub fn value(t: T) -> Self { diff --git a/editoast/authz/src/v2/rolling_stock.rs b/editoast/authz/src/v2/rolling_stock.rs index 87f72420a85..32f8707bc80 100644 --- a/editoast/authz/src/v2/rolling_stock.rs +++ b/editoast/authz/src/v2/rolling_stock.rs @@ -14,6 +14,8 @@ use crate::RollingStockPrivilege; use crate::Subject; use crate::User; use crate::v2::Actor; +use crate::v2::ResourcesList; +use crate::v2::subject_roles; use crate::v2::validate_direct_grant; pub fn rolling_stock_privileges( @@ -441,6 +443,63 @@ pub fn rolling_stock_revoke_grant( .with_check(Check::IsNotLastRollingStockOwner(subject, rolling_stock)) } +pub fn rolling_stock_list( + user: User, + privilege: RollingStockPrivilege, +) -> Protected> { + subject_roles(Subject::user(user)).then(move |openfga, roles| { + async move { + if roles.contains(&Role::Admin) { + return Ok(ResourcesList::All); + } + let authorized_rolling_stocks = match privilege { + RollingStockPrivilege::CanRestrictedRead => { + openfga + .list_objects(RollingStock::can_restricted_read().query_objects(&user)) + .await? + } + RollingStockPrivilege::CanRead => { + openfga + .list_objects(RollingStock::can_read().query_objects(&user)) + .await? + } + RollingStockPrivilege::CanShareRead => { + openfga + .list_objects(RollingStock::can_share_read().query_objects(&user)) + .await? + } + RollingStockPrivilege::CanWrite => { + openfga + .list_objects(RollingStock::can_write().query_objects(&user)) + .await? + } + RollingStockPrivilege::CanShareWrite => { + openfga + .list_objects(RollingStock::can_share_write().query_objects(&user)) + .await? + } + RollingStockPrivilege::CanDelete => { + openfga + .list_objects(RollingStock::can_delete().query_objects(&user)) + .await? + } + RollingStockPrivilege::CanShareOwnership => { + openfga + .list_objects(RollingStock::can_share_ownership().query_objects(&user)) + .await? + } + RollingStockPrivilege::CanRevoke => { + openfga + .list_objects(RollingStock::can_revoke().query_objects(&user)) + .await? + } + }; + Ok(ResourcesList::Privileged(authorized_rolling_stocks)) + } + .boxed() + }) +} + #[cfg(test)] mod tests { use rstest::rstest; @@ -781,7 +840,6 @@ mod tests { .execute() .await .unwrap(); - assert_eq!( openfga .rolling_stock_direct_grant(Subject::user(1), RollingStock(1)) @@ -813,6 +871,79 @@ mod tests { ); } + #[tokio::test] + async fn check_rolling_stock_list_no_rights_and_admin() { + let openfga = crate::authz_client!(); + openfga + .prepare_writes() + .write(&RollingStock::reader().tuple(&User(1), &RollingStock(1))) + .write(&RollingStock::reader().tuple(&User(1), &RollingStock(3))) + .write(&RollingStock::writer().tuple(&User(2), &RollingStock(2))) + .write(&User::role().tuple(&Role::Admin, &User(3))) + .execute() + .await + .unwrap(); + let rolling_stocks_1 = openfga + .rolling_stock_list(User(1), RollingStockPrivilege::CanRead) + .await + .unwrap_privileged() + .into_iter(); + let rolling_stocks_2 = openfga + .rolling_stock_list(User(2), RollingStockPrivilege::CanRead) + .await + .unwrap_privileged() + .into_iter(); + let rolling_stocks_no_rights = openfga + .rolling_stock_list(User(4), RollingStockPrivilege::CanRead) + .await + .unwrap_privileged(); + let rolling_stocks_admin = openfga + .rolling_stock_list(User(3), RollingStockPrivilege::CanRead) + .await; + assert_eq!( + rolling_stocks_1.sorted().collect_vec(), + vec![RollingStock(1), RollingStock(3)] + ); + assert_eq!( + rolling_stocks_2.sorted().collect_vec(), + vec![RollingStock(2)] + ); + assert_eq!(rolling_stocks_no_rights, vec![]); + assert!(matches!(rolling_stocks_admin, ResourcesList::All)); + } + + #[tokio::test] + async fn rolling_stock_list_only_returns_resources_with_the_queried_privilege() { + let openfga = crate::authz_client!(); + openfga + .prepare_writes() + .write(&RollingStock::reader().tuple(&User(1), &RollingStock(1))) + .write(&RollingStock::writer().tuple(&User(2), &RollingStock(2))) + .execute() + .await + .unwrap(); + + // A reader grant grants read access and not write access + let reader_can_read = openfga + .rolling_stock_list(User(1), RollingStockPrivilege::CanRead) + .await + .unwrap_privileged(); + assert_eq!(reader_can_read, vec![RollingStock(1)]); + + let reader_can_write = openfga + .rolling_stock_list(User(1), RollingStockPrivilege::CanWrite) + .await + .unwrap_privileged(); + assert_eq!(reader_can_write, vec![]); + + // A writer grant grants both read and write access + let writer_can_write = openfga + .rolling_stock_list(User(2), RollingStockPrivilege::CanWrite) + .await + .unwrap_privileged(); + assert_eq!(writer_can_write, vec![RollingStock(2)]); + } + #[rstest] #[case::rolling_stock_privileges( rolling_stock_privileges(User(1), RollingStock(1)).checks, @@ -834,6 +965,11 @@ mod tests { Check::HasRollingStockPrivilege(Actor::Issuer, RollingStockPrivilege::CanRead, RollingStock(1)) ] )] + #[rstest] + #[case::rolling_stock_list( + rolling_stock_list(User(1), RollingStockPrivilege::CanRead).checks, + &[] + )] fn protected_contains_expected_checks( #[case] protected_checks: HashSet, #[case] expected_checks: &[Check], diff --git a/editoast/authz/src/v2/test_client_ext.rs b/editoast/authz/src/v2/test_client_ext.rs index 0d9fceea3bd..8610268a5c3 100644 --- a/editoast/authz/src/v2/test_client_ext.rs +++ b/editoast/authz/src/v2/test_client_ext.rs @@ -109,6 +109,11 @@ pub trait TestClientExt { async fn project_privileges(&self, user: User, project: Project) -> HashSet; async fn project_list(&self, user: User) -> ResourcesList; async fn project_granted_subjects(&self, project: Project) -> Vec; + async fn rolling_stock_list( + &self, + user: User, + privilege: RollingStockPrivilege, + ) -> ResourcesList; } impl TestClientExt for fga::Client { @@ -360,4 +365,18 @@ impl TestClientExt for fga::Client { .await .unwrap() } + + async fn rolling_stock_list( + &self, + user: User, + privilege: RollingStockPrivilege, + ) -> ResourcesList { + let authorize = special_authorizers::Authorize(self); + authorize + .access_value(crate::v2::rolling_stock::rolling_stock_list( + user, privilege, + )) + .await + .unwrap() + } } diff --git a/editoast/core_client/src/pathfinding.rs b/editoast/core_client/src/pathfinding.rs index 4282cf72001..5a40add3ccb 100644 --- a/editoast/core_client/src/pathfinding.rs +++ b/editoast/core_client/src/pathfinding.rs @@ -141,6 +141,9 @@ pub enum PathfindingInputError { items: Vec, }, NotEnoughPathItems, + UnauthorizedRollingStock { + rolling_stock_id: i64, + }, RollingStockNotFound { rolling_stock_name: String, }, diff --git a/editoast/openapi.yaml b/editoast/openapi.yaml index 756f97084e9..aa8cf9185df 100644 --- a/editoast/openapi.yaml +++ b/editoast/openapi.yaml @@ -5809,6 +5809,19 @@ components: type: string enum: - not_enough_path_items + - type: object + title: PathfindingInputErrorUnauthorizedRollingStock + required: + - rolling_stock_id + - error_type + properties: + error_type: + type: string + enum: + - unauthorized_rolling_stock + rolling_stock_id: + type: integer + format: int64 - type: object title: PathfindingInputErrorRollingStockNotFound required: diff --git a/editoast/src/authorizers.rs b/editoast/src/authorizers.rs index 1063e35ede8..415d9422e46 100644 --- a/editoast/src/authorizers.rs +++ b/editoast/src/authorizers.rs @@ -2,12 +2,14 @@ use std::convert::Infallible; use std::marker::PhantomData; use std::ops::Not as _; +use crate::views::AuthorizationError; use authz::ProjectGrant; use authz::v2::Access; use authz::v2::Actor; use authz::v2::Authorizer; use authz::v2::Check; use authz::v2::Protected; +use editoast_models::prelude::RetrieveBatchUnchecked as _; use futures::StreamExt as _; use futures::stream::FuturesUnordered; use tracing::Instrument as _; @@ -281,6 +283,70 @@ impl Authorizer for UserAuthorizer<'_> { #[error(transparent)] pub struct Error(#[from] pub authz::v2::OpenFgaError); +/// Ensures the issuer holds a privilege satisfying `required`. +/// `protected` is an operation yielding the set of privileges the issuer holds on a resource. +/// Access is granted when the operation is authorized and the issuer holds a privilege equal to `required`. +pub async fn require( + authorizer: &U, + protected: Protected, + required: &::Item, +) -> Result<(), AuthorizationError> +where + I: IntoIterator, + ::Item: PartialEq, + U: Authorizer, +{ + let access = authorizer + .authorize(protected) + .await + .map_err(|e| AuthorizationError::from(e.0))?; + let Ok(privileges) = access.access().await? else { + return Err(AuthorizationError::Forbidden); + }; + if privileges + .into_iter() + .any(|privilege| privilege == *required) + { + Ok(()) + } else { + Err(AuthorizationError::Forbidden) + } +} + +/// Ensures the issuer can read every rolling stock of `rolling_stock_names`, and fails with a +/// [`AuthorizationError::Forbidden`] as soon as one of them isn't readable. +pub async fn require_readable_rolling_stocks( + rolling_stock_names: impl IntoIterator, + conn: &mut database::DbConnection, + authn_state: &crate::authentication::State, + openfga: &fga::Client, +) -> crate::error::Result<()> { + let Some(user) = authn_state.user() else { + return Ok(()); + }; + + // Rolling stocks are referenced by name, the authorization by id + let rolling_stock_names = rolling_stock_names + .into_iter() + .collect::>(); + let rolling_stocks: Vec = + editoast_models::RollingStock::retrieve_batch_unchecked(conn, rolling_stock_names) + .await + .map_err(crate::views::rolling_stock::RollingStockError::from)?; + + // Not using rolling_stock_list: we bail out as soon as one rolling stock isn't readable + let authorizer = authn_state.authorizer(openfga); + for rolling_stock in rolling_stocks { + require( + &authorizer, + authz::v2::rolling_stock_privileges(user, authz::RollingStock(rolling_stock.id)), + &authz::RollingStockPrivilege::CanRead, + ) + .await?; + } + Ok(()) +} + #[cfg(test)] mod tests { use authz::InfraGrant; diff --git a/editoast/src/views/infra/mod.rs b/editoast/src/views/infra/mod.rs index 51d19ec31f9..c1e3b75a1c5 100644 --- a/editoast/src/views/infra/mod.rs +++ b/editoast/src/views/infra/mod.rs @@ -1066,30 +1066,39 @@ pub mod tests { } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] - async fn list_filters_authorized_infras() { + async fn user_only_sees_its_related_infras() { let app = test_app!().build(); let db_pool = app.db_pool(); - let infra = create_small_infra(&mut db_pool.get_ok()).await; - let infra_no_grant = create_small_infra(&mut db_pool.get_ok()).await; - - // Regular user with the correct roles should see only the infra he is associated with: + let infra_1 = create_small_infra(&mut db_pool.get_ok()).await; + let infra_2 = create_small_infra(&mut db_pool.get_ok()).await; + let _infra_no_grant = create_small_infra(&mut db_pool.get_ok()).await; let user = app .user("user_identity", "user_name") - .with_infra_grant(infra.id, InfraGrant::Reader) + .with_infra_grant(infra_1.id, InfraGrant::Reader) + .with_infra_grant(infra_2.id, InfraGrant::Reader) .create() .await; let response: InfraListResponse = app .get("/infra/") .by_user(user.as_ref()) .await - .assert_status(StatusCode::OK) + .assert_status_ok() .json(); assert_eq!( - response.results.iter().map(|infra| infra.id).collect_vec(), - vec![infra.id] + response + .results + .iter() + .map(|infra| infra.id) + .collect::>(), + vec![infra_1.id, infra_2.id] ); + } - // An admin should see all the infras: + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn admin_can_see_unrelated_infras() { + let app = test_app!().build(); + let db_pool = app.db_pool(); + let infra_no_grant = create_small_infra(&mut db_pool.get_ok()).await; let admin = app .user("admin", "admin") .with_roles([Role::Admin]) @@ -1099,11 +1108,15 @@ pub mod tests { .get("/infra/") .by_user(admin.as_ref()) .await - .assert_status(StatusCode::OK) + .assert_status_ok() .json(); assert_eq!( - response.results.iter().map(|infra| infra.id).collect_vec(), - vec![infra.id, infra_no_grant.id] + response + .results + .iter() + .map(|infra| infra.id) + .collect::>(), + vec![infra_no_grant.id] ); } diff --git a/editoast/src/views/level_crossing_occupancy.rs b/editoast/src/views/level_crossing_occupancy.rs index 645b850dc3d..b3db75af130 100644 --- a/editoast/src/views/level_crossing_occupancy.rs +++ b/editoast/src/views/level_crossing_occupancy.rs @@ -1,5 +1,6 @@ use crate::AppState; use crate::authentication; +use crate::authorizers::SystemAuthorizer; use crate::error::Result; use crate::views::AuthorizationError; use crate::views::path::pathfinding::PathfindingResult; @@ -16,6 +17,9 @@ use editoast_models::Timetable; use editoast_models::TrainSchedule; use editoast_models::train_schedule::OccurrenceId; +use authz::RollingStockPrivilege; +use authz::v2::Authorizer as _; +use authz::v2::ResourcesList; use axum::Extension; use axum::extract::Json; use axum::extract::State; @@ -24,7 +28,6 @@ use common::units::millisecond; use common::units::quantities::Offset; use core_client::pathfinding::TrackRange; use core_client::simulation::ReportTrain; -use database::DbConnection; use editoast_derive::EditoastError; use editoast_models::TrainScheduleException; use editoast_models::prelude::*; @@ -40,6 +43,7 @@ use schemas::timetable_type::TimetableType; use serde::Deserialize; use serde::Serialize; use std::collections::HashMap; +use std::collections::HashSet; use thiserror::Error; use utoipa::ToSchema; @@ -76,6 +80,7 @@ pub(in crate::views) struct LevelCrossingOccupancyForm { } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[cfg_attr(test, derive(PartialEq))] pub(in crate::views) struct LevelCrossingOccupancy { #[serde(flatten)] #[schema(inline)] @@ -169,15 +174,30 @@ pub(in crate::views) async fn occupancy( }) .await?; - // Collect all occurrences from all trains + // Collect all occurrences from all trains, grouped by the rolling stock name of their train let train_occurrences = trains .iter() .flat_map(|train| { + let train_exceptions = exceptions.remove(&train.id).unwrap_or_default(); train - .iter_occurrences(&exceptions.remove(&train.id).unwrap_or_default()) - .collect::>() + .iter_occurrences(&train_exceptions) + .map(|occurrence| (train.rolling_stock_name.clone(), occurrence)) + .collect_vec() }) - .collect_vec(); + .into_group_map(); + + let rolling_stock_names: HashSet<_> = train_occurrences.keys().cloned().collect(); + let rolling_stocks: Vec<_> = RollingStock::retrieve_batch_unchecked(conn, rolling_stock_names) + .await + .map_err(RollingStockError::from)?; + + let train_occurrences = filter_readable_occurrences( + train_occurrences, + &rolling_stocks, + &authn_state, + regulator.openfga(), + ) + .await?; // Extract train schedules for simulation let train_schedules = train_occurrences @@ -196,7 +216,10 @@ pub(in crate::views) async fn occupancy( ) .await?; - let rolling_stock_lengths = load_rolling_stock_lengths(&train_schedules, conn).await?; + let rolling_stock_lengths: HashMap<_, _> = rolling_stocks + .into_iter() + .map(|rs| (rs.name, common::units::millimeter::from(rs.length) as u64)) + .collect(); // For each occurrence + simulation result, compute level crossing occupancy and group by level crossing id let mut results: HashMap> = HashMap::new(); @@ -239,24 +262,51 @@ pub(in crate::views) async fn occupancy( Ok(Json(results)) } -async fn load_rolling_stock_lengths( - train_schedules: &[TrainOccurrence], - conn: &mut DbConnection, -) -> Result> { - let rolling_stocks_names = train_schedules.iter().map(|t| t.rolling_stock_name.clone()); +/// Discards the occurrences whose rolling stock the user isn't allowed to read +async fn filter_readable_occurrences( + train_occurrences: HashMap>, + rolling_stocks: &[RollingStock], + authn_state: &crate::authentication::State, + openfga: &fga::Client, +) -> Result> { + // The request bypasses authorization + let Some(user) = authn_state.user() else { + return Ok(train_occurrences.into_values().flatten().collect()); + }; - let rolling_stocks: Vec<_> = RollingStock::retrieve_batch_unchecked(conn, rolling_stocks_names) - .await - .map_err(RollingStockError::from)?; + // Listing the readable rolling stocks cannot be rejected: the issuer is already authenticated + let Ok(authorized_rolling_stocks) = SystemAuthorizer::new_infallible(openfga) + .authorize(authz::v2::rolling_stock_list( + user, + RollingStockPrivilege::CanRead, + )) + .await? + .access() + .await?; - Ok(rolling_stocks + let ResourcesList::Privileged(authorized_rolling_stocks) = authorized_rolling_stocks else { + // The user is an admin: every rolling stock is readable + return Ok(train_occurrences.into_values().flatten().collect()); + }; + let authorized_rolling_stock_ids = authorized_rolling_stocks .into_iter() - .map(|rs| { - ( - rs.name.clone(), - common::units::millimeter::from(rs.length) as u64, - ) + .map(|rolling_stock| rolling_stock.0) + .collect::>(); + + // Occurrences are grouped by rolling stock name, the authorization by id + + let authorized_rolling_stock_names = rolling_stocks + .iter() + .filter(|rs| authorized_rolling_stock_ids.contains(&rs.id)) + .map(|rs| rs.name.clone()) + .collect::>(); + + Ok(train_occurrences + .into_iter() + .filter(|(rolling_stock_name, _)| { + authorized_rolling_stock_names.contains(rolling_stock_name) }) + .flat_map(|(_, occurrences)| occurrences) .collect()) } @@ -379,12 +429,15 @@ fn find_pedal_position( mod tests { use super::*; use crate::fixtures::create_fast_rolling_stock; - use crate::fixtures::create_hourly_timetable_with_train_schedule_set; use crate::fixtures::create_small_infra; use crate::fixtures::create_timetable_with_train_schedule_set; + use crate::views::test_app::TestApp; + use crate::views::test_app::TestRequestExt as _; use crate::views::test_app::test_app; - use axum_test::TestResponse; + use authz::InfraGrant; + use authz::Role; + use authz::RollingStockGrant; use chrono::TimeDelta; use core_client::mocking::MockingClient; use core_client::pathfinding::PathfindingResultSuccess; @@ -531,11 +584,7 @@ mod tests { }) } - async fn init_level_crossing_test( - lc_position: f64, - lc_id: &str, - track: &str, - ) -> (TestResponse, Identifier, TrainSchedule) { + fn mocked_core() -> MockingClient { let mut core = MockingClient::new(); core.stub("/pathfinding/blocks") .response(StatusCode::OK) @@ -545,17 +594,17 @@ mod tests { .response(StatusCode::OK) .json(simulation_with_realistic_positions()) .finish(); - - create_and_fetch_occupancy(core, lc_id, track, lc_position).await + core } - async fn create_and_fetch_occupancy( - core: MockingClient, + /// Creates the infra, the rolling stock, the timetable, the level crossing and the train the + /// occupancy tests run on, and returns the form querying them + async fn create_occupancy_fixtures( + app: &TestApp, lc_id: &str, track: &str, lc_position: f64, - ) -> (TestResponse, Identifier, TrainSchedule) { - let app = test_app!().skip_authz().core_client(core.into()).build(); + ) -> (LevelCrossingOccupancyForm, TrainSchedule, RollingStock) { let db_pool = app.db_pool(); let small_infra = create_small_infra(&mut db_pool.get_ok()).await; let rolling_stock = @@ -583,46 +632,65 @@ mod tests { .await .expect("Failed to create level crossing"); - let train = editoast_models::TrainSchedule::default() + let train = create_train(app, train_schedule_set.id, &rolling_stock.name).await; + + let form = LevelCrossingOccupancyForm { + train_ids: vec![train.id], + level_crossing_ids: vec![level_crossing.obj_id.into()], + infra_id: small_infra.id, + timetable_id: timetable.id, + electrical_profile_set_id: None, + }; + (form, train, rolling_stock) + } + + /// A train running from `Mid_West_station` to `Mid_East_station`, paced every 15 minutes + /// over an hour + async fn create_train( + app: &TestApp, + train_schedule_set_id: i64, + rolling_stock_name: &str, + ) -> TrainSchedule { + TrainSchedule::default() .into_changeset() - .train_schedule_set_id(train_schedule_set.id) - .rolling_stock_name(rolling_stock.name) + .train_schedule_set_id(train_schedule_set_id) + .rolling_stock_name(rolling_stock_name.to_string()) .path(vec![ PathItem::new_operational_point("Mid_West_station"), PathItem::new_operational_point("Mid_East_station"), ]) .interval(Some(TimeDelta::minutes(15))) .time_window(Some(TimeDelta::hours(1))) - .create(&mut db_pool.get_ok()) + .create(&mut app.db_pool().get_ok()) .await - .expect("Failed to create train"); - - ( - app.post("/level_crossing_occupancy") - .json(&LevelCrossingOccupancyForm { - train_ids: vec![train.id], - level_crossing_ids: vec![level_crossing.obj_id.clone().into()], - infra_id: small_infra.id, - timetable_id: timetable.id, - electrical_profile_set_id: None, - }) - .await, - level_crossing.obj_id.clone().into(), - train, - ) + .expect("Failed to create train") } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_level_crossing_occupancy_endpoint() { - // Level crossing at 750m on TC1 (on train path) - let (response, level_crossing_id, train) = - init_level_crossing_test(750.0, "LC_TC1", "TC1").await; + let app = test_app!().core_client(mocked_core().into()).build(); + let (form, train, rolling_stock) = + create_occupancy_fixtures(&app, "LC_TC1", "TC1", 750.0).await; + + let user = app + .user("authorized", "Authorized") + .with_infra_grant(form.infra_id, InfraGrant::Reader) + .with_rolling_stock_grant(rolling_stock.id, RollingStockGrant::Reader) + .with_roles([Role::OperationalStudies]) + .create() + .await; - let occupancies: HashMap> = - response.assert_status_ok().json(); + let occupancies: HashMap> = app + .post("/level_crossing_occupancy") + .json(&form) + .by_user(&user.info) + .await + .assert_status_ok() + .json(); - assert!(occupancies.contains_key(&level_crossing_id)); - let lc_occupancies = occupancies.get(&level_crossing_id).unwrap(); + let level_crossing_obj_id = &form.level_crossing_ids[0]; + assert!(occupancies.contains_key(level_crossing_obj_id)); + let lc_occupancies = occupancies.get(level_crossing_obj_id).unwrap(); assert_eq!(lc_occupancies.len(), 4); // Expected values: @@ -644,45 +712,116 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_level_crossing_occupancy_returns_empty() { - // Level crossing at 750m on TX1 (not on train path) - let (response, level_crossing_id, ..) = - init_level_crossing_test(750.0, "LC_TX1", "TX1").await; + // A level crossing at 750m on TX1, which is not on the train path + let app = test_app!().core_client(mocked_core().into()).build(); + let (form, _, rolling_stock) = + create_occupancy_fixtures(&app, "LC_TX1", "TX1", 750.0).await; + + let user = app + .user("authorized", "Authorized") + .with_infra_grant(form.infra_id, InfraGrant::Reader) + .with_rolling_stock_grant(rolling_stock.id, RollingStockGrant::Reader) + .with_roles([Role::OperationalStudies]) + .create() + .await; - let occupancies: HashMap> = - response.assert_status_ok().json(); + // WHEN + let occupancies: HashMap> = app + .post("/level_crossing_occupancy") + .json(&form) + .by_user(&user.info) + .await + .assert_status_ok() + .json(); - // Level crossing should exist but have no occupancies + // The level crossing is reported, without any occupancy assert_eq!( - occupancies - .get(&level_crossing_id) - .map(|vec| vec.len()) - .unwrap_or(0), - 0 + occupancies.get(&form.level_crossing_ids[0]).unwrap(), + &Vec::new() ); } + /// The trains whose rolling stock the user cannot read are filtered out of the response, + /// instead of failing the whole request with a 403 #[tokio::test(flavor = "multi_thread", worker_threads = 1)] - async fn test_level_crossing_occupancy_rejects_hourly_timetable() { - let app = test_app!() - .skip_authz() - .core_client(MockingClient::new().into()) - .build(); - let db_pool = app.db_pool(); - let small_infra = create_small_infra(&mut db_pool.get_ok()).await; - let (timetable, _train_schedule_set) = - create_hourly_timetable_with_train_schedule_set(&mut db_pool.get_ok()).await; + async fn test_level_crossing_occupancy_without_rolling_stock_permission() { + // GIVEN + let app = test_app!().core_client(mocked_core().into()).build(); + let (form, ..) = create_occupancy_fixtures(&app, "LC_TC1", "TC1", 750.0).await; + + // a user that has the role to reach the endpoint and a read grant on the infra, + // but no read grant on the rolling stock of the train + let user = app + .user("unauthorized", "Unauthorized") + .with_infra_grant(form.infra_id, InfraGrant::Reader) + .with_roles([Role::OperationalStudies]) + .create() + .await; - let response = app + // WHEN + let occupancies: HashMap> = app .post("/level_crossing_occupancy") - .json(&LevelCrossingOccupancyForm { - train_ids: vec![], - level_crossing_ids: vec![], - infra_id: small_infra.id, - timetable_id: timetable.id, - electrical_profile_set_id: None, - }) + .json(&form) + .by_user(&user.info) + .await + .assert_status_ok() + .json(); + + assert_eq!( + occupancies.get(&form.level_crossing_ids[0]).unwrap(), + &Vec::new() + ); + } + + /// Among several trains, only the occurrences of those whose rolling stock the user can read + /// are reported, the others are filtered out + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_level_crossing_occupancy_filters_out_unreadable_trains_only() { + // GIVEN + let app = test_app!().core_client(mocked_core().into()).build(); + let (mut form, readable_train, readable_rolling_stock) = + create_occupancy_fixtures(&app, "LC_TC1", "TC1", 750.0).await; + + // a second train of the same timetable, running on another rolling stock + let unreadable_rolling_stock = + create_fast_rolling_stock(&mut app.db_pool().get_ok(), "unreadable_rolling_stock") + .await; + let unreadable_train = create_train( + &app, + readable_train.train_schedule_set_id, + &unreadable_rolling_stock.name, + ) + .await; + form.train_ids.push(unreadable_train.id); + + // a user granted a read access on the rolling stock of the first train only + let user = app + .user("authorized", "Authorized") + .with_infra_grant(form.infra_id, InfraGrant::Reader) + .with_rolling_stock_grant(readable_rolling_stock.id, RollingStockGrant::Reader) + .with_roles([Role::OperationalStudies]) + .create() .await; - response.assert_status_unprocessable_entity(); + // WHEN + let occupancies: HashMap> = app + .post("/level_crossing_occupancy") + .json(&form) + .by_user(&user.info) + .await + .assert_status_ok() + .json(); + + // THEN the occurrences of the readable train are reported, and only those + let reported_occurrences: HashSet<_> = occupancies + .get(&form.level_crossing_ids[0]) + .expect("the level crossing should be reported") + .iter() + .map(|occupancy| occupancy.occurrence_id.clone()) + .collect(); + let readable_occurrences: HashSet<_> = (0..4) + .map(|index| OccurrenceId::new_base(readable_train.id, index)) + .collect(); + assert_eq!(reported_occurrences, readable_occurrences); } } diff --git a/editoast/src/views/rolling_stock.rs b/editoast/src/views/rolling_stock.rs index 162ed7fed1e..54c0b9637b2 100644 --- a/editoast/src/views/rolling_stock.rs +++ b/editoast/src/views/rolling_stock.rs @@ -3,6 +3,12 @@ pub(in crate::views) mod towed; type RollingStockForm = schemas::RollingStock; +use authz::RollingStockGrant; +use authz::RollingStockPrivilege; +use authz::v2; +use authz::v2::rolling_stock_privileges; +use axum::Extension; + use std::io::Cursor; use std::sync::Arc; @@ -36,6 +42,9 @@ use thiserror::Error; use utoipa::IntoParams; use utoipa::ToSchema; +use crate::AppState; +use crate::authentication; +use crate::authorizers::SystemAuthorizer; use crate::error::InternalError; use crate::error::Result; @@ -180,9 +189,22 @@ pub struct RollingStockNameParam { ) )] pub(in crate::views) async fn get( - State(db_pool): State>, + State(AppState { + regulator, db_pool, .. + }): State, + Extension(authn_state): Extension, Path(rolling_stock_id): Path, ) -> Result> { + if let Some(user) = authn_state.user() { + let authorizer = authn_state.authorizer(regulator.openfga()); + crate::authorizers::require( + &authorizer, + rolling_stock_privileges(user, authz::RollingStock(rolling_stock_id)), + &RollingStockPrivilege::CanRead, + ) + .await?; + } + let rolling_stock = retrieve_existing_rolling_stock( &mut db_pool.get().await?, RollingStockKey::Id(rolling_stock_id), @@ -204,7 +226,10 @@ pub(in crate::views) async fn get( ) )] pub(in crate::views) async fn get_by_name( - State(db_pool): State>, + State(AppState { + regulator, db_pool, .. + }): State, + Extension(authn_state): Extension, Path(rolling_stock_name): Path, ) -> Result> { let rolling_stock = retrieve_existing_rolling_stock( @@ -212,6 +237,17 @@ pub(in crate::views) async fn get_by_name( RollingStockKey::Name(rolling_stock_name), ) .await?; + + if let Some(user) = authn_state.user() { + let authorizer = authn_state.authorizer(regulator.openfga()); + crate::authorizers::require( + &authorizer, + rolling_stock_privileges(user, authz::RollingStock(rolling_stock.id)), + &RollingStockPrivilege::CanRead, + ) + .await?; + } + let rolling_stock_with_liveries = RollingStockWithLiveries::try_fetch(&mut db_pool.get().await?, rolling_stock).await?; Ok(Json(rolling_stock_with_liveries)) @@ -258,8 +294,11 @@ pub(in crate::views) struct PostRollingStockQueryParams { ) )] pub(in crate::views) async fn create( - State(db_pool): State>, + State(AppState { + regulator, db_pool, .. + }): State, Query(query_params): Query, + Extension(authn_state): Extension, Json(rolling_stock_form): Json, ) -> Result> { let conn = &mut db_pool.get().await?; @@ -272,6 +311,18 @@ pub(in crate::views) async fn create( .await .map_err(RollingStockError::from)?; + if let authentication::State::Authenticated { user, .. } = &authn_state { + v2::rolling_stock_set_grant( + authz::Subject::User(*user), + authz::RollingStock(rolling_stock.id), + RollingStockGrant::Owner, + ) + .authorize(&SystemAuthorizer::new_infallible(regulator.openfga())) + .await? + .access() + .await?; + } + Ok(Json(rolling_stock)) } @@ -287,10 +338,22 @@ pub(in crate::views) async fn create( ) )] pub(in crate::views) async fn update( - State(db_pool): State>, + State(AppState { + regulator, db_pool, .. + }): State, + Extension(authn_state): Extension, Path(rolling_stock_id): Path, Json(rolling_stock_form): Json, ) -> Result> { + if let Some(user) = authn_state.user() { + let authorizer = authn_state.authorizer(regulator.openfga()); + crate::authorizers::require( + &authorizer, + rolling_stock_privileges(user, authz::RollingStock(rolling_stock_id)), + &RollingStockPrivilege::CanWrite, + ) + .await?; + } let new_rolling_stock = db_pool .get() .await? @@ -350,10 +413,23 @@ pub(in crate::views) struct DeleteRollingStockQueryParams { ) )] pub(in crate::views) async fn delete( - State(db_pool): State>, + State(AppState { + regulator, db_pool, .. + }): State, + Extension(authn_state): Extension, Path(rolling_stock_id): Path, Query(DeleteRollingStockQueryParams { force }): Query, ) -> Result { + if let Some(user) = authn_state.user() { + let authorizer = authn_state.authorizer(regulator.openfga()); + crate::authorizers::require( + &authorizer, + rolling_stock_privileges(user, authz::RollingStock(rolling_stock_id)), + &RollingStockPrivilege::CanDelete, + ) + .await?; + } + let conn = &mut db_pool.get().await?; let rolling_stock = RollingStock::retrieve_or_fail(conn.clone(), rolling_stock_id, || { @@ -410,12 +486,23 @@ pub(in crate::views) struct RollingStockLockedUpdateForm { ) )] pub(in crate::views) async fn update_locked( - State(db_pool): State>, + State(AppState { + regulator, db_pool, .. + }): State, + Extension(authn_state): Extension, Path(rolling_stock_id): Path, Json(RollingStockLockedUpdateForm { locked }): Json, ) -> Result { let conn = &mut db_pool.get().await?; - + if let Some(user) = authn_state.user() { + let authorizer = authn_state.authorizer(regulator.openfga()); + crate::authorizers::require( + &authorizer, + rolling_stock_privileges(user, authz::RollingStock(rolling_stock_id)), + &RollingStockPrivilege::CanWrite, + ) + .await?; + }; RollingStock::changeset() .locked(locked) .update_or_fail(conn, rolling_stock_id, || RollingStockError::KeyNotFound { @@ -426,6 +513,7 @@ pub(in crate::views) async fn update_locked( Ok(StatusCode::NO_CONTENT) } +// TODO delete that struct: it is used to document the API and is wrong #[derive(ToSchema)] #[allow(unused)] // Schema only struct RollingStockLiveryCreateForm { @@ -483,11 +571,25 @@ async fn parse_multipart_content( (status = 404, description = "The requested rolling stock was not found"), ) )] +// TODO update openapi: the request body description is wrong pub(in crate::views) async fn create_livery( - State(db_pool): State>, + State(AppState { + regulator, db_pool, .. + }): State, + Extension(authn_state): Extension, Path(rolling_stock_id): Path, form: Multipart, ) -> Result> { + if let Some(user) = authn_state.user() { + let authorizer = authn_state.authorizer(regulator.openfga()); + crate::authorizers::require( + &authorizer, + authz::v2::rolling_stock_privileges(user, authz::RollingStock(rolling_stock_id)), + &RollingStockPrivilege::CanWrite, + ) + .await?; + } + let conn = &mut db_pool.get().await?; let (name, images) = parse_multipart_content(form) @@ -544,20 +646,29 @@ pub(in crate::views) async fn create_livery( ) )] pub(in crate::views) async fn get_usage( - State(db_pool): State>, + State(AppState { + regulator, db_pool, .. + }): State, + Extension(authn_state): Extension, Path(rolling_stock_id): Path, ) -> Result>> { + if let Some(user) = authn_state.user() { + let authorizer = authn_state.authorizer(regulator.openfga()); + crate::authorizers::require( + &authorizer, + authz::v2::rolling_stock_privileges(user, authz::RollingStock(rolling_stock_id)), + &RollingStockPrivilege::CanRestrictedRead, + ) + .await?; + }; let mut conn = db_pool.get().await?; - let rolling_stock = RollingStock::retrieve_or_fail(conn.clone(), rolling_stock_id, || { RollingStockError::KeyNotFound { rolling_stock_key: RollingStockKey::Id(rolling_stock_id), } }) .await?; - let related_train_schedules = rolling_stock.get_usage(&mut conn).await?; - Ok(Json(related_train_schedules)) } @@ -671,10 +782,12 @@ async fn create_compound_image( #[cfg(test)] pub mod tests { + use authz::RollingStockGrant; use editoast_models::rolling_stock::TrainMainCategory; use itertools::Itertools; use pretty_assertions::assert_eq; use serde_json::json; + use strum::IntoEnumIterator as _; use uuid::Uuid; use super::*; @@ -689,6 +802,7 @@ pub mod tests { use crate::fixtures::simple_paced_train_changeset; use crate::views::test_app; use crate::views::test_app::TestApp; + use crate::views::test_app::TestRequestExt; use editoast_models::rolling_stock::RollingStock; impl TestApp { @@ -720,15 +834,22 @@ pub mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn create_rolling_stock_successfully() { // GIVEN - let app = test_app!().skip_authz().build(); + let app = test_app!().build(); let db_pool = app.db_pool(); + let user = app + .user(uuid::Uuid::new_v4().to_string(), "name") + .with_roles([Role::OperationalStudies]) + .create() + .await; + let rs_name = "fast_rolling_stock_name"; let fast_rolling_stock_form = fast_rolling_stock_form(rs_name); // WHEN let raw_response = app .rolling_stock_create_request(&fast_rolling_stock_form) + .by_user(user.as_ref()) .await; // THEN @@ -738,12 +859,15 @@ pub mod tests { .await .expect("Failed to retrieve rolling stock") .expect("Rolling stock not found"); - assert_eq!(rolling_stock.name, rs_name); assert_eq!( fast_rolling_stock_form.startup_time, rolling_stock.startup_time ); + // Check if the issuer was added as owner to the rolling stock + app.assert_rolling_stock_grant(rolling_stock.id, user.id, Some(RollingStockGrant::Owner)) + .await; + let rolling_stock: RollingStockForm = rolling_stock.into(); assert_eq!( rolling_stock @@ -801,139 +925,274 @@ pub mod tests { ); } - #[tokio::test(flavor = "multi_thread", worker_threads = 1)] - async fn get_rolling_stock_usage_with_no_usage_returns_empty_ok() { - let app = test_app!().skip_authz().build(); - let stock_name = Uuid::new_v4().to_string(); - let rolling_stock = fast_rolling_stock_form(stock_name.as_str()); - let RollingStock { id, .. } = app - .rolling_stock_create_request(&rolling_stock) - .await - .assert_status_ok() - .json(); - let related_schedules: Vec = app - .get(&format!("/rolling_stock/{id}/usage")) - .await - .assert_status_ok() - .json(); - assert!(related_schedules.is_empty()); - } + mod get_rolling_stock_usage { + use super::*; + use authz::v2::TestClientExt as _; + use pretty_assertions::assert_eq; + + mod authorization { + use std::iter::once; + + use super::*; + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn all_grant_levels_should_allow_usage() { + let app = test_app!().build(); + let rolling_stock_id = + create_fast_rolling_stock(&mut app.db_pool().get_ok(), "rolling_stock") + .await + .id; + for grant in RollingStockGrant::iter() { + let user = app + .user(uuid::Uuid::new_v4().to_string(), "name") + .with_rolling_stock_grant(rolling_stock_id, grant) + .create() + .await; + app.get(&format!("/rolling_stock/{rolling_stock_id}/usage")) + .by_user(user.as_ref()) + .await + .assert_status_ok(); + } + } - #[tokio::test(flavor = "multi_thread", worker_threads = 1)] - async fn get_rolling_stock_usage_with_related_schedules_returns_schedules_list() { - let app = test_app!().skip_authz().build(); - let db_pool = app.db_pool(); + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn roles_should_not_authorize_user() { + let app = test_app!().build(); + let rolling_stock_id = + create_fast_rolling_stock(&mut app.db_pool().get_ok(), "rolling_stock") + .await + .id; + for role in authz::Role::iter().map(Option::Some).chain(once(None)) { + let user_builder = app.user(uuid::Uuid::new_v4().to_string(), "name"); + match role { + Some(Role::Admin) => continue, // admins should be authorized + Some(role) => user_builder.with_roles(vec![role]), + None => user_builder, + } + .create() + .await; + let user = app + .user(uuid::Uuid::new_v4().to_string(), "name") + .create() + .await; + app.get(&format!("/rolling_stock/{rolling_stock_id}/usage")) + .by_user(user.as_ref()) + .await + .assert_status_forbidden(); + } + } - let create_rolling_stock_request = - app.rolling_stock_create_request(&fast_rolling_stock_form(&Uuid::new_v4().to_string())); - let rolling_stock: RollingStock = (create_rolling_stock_request) - .await - .assert_status_ok() - .json(); - let create_other_rolling_stock_request = - app.rolling_stock_create_request(&fast_rolling_stock_form(&Uuid::new_v4().to_string())); - let other_rolling_stock: RollingStock = (create_other_rolling_stock_request) - .await - .assert_status_ok() - .json(); + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn skip_authz_should_succeed() { + let app = test_app!().skip_authz().build(); + let rolling_stock_id = + create_fast_rolling_stock(&mut app.db_pool().get_ok(), "rolling_stock") + .await + .id; + app.get(&format!("/rolling_stock/{rolling_stock_id}/usage")) + .skip_authz() + .await + .assert_status_ok(); + } + } - let project = create_project(&mut db_pool.get_ok(), &Uuid::new_v4().to_string()).await; - let study = create_study( - &mut db_pool.get_ok(), - &Uuid::new_v4().to_string(), - project.id, - ) - .await; + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn get_rolling_stock_usage_with_no_usage_returns_empty_ok() { + let app = test_app!().build(); + let stock_name = Uuid::new_v4().to_string(); + let rolling_stock = fast_rolling_stock_form(stock_name.as_str()); + let user = app + .user(uuid::Uuid::new_v4().to_string(), "name") + .with_roles([Role::OperationalStudies]) + .create() + .await; + let RollingStock { id, .. } = app + .rolling_stock_create_request(&rolling_stock) + .by_user(user.as_ref()) + .await + .assert_status_ok() + .json(); + + // TODO remove me once `POST:/rolling_stock` setups the grants on the created rolling + // stock + app.openfga() + .rolling_stock_set_grant( + authz::RollingStock(id), + authz::Subject::user(user.clone()), + RollingStockGrant::Reader, + ) + .await; - let (timetable_1, train_schedule_set_1) = - create_timetable_with_train_schedule_set(&mut db_pool.get_ok()).await; + let related_schedules: Vec = app + .get(&format!("/rolling_stock/{id}/usage")) + .by_user(user.as_ref()) + .await + .assert_status_ok() + .json(); + assert!(related_schedules.is_empty()); + } - let (timetable_2, train_schedule_set_2) = - create_timetable_with_train_schedule_set(&mut db_pool.get_ok()).await; - let (timetable_3, train_schedule_set_3) = - create_timetable_with_train_schedule_set(&mut db_pool.get_ok()).await; + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn get_rolling_stock_usage_with_related_schedules_returns_schedules_list() { + let app = test_app!().build(); + let db_pool = app.db_pool(); + let user = app + .user(uuid::Uuid::new_v4().to_string(), "name") + .with_roles([Role::OperationalStudies]) + .create() + .await; - let infra = create_small_infra(&mut db_pool.get_ok()).await; - let scenario_1 = create_scenario( - &mut db_pool.get_ok(), - &Uuid::new_v4().to_string(), - study.id, - timetable_1.id, - infra.id, - ) - .await; - let scenario_2 = create_scenario( - &mut db_pool.get_ok(), - &Uuid::new_v4().to_string(), - study.id, - timetable_2.id, - infra.id, - ) - .await; - // scenario_3 will not use the required rolling stock and should thus not be queried - let _scenario_3 = create_scenario( - &mut db_pool.get_ok(), - &Uuid::new_v4().to_string(), - study.id, - timetable_3.id, - infra.id, - ) - .await; + let create_rolling_stock_request = app.rolling_stock_create_request( + &fast_rolling_stock_form(&Uuid::new_v4().to_string()), + ); + let rolling_stock: RollingStock = (create_rolling_stock_request) + .by_user(user.as_ref()) + .await + .assert_status_ok() + .json(); + let create_other_rolling_stock_request = app.rolling_stock_create_request( + &fast_rolling_stock_form(&Uuid::new_v4().to_string()), + ); + let other_rolling_stock: RollingStock = (create_other_rolling_stock_request) + .by_user(user.as_ref()) + .await + .assert_status_ok() + .json(); + + // TODO remove me once `POST:/rolling_stock` setups the grants on the created rolling + // stock + app.openfga() + .rolling_stock_set_grant( + authz::RollingStock(rolling_stock.id), + authz::Subject::user(user.clone()), + RollingStockGrant::Reader, + ) + .await; + app.openfga() + .rolling_stock_set_grant( + authz::RollingStock(other_rolling_stock.id), + authz::Subject::user(user.clone()), + RollingStockGrant::Reader, + ) + .await; - simple_paced_train_changeset(train_schedule_set_1.id) - .rolling_stock_name(rolling_stock.name.clone()) - .create(&mut db_pool.get_ok()) - .await - .unwrap(); - simple_paced_train_changeset(train_schedule_set_2.id) - .rolling_stock_name(rolling_stock.name) - .create(&mut db_pool.get_ok()) - .await - .unwrap(); - simple_paced_train_changeset(train_schedule_set_3.id) - .rolling_stock_name(other_rolling_stock.name) - .create(&mut db_pool.get_ok()) - .await - .unwrap(); + let project = create_project(&mut db_pool.get_ok(), &Uuid::new_v4().to_string()).await; + let study = create_study( + &mut db_pool.get_ok(), + &Uuid::new_v4().to_string(), + project.id, + ) + .await; - let related_scenarios: Vec = app - .get(&format!("/rolling_stock/{}/usage", rolling_stock.id)) - .await - .assert_status_ok() - .json(); - let expected_scenarios = [ - ScenarioReference { - project_id: project.id, - project_name: project.name.clone(), - study_id: study.id, - study_name: study.name.clone(), - scenario_id: scenario_1.id, - scenario_name: scenario_1.name.clone(), - }, - ScenarioReference { - project_id: project.id, - project_name: project.name.clone(), - study_id: study.id, - study_name: study.name.clone(), - scenario_id: scenario_2.id, - scenario_name: scenario_2.name.clone(), - }, - ]; - assert_eq!( - related_scenarios.iter().sorted().collect_vec(), - expected_scenarios.iter().sorted().collect_vec() - ); - } + let (timetable_1, train_schedule_set_1) = + create_timetable_with_train_schedule_set(&mut db_pool.get_ok()).await; + + let (timetable_2, train_schedule_set_2) = + create_timetable_with_train_schedule_set(&mut db_pool.get_ok()).await; + let (timetable_3, train_schedule_set_3) = + create_timetable_with_train_schedule_set(&mut db_pool.get_ok()).await; + + let infra = create_small_infra(&mut db_pool.get_ok()).await; + let scenario_1 = create_scenario( + &mut db_pool.get_ok(), + &Uuid::new_v4().to_string(), + study.id, + timetable_1.id, + infra.id, + ) + .await; + let scenario_2 = create_scenario( + &mut db_pool.get_ok(), + &Uuid::new_v4().to_string(), + study.id, + timetable_2.id, + infra.id, + ) + .await; + // scenario_3 will not use the required rolling stock and should thus not be queried + let _scenario_3 = create_scenario( + &mut db_pool.get_ok(), + &Uuid::new_v4().to_string(), + study.id, + timetable_3.id, + infra.id, + ) + .await; - #[tokio::test(flavor = "multi_thread", worker_threads = 1)] - async fn get_invalid_rolling_stock_id_returns_404_not_found() { - let app = test_app!().skip_authz().build(); - let db_pool = app.db_pool(); - let _ = RollingStock::delete_static(&mut db_pool.get_ok(), 1).await; + simple_paced_train_changeset(train_schedule_set_1.id) + .rolling_stock_name(rolling_stock.name.clone()) + .create(&mut db_pool.get_ok()) + .await + .unwrap(); + simple_paced_train_changeset(train_schedule_set_2.id) + .rolling_stock_name(rolling_stock.name) + .create(&mut db_pool.get_ok()) + .await + .unwrap(); + simple_paced_train_changeset(train_schedule_set_3.id) + .rolling_stock_name(other_rolling_stock.name) + .create(&mut db_pool.get_ok()) + .await + .unwrap(); - app.get("/rolling_stock/1/usage") - .await - .assert_status_not_found(); + let related_scenarios: Vec = app + .get(&format!("/rolling_stock/{}/usage", rolling_stock.id)) + .by_user(user.as_ref()) + .await + .assert_status_ok() + .json(); + let expected_scenarios = [ + ScenarioReference { + project_id: project.id, + project_name: project.name.clone(), + study_id: study.id, + study_name: study.name.clone(), + scenario_id: scenario_1.id, + scenario_name: scenario_1.name.clone(), + }, + ScenarioReference { + project_id: project.id, + project_name: project.name.clone(), + study_id: study.id, + study_name: study.name.clone(), + scenario_id: scenario_2.id, + scenario_name: scenario_2.name.clone(), + }, + ]; + assert_eq!( + related_scenarios.iter().sorted().collect_vec(), + expected_scenarios.iter().sorted().collect_vec() + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn get_invalid_rolling_stock_id_returns_404_not_found() { + // TODO: skipping authz here is not trivial because the checks execution order is + // undefined. It could indifferently return a 403 Forbidden or a 404 not found if the + // rolling stock does not exist. + // Not: in practice, it seems to always return 404 not found as that check future executes + // faster than the 403 one. + // => do we: + // - keep skipping authz here for the time being ? + // - update `Authorizer::authorize` implementations to define a check order ? + // 1. FuturesUnordered => FuturesOrdered in `authorize` + // 2. #[derive(PartialOrd, Ord)] on Check + // 3. use an ordered collection in `Protected.checks` that uses the PartialOrd + // trait + // - update the protected ops to insert the checks in the correct order + // 1. FuturesUnordered => FuturesOrdered in `authorize` + // 2. use an ordered collection in `Protected.checks` that keeps insertion order + // 3. make sure when we create protected ops that we insert the checks in the + // correct order + // github discussion ref: https://github.com/OpenRailAssociation/osrd/pull/17383#issuecomment-4868190929 + let app = test_app!().skip_authz().build(); + let db_pool = app.db_pool(); + let _ = RollingStock::delete_static(&mut db_pool.get_ok(), 1).await; + + app.get("/rolling_stock/1/usage") + .await + .assert_status_not_found(); + } } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] @@ -1012,15 +1271,24 @@ pub mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn get_rolling_stock_by_id() { // GIVEN - let app = test_app!().skip_authz().build(); + let app = test_app!().build(); let db_pool = app.db_pool(); let rs_name = "fast_rolling_stock_name"; let fast_rolling_stock = create_fast_rolling_stock(&mut db_pool.get_ok(), rs_name).await; + // a user with the role to reach the endpoint and a read grant on the rolling stock + let user = app + .user("authorized", "Authorized") + .with_roles([Role::OperationalStudies]) + .with_rolling_stock_grant(fast_rolling_stock.id, authz::RollingStockGrant::Reader) + .create() + .await; + // WHEN let raw_response = app .rolling_stock_get_by_id_request(fast_rolling_stock.id) + .by_user(&user.info) .await; // THEN @@ -1029,6 +1297,48 @@ pub mod tests { assert_eq!(response, fast_rolling_stock); } + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn get_rolling_stock_by_id_with_privilege_and_no_roles() { + let app = test_app!().build(); + let db_pool = app.db_pool(); + + let fast_rolling_stock = + create_fast_rolling_stock(&mut db_pool.get_ok(), "fast_rolling_stock_name").await; + + // a user that does not have the role to reach the endpoint but has a read grant on the rolling stock + let user = app + .user("unauthorized", "Unauthorized") + .with_rolling_stock_grant(fast_rolling_stock.id, authz::RollingStockGrant::Reader) + .create() + .await; + + app.rolling_stock_get_by_id_request(fast_rolling_stock.id) + .by_user(&user.info) + .await + .assert_status_forbidden(); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn get_rolling_stock_by_id_without_permission() { + let app = test_app!().build(); + let db_pool = app.db_pool(); + + let fast_rolling_stock = + create_fast_rolling_stock(&mut db_pool.get_ok(), "fast_rolling_stock_name").await; + + // a user that has the role to reach the endpoint but no read grant on the rolling stock + let user = app + .user("unauthorized", "Unauthorized") + .with_roles([Role::OperationalStudies]) + .create() + .await; + + app.rolling_stock_get_by_id_request(fast_rolling_stock.id) + .by_user(&user.info) + .await + .assert_status_forbidden(); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn get_rolling_stock_by_name() { // GIVEN @@ -1070,26 +1380,32 @@ pub mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn update_unlocked_rolling_stock() { // GIVEN - let app = test_app!().skip_authz().build(); + let app = test_app!().build(); let db_pool = app.db_pool(); let rs_name = "fast_rolling_stock_name"; let fast_rolling_stock = create_fast_rolling_stock(&mut db_pool.get_ok(), rs_name).await; + let user = app + .user("writer", "Writer") + .with_roles([Role::OperationalStudies]) + .with_rolling_stock_grant(fast_rolling_stock.id, RollingStockGrant::Writer) + .create() + .await; + let mut rolling_stock_form: RollingStockForm = fast_rolling_stock.clone().into(); let updated_rs_name = "updated_fast_rolling_stock_name"; rolling_stock_form.name = updated_rs_name.to_string(); // WHEN - let raw_response = app - .put(format!("/rolling_stock/{}", fast_rolling_stock.id).as_str()) + app.put(format!("/rolling_stock/{}", fast_rolling_stock.id).as_str()) + .by_user(user.as_ref()) .json(&&rolling_stock_form) - .await; + .await + .assert_status_ok(); // THEN - raw_response.assert_status_ok(); - let updated_rolling_stock: RollingStock = RollingStock::retrieve(db_pool.get_ok(), fast_rolling_stock.id) .await @@ -1104,11 +1420,148 @@ pub mod tests { } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] - async fn update_rolling_stock_with_new_categories() { + async fn update_rolling_stock_without_authorization() { + let app = test_app!().build(); + let db_pool = app.db_pool(); + + let fast_rolling_stock = + create_fast_rolling_stock(&mut db_pool.get_ok(), "unauthorized_rolling_stock").await; + + // A user with the OperationalStudies role but only a read grant on the rolling stock + let user = app + .user("reader", "Reader") + .with_roles([Role::OperationalStudies]) + .with_rolling_stock_grant(fast_rolling_stock.id, RollingStockGrant::Reader) + .create() + .await; + + let mut rolling_stock_form: RollingStockForm = fast_rolling_stock.clone().into(); + rolling_stock_form.name = "should_not_be_updated".to_string(); + + app.put(format!("/rolling_stock/{}", fast_rolling_stock.id).as_str()) + .by_user(user.as_ref()) + .json(&&rolling_stock_form) + .await + .assert_status_forbidden(); + + // The rolling stock should not have been modified + let rolling_stock: RollingStock = + RollingStock::retrieve(db_pool.get_ok(), fast_rolling_stock.id) + .await + .expect("Failed to retrieve rolling stock") + .expect("Rolling stock not found"); + + assert_eq!(rolling_stock.name, fast_rolling_stock.name); + assert_eq!(rolling_stock.version, fast_rolling_stock.version); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn update_rolling_stock_as_admin_without_grant() { + // GIVEN + let app = test_app!().build(); + let db_pool = app.db_pool(); + + let fast_rolling_stock = + create_fast_rolling_stock(&mut db_pool.get_ok(), "admin_updated_rolling_stock").await; + + // An admin holds no grant on the rolling stock but can still update it + let admin = app + .user("admin", "Admin") + .with_roles([Role::Admin]) + .create() + .await; + + let mut rolling_stock_form: RollingStockForm = fast_rolling_stock.clone().into(); + let updated_rs_name = "updated_by_admin"; + rolling_stock_form.name = updated_rs_name.to_string(); + + // WHEN + app.put(format!("/rolling_stock/{}", fast_rolling_stock.id).as_str()) + .by_user(admin.as_ref()) + .json(&&rolling_stock_form) + .await + .assert_status_ok(); + + // THEN + let updated_rolling_stock: RollingStock = + RollingStock::retrieve(db_pool.get_ok(), fast_rolling_stock.id) + .await + .expect("Failed to retrieve rolling stock") + .expect("Rolling stock not found"); + assert_eq!(updated_rolling_stock.name, updated_rs_name); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn update_rolling_stock_with_skip_authz_without_grant() { // GIVEN let app = test_app!().skip_authz().build(); let db_pool = app.db_pool(); + let fast_rolling_stock = + create_fast_rolling_stock(&mut db_pool.get_ok(), "skip_authz_updated_rolling_stock") + .await; + + let mut rolling_stock_form: RollingStockForm = fast_rolling_stock.clone().into(); + let updated_rs_name = "updated_with_skip_authz"; + rolling_stock_form.name = updated_rs_name.to_string(); + + // WHEN (no grant set up, authorization is skipped) + app.put(format!("/rolling_stock/{}", fast_rolling_stock.id).as_str()) + .json(&&rolling_stock_form) + .await + .assert_status_ok(); + + // THEN + let updated_rolling_stock: RollingStock = + RollingStock::retrieve(db_pool.get_ok(), fast_rolling_stock.id) + .await + .expect("Failed to retrieve rolling stock") + .expect("Rolling stock not found"); + assert_eq!(updated_rolling_stock.name, updated_rs_name); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn update_rolling_stock_without_operational_studies_role() { + // GIVEN + let app = test_app!().build(); + let db_pool = app.db_pool(); + + let fast_rolling_stock = + create_fast_rolling_stock(&mut db_pool.get_ok(), "missing_role_rolling_stock").await; + + // A user with the right write grant but lacking the OperationalStudies role + let user = app + .user("writer", "Writer") + .with_rolling_stock_grant(fast_rolling_stock.id, RollingStockGrant::Writer) + .create() + .await; + + let mut rolling_stock_form: RollingStockForm = fast_rolling_stock.clone().into(); + rolling_stock_form.name = "should_not_be_updated".to_string(); + + // WHEN + app.put(format!("/rolling_stock/{}", fast_rolling_stock.id).as_str()) + .by_user(user.as_ref()) + .json(&&rolling_stock_form) + .await + .assert_status_forbidden(); + + // THEN the rolling stock should not have been modified + let rolling_stock: RollingStock = + RollingStock::retrieve(db_pool.get_ok(), fast_rolling_stock.id) + .await + .expect("Failed to retrieve rolling stock") + .expect("Rolling stock not found"); + assert_eq!(rolling_stock.name, fast_rolling_stock.name); + assert_eq!(rolling_stock.version, fast_rolling_stock.version); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn update_rolling_stock_with_new_categories() { + // GIVEN + let app = test_app!().build(); + let db_pool = app.db_pool(); + let fast_rolling_stock = create_fast_rolling_stock(&mut db_pool.get_ok(), "fast_rolling_stock_with_categories") .await; @@ -1126,10 +1579,18 @@ pub mod tests { let other_categories = vec![schemas::rolling_stock::TrainMainCategory::RegionalTrain]; rolling_stock_form.other_categories = other_categories; + let user = app + .user("writer", "Writer") + .with_roles([Role::OperationalStudies]) + .with_rolling_stock_grant(fast_rolling_stock.id, RollingStockGrant::Writer) + .create() + .await; + // WHEN let raw_response = app .put(format!("/rolling_stock/{}", fast_rolling_stock.id).as_str()) .json(&&rolling_stock_form) + .by_user(user.as_ref()) .await; // THEN @@ -1157,13 +1618,20 @@ pub mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn update_rolling_stock_categories_should_fail_when_invalid() { // GIVEN - let app = test_app!().skip_authz().build(); + let app = test_app!().build(); let db_pool = app.db_pool(); let fast_rolling_stock = create_fast_rolling_stock(&mut db_pool.get_ok(), "fast_rolling_stock_with_categories") .await; + let user = app + .user("writer", "Writer") + .with_roles([Role::OperationalStudies]) + .with_rolling_stock_grant(fast_rolling_stock.id, RollingStockGrant::Writer) + .create() + .await; + let mut rolling_stock_form: RollingStockForm = fast_rolling_stock.clone().into(); let primary_category = TrainMainCategory(schemas::rolling_stock::TrainMainCategory::HighSpeedTrain); @@ -1175,6 +1643,7 @@ pub mod tests { let raw_response = app .put(format!("/rolling_stock/{}", fast_rolling_stock.id).as_str()) .json(&&rolling_stock_form) + .by_user(user.as_ref()) .await; // THEN @@ -1196,13 +1665,19 @@ pub mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn update_rolling_stock_failure_name_already_used() { // GIVEN - let app = test_app!().skip_authz().build(); + let app = test_app!().build(); let db_pool = app.db_pool(); let first_rs_name = "first_fast_rolling_stock_name"; let first_fast_rolling_stock = create_fast_rolling_stock(&mut db_pool.get_ok(), first_rs_name).await; + let user = app + .user("writer", "Writer") + .with_roles([Role::OperationalStudies]) + .with_rolling_stock_grant(first_fast_rolling_stock.id, RollingStockGrant::Writer) + .create() + .await; let second_rs_name = "second_fast_rolling_stock_name"; let second_fast_rolling_stock = create_rolling_stock_with_energy_sources(&mut db_pool.get_ok(), second_rs_name).await; @@ -1213,6 +1688,7 @@ pub mod tests { let raw_response = app .put(format!("/rolling_stock/{}", first_fast_rolling_stock.id).as_str()) .json(&second_fast_rolling_stock_form) + .by_user(user.as_ref()) .await; // THEN @@ -1227,7 +1703,7 @@ pub mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn update_locked_rolling_stock_fails() { // GIVEN - let app = test_app!().skip_authz().build(); + let app = test_app!().build(); let db_pool = app.db_pool(); let locked_rs_name = "locked_fast_rolling_stock_name"; @@ -1241,6 +1717,13 @@ pub mod tests { .await .expect("Failed to create rolling stock"); + let user = app + .user("writer", "Writer") + .with_roles([Role::OperationalStudies]) + .with_rolling_stock_grant(locked_fast_rolling_stock.id, RollingStockGrant::Writer) + .create() + .await; + let mut second_fast_rolling_stock_form: RollingStockForm = schemas::fixtures::fast_rolling_stock(); second_fast_rolling_stock_form.name = "second_fast_rolling_stock_name".to_owned(); @@ -1249,6 +1732,7 @@ pub mod tests { let raw_response = app .put(format!("/rolling_stock/{}", locked_fast_rolling_stock.id).as_str()) .json(&second_fast_rolling_stock_form) + .by_user(user.as_ref()) .await; // THEN @@ -1269,16 +1753,23 @@ pub mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn patch_lock_rolling_stock_successfully() { - let app = test_app!().skip_authz().build(); + let app = test_app!().build(); let db_pool = app.db_pool(); let rs_name = "fast_rolling_stock_name"; let fast_rolling_stock = create_fast_rolling_stock(&mut db_pool.get_ok(), rs_name).await; + let user = app + .user("authorized", "Authorized") + .with_roles([Role::OperationalStudies]) + .with_rolling_stock_grant(fast_rolling_stock.id, authz::RollingStockGrant::Owner) + .create() + .await; assert!(!fast_rolling_stock.locked); app.patch(format!("/rolling_stock/{}/locked", fast_rolling_stock.id).as_str()) .json(&json!({ "locked": true })) + .by_user(user.as_ref()) .await .assert_status_no_content(); @@ -1293,7 +1784,7 @@ pub mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn patch_unlock_rolling_stock_successfully() { - let app = test_app!().skip_authz().build(); + let app = test_app!().build(); let db_pool = app.db_pool(); let locked_rs_name = "locked_fast_rolling_stock_name"; @@ -1308,8 +1799,19 @@ pub mod tests { .expect("Failed to create rolling stock"); assert!(locked_fast_rolling_stock.locked); + let user = app + .user("authorized", "Authorized") + .with_roles([Role::OperationalStudies]) + .with_rolling_stock_grant( + locked_fast_rolling_stock.id, + authz::RollingStockGrant::Owner, + ) + .create() + .await; + app.patch(format!("/rolling_stock/{}/locked", locked_fast_rolling_stock.id).as_str()) .json(&json!({ "locked": false })) + .by_user(user.as_ref()) .await .assert_status_no_content(); @@ -1322,6 +1824,132 @@ pub mod tests { assert!(!fast_rolling_stock.locked); } + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn patch_locked_rolling_stock_without_sufficient_grant() { + // GIVEN + let app = test_app!().build(); + let db_pool = app.db_pool(); + + let fast_rolling_stock = + create_fast_rolling_stock(&mut db_pool.get_ok(), "unauthorized_rolling_stock").await; + assert!(!fast_rolling_stock.locked); + + // A user with the OperationalStudies role but only a read grant on the rolling stock + let user = app + .user("reader", "Reader") + .with_roles([Role::OperationalStudies]) + .with_rolling_stock_grant(fast_rolling_stock.id, authz::RollingStockGrant::Reader) + .create() + .await; + + // WHEN + app.patch(format!("/rolling_stock/{}/locked", fast_rolling_stock.id).as_str()) + .json(&json!({ "locked": true })) + .by_user(user.as_ref()) + .await + .assert_status_forbidden(); + + // THEN the locked flag should not have changed + let fast_rolling_stock: RollingStock = + RollingStock::retrieve(db_pool.get_ok(), fast_rolling_stock.id) + .await + .expect("Failed to retrieve rolling stock") + .expect("Rolling stock not found"); + assert!(!fast_rolling_stock.locked); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn patch_locked_rolling_stock_as_admin_without_grant() { + // GIVEN + let app = test_app!().build(); + let db_pool = app.db_pool(); + + let fast_rolling_stock = + create_fast_rolling_stock(&mut db_pool.get_ok(), "admin_locked_rolling_stock").await; + assert!(!fast_rolling_stock.locked); + + // An admin holds no grant on the rolling stock but can still lock it + let admin = app + .user("admin", "Admin") + .with_roles([Role::Admin]) + .create() + .await; + + // WHEN + app.patch(format!("/rolling_stock/{}/locked", fast_rolling_stock.id).as_str()) + .json(&json!({ "locked": true })) + .by_user(admin.as_ref()) + .await + .assert_status_no_content(); + + // THEN + let fast_rolling_stock: RollingStock = + RollingStock::retrieve(db_pool.get_ok(), fast_rolling_stock.id) + .await + .expect("Failed to retrieve rolling stock") + .expect("Rolling stock not found"); + assert!(fast_rolling_stock.locked); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn patch_locked_rolling_stock_with_skip_authz_without_grant() { + // GIVEN + let app = test_app!().skip_authz().build(); + let db_pool = app.db_pool(); + + let fast_rolling_stock = + create_fast_rolling_stock(&mut db_pool.get_ok(), "skip_authz_locked_rolling_stock") + .await; + assert!(!fast_rolling_stock.locked); + + // WHEN (no grant set up, authorization is skipped) + app.patch(format!("/rolling_stock/{}/locked", fast_rolling_stock.id).as_str()) + .json(&json!({ "locked": true })) + .await + .assert_status_no_content(); + + // THEN + let fast_rolling_stock: RollingStock = + RollingStock::retrieve(db_pool.get_ok(), fast_rolling_stock.id) + .await + .expect("Failed to retrieve rolling stock") + .expect("Rolling stock not found"); + assert!(fast_rolling_stock.locked); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn patch_locked_rolling_stock_without_operational_studies_role() { + // GIVEN + let app = test_app!().build(); + let db_pool = app.db_pool(); + + let fast_rolling_stock = + create_fast_rolling_stock(&mut db_pool.get_ok(), "missing_role_rolling_stock").await; + assert!(!fast_rolling_stock.locked); + + // A user with a write grant but lacking the OperationalStudies role + let user = app + .user("writer", "Writer") + .with_rolling_stock_grant(fast_rolling_stock.id, authz::RollingStockGrant::Writer) + .create() + .await; + + // WHEN + app.patch(format!("/rolling_stock/{}/locked", fast_rolling_stock.id).as_str()) + .json(&json!({ "locked": true })) + .by_user(&user.info) + .await + .assert_status_forbidden(); + + // THEN the locked flag should not have changed + let fast_rolling_stock: RollingStock = + RollingStock::retrieve(db_pool.get_ok(), fast_rolling_stock.id) + .await + .expect("Failed to retrieve rolling stock") + .expect("Rolling stock not found"); + assert!(!fast_rolling_stock.locked); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn get_power_restrictions_list() { // GIVEN @@ -1348,7 +1976,7 @@ pub mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn delete_locked_rolling_stock_fails() { // GIVEN - let app = test_app!().skip_authz().build(); + let app = test_app!().build(); let db_pool = app.db_pool(); let locked_rs_name = "locked_fast_rolling_stock_name"; @@ -1362,9 +1990,16 @@ pub mod tests { .await .expect("Failed to create rolling stock"); + let user = app + .user("owner", "Owner") + .with_roles([Role::OperationalStudies]) + .with_rolling_stock_grant(locked_fast_rolling_stock.id, RollingStockGrant::Owner) + .create() + .await; // WHEN let raw_response = app .delete(format!("/rolling_stock/{}", locked_fast_rolling_stock.id).as_str()) + .by_user(&user.info) .await; // THEN @@ -1383,16 +2018,24 @@ pub mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn delete_unlocked_unused_rolling_stock_succeeds() { // GIVEN - let app = test_app!().skip_authz().build(); + let app = test_app!().build(); let db_pool = app.db_pool(); let rs_name = "fast_rolling_stock_name"; let fast_rolling_stock = create_fast_rolling_stock(&mut db_pool.get_ok(), rs_name).await; assert!(!fast_rolling_stock.locked); + let user = app + .user("owner", "Owner") + .with_roles([Role::OperationalStudies]) + .with_rolling_stock_grant(fast_rolling_stock.id, RollingStockGrant::Owner) + .create() + .await; + // WHEN let raw_response = app .delete(format!("/rolling_stock/{}", fast_rolling_stock.id).as_str()) + .by_user(&user.info) .await; // THEN @@ -1405,6 +2048,124 @@ pub mod tests { assert!(!rolling_stock_exists); } + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn delete_rolling_stock_without_authorization() { + // GIVEN + let app = test_app!().build(); + let db_pool = app.db_pool(); + + let fast_rolling_stock = + create_fast_rolling_stock(&mut db_pool.get_ok(), "unauthorized_rolling_stock").await; + + // A user with the OperationalStudies role but only a write grant on the rolling stock + // (delete requires an owner/delete grant) + let user = app + .user("writer", "Writer") + .with_roles([Role::OperationalStudies]) + .with_rolling_stock_grant(fast_rolling_stock.id, RollingStockGrant::Writer) + .create() + .await; + + // WHEN + let raw_response = app + .delete(format!("/rolling_stock/{}", fast_rolling_stock.id).as_str()) + .by_user(user.as_ref()) + .await; + + // THEN + raw_response.assert_status_forbidden(); + + // The rolling stock should still exist + let rolling_stock_exists = + RollingStock::exists(&mut db_pool.get_ok(), fast_rolling_stock.id) + .await + .expect("Failed to check if rolling stock exists"); + assert!(rolling_stock_exists); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn delete_rolling_stock_as_admin_without_grant() { + // GIVEN + let app = test_app!().build(); + let db_pool = app.db_pool(); + + let fast_rolling_stock = + create_fast_rolling_stock(&mut db_pool.get_ok(), "admin_deleted_rolling_stock").await; + + // An admin holds no grant on the rolling stock but can still delete it + let admin = app + .user("admin", "Admin") + .with_roles([Role::Admin]) + .create() + .await; + + // WHEN + app.delete(format!("/rolling_stock/{}", fast_rolling_stock.id).as_str()) + .by_user(admin.as_ref()) + .await + .assert_status_no_content(); + + // THEN + let rolling_stock_exists = + RollingStock::exists(&mut db_pool.get_ok(), fast_rolling_stock.id) + .await + .expect("Failed to check if rolling stock exists"); + assert!(!rolling_stock_exists); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn delete_rolling_stock_with_skip_authz_without_user() { + // GIVEN + let app = test_app!().skip_authz().build(); + let db_pool = app.db_pool(); + + let fast_rolling_stock = + create_fast_rolling_stock(&mut db_pool.get_ok(), "skip_authz_deleted_rolling_stock") + .await; + + // WHEN (no grant set up, authorization is skipped) + app.delete(format!("/rolling_stock/{}", fast_rolling_stock.id).as_str()) + .await + .assert_status_no_content(); + + // THEN + let rolling_stock_exists = + RollingStock::exists(&mut db_pool.get_ok(), fast_rolling_stock.id) + .await + .expect("Failed to check if rolling stock exists"); + assert!(!rolling_stock_exists); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn delete_rolling_stock_without_operational_studies_role() { + // GIVEN + let app = test_app!().build(); + let db_pool = app.db_pool(); + + let fast_rolling_stock = + create_fast_rolling_stock(&mut db_pool.get_ok(), "missing_role_rolling_stock").await; + + // A user with an owner grant but lacking the OperationalStudies role + let user = app + .user("owner", "Owner") + .with_rolling_stock_grant(fast_rolling_stock.id, RollingStockGrant::Owner) + .create() + .await; + + // WHEN + app.delete(format!("/rolling_stock/{}", fast_rolling_stock.id).as_str()) + .by_user(user.as_ref()) + .await + .assert_status_forbidden(); + + // THEN the rolling stock should still exist + let rolling_stock_exists = + RollingStock::exists(&mut db_pool.get_ok(), fast_rolling_stock.id) + .await + .expect("Failed to check if rolling stock exists"); + assert!(rolling_stock_exists); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn delete_unlocked_used_rolling_stock_requires_force_flag() { // GIVEN @@ -1471,4 +2232,167 @@ pub mod tests { assert!(!rolling_stock_exists); } + + mod post_rolling_stock_livery { + use super::*; + + const DATA: &[u8] = &[ + // PNG Signature (8 bytes) + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // IHDR Chunk (Image Header) + 0x00, 0x00, 0x00, 0x0D, // Chunk Length + 0x49, 0x48, 0x44, 0x52, // "IHDR" + 0x00, 0x00, 0x00, 0x02, // Width: 2 pixels + 0x00, 0x00, 0x00, 0x02, // Height: 2 pixels + 0x08, // Bit depth: 8 + 0x02, // Color type: Truecolor (RGB) + 0x00, // Compression method: 0 (deflate) + 0x00, // Filter method: 0 + 0x00, // Interlace method: 0 (no interlace) + 0xFD, 0xD4, 0x9A, 0x73, // CRC + // IDAT Chunk (Image Data) + 0x00, 0x00, 0x00, 0x13, // Chunk Length + 0x49, 0x44, 0x41, 0x54, // "IDAT" + 0x78, 0x01, // zlib compression header + 0x63, 0x64, 0x60, 0xF8, 0xCF, 0xC0, 0xC0, 0xC0, 0x04, 0xC4, 0x40, 0x00, 0x00, 0x0B, + 0x1F, 0x01, // Compressed image data + 0x03, 0xD5, 0xA9, 0x3F, 0xA9, // CRC + // IEND Chunk (Image End) + 0x00, 0x00, 0x00, 0x00, // Chunk Length + 0x49, 0x45, 0x4E, 0x44, // "IEND" + 0xAE, 0x42, 0x60, 0x82, // CRC + ]; + + mod authorization { + use axum_test::multipart::MultipartForm; + use axum_test::multipart::Part; + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + + fn valid_request_body() -> MultipartForm { + let part_name = Part::text(Uuid::new_v4().to_string()); + let part_images = Part::bytes(DATA) + .file_name(Uuid::new_v4().to_string()) + .mime_type("image/bpm"); + MultipartForm::new() + .add_part("name", part_name) + .add_part("images", part_images) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn authorized_grant_level() { + let app = test_app!().build(); + let rolling_stock_id = + create_fast_rolling_stock(&mut app.db_pool().get_ok(), "rolling stock") + .await + .id; + let grant = RollingStockGrant::Writer; + let user = app + .user(Uuid::new_v4().to_string(), "name") + .with_rolling_stock_grant(rolling_stock_id, grant) + .with_roles([Role::OperationalStudies]) + .create() + .await; + app.post(&format!("/rolling_stock/{rolling_stock_id}/livery")) + .multipart(valid_request_body()) + .by_user(user.as_ref()) + .await + .assert_status_ok(); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn reader_and_no_grant_are_forbidden() { + let app = test_app!().build(); + let rolling_stock_id = + create_fast_rolling_stock(&mut app.db_pool().get_ok(), "rolling stock") + .await + .id; + let user_no_grant = app + .user(Uuid::new_v4().to_string(), "name") + .with_roles([Role::OperationalStudies]) + .create() + .await; + let user_reader = app + .user(Uuid::new_v4().to_string(), "name") + .with_roles([Role::OperationalStudies]) + .with_rolling_stock_grant(rolling_stock_id, RollingStockGrant::Reader) + .create() + .await; + app.post(&format!("/rolling_stock/{rolling_stock_id}/livery")) + .multipart(valid_request_body()) + .by_user(user_no_grant.as_ref()) + .await + .assert_status_forbidden(); + app.post(&format!("/rolling_stock/{rolling_stock_id}/livery")) + .multipart(valid_request_body()) + .by_user(user_reader.as_ref()) + .await + .assert_status_forbidden(); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn admin_is_authorized_without_grant() { + let app = test_app!().build(); + let rolling_stock_id = + create_fast_rolling_stock(&mut app.db_pool().get_ok(), "rolling stock") + .await + .id; + let user = app + .user(Uuid::new_v4().to_string(), "name") + .with_roles([Role::Admin]) + .create() + .await; + app.post(&format!("/rolling_stock/{rolling_stock_id}/livery")) + .multipart(valid_request_body()) + .by_user(user.as_ref()) + .await + .assert_status_ok(); + } + + #[rstest] + #[case::admin(Role::Admin, StatusCode::OK)] + #[case::operational_studies(Role::OperationalStudies, StatusCode::OK)] + #[case::stdcm(Role::Stdcm, StatusCode::FORBIDDEN)] + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn need_operational_studies_or_admin_role_and_grant( + #[case] role: Role, + #[case] expected_status_code: StatusCode, + ) { + let app = test_app!().build(); + let rolling_stock_id = + create_fast_rolling_stock(&mut app.db_pool().get_ok(), "rolling stock") + .await + .id; + let user = app + .user(Uuid::new_v4().to_string(), "name") + .with_roles([role]) + .with_rolling_stock_grant(rolling_stock_id, RollingStockGrant::Writer) + .create() + .await; + let response = app + .post(&format!("/rolling_stock/{rolling_stock_id}/livery")) + .multipart(valid_request_body()) + .by_user(user.as_ref()) + .await; + assert_eq!(response.status_code(), expected_status_code); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn skip_authz_is_authorized() { + let app = test_app!().build(); + let rolling_stock_id = + create_fast_rolling_stock(&mut app.db_pool().get_ok(), "rolling stock") + .await + .id; + app.post(&format!("/rolling_stock/{rolling_stock_id}/livery")) + .multipart(valid_request_body()) + .skip_authz() + .await + .assert_status_ok(); + } + } + + // TODO Add tests + } } diff --git a/editoast/src/views/rolling_stock/light.rs b/editoast/src/views/rolling_stock/light.rs index 7916c638746..a15af5b9683 100644 --- a/editoast/src/views/rolling_stock/light.rs +++ b/editoast/src/views/rolling_stock/light.rs @@ -1,3 +1,7 @@ +use authz::RollingStockPrivilege; +use authz::v2::Authorizer; +use authz::v2::rolling_stock_privileges; +use axum::Extension; use axum::extract::Json; use axum::extract::Path; use axum::extract::Query; @@ -5,7 +9,6 @@ use axum::extract::State; use common::units; use common::units::quantities::Length; use database::DbConnection; -use database::DbConnectionPoolV2; use editoast_models::prelude::*; use editoast_models::rolling_stock::RollingStock; use editoast_models::rolling_stock::TrainMainCategory; @@ -19,7 +22,6 @@ use schemas::rolling_stock::SupportedSignalingSystem; use serde::Serialize; use std::collections::HashMap; use std::collections::HashSet; -use std::sync::Arc; use uom::si::f64::Mass; use uom::si::f64::Velocity; use utoipa::ToSchema; @@ -28,6 +30,8 @@ use super::RollingStockError; use super::RollingStockIdParam; use super::RollingStockKey; use super::RollingStockNameParam; +use crate::AppState; +use crate::authorizers::SystemAuthorizer; use crate::error::Result; use crate::views::pagination::PaginatedList; use crate::views::pagination::PaginationQueryParams; @@ -84,14 +88,44 @@ pub(in crate::views) struct LightRollingStockWithLiveriesCountList { ) )] pub(in crate::views) async fn list( - State(db_pool): State>, + State(AppState { + db_pool, regulator, .. + }): State, + + Extension(authn_state): Extension, Query(page_settings): Query>, ) -> Result> { - let settings = page_settings - .into_selection_settings() - .order_by(|| RollingStock::ID.asc()); + let conn = &mut db_pool.get().await?; + let default_settings = page_settings.into_selection_settings(); + let settings = if let Some(user) = authn_state.user() { + let system_authorizer = SystemAuthorizer::new_infallible(regulator.openfga()); + let Ok(authorized_rolling_stocks) = system_authorizer + .authorize(authz::v2::rolling_stock_list( + user, + RollingStockPrivilege::CanRead, + )) + .await? + .access() + .await?; + match authorized_rolling_stocks { + authz::v2::ResourcesList::All => default_settings, + authz::v2::ResourcesList::Privileged(authorized_rolling_stocks) => default_settings + .filter(move || { + RollingStock::ID.eq_any( + authorized_rolling_stocks + .iter() + .map(|rolling_stock| rolling_stock.0) + .collect(), + ) + }), + } + } else { + default_settings + }; + let (rolling_stocks, stats) = - RollingStock::list_paginated(&mut db_pool.get().await?, settings).await?; + RollingStock::list_paginated(conn, settings.order_by(move || RollingStock::ID.asc())) + .await?; let results = rolling_stocks.into_iter().zip(db_pool.iter_conn()).map( |(rolling_stock, conn)| async move { @@ -118,9 +152,22 @@ pub(in crate::views) async fn list( ) )] pub(in crate::views) async fn get( - State(db_pool): State>, + State(AppState { + regulator, db_pool, .. + }): State, + Extension(authn_state): Extension, Path(light_rolling_stock_id): Path, ) -> Result> { + if let Some(user) = authn_state.user() { + let authorizer = authn_state.authorizer(regulator.openfga()); + crate::authorizers::require( + &authorizer, + rolling_stock_privileges(user, authz::RollingStock(light_rolling_stock_id)), + &RollingStockPrivilege::CanRead, + ) + .await?; + } + let rolling_stock = RollingStock::retrieve_or_fail(db_pool.get().await?, light_rolling_stock_id, || { RollingStockError::KeyNotFound { @@ -144,7 +191,10 @@ pub(in crate::views) async fn get( ) )] pub(in crate::views) async fn get_by_name( - State(db_pool): State>, + State(AppState { + regulator, db_pool, .. + }): State, + Extension(authn_state): Extension, Path(light_rolling_stock_name): Path, ) -> Result> { let rolling_stock = RollingStock::retrieve_or_fail( @@ -155,6 +205,17 @@ pub(in crate::views) async fn get_by_name( }, ) .await?; + + if let Some(user) = authn_state.user() { + let authorizer = authn_state.authorizer(regulator.openfga()); + crate::authorizers::require( + &authorizer, + rolling_stock_privileges(user, authz::RollingStock(rolling_stock.id)), + &RollingStockPrivilege::CanRead, + ) + .await?; + } + let light_rolling_stock_with_liveries = LightRollingStockWithLiveries::try_fetch(&mut db_pool.get().await?, rolling_stock).await?; Ok(Json(light_rolling_stock_with_liveries)) @@ -271,6 +332,8 @@ impl From for LightModeEffortCurves { #[cfg(test)] mod tests { + use authz::Role; + use authz::RollingStockGrant; use pretty_assertions::assert_eq; use std::collections::HashSet; @@ -282,6 +345,7 @@ mod tests { use crate::error::InternalError; use crate::fixtures::create_fast_rolling_stock; use crate::views::test_app; + use crate::views::test_app::TestRequestExt; fn is_sorted(data: &[i64]) -> bool { for elem in data.windows(2) { @@ -298,18 +362,81 @@ mod tests { app.get("/light_rolling_stock").await.assert_status_ok(); } + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn rolling_stock_list_user_only_sees_its_related_rolling_stocks() { + let app = test_app!().build(); + let db_pool = app.db_pool(); + let rs_1 = create_fast_rolling_stock(&mut db_pool.get_ok(), "rs_1").await; + let rs_2 = create_fast_rolling_stock(&mut db_pool.get_ok(), "rs_2").await; + let _rs_no_grant = create_fast_rolling_stock(&mut db_pool.get_ok(), "rs_no_grant").await; + let user = app + .user("user_identity", "user_name") + .with_rolling_stock_grant(rs_1.id, RollingStockGrant::Reader) + .with_rolling_stock_grant(rs_2.id, RollingStockGrant::Reader) + .create() + .await; + let response: LightRollingStockWithLiveriesCountList = app + .get("/light_rolling_stock") + .by_user(user.as_ref()) + .await + .assert_status_ok() + .json(); + assert_eq!( + response + .results + .iter() + .map(|rolling_stock| rolling_stock.rolling_stock.id) + .collect::>(), + vec![rs_1.id, rs_2.id] + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn rolling_stock_list_admin_can_see_unrelated_rolling_stock() { + let app = test_app!().build(); + let db_pool = app.db_pool(); + let rs_no_grant = create_fast_rolling_stock(&mut db_pool.get_ok(), "rs_no_grant").await; + let admin = app + .user("admin", "admin") + .with_roles([Role::Admin]) + .create() + .await; + let response: LightRollingStockWithLiveriesCountList = app + .get("/light_rolling_stock/") + .by_user(admin.as_ref()) + .await + .assert_status_ok() + .json(); + assert_eq!( + response + .results + .iter() + .map(|rolling_stock| rolling_stock.rolling_stock.id) + .collect::>(), + vec![rs_no_grant.id] + ); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn get_light_rolling_stock() { // GIVEN - let app = test_app!().skip_authz().build(); + let app = test_app!().build(); let db_pool = app.db_pool(); let rs_name = "fast_rolling_stock_name"; let fast_rolling_stock = create_fast_rolling_stock(&mut db_pool.get_ok(), rs_name).await; + // a user with a read grant on the rolling stock + let user = app + .user("authorized", "Authorized") + .with_rolling_stock_grant(fast_rolling_stock.id, authz::RollingStockGrant::Reader) + .create() + .await; + // WHEN let response: LightRollingStockWithLiveries = app .get(format!("/light_rolling_stock/{}", fast_rolling_stock.id).as_str()) + .by_user(&user.info) .await .assert_status_ok() .json(); @@ -321,15 +448,23 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn get_light_rolling_stock_by_name() { // GIVEN - let app = test_app!().skip_authz().build(); + let app = test_app!().build(); let db_pool = app.db_pool(); let rs_name = "fast_rolling_stock_name"; let fast_rolling_stock = create_fast_rolling_stock(&mut db_pool.get_ok(), rs_name).await; + // a user with a read grant on the rolling stock + let user = app + .user("authorized", "Authorized") + .with_rolling_stock_grant(fast_rolling_stock.id, authz::RollingStockGrant::Reader) + .create() + .await; + // WHEN let response: LightRollingStockWithLiveries = app .get(format!("/light_rolling_stock/name/{rs_name}").as_str()) + .by_user(&user.info) .await .assert_status_ok() .json(); @@ -348,6 +483,48 @@ mod tests { .assert_status_not_found(); } + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn get_light_rolling_stock_without_permission() { + let app = test_app!().build(); + let db_pool = app.db_pool(); + + let fast_rolling_stock = + create_fast_rolling_stock(&mut db_pool.get_ok(), "fast_rolling_stock_name").await; + + // a user that has the role to reach the endpoint but no read grant on the rolling stock + let user = app + .user("unauthorized", "Unauthorized") + .with_roles([authz::Role::OperationalStudies]) + .create() + .await; + + app.get(format!("/light_rolling_stock/{}", fast_rolling_stock.id).as_str()) + .by_user(&user.info) + .await + .assert_status_forbidden(); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn get_light_rolling_stock_by_name_without_permission() { + let app = test_app!().build(); + let db_pool = app.db_pool(); + + let rs_name = "fast_rolling_stock_name"; + create_fast_rolling_stock(&mut db_pool.get_ok(), rs_name).await; + + // a user that has the role to reach the endpoint but no read grant on the rolling stock + let user = app + .user("unauthorized", "Unauthorized") + .with_roles([authz::Role::OperationalStudies]) + .create() + .await; + + app.get(format!("/light_rolling_stock/name/{rs_name}").as_str()) + .by_user(&user.info) + .await + .assert_status_forbidden(); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn list_light_rolling_stock_increasing_ids() { let app = test_app!().skip_authz().build(); diff --git a/editoast/src/views/timetable.rs b/editoast/src/views/timetable.rs index 21c9ce373f9..c60739a112b 100644 --- a/editoast/src/views/timetable.rs +++ b/editoast/src/views/timetable.rs @@ -282,13 +282,13 @@ pub struct ElectricalProfileSetIdQueryParam { (status = 200, description = "The paginated list of timetable requirements", body = inline(TrainRequirementsPage)), ), )] +// TODO test the endpoint pub(in crate::views) async fn requirements( State(AppState { db_pool, valkey_client, core_client, config, - regulator, .. }): State, Extension(authn_state): Extension, @@ -299,12 +299,8 @@ pub(in crate::views) async fn requirements( electrical_profile_set_id, }): Query, ) -> Result> { - if let authentication::State::Authenticated { user, .. } = &authn_state { - v2::infra_privileges(*user, authz::Infra(infra_id)) - .map(async |privileges| privileges.contains(&authz::InfraPrivilege::CanRestrictedRead)) - .ok_or(AuthorizationError::Forbidden) - .run::(&authn_state.authorizer(regulator.openfga())) - .await??; + if !matches!(&authn_state, authentication::State::Skip) { + return Err(AuthorizationError::Forbidden.into()); } let conn = &mut db_pool.get().await?; diff --git a/editoast/src/views/timetable/conflicts.rs b/editoast/src/views/timetable/conflicts.rs index d427c57f52e..7732eb56a8a 100644 --- a/editoast/src/views/timetable/conflicts.rs +++ b/editoast/src/views/timetable/conflicts.rs @@ -1,6 +1,7 @@ use authz::v2; use std::collections::HashMap; +use authz::RollingStockPrivilege; use axum::Extension; use axum::Json; use axum::extract::Path; @@ -12,6 +13,7 @@ use common::units::quantities::Offset; use editoast_models::prelude::*; use itertools::Itertools as _; use itertools::izip; +use schemas::paced_train::RollingStockChangeGroup; use schemas::timetable_type::TimetableType; use serde::Deserialize; use serde::Serialize; @@ -19,6 +21,7 @@ use utoipa::ToSchema; use crate::AppState; use crate::authentication; +use crate::authorizers::SystemAuthorizer; use crate::error::Result; use crate::views::AuthorizationError; use crate::views::infra::InfraIdQueryParam; @@ -369,6 +372,14 @@ pub(in crate::views) async fn conflicts( }) .collect(); + let train_schedules_with_exceptions = filter_unauthorized_train_schedules_and_exceptions( + regulator.openfga(), + conn.clone(), + authn_state, + train_schedules_with_exceptions, + ) + .await?; + // Flatten paced trains occurrences let (occurrence_ids, occurrence_trains): (Vec<_>, Vec<_>) = train_schedules_with_exceptions .iter() @@ -449,22 +460,103 @@ pub(in crate::views) async fn conflicts( Ok(Json(conflicts_response?)) } +/// Take a collection of train schedules and their associated exceptions and filter out those with +/// an unauthorized rolling stock. When a train schedule is filtered out all its exceptions are +/// skipped aswell, but when an exception is filtered its associated train schedule is kept if its +/// rolling stock is authorized given the provided authentication state. +pub async fn filter_unauthorized_train_schedules_and_exceptions( + openfga: &fga::Client, + conn: DbConnection, + authn_state: crate::authentication::State, + train_schedules_with_exceptions: Vec<( + editoast_models::TrainSchedule, + Vec, + )>, +) -> crate::error::Result< + Vec<( + editoast_models::TrainSchedule, + Vec, + )>, +> { + let Some(user) = authn_state.user() else { + return Ok(train_schedules_with_exceptions); + }; + let system_authorizer = SystemAuthorizer::new_infallible(openfga); + let Ok(authorized_train_schedules) = + authz::v2::rolling_stock_list(user, RollingStockPrivilege::CanRead) + .authorize(&system_authorizer) + .await? + .access() + .await?; + match authorized_train_schedules { + authz::v2::ResourcesList::All => Ok(train_schedules_with_exceptions), + authz::v2::ResourcesList::Privileged(authorized_rs_list) => { + let authorized_rolling_stocks: Vec = + editoast_models::RollingStock::retrieve_batch_unchecked( + &mut conn.clone(), + authorized_rs_list.iter().map(|rs| rs.0), + ) + .await?; + let authorized_rolling_stock_names: Vec = authorized_rolling_stocks + .into_iter() + .map(|rolling_stock| rolling_stock.name) + .collect(); + + Ok(train_schedules_with_exceptions + .into_iter() + .filter(|(train_schedule, _)| { + authorized_rolling_stock_names.contains(&train_schedule.rolling_stock_name) + }) + .map(|(train_schedule, exceptions)| { + ( + train_schedule, + exceptions + .into_iter() + .filter(|exception| { + if let Some(RollingStockChangeGroup { + rolling_stock_name, .. + }) = &exception.change_groups.rolling_stock + { + authorized_rolling_stock_names.contains(rolling_stock_name) + } else { + true + } + }) + .collect(), + ) + }) + .collect_vec()) + } + } +} + #[cfg(test)] mod tests { use crate::error::InternalError; + use crate::fixtures::create_fast_rolling_stock; use crate::fixtures::create_hourly_timetable_with_train_schedule_set; use crate::fixtures::create_small_infra; + use crate::fixtures::create_timetable_with_train_schedule_set; + use crate::fixtures::create_train_schedule_exception; use crate::fixtures::simple_paced_train_base; + use crate::fixtures::simple_paced_train_changeset; + use crate::views::test_app::TestRequestExt as _; use crate::views::test_app::test_app; use super::*; + use authz::InfraGrant; + use authz::RollingStockGrant; use common::units; use core_client::simulation::RoutingRequirement; use core_client::simulation::RoutingZoneRequirement; use core_client::simulation::SpacingRequirement; use editoast_models::train_schedule::TrainScheduleChangeset; + use pretty_assertions::assert_eq; use reqwest::StatusCode; use rstest::rstest; + use schemas::TrainScheduleExceptionChangeGroups; + use schemas::paced_train::RollingStockChangeGroup; + use schemas::train_schedule::Comfort; fn spacing(zone: &str, begin_time: u64, end_time: u64) -> SpacingRequirement { SpacingRequirement { @@ -663,12 +755,17 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn conflicts_hourly_rejects_period_over_24h() { - let app = test_app!().skip_authz().build(); + let app = test_app!().build(); let pool = app.db_pool(); let infra = create_small_infra(&mut pool.get_ok()).await; let (timetable, train_schedule_set) = create_hourly_timetable_with_train_schedule_set(&mut pool.get_ok()).await; + let user = app + .user("user", "User") + .with_infra_grant(infra.id, InfraGrant::Reader) + .create() + .await; // Period = 5 * 7 = 35h. for time_window in [5, 7] { @@ -692,9 +789,111 @@ mod tests { ) .as_str(), ) + .by_user(user.as_ref()) .await .assert_status(StatusCode::UNPROCESSABLE_ENTITY) .json(); assert_eq!(response.error_type, "editoast:timetable:InvalidPeriod"); } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn filter_unauthorized_train_schedules() { + let app = test_app!().build(); + let pool = app.db_pool(); + let rs_authorized = create_fast_rolling_stock(&mut pool.get_ok(), "authorized_rs").await; + let rs_no_grant = create_fast_rolling_stock(&mut pool.get_ok(), "forbidden_rs").await; + let (timetable, train_schedule_set) = + create_timetable_with_train_schedule_set(&mut pool.get_ok()).await; + let train_schedule_authorized = simple_paced_train_changeset(train_schedule_set.id) + .train_name("train_schedule_authorized".into()) + .rolling_stock_name(rs_authorized.name.clone()) + .create(&mut pool.get_ok()) + .await + .expect("failed to create train schedule"); + let train_schedule_unauthorized_exception = + simple_paced_train_changeset(train_schedule_set.id) + .train_name("train_schedule_forbidden_exception".into()) + .rolling_stock_name(rs_authorized.name.clone()) + .create(&mut pool.get_ok()) + .await + .expect("failed to create train schedule"); + let train_schedule_unauthorized = simple_paced_train_changeset(train_schedule_set.id) + .train_name("train_schedule_no_grant".into()) + .rolling_stock_name(rs_no_grant.name.clone()) + .create(&mut pool.get_ok()) + .await + .expect("failed to create train schedule"); + let change_group_authorized = TrainScheduleExceptionChangeGroups { + rolling_stock: Some(RollingStockChangeGroup { + rolling_stock_name: rs_authorized.name.clone(), + comfort: Comfort::AirConditioning, + }), + ..Default::default() + }; + let change_group_no_grant = TrainScheduleExceptionChangeGroups { + rolling_stock: Some(RollingStockChangeGroup { + rolling_stock_name: rs_no_grant.name.clone(), + comfort: Comfort::AirConditioning, + }), + ..Default::default() + }; + let exception_authorized: schemas::TrainScheduleException = + create_train_schedule_exception( + &mut pool.get_ok(), + timetable.id, + train_schedule_authorized.id, + None, + None, + Some(change_group_authorized), + ) + .await + .into(); + let exception_unauthorized: schemas::TrainScheduleException = + create_train_schedule_exception( + &mut pool.get_ok(), + timetable.id, + train_schedule_authorized.id, + None, + None, + Some(change_group_no_grant), + ) + .await + .into(); + + let openfga = app.openfga(); + + let user = app + .user("user", "User") + .with_rolling_stock_grant(rs_authorized.id, RollingStockGrant::Reader) + .create() + .await; + let authn_state = crate::authentication::State::Authenticated { + user: authz::User(user.id), + roles: vec![], + }; + let train_schedules_with_exceptions = vec![ + ( + train_schedule_authorized.clone(), + vec![exception_authorized.clone()], + ), + ( + train_schedule_unauthorized_exception.clone(), + vec![exception_unauthorized], + ), + (train_schedule_unauthorized, vec![]), + ]; + let authorized_train_schedules = filter_unauthorized_train_schedules_and_exceptions( + openfga, + pool.get_ok(), + authn_state, + train_schedules_with_exceptions, + ) + .await + .expect("the authorization filter method should succeed"); + let expected_response = vec![ + (train_schedule_authorized, vec![exception_authorized]), + (train_schedule_unauthorized_exception, vec![]), + ]; + assert_eq!(expected_response, authorized_train_schedules); + } } diff --git a/editoast/src/views/timetable/stdcm.rs b/editoast/src/views/timetable/stdcm.rs index 717ead01461..3b41307a72a 100644 --- a/editoast/src/views/timetable/stdcm.rs +++ b/editoast/src/views/timetable/stdcm.rs @@ -1,7 +1,12 @@ pub(crate) mod request; use authz; +use authz::RollingStockPrivilege; use authz::v2; +use authz::v2::Actor; +use authz::v2::Authorizer as _; +use authz::v2::Check; +use authz::v2::Protected; use axum::Extension; use axum::extract::Json; use axum::extract::Path; @@ -144,6 +149,10 @@ enum StdcmError { expected_max: f64, }, #[error(transparent)] + #[editoast_error(forward)] + #[serde(skip)] + Authorization(AuthorizationError), + #[error(transparent)] #[from(forward)] #[serde(skip)] Database(editoast_models::Error), @@ -201,6 +210,25 @@ pub(in crate::views) async fn stdcm( Query(query): Query, Json(request): Json, ) -> Result { + let consist_schedule_values = &request.consist_schedule.values; + if authn_state.user().is_some() { + let authorizer = authn_state.authorizer(regulator.openfga()); + let checks = consist_schedule_values + .iter() + .map(|consist| { + Check::HasRollingStockPrivilege( + Actor::Issuer, + RollingStockPrivilege::CanRead, + authz::RollingStock(consist.rolling_stock_id), + ) + }) + .map(Protected::check); + let protected = Protected::from_iter(checks); + authz::v2::Access::access(authorizer.authorize(protected).await?) + .await? + .map_err(|_| StdcmError::Authorization(AuthorizationError::Forbidden))?; + } + let mut conn = db_pool.get().await?; let timetable_id = id; @@ -230,9 +258,7 @@ pub(in crate::views) async fn stdcm( let work_schedules = request.get_work_schedules(&mut conn).await?; // 3. Get RollingStock - let rolling_stock_ids: Vec = request - .consist_schedule - .values + let rolling_stock_ids: Vec = consist_schedule_values .iter() .map(|consist_config| consist_config.rolling_stock_id) .collect(); @@ -589,6 +615,9 @@ pub fn as_core_work_schedule( #[cfg(test)] mod tests { + use authz::InfraGrant; + use authz::Role; + use authz::RollingStockGrant; use axum::http::StatusCode; use chrono::DateTime; use common::units; @@ -626,6 +655,7 @@ mod tests { use crate::fixtures::create_timetable; use crate::fixtures::create_towed_rolling_stock; use crate::views::path::pathfinding::PathfindingResult; + use crate::views::test_app::TestRequestExt as _; use crate::views::test_app::TestResponseExt as _; use crate::views::test_app::test_app; use crate::views::timetable::stdcm::Request; @@ -998,12 +1028,19 @@ mod tests { core }; - let app = test_app!().skip_authz().core_client(core.into()).build(); + let app = test_app!().core_client(core.into()).build(); let db_pool = app.db_pool(); let small_infra = create_small_infra(&mut db_pool.get_ok()).await; let timetable = create_timetable(&mut db_pool.get_ok()).await; let rolling_stock = create_fast_rolling_stock(&mut db_pool.get_ok(), &Uuid::new_v4().to_string()).await; + let user = app + .user("user", "identity") + .with_roles([Role::Stdcm]) + .with_rolling_stock_grant(rolling_stock.id, RollingStockGrant::Reader) + .with_infra_grant(small_infra.id, InfraGrant::Reader) + .create() + .await; let consist_schedule = build_single_consist(build_consist_config( rolling_stock.id, Some(mass.get::()), @@ -1015,7 +1052,8 @@ mod tests { let stdcm_response: StdcmProgression = app .post(format!("/timetable/{}/stdcm?infra={}", timetable.id, small_infra.id).as_str()) - .json(&get_stdcm_payload(None, consist_schedule)) + .by_user(user.as_ref()) + .json(&get_stdcm_payload(None, consist_schedule.clone())) .await .assert_status_ok() .last_jsonl(); @@ -1080,12 +1118,19 @@ mod tests { }) .finish(); - let app = test_app!().skip_authz().core_client(core.into()).build(); + let app = test_app!().core_client(core.into()).build(); let db_pool = app.db_pool(); let small_infra = create_small_infra(&mut db_pool.get_ok()).await; let timetable = create_timetable(&mut db_pool.get_ok()).await; let rolling_stock = create_fast_rolling_stock(&mut db_pool.get_ok(), &Uuid::new_v4().to_string()).await; + let user = app + .user("user", "identity") + .with_roles([Role::Stdcm]) + .with_rolling_stock_grant(rolling_stock.id, RollingStockGrant::Reader) + .with_infra_grant(small_infra.id, InfraGrant::Reader) + .create() + .await; let consist_schedule = build_single_consist(build_consist_config( rolling_stock.id, total_mass, @@ -1097,6 +1142,7 @@ mod tests { let stdcm_response: InternalError = app .post(format!("/timetable/{}/stdcm?infra={}", timetable.id, small_infra.id).as_str()) + .by_user(user.as_ref()) .json(&get_stdcm_payload(None, consist_schedule)) .await .assert_status_bad_request() @@ -1121,12 +1167,19 @@ mod tests { }) .finish(); - let app = test_app!().skip_authz().core_client(core.into()).build(); + let app = test_app!().core_client(core.into()).build(); let db_pool = app.db_pool(); let small_infra = create_small_infra(&mut db_pool.get_ok()).await; let timetable = create_timetable(&mut db_pool.get_ok()).await; let rolling_stock = create_fast_rolling_stock(&mut db_pool.get_ok(), &Uuid::new_v4().to_string()).await; + let user = app + .user("user", "identity") + .with_roles([Role::Stdcm]) + .with_rolling_stock_grant(rolling_stock.id, RollingStockGrant::Reader) + .with_infra_grant(small_infra.id, InfraGrant::Reader) + .create() + .await; let consist_schedule = build_single_consist(build_consist_config( rolling_stock.id, None, @@ -1138,6 +1191,7 @@ mod tests { let stdcm_response: StdcmProgression = app .post(format!("/timetable/{}/stdcm?infra={}", timetable.id, small_infra.id).as_str()) + .by_user(user.as_ref()) .json(&get_stdcm_payload(None, consist_schedule)) .await .assert_status_ok() @@ -1172,12 +1226,17 @@ mod tests { }) .finish(); - let app = test_app!().skip_authz().core_client(core.into()).build(); + let app = test_app!().core_client(core.into()).build(); let db_pool = app.db_pool(); let small_infra = create_small_infra(&mut db_pool.get_ok()).await; let timetable = create_timetable(&mut db_pool.get_ok()).await; let rolling_stock = create_fast_rolling_stock(&mut db_pool.get_ok(), &Uuid::new_v4().to_string()).await; + let user = app + .user("admin", "identity") + .with_roles([Role::Admin]) + .create() + .await; let consist_schedule = build_single_consist(build_consist_config( rolling_stock.id, None, @@ -1189,6 +1248,7 @@ mod tests { let stdcm_response: Vec = app .post(format!("/timetable/{}/stdcm?infra={}", timetable.id, small_infra.id).as_str()) + .by_user(user.as_ref()) .json(&get_stdcm_payload(None, consist_schedule)) .await .assert_status_ok() @@ -1242,12 +1302,19 @@ mod tests { }) .finish(); - let app = test_app!().skip_authz().core_client(core.into()).build(); + let app = test_app!().core_client(core.into()).build(); let db_pool = app.db_pool(); let small_infra = create_small_infra(&mut db_pool.get_ok()).await; let timetable = create_timetable(&mut db_pool.get_ok()).await; let rolling_stock = create_fast_rolling_stock(&mut db_pool.get_ok(), &Uuid::new_v4().to_string()).await; + let user = app + .user("user", "identity") + .with_roles([Role::Stdcm]) + .with_rolling_stock_grant(rolling_stock.id, RollingStockGrant::Reader) + .with_infra_grant(small_infra.id, InfraGrant::Reader) + .create() + .await; let consist_schedule = build_single_consist(build_consist_config( rolling_stock.id, None, @@ -1259,6 +1326,7 @@ mod tests { let stdcm_response: Vec = app .post(format!("/timetable/{}/stdcm?infra={}", timetable.id, small_infra.id).as_str()) + .by_user(user.as_ref()) .json(&get_stdcm_payload(None, consist_schedule)) .await .assert_status_ok() @@ -1428,7 +1496,7 @@ mod tests { }) .finish(); - let app = test_app!().skip_authz().core_client(core.into()).build(); + let app = test_app!().core_client(core.into()).build(); let db_pool = app.db_pool(); let small_infra = create_small_infra(&mut db_pool.get_ok()).await; let timetable = create_timetable(&mut db_pool.get_ok()).await; @@ -1474,9 +1542,19 @@ mod tests { // WS -> MWS // Consist change // MWS -> SS + // + let user = app + .user("user", "identity") + .with_roles([Role::Stdcm]) + .with_rolling_stock_grant(first_rolling_stock.id, RollingStockGrant::Reader) + .with_rolling_stock_grant(second_rolling_stock.id, RollingStockGrant::Reader) + .with_infra_grant(small_infra.id, InfraGrant::Reader) + .create() + .await; let stdcm_response: StdcmProgression = app .post(format!("/timetable/{}/stdcm?infra={}", timetable.id, small_infra.id).as_str()) + .by_user(user.as_ref()) .json(&payload) .await .assert_status_ok() @@ -1525,7 +1603,7 @@ mod tests { }) .finish(); - let app = test_app!().skip_authz().core_client(core.into()).build(); + let app = test_app!().core_client(core.into()).build(); let db_pool = app.db_pool(); let small_infra = create_small_infra(&mut db_pool.get_ok()).await; let timetable = create_timetable(&mut db_pool.get_ok()).await; @@ -1535,6 +1613,15 @@ mod tests { create_fast_rolling_stock(&mut db_pool.get_ok(), &Uuid::new_v4().to_string()).await; let third_rolling_stock = create_fast_rolling_stock(&mut db_pool.get_ok(), &Uuid::new_v4().to_string()).await; + let user = app + .user("user", "identity") + .with_roles([Role::Stdcm]) + .with_rolling_stock_grant(first_rolling_stock.id, RollingStockGrant::Reader) + .with_rolling_stock_grant(second_rolling_stock.id, RollingStockGrant::Reader) + .with_rolling_stock_grant(third_rolling_stock.id, RollingStockGrant::Reader) + .with_infra_grant(small_infra.id, InfraGrant::Reader) + .create() + .await; let mut payload = get_stdcm_payload( None, @@ -1560,6 +1647,7 @@ mod tests { let stdcm_response: StdcmProgression = app .post(format!("/timetable/{}/stdcm?infra={}", timetable.id, small_infra.id).as_str()) + .by_user(user.as_ref()) .json(&payload) .await .assert_status_ok() @@ -1649,7 +1737,7 @@ mod tests { }) .finish(); - let app = test_app!().skip_authz().core_client(core.into()).build(); + let app = test_app!().core_client(core.into()).build(); let db_pool = app.db_pool(); let small_infra = create_small_infra(&mut db_pool.get_ok()).await; let timetable = create_timetable(&mut db_pool.get_ok()).await; @@ -1666,8 +1754,16 @@ mod tests { Some(towed_rolling_stock.id), )); + let user = app + .user("user", "identity") + .with_roles([Role::Stdcm]) + .with_rolling_stock_grant(rolling_stock.id, RollingStockGrant::Reader) + .with_infra_grant(small_infra.id, InfraGrant::Reader) + .create() + .await; let stdcm_response: StdcmProgression = app .post(format!("/timetable/{}/stdcm?infra={}", timetable.id, small_infra.id).as_str()) + .by_user(user.as_ref()) .json(&get_stdcm_payload(None, consist_schedule)) .await .assert_status_ok() @@ -1683,4 +1779,146 @@ mod tests { }) ); } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn user_with_no_grant_is_forbidden() { + let mass = Mass::, f64>::new::(1000000.0); + let length = Length::, f64>::new::(400.0); + let maximum_speed = Velocity::, f64>::new::(30.0); + let app = test_app!().build(); + let db_pool = app.db_pool(); + let small_infra = create_small_infra(&mut db_pool.get_ok()).await; + let timetable = create_timetable(&mut db_pool.get_ok()).await; + let rolling_stock = + create_fast_rolling_stock(&mut db_pool.get_ok(), &Uuid::new_v4().to_string()).await; + let user = app + .user("user", "identity") + .with_roles([Role::Stdcm]) + .create() + .await; + let consist_schedule = build_single_consist(build_consist_config( + rolling_stock.id, + Some(mass.get::()), + Some(length.get::()), + Some(maximum_speed.get::()), + Some(LoadingGaugeType::Glott), + None, + )); + + app.post(format!("/timetable/{}/stdcm?infra={}", timetable.id, small_infra.id).as_str()) + .by_user(user.as_ref()) + .json(&get_stdcm_payload(None, consist_schedule)) + .await + .assert_status_forbidden(); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn user_without_stdcm_role_is_forbidden() { + let mass = Mass::, f64>::new::(1000000.0); + let length = Length::, f64>::new::(400.0); + let maximum_speed = Velocity::, f64>::new::(30.0); + let app = test_app!().build(); + let db_pool = app.db_pool(); + let small_infra = create_small_infra(&mut db_pool.get_ok()).await; + let timetable = create_timetable(&mut db_pool.get_ok()).await; + let rolling_stock = + create_fast_rolling_stock(&mut db_pool.get_ok(), &Uuid::new_v4().to_string()).await; + let user = app + .user("user", "identity") + .with_rolling_stock_grant(rolling_stock.id, RollingStockGrant::Reader) + .with_infra_grant(small_infra.id, InfraGrant::Reader) + .create() + .await; + let consist_schedule = build_single_consist(build_consist_config( + rolling_stock.id, + Some(mass.get::()), + Some(length.get::()), + Some(maximum_speed.get::()), + Some(LoadingGaugeType::Glott), + None, + )); + + app.post(format!("/timetable/{}/stdcm?infra={}", timetable.id, small_infra.id).as_str()) + .by_user(user.as_ref()) + .json(&get_stdcm_payload(None, consist_schedule)) + .await + .assert_status_forbidden(); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn user_without_infra_grant_is_forbidden() { + let mass = Mass::, f64>::new::(1000000.0); + let length = Length::, f64>::new::(400.0); + let maximum_speed = Velocity::, f64>::new::(30.0); + let app = test_app!().build(); + let db_pool = app.db_pool(); + let small_infra = create_small_infra(&mut db_pool.get_ok()).await; + let timetable = create_timetable(&mut db_pool.get_ok()).await; + let rolling_stock = + create_fast_rolling_stock(&mut db_pool.get_ok(), &Uuid::new_v4().to_string()).await; + let user = app + .user("user", "identity") + .with_roles([Role::Stdcm]) + .with_rolling_stock_grant(rolling_stock.id, RollingStockGrant::Reader) + .create() + .await; + let consist_schedule = build_single_consist(build_consist_config( + rolling_stock.id, + Some(mass.get::()), + Some(length.get::()), + Some(maximum_speed.get::()), + Some(LoadingGaugeType::Glott), + None, + )); + + app.post(format!("/timetable/{}/stdcm?infra={}", timetable.id, small_infra.id).as_str()) + .by_user(user.as_ref()) + .json(&get_stdcm_payload(None, consist_schedule)) + .await + .assert_status_forbidden(); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn multiple_consist_one_missing_grant_makes_request_forbidden() { + let app = test_app!().build(); + let db_pool = app.db_pool(); + let small_infra = create_small_infra(&mut db_pool.get_ok()).await; + let timetable = create_timetable(&mut db_pool.get_ok()).await; + let first_rolling_stock = + create_fast_rolling_stock(&mut db_pool.get_ok(), &Uuid::new_v4().to_string()).await; + let second_rolling_stock = + create_fast_rolling_stock(&mut db_pool.get_ok(), &Uuid::new_v4().to_string()).await; + let third_rolling_stock = + create_fast_rolling_stock(&mut db_pool.get_ok(), &Uuid::new_v4().to_string()).await; + // User missing the Reader privilege on one of the request rolling stocks + let user = app + .user("user", "identity") + .with_roles([Role::Stdcm]) + .with_rolling_stock_grant(first_rolling_stock.id, RollingStockGrant::Reader) + .with_rolling_stock_grant(second_rolling_stock.id, RollingStockGrant::Reader) + .with_infra_grant(small_infra.id, InfraGrant::Reader) + .create() + .await; + + let mut payload = get_stdcm_payload( + None, + ConsistSchedule { + boundaries: vec![1, 2], + values: vec![ + build_consist_config(first_rolling_stock.id, None, None, None, None, None), + build_consist_config(second_rolling_stock.id, None, None, None, None, None), + build_consist_config(third_rolling_stock.id, None, None, None, None, None), + ], + }, + ); + + payload.steps.push(build_step("MES")); + payload.steps.push(build_step("SES")); + + app.post(format!("/timetable/{}/stdcm?infra={}", timetable.id, small_infra.id).as_str()) + .by_user(user.as_ref()) + .json(&payload) + .await + .assert_status_forbidden(); + } } diff --git a/editoast/src/views/timetable/train_schedule.rs b/editoast/src/views/timetable/train_schedule.rs index e8220006a61..e0b936d1b68 100644 --- a/editoast/src/views/timetable/train_schedule.rs +++ b/editoast/src/views/timetable/train_schedule.rs @@ -1,11 +1,17 @@ use std::collections::BTreeSet; use std::collections::HashMap; use std::collections::HashSet; +use std::convert::Infallible; use std::iter::Extend as _; use std::sync::Arc; use authz; +use authz::InfraPrivilege; +use authz::RollingStockPrivilege; use authz::v2; +use authz::v2::Authorizer as _; +use authz::v2::infra_privileges; +use authz::v2::rolling_stock_privileges; use axum::Extension; use axum::extract::Json; use axum::extract::Path; @@ -16,6 +22,7 @@ use common::units::millisecond; use core_client::AsCoreRequest; use core_client::CoreClient; use core_client::pathfinding::PathfindingInputError; +use core_client::pathfinding::PathfindingInputError::UnauthorizedRollingStock; use core_client::pathfinding::PathfindingResultSuccess; use core_client::signal_projection::SignalUpdate; use core_client::simulation::PhysicsConsist; @@ -35,10 +42,13 @@ use itertools::Either; use itertools::Itertools as _; use itertools::izip; use reqwest::StatusCode; +use schemas::TrainScheduleExceptionChangeGroups; use schemas::infra::OperationalPoint; +use schemas::paced_train::RollingStockChangeGroup; use schemas::paced_train::TrainSchedule; use schemas::primitives::NonBlankString; use schemas::primitives::TimeWindow; +use schemas::rolling_stock::RollingResistanceRaw; use schemas::train_schedule::OperationalPointPartReference; use schemas::train_schedule::OperationalPointReference; use schemas::train_schedule::PathItemLocation; @@ -51,6 +61,7 @@ use utoipa::ToSchema; use super::AppState; use crate::authentication; +use crate::authorizers::SystemAuthorizer; use crate::error::EditoastError as _; use crate::error::Result; use crate::views::AuthorizationError; @@ -406,21 +417,57 @@ pub(in crate::views) async fn simulation_summary( .map::(|train_occurrence| train_occurrence.rolling_stock_name.to_string()) .collect::>(); - let consists = + let rolling_stocks = RollingStock::retrieve_batch_unchecked::<_, Vec<_>>(&mut conn.clone(), rolling_stocks_ids) .await - .map_err(RollingStockError::from)? - .into_iter() - .map(|rolling_stock| { - ( - rolling_stock.name.clone(), - PhysicsConsistParameters::from_traction_engine(rolling_stock.into()), - ) - }) - .collect::>(); + .map_err(RollingStockError::from)?; + + // Check user privilege on the rolling stocks used by the train occurrences. + // Those the user cannot read are kept aside to be reported per occurrence below. + let unauthorized_rolling_stocks = match authn_state.user() { + Some(user) => { + let system_authorizer = SystemAuthorizer::new_infallible(regulator.openfga()); + let Ok(authorized_rolling_stocks) = system_authorizer + .authorize(authz::v2::rolling_stock_list( + user, + RollingStockPrivilege::CanRead, + )) + .await? + .access() + .await?; + match authorized_rolling_stocks { + authz::v2::ResourcesList::All => HashMap::new(), + authz::v2::ResourcesList::Privileged(authorized_rolling_stocks) => { + let authorized_rolling_stock_ids = authorized_rolling_stocks + .into_iter() + .map(|rolling_stock| rolling_stock.0) + .collect::>(); + rolling_stocks + .iter() + .filter(|rolling_stock| { + !authorized_rolling_stock_ids.contains(&rolling_stock.id) + }) + .map(|rolling_stock| (rolling_stock.name.clone(), rolling_stock.id)) + .collect() + } + } + } + None => HashMap::new(), + }; + + let consists = rolling_stocks + .into_iter() + .filter(|rolling_stock| !unauthorized_rolling_stocks.contains_key(&rolling_stock.name)) + .map(|rolling_stock| { + ( + rolling_stock.name.clone(), + PhysicsConsistParameters::from_traction_engine(rolling_stock.into()), + ) + }) + .collect::>(); // Associate train schedules with their consist, when possible - let (train_occurrences_with_physics_consist, not_found_rolling_stock_names) = train_occurrences + let (train_occurrences_with_physics_consist, occurrences_without_consist) = train_occurrences .into_iter() .map(|(occurrence_id, train_occurrence)| { let rolling_stock_name = train_occurrence.rolling_stock_name.clone(); @@ -501,13 +548,15 @@ pub(in crate::views) async fn simulation_summary( }; (occurrence_id, summary_response) }) - .chain(not_found_rolling_stock_names.into_iter().map( + .chain(occurrences_without_consist.into_iter().map( |(occurrence_id, rolling_stock_name)| { + let input_error = match unauthorized_rolling_stocks.get(&rolling_stock_name) { + Some(&rolling_stock_id) => UnauthorizedRollingStock { rolling_stock_id }, + None => PathfindingInputError::RollingStockNotFound { rolling_stock_name }, + }; ( occurrence_id, - SummaryResponse::PathfindingInputError( - PathfindingInputError::RollingStockNotFound { rolling_stock_name }, - ), + SummaryResponse::PathfindingInputError(input_error), ) }, )) @@ -646,10 +695,8 @@ pub(in crate::views) async fn get_path( }; let rolling_stock_name = train_occurrence.rolling_stock_name().to_owned(); - let Some(consist) = RollingStock::retrieve(conn.clone(), rolling_stock_name.clone()) - .await? - .map(schemas::RollingStock::from) - .map(PhysicsConsistParameters::from_traction_engine) + let Some(rolling_stock_model) = + RollingStock::retrieve(conn.clone(), rolling_stock_name.clone()).await? else { let failure = PathfindingFailure::PathfindingInputError( PathfindingInputError::RollingStockNotFound { rolling_stock_name }, @@ -657,6 +704,19 @@ pub(in crate::views) async fn get_path( return Ok(Json(PathfindingResult::Failure(failure))); }; + if let Some(user) = authn_state.user() { + let system_authorizer = SystemAuthorizer::new_infallible(regulator.openfga()); + crate::authorizers::require( + &system_authorizer, + rolling_stock_privileges(user, authz::RollingStock(rolling_stock_model.id)), + &RollingStockPrivilege::CanRead, + ) + .await?; + } + + let rolling_stock = schemas::RollingStock::::from(rolling_stock_model); + let consist = PhysicsConsistParameters::from_traction_engine(rolling_stock); + // The path items are kept whole, and not only their location, to relate each of them to its // schedule item below let path = train_occurrence.path(); @@ -768,11 +828,18 @@ pub(in crate::views) async fn simulation( .await?; if let authentication::State::Authenticated { user, .. } = &authn_state { + let authorizer = authn_state.authorizer(regulator.openfga()); v2::infra_privileges(*user, authz::Infra(infra_id)) .map(async |privileges| privileges.contains(&authz::InfraPrivilege::CanRestrictedRead)) .ok_or(AuthorizationError::Forbidden) - .run::(&authn_state.authorizer(regulator.openfga())) + .run::(&authorizer) .await??; + crate::authorizers::require( + &authorizer, + infra_privileges(*user, authz::Infra(infra_id)), + &InfraPrivilege::CanRestrictedRead, + ) + .await?; } // Retrieve train_schedule or fail @@ -797,16 +864,43 @@ pub(in crate::views) async fn simulation( }; let rolling_stock_name = train_schedule.rolling_stock_name().to_owned(); - let Some(consist) = RollingStock::retrieve(db_pool.get().await?, rolling_stock_name.clone()) - .await? - .map(|rs| PhysicsConsistParameters::from_traction_engine(rs.into())) - else { - return Ok(Json(simulation::Response::PathfindingFailed { - pathfinding_failed: PathfindingFailure::PathfindingInputError( - PathfindingInputError::RollingStockNotFound { rolling_stock_name }, - ), - })); + let rolling_stock = + RollingStock::retrieve(db_pool.get().await?, rolling_stock_name.clone()).await?; + let Some(rolling_stock) = rolling_stock else { + return Err(TrainScheduleError::RollingStockNotFound { rolling_stock_name }.into()); }; + // Check user privilege on infra and rolling stock + // Done here because we need to retrieve the exception if it exists. + if let Some(user) = authn_state.user() { + let authorizer = authn_state.authorizer(regulator.openfga()); + crate::authorizers::require( + &authorizer, + infra_privileges(user, authz::Infra(infra_id)), + &InfraPrivilege::CanRead, + ) + .await?; + match crate::authorizers::require( + &authorizer, + rolling_stock_privileges(user, authz::RollingStock(rolling_stock.id)), + &RollingStockPrivilege::CanRead, + ) + .await + { + Ok(()) => {} + Err(AuthorizationError::Forbidden) => { + return Ok(Json(simulation::Response::PathfindingFailed { + pathfinding_failed: PathfindingFailure::PathfindingInputError( + UnauthorizedRollingStock { + rolling_stock_id: rolling_stock.id, + }, + ), + })); + } + Err(err) => return Err(err.into()), + } + } + + let consist = PhysicsConsistParameters::from_traction_engine(rolling_stock.into()); let path_item_locations = train_schedule.locations(); let op_cache = OperationalPointCache::load_path_items( @@ -877,6 +971,7 @@ pub(in crate::views) async fn simulation( (status = 200, description = "ETCS Braking Curves Output", body = core_client::etcs_braking_curves::Response), ), )] +// TODO test the endpoint pub(in crate::views) async fn etcs_braking_curves( State(AppState { config, @@ -933,6 +1028,25 @@ pub(in crate::views) async fn etcs_braking_curves( None => train_schedule.clone().into_train_occurrence(), }; + let rs = RollingStock::retrieve_or_fail( + db_pool.get().await?, + train_occurrence.rolling_stock_name.clone(), + || TrainScheduleError::RollingStockNotFound { + rolling_stock_name: train_occurrence.rolling_stock_name.clone(), + }, + ) + .await?; + + if let Some(user) = authn_state.user() { + let system_authorizer = SystemAuthorizer::new_infallible(regulator.openfga()); + crate::authorizers::require( + &system_authorizer, + rolling_stock_privileges(user, authz::RollingStock(rs.id)), + &RollingStockPrivilege::CanRead, + ) + .await?; + } + // Compute simulation of a train schedule let (simulation_result, pathfinding_result) = train_simulation_ordered_batch( &mut db_pool.get().await?, @@ -964,14 +1078,6 @@ pub(in crate::views) async fn etcs_braking_curves( }; // Build physics consist - let rs = RollingStock::retrieve_or_fail( - db_pool.get().await?, - train_occurrence.rolling_stock_name.clone(), - || TrainScheduleError::RollingStockNotFound { - rolling_stock_name: train_occurrence.rolling_stock_name.clone(), - }, - ) - .await?; let physics_consist: PhysicsConsist = PhysicsConsistParameters::from_traction_engine(rs.into()).into(); @@ -1115,6 +1221,17 @@ pub(in crate::views) async fn project_path( }) .collect(); + // Check user privilege on the rolling stocks of the occurrences: they are all simulated + crate::authorizers::require_readable_rolling_stocks( + simulation_contexts + .iter() + .map(|context| context.train_schedule.rolling_stock_name.clone()), + conn, + &authn_state, + regulator.openfga(), + ) + .await?; + let project_path_result = compute_projected_train_paths( conn, core_client, @@ -1235,6 +1352,20 @@ pub(in crate::views) async fn project_path_op( }) .unzip(); + // Check user privilege on the rolling stocks of the occurrences. + // Without a simulation, no rolling stock is involved: the check is skipped. + if use_simulation { + crate::authorizers::require_readable_rolling_stocks( + occurrences + .iter() + .map(|occurrence| occurrence.rolling_stock_name.clone()), + conn, + &authn_state, + regulator.openfga(), + ) + .await?; + } + // Transform operational point references into a list of path item locations let path_item_locations_projection = operational_points_refs .iter() @@ -1400,7 +1531,7 @@ pub(in crate::views) async fn occupancy_blocks( ) .await?; - let mut exceptions = + let exceptions = editoast_models::TrainScheduleException::retrieve_exceptions_by_train_schedules( conn, timetable_id, @@ -1411,6 +1542,107 @@ pub(in crate::views) async fn occupancy_blocks( .map_into::() .into_group_map_by(|e| e.train_schedule_id); + // Retrieve the names of the rolling stocks used by the train schedules and exceptions: + let rolling_stocks_from_exceptions = exceptions.values().flatten().filter_map(|exception| { + if let TrainScheduleExceptionChangeGroups { + rolling_stock: + Some(RollingStockChangeGroup { + rolling_stock_name, .. + }), + .. + } = &exception.change_groups + { + Some(rolling_stock_name.clone()) + } else { + None + } + }); + + let rolling_stock_names: HashSet = train_schedules + .iter() + .map(|train_schedule| train_schedule.rolling_stock_name.clone()) + .chain(rolling_stocks_from_exceptions) + .collect::>(); + + // The paced trains and exceptions using a missing rolling stock are filtered out afterwards: + // 1. We retrieve from the database the rolling stocks used by the request paced trains and + // exceptions. + // 2. We filter out unauthorized rolling stocks. + // 3. We only keep the paced trains and exceptions which are using an authorized rolling stock. + // => The paced trains and exceptions which are associated with a missing rolling stock will be + // automatically filtered out in the step `(3)`. + let rolling_stocks: Vec<_> = RollingStock::retrieve_batch_unchecked::, _>( + &mut conn.clone(), + rolling_stock_names, + ) + .await?; + + // Filter out unauthorized rolling stocks: + let authorized_rolling_stocks: HashSet = match &authn_state { + crate::authentication::State::Skip => { + // The services should not be calling this endpoint + Err(AuthorizationError::Unauthenticated)? + } + crate::authentication::State::Authenticated { user, .. } => { + let system_authorizer = SystemAuthorizer::new_infallible(regulator.openfga()); + let protected_authorized_rs = rolling_stocks.into_iter().map(|rolling_stock| { + authz::v2::rolling_stock_privileges(*user, authz::RollingStock(rolling_stock.id)) + .map(async move |grants| { + grants + .contains(&RollingStockPrivilege::CanRead) + .then_some(rolling_stock.name.clone()) + }) + }); + let accesses = system_authorizer + .authorize_all(protected_authorized_rs) + .await?; + let Ok(authorized_rs) = authz::v2::Access::access_all(accesses) + .await? + .into_iter() + .collect::, Infallible>>(); + authorized_rs.into_iter().flatten().collect::>() + } + }; + + // Filter out the train schedules and exceptions which are using unauthorized rolling stocks: + let train_schedules = train_schedules + .into_iter() + .filter(|train_schedule| { + authorized_rolling_stocks.contains(train_schedule.rolling_stock_name.as_str()) + }) + .collect_vec(); + let mut exceptions: HashMap> = exceptions + .into_iter() + .filter(|(train_schedule, _exceptions)| { + // Filter out the exceptions related to unauthorized train schedules: + train_schedules + .iter() + .map(|train| train.id) + .contains(train_schedule) + }) + .map(|(train_schedule, exceptions)| { + let filtered_exceptions = exceptions + .into_iter() + .filter(|exception| { + // Filter out exceptions updating the rolling stock to an unauthorized one: + if let TrainScheduleExceptionChangeGroups { + rolling_stock: + Some(RollingStockChangeGroup { + rolling_stock_name, .. + }), + .. + } = &exception.change_groups + { + authorized_rolling_stocks.contains(rolling_stock_name) + } else { + true + } + }) + .collect_vec(); + (train_schedule, filtered_exceptions) + }) + .collect(); + let simulation_contexts: Vec = train_schedules .iter() .flat_map(|train_schedule| { @@ -1438,6 +1670,11 @@ pub(in crate::views) async fn occupancy_blocks( .map(|c| c.train_schedule.clone()) .collect::>(); + // TODO: core-task should be able to handle this case on its own + if train_schedules.is_empty() { + return Ok(Json(HashMap::new())); + } + let occupancy_blocks_result = compute_occupancy_blocks( conn, core_client, @@ -1587,6 +1824,20 @@ pub(in crate::views) async fn track_occupancy( }) .unzip(); + // Check user privilege on the rolling stocks of the occurrences. + // Without a simulation, no rolling stock is involved: the check is skipped. + if use_simulation { + crate::authorizers::require_readable_rolling_stocks( + trains + .iter() + .map(|occurrence| occurrence.rolling_stock_name.clone()), + conn, + &authn_state, + regulator.openfga(), + ) + .await?; + } + let op_location = PathItemLocation::OperationalPointPartReference(OperationalPointPartReference { operational_point: operational_point_reference.clone(), @@ -1914,6 +2165,8 @@ pub(in crate::views) async fn move_train_schedules_to_another_train_schedule_set mod tests { use std::collections::HashMap; + use authz::InfraGrant; + use authz::RollingStockGrant; use axum::http::StatusCode; use chrono::Duration; use chrono::TimeDelta; @@ -1975,6 +2228,7 @@ mod tests { use crate::views::path::pathfinding::PathfindingResult; use crate::views::test_app; use crate::views::test_app::TestApp; + use crate::views::test_app::TestRequestExt; use crate::views::tests::mocked_core_pathfinding_sim_and_proj; use crate::views::timetable::simulation; @@ -2255,6 +2509,7 @@ mod tests { struct SimulationTestsSetup { app: TestApp, infra_id: i64, + rolling_stock_id: i64, timetable: Timetable, train_schedule: editoast_models::TrainSchedule, exception: TrainScheduleException, @@ -2299,13 +2554,13 @@ mod tests { let core = mocked_core_pathfinding_sim_and_proj(); let app = test_app!() - .skip_authz() .db_pool(db_pool) .core_client(core.into()) .build(); SimulationTestsSetup { app, infra_id: small_infra.id, + rolling_stock_id: rolling_stock.id, timetable, train_schedule, exception, @@ -2317,9 +2572,17 @@ mod tests { let SimulationTestsSetup { app, infra_id, + rolling_stock_id, train_schedule, .. } = simulation_tests_initial_setup().await; + let user = app + .user("authorized", "authorized") + .with_infra_grant(infra_id, authz::InfraGrant::Reader) + .with_rolling_stock_grant(rolling_stock_id, authz::RollingStockGrant::Reader) + .with_roles([authz::Role::OperationalStudies]) + .create() + .await; let response: core_client::simulation::Response = app .get( format!( @@ -2328,6 +2591,7 @@ mod tests { ) .as_str(), ) + .by_user(&user.info) .await .assert_status_ok() .json(); @@ -2343,9 +2607,17 @@ mod tests { let SimulationTestsSetup { app, infra_id, + rolling_stock_id, train_schedule, .. } = simulation_tests_initial_setup().await; + let user = app + .user("authorized", "authorized") + .with_infra_grant(infra_id, authz::InfraGrant::Reader) + .with_rolling_stock_grant(rolling_stock_id, authz::RollingStockGrant::Reader) + .with_roles([authz::Role::OperationalStudies]) + .create() + .await; let response: InternalError = app .get( format!( @@ -2354,6 +2626,7 @@ mod tests { ) .as_str(), ) + .by_user(&user.info) .await .assert_status_not_found() .json(); @@ -2369,10 +2642,18 @@ mod tests { let SimulationTestsSetup { app, infra_id, + rolling_stock_id, train_schedule, exception, .. } = simulation_tests_initial_setup().await; + let user = app + .user("authorized", "authorized") + .with_infra_grant(infra_id, authz::InfraGrant::Reader) + .with_rolling_stock_grant(rolling_stock_id, authz::RollingStockGrant::Reader) + .with_roles([authz::Role::OperationalStudies]) + .create() + .await; let response: simulation::Response = app .get( format!( @@ -2381,6 +2662,7 @@ mod tests { ) .as_str(), ) + .by_user(&user.info) .await .assert_status_ok() .json(); @@ -2433,10 +2715,18 @@ mod tests { let SimulationTestsSetup { app, infra_id, + rolling_stock_id, train_schedule, exception, .. } = simulation_tests_initial_setup().await; + let user = app + .user("authorized", "authorized") + .with_infra_grant(infra_id, authz::InfraGrant::Reader) + .with_rolling_stock_grant(rolling_stock_id, authz::RollingStockGrant::Reader) + .with_roles([authz::Role::OperationalStudies]) + .create() + .await; let mut change_group = exception.change_groups; change_group.rolling_stock = Some(RollingStockChangeGroup { @@ -2451,7 +2741,7 @@ mod tests { .expect("Fail to update exception"); // WHEN - let response: simulation::Response = app + let response: InternalError = app .get( format!( "/train_schedules/{}/simulation/?infra_id={infra_id}&exception_id={}", @@ -2459,134 +2749,468 @@ mod tests { ) .as_str(), ) + .by_user(&user.info) .await - .assert_status_ok() + .assert_status_not_found() .json(); // THEN assert_eq!( - response, - simulation::Response::PathfindingFailed { - pathfinding_failed: PathfindingFailure::PathfindingInputError( - PathfindingInputError::RollingStockNotFound { - rolling_stock_name: "R2D2".into() - } - ) - } + &response.error_type, + "editoast:train_schedule:RollingStockNotFound" ); } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn paced_train_simulation_not_found() { - let SimulationTestsSetup { app, infra_id, .. } = simulation_tests_initial_setup().await; + let SimulationTestsSetup { + app, + infra_id, + rolling_stock_id, + .. + } = simulation_tests_initial_setup().await; + let user = app + .user("authorized", "authorized") + .with_infra_grant(infra_id, authz::InfraGrant::Reader) + .with_rolling_stock_grant(rolling_stock_id, authz::RollingStockGrant::Reader) + .with_roles([authz::Role::OperationalStudies]) + .create() + .await; let response: InternalError = app .get(format!("/train_schedules/{}/simulation/?infra_id={}", 0, infra_id).as_str()) + .by_user(&user.info) .await .assert_status_not_found() .json(); assert_eq!(&response.error_type, "editoast:train_schedule:NotFound") } - #[tokio::test(flavor = "multi_thread", worker_threads = 1)] - async fn paced_train_simulation_summary() { - // Setup tests tools - let core = mocked_core_pathfinding_sim_and_proj(); - let app = test_app!() - .skip_authz() - .db_pool(DbConnectionPoolV2::for_tests()) - .core_client(core.into()) - .build(); - let db_pool = app.db_pool(); + async fn paced_train_simulation_with_privilege_and_no_roles() { + // GIVEN + let SimulationTestsSetup { + app, + infra_id, + rolling_stock_id, + train_schedule, + .. + } = simulation_tests_initial_setup().await; - // Setup tests data - let small_infra = create_small_infra(&mut db_pool.get_ok()).await; + // a user that does not have the role to reach the endpoint but has a read grant on the infra + // and the rolling stock + let user = app + .user("unauthorized", "Unauthorized") + .with_infra_grant(infra_id, authz::InfraGrant::Reader) + .with_rolling_stock_grant(rolling_stock_id, authz::RollingStockGrant::Reader) + .create() + .await; - let (timetable, train_schedule_set) = - create_timetable_with_train_schedule_set(&mut db_pool.get_ok()).await; - create_fast_rolling_stock(&mut db_pool.get_ok(), "R2D2").await; - create_rolling_stock_with_energy_sources( - &mut app.db_pool().get_ok(), - "exception_rolling_stock", + // WHEN / THEN + app.get( + format!( + "/train_schedules/{}/simulation/?infra_id={infra_id}", + train_schedule.id + ) + .as_str(), ) - .await; + .by_user(&user.info) + .await + .assert_status_forbidden(); + } - let train_schedule = TrainSchedule { - train_occurrence: schemas::TrainOccurrence::fake(), - paced: Some(Paced { - time_window: Duration::hours(1).try_into().unwrap(), - interval: Duration::minutes(15).try_into().unwrap(), - exceptions: vec![], - }), - }; - let train_schedule: TrainScheduleChangeset = train_schedule.into(); - let train_schedule = train_schedule - .train_schedule_set_id(train_schedule_set.id) - .create(&mut db_pool.get_ok()) - .await - .expect("Failed to create train schedule"); + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn paced_train_simulation_without_permission() { + // GIVEN + let SimulationTestsSetup { + app, + infra_id, + rolling_stock_id, + train_schedule, + .. + } = simulation_tests_initial_setup().await; - // Add one exception which will not change the simulation from base - let _exception_1 = create_train_schedule_exception( - &mut db_pool.get_ok(), - timetable.id, - train_schedule.id, - None, - Some("change_train_name".to_string()), - Some(TrainScheduleExceptionChangeGroups { - train_name: Some(TrainNameChangeGroup { - value: "exception_name_but_same_simulation".into(), - }), - ..Default::default() - }), - ) - .await; + // a user that has the role to reach the endpoint and a read grant on the rolling stock, + // but no read grant on the infra + let user = app + .user("unauthorized", "Unauthorized") + .with_rolling_stock_grant(rolling_stock_id, authz::RollingStockGrant::Reader) + .with_roles([authz::Role::OperationalStudies]) + .create() + .await; - // Add one exception which will change the simulation from base - let exception_2 = create_train_schedule_exception( - &mut db_pool.get_ok(), - timetable.id, - train_schedule.id, - None, - Some("change_initial_speed".to_string()), - Some(TrainScheduleExceptionChangeGroups { - initial_speed: Some(InitialSpeedChangeGroup { value: 1.23 }), - ..Default::default() - }), + // WHEN / THEN + app.get( + format!( + "/train_schedules/{}/simulation/?infra_id={infra_id}", + train_schedule.id + ) + .as_str(), ) - .await; + .by_user(&user.info) + .await + .assert_status_forbidden(); + } - // Add one exception which will change the simulation from base with another rolling stock - let exception_3 = create_train_schedule_exception( - &mut db_pool.get_ok(), - timetable.id, - train_schedule.id, - Some(2), - Some("change_rolling_stock".to_string()), - Some(TrainScheduleExceptionChangeGroups { - rolling_stock: Some(RollingStockChangeGroup { - rolling_stock_name: "exception_rolling_stock".to_string(), - // This property is what make the simulation request different - // hence producing a different simulation - comfort: Comfort::AirConditioning, - }), - ..Default::default() - }), - ) - .await; + /// A rolling stock the user cannot read is reported as a pathfinding input error in the + /// response body, not as a 403: the endpoint answers about the train, not about the user. + fn unauthorized_rolling_stock_response(rolling_stock_id: i64) -> simulation::Response { + simulation::Response::PathfindingFailed { + pathfinding_failed: PathfindingFailure::PathfindingInputError( + PathfindingInputError::UnauthorizedRollingStock { rolling_stock_id }, + ), + } + } - // Add one exception with a different path whose path item don’t exists - let exception_4 = create_train_schedule_exception( - &mut db_pool.get_ok(), - timetable.id, - train_schedule.id, - None, - Some("unknown_path_item".to_string()), - Some(TrainScheduleExceptionChangeGroups { - path_and_schedule: Some(PathAndScheduleChangeGroup { - path: vec![ - PathItem::new_operational_point("unknown_origin"), - PathItem::new_operational_point("unknown_destination"), + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn paced_train_simulation_without_rolling_stock_permission() { + // GIVEN + let SimulationTestsSetup { + app, + infra_id, + rolling_stock_id, + train_schedule, + .. + } = simulation_tests_initial_setup().await; + + // a user that has the role to reach the endpoint and a read grant on the infra, + // but no read grant on the rolling stock + let user = app + .user("unauthorized", "Unauthorized") + .with_infra_grant(infra_id, authz::InfraGrant::Reader) + .with_roles([authz::Role::OperationalStudies]) + .create() + .await; + + // WHEN + let response: simulation::Response = app + .get( + format!( + "/train_schedules/{}/simulation/?infra_id={infra_id}", + train_schedule.id + ) + .as_str(), + ) + .by_user(&user.info) + .await + .assert_status_ok() + .json(); + + // THEN + assert_eq!( + response, + unauthorized_rolling_stock_response(rolling_stock_id) + ); + } + + const EXCEPTION_ROLLING_STOCK_NAME: &str = "exception_rolling_stock"; + + /// Creates a rolling stock and rewrites `exception` so it swaps the train schedule onto it. + async fn swap_exception_rolling_stock( + app: &TestApp, + train_schedule: &editoast_models::TrainSchedule, + exception: TrainScheduleException, + ) -> TrainScheduleException { + create_fast_rolling_stock(&mut app.db_pool().get_ok(), EXCEPTION_ROLLING_STOCK_NAME).await; + + let mut change_groups = exception.change_groups; + change_groups.rolling_stock = Some(RollingStockChangeGroup { + rolling_stock_name: EXCEPTION_ROLLING_STOCK_NAME.into(), + comfort: Comfort::AirConditioning, + }); + editoast_models::TrainScheduleException::changeset() + .change_groups(change_groups) + .update(&mut app.db_pool().get_ok(), train_schedule.id) + .await + .expect("Failed to update exception") + .expect("Failed to update exception") + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn paced_train_simulation_with_grant_on_another_rolling_stock() { + // GIVEN + let SimulationTestsSetup { + app, + infra_id, + rolling_stock_id, + train_schedule, + .. + } = simulation_tests_initial_setup().await; + + let other_rolling_stock = + create_fast_rolling_stock(&mut app.db_pool().get_ok(), "other_rolling_stock").await; + + // a user granted on another rolling stock than the one used by the train schedule + let user = app + .user("unauthorized", "Unauthorized") + .with_infra_grant(infra_id, authz::InfraGrant::Reader) + .with_rolling_stock_grant(other_rolling_stock.id, authz::RollingStockGrant::Reader) + .with_roles([authz::Role::OperationalStudies]) + .create() + .await; + + // WHEN + let response: simulation::Response = app + .get( + format!( + "/train_schedules/{}/simulation/?infra_id={infra_id}", + train_schedule.id + ) + .as_str(), + ) + .by_user(&user.info) + .await + .assert_status_ok() + .json(); + + // THEN + assert_eq!( + response, + unauthorized_rolling_stock_response(rolling_stock_id) + ); + } + + /// An exception can swap the rolling stock of a train schedule: privileges must be + /// checked against the rolling stock the exception resolves to, not the base one. + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn paced_train_exception_simulation_without_permission_on_exception_rolling_stock() { + // GIVEN + let SimulationTestsSetup { + app, + infra_id, + rolling_stock_id, + train_schedule, + exception, + .. + } = simulation_tests_initial_setup().await; + + let exception = swap_exception_rolling_stock(&app, &train_schedule, exception).await; + let swapped_rolling_stock = RollingStock::retrieve( + app.db_pool().get_ok(), + EXCEPTION_ROLLING_STOCK_NAME.to_string(), + ) + .await + .expect("Failed to retrieve rolling stock") + .expect("Swapped rolling stock not found"); + + // a user granted on the base rolling stock only, not on the one the exception swaps to + let user = app + .user("unauthorized", "Unauthorized") + .with_infra_grant(infra_id, authz::InfraGrant::Reader) + .with_rolling_stock_grant(rolling_stock_id, authz::RollingStockGrant::Reader) + .with_roles([authz::Role::OperationalStudies]) + .create() + .await; + + // WHEN + let response: simulation::Response = app + .get( + format!( + "/train_schedules/{}/simulation/?infra_id={infra_id}&exception_id={}", + train_schedule.id, exception.id + ) + .as_str(), + ) + .by_user(&user.info) + .await + .assert_status_ok() + .json(); + + // THEN the failure names the rolling stock the exception resolves to, not the base one + assert_eq!( + response, + unauthorized_rolling_stock_response(swapped_rolling_stock.id) + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn paced_train_exception_simulation_with_permission_on_exception_rolling_stock() { + // GIVEN + let SimulationTestsSetup { + app, + infra_id, + train_schedule, + exception, + .. + } = simulation_tests_initial_setup().await; + let exception = swap_exception_rolling_stock(&app, &train_schedule, exception).await; + let swapped_rolling_stock = RollingStock::retrieve( + app.db_pool().get_ok(), + EXCEPTION_ROLLING_STOCK_NAME.to_string(), + ) + .await + .expect("Failed to retrieve rolling stock") + .expect("Swapped rolling stock not found"); + + // a user granted on the rolling stock the exception swaps to, not on the base one + let user = app + .user("authorized", "Authorized") + .with_infra_grant(infra_id, authz::InfraGrant::Reader) + .with_rolling_stock_grant(swapped_rolling_stock.id, authz::RollingStockGrant::Reader) + .with_roles([authz::Role::OperationalStudies]) + .create() + .await; + + let response: simulation::Response = app + .get( + format!( + "/train_schedules/{}/simulation/?infra_id={infra_id}&exception_id={}", + train_schedule.id, exception.id + ) + .as_str(), + ) + .by_user(&user.info) + .await + .assert_status_ok() + .json(); + assert_eq!( + response, + simulation::Response::Success(SimulationResponseSuccess { + base: ReportTrain { + positions: vec![0, 500_000, 15_050_000], + times: vec![0, 30_000, 100_000], + speeds: vec![], + energy_consumption: 0.0, + path_item_times: vec![0, 1, 2, 3] + }, + provisional: ReportTrain { + positions: vec![0, 500_000, 15_050_000], + times: vec![0, 30_000, 100_000], + speeds: vec![], + energy_consumption: 0.0, + path_item_times: vec![0, 1, 2, 3] + }, + final_output: CompleteReportTrain { + report_train: ReportTrain { + positions: vec![0, 500_000, 15_050_000], + times: vec![0, 30_000, 100_000], + speeds: vec![], + energy_consumption: 0.0, + path_item_times: vec![0, 1, 2, 3] + }, + signal_critical_positions: vec![], + zone_updates: vec![], + spacing_requirements: vec![], + routing_requirements: vec![] + }, + mrsp: SpeedLimitProperties { + boundaries: vec![], + values: vec![] + }, + electrical_profiles: ElectricalProfiles { + boundaries: vec![], + values: vec![] + } + }) + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn paced_train_simulation_summary() { + // Setup tests tools + let core = mocked_core_pathfinding_sim_and_proj(); + let app = test_app!() + .db_pool(DbConnectionPoolV2::for_tests()) + .core_client(core.into()) + .build(); + let db_pool = app.db_pool(); + + // Setup tests data + let small_infra = create_small_infra(&mut db_pool.get_ok()).await; + + let (timetable, train_schedule_set) = + create_timetable_with_train_schedule_set(&mut db_pool.get_ok()).await; + let rolling_stock = create_fast_rolling_stock(&mut db_pool.get_ok(), "R2D2").await; + let exception_rolling_stock = create_rolling_stock_with_energy_sources( + &mut app.db_pool().get_ok(), + "exception_rolling_stock", + ) + .await; + + let user = app + .user("authorized", "authorized") + .with_infra_grant(small_infra.id, authz::InfraGrant::Reader) + .with_rolling_stock_grant(rolling_stock.id, authz::RollingStockGrant::Reader) + .with_rolling_stock_grant(exception_rolling_stock.id, authz::RollingStockGrant::Reader) + .with_roles([authz::Role::OperationalStudies]) + .create() + .await; + + let train_schedule = TrainSchedule { + train_occurrence: schemas::TrainOccurrence::fake(), + paced: Some(Paced { + time_window: Duration::hours(1).try_into().unwrap(), + interval: Duration::minutes(15).try_into().unwrap(), + exceptions: vec![], + }), + }; + let train_schedule: TrainScheduleChangeset = train_schedule.into(); + let train_schedule = train_schedule + .train_schedule_set_id(train_schedule_set.id) + .create(&mut db_pool.get_ok()) + .await + .expect("Failed to create train schedule"); + + // Add one exception which will not change the simulation from base + let _exception_1 = create_train_schedule_exception( + &mut db_pool.get_ok(), + timetable.id, + train_schedule.id, + None, + Some("change_train_name".to_string()), + Some(TrainScheduleExceptionChangeGroups { + train_name: Some(TrainNameChangeGroup { + value: "exception_name_but_same_simulation".into(), + }), + ..Default::default() + }), + ) + .await; + + // Add one exception which will change the simulation from base + let exception_2 = create_train_schedule_exception( + &mut db_pool.get_ok(), + timetable.id, + train_schedule.id, + None, + Some("change_initial_speed".to_string()), + Some(TrainScheduleExceptionChangeGroups { + initial_speed: Some(InitialSpeedChangeGroup { value: 1.23 }), + ..Default::default() + }), + ) + .await; + + // Add one exception which will change the simulation from base with another rolling stock + let exception_3 = create_train_schedule_exception( + &mut db_pool.get_ok(), + timetable.id, + train_schedule.id, + Some(2), + Some("change_rolling_stock".to_string()), + Some(TrainScheduleExceptionChangeGroups { + rolling_stock: Some(RollingStockChangeGroup { + rolling_stock_name: "exception_rolling_stock".to_string(), + // This property is what make the simulation request different + // hence producing a different simulation + comfort: Comfort::AirConditioning, + }), + ..Default::default() + }), + ) + .await; + + // Add one exception with a different path whose path item don’t exists + let exception_4 = create_train_schedule_exception( + &mut db_pool.get_ok(), + timetable.id, + train_schedule.id, + None, + Some("unknown_path_item".to_string()), + Some(TrainScheduleExceptionChangeGroups { + path_and_schedule: Some(PathAndScheduleChangeGroup { + path: vec![ + PathItem::new_operational_point("unknown_origin"), + PathItem::new_operational_point("unknown_destination"), ], schedule: vec![], margins: schemas::train_schedule::Margins { @@ -2607,6 +3231,7 @@ mod tests { "timetable_id": timetable.id, "ids": vec![train_schedule.id], })) + .by_user(&user.info) .await .assert_status_ok() .json(); @@ -2695,7 +3320,6 @@ mod tests { async fn paced_train_simulation_summary_with_all_occurrences_disabled() { let core = mocked_core_pathfinding_sim_and_proj(); let app = test_app!() - .skip_authz() .db_pool(DbConnectionPoolV2::for_tests()) .core_client(core.into()) .build(); @@ -2704,7 +3328,15 @@ mod tests { let small_infra = create_small_infra(&mut db_pool.get_ok()).await; let (timetable, train_schedule_set) = create_timetable_with_train_schedule_set(&mut db_pool.get_ok()).await; - create_fast_rolling_stock(&mut db_pool.get_ok(), "R2D2").await; + let rolling_stock = create_fast_rolling_stock(&mut db_pool.get_ok(), "R2D2").await; + + let user = app + .user("authorized", "authorized") + .with_infra_grant(small_infra.id, authz::InfraGrant::Reader) + .with_rolling_stock_grant(rolling_stock.id, authz::RollingStockGrant::Reader) + .with_roles([authz::Role::OperationalStudies]) + .create() + .await; let train_schedule = TrainSchedule { train_occurrence: schemas::TrainOccurrence::fake(), @@ -2719,46 +3351,165 @@ mod tests { .train_schedule_set_id(train_schedule_set.id) .create(&mut db_pool.get_ok()) .await - .expect("Failed to create train schedule"); + .expect("Failed to create train schedule"); + + for i in 0..4 { + TrainScheduleException::changeset() + .timetable_id(timetable.id) + .train_schedule_id(train_schedule.id) + .occurrence_index(Some(i)) + .key(Some(format!("disabled_occurrence_{}", i))) + .disabled(true) + .change_groups(TrainScheduleExceptionChangeGroups { + initial_speed: Some(InitialSpeedChangeGroup { value: 1.23 }), + ..Default::default() + }) + .create(&mut db_pool.get_ok()) + .await + .expect("Failed to create exception"); + } + + let mut response: HashMap = app + .post("/train_schedules/simulation_summary") + .json(&json!({ + "infra_id": small_infra.id, + "timetable_id": timetable.id, + "ids": vec![train_schedule.id], + })) + .by_user(&user.info) + .await + .assert_status_ok() + .json(); + + assert_eq!(response.len(), 1); + let train_schedule_summary = response + .remove(&train_schedule.id) + .expect("missing simulation summary for train schedule"); + assert_eq!(train_schedule_summary.exceptions.len(), 4); + } + + /// Like [`simulation`], a rolling stock the user cannot read is reported as a pathfinding + /// input error, per occurrence, instead of failing the whole request with a 403. + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn paced_train_simulation_summary_without_rolling_stock_permission() { + // GIVEN + let SimulationTestsSetup { + app, + infra_id, + rolling_stock_id, + timetable, + train_schedule, + exception, + } = simulation_tests_initial_setup().await; + + // a user that has the role to reach the endpoint and a read grant on the infra, + // but no read grant on the rolling stock + let user = app + .user("unauthorized", "Unauthorized") + .with_infra_grant(infra_id, authz::InfraGrant::Reader) + .with_roles([authz::Role::OperationalStudies]) + .create() + .await; + + // WHEN + let mut response: HashMap = app + .post("/train_schedules/simulation_summary") + .json(&json!({ + "infra_id": infra_id, + "timetable_id": timetable.id, + "ids": vec![train_schedule.id], + })) + .by_user(&user.info) + .await + .assert_status_ok() + .json(); - for i in 0..4 { - TrainScheduleException::changeset() - .timetable_id(timetable.id) - .train_schedule_id(train_schedule.id) - .occurrence_index(Some(i)) - .key(Some(format!("disabled_occurrence_{}", i))) - .disabled(true) - .change_groups(TrainScheduleExceptionChangeGroups { - initial_speed: Some(InitialSpeedChangeGroup { value: 1.23 }), - ..Default::default() - }) - .create(&mut db_pool.get_ok()) - .await - .expect("Failed to create exception"); - } + // THEN + let summary = response + .remove(&train_schedule.id) + .expect("missing simulation summary for train schedule"); + let unauthorized = SummaryResponse::PathfindingInputError( + PathfindingInputError::UnauthorizedRollingStock { rolling_stock_id }, + ); + assert_eq!(summary.train_schedule, unauthorized); + assert_eq!(summary.exceptions.get(&exception.id), Some(&unauthorized)); + } + /// An exception can swap the rolling stock of an occurrence: the occurrences the user is + /// allowed to read must still be simulated, only the swapped one is rejected. + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn paced_train_simulation_summary_without_permission_on_exception_rolling_stock() { + // GIVEN + let SimulationTestsSetup { + app, + infra_id, + rolling_stock_id, + timetable, + train_schedule, + exception, + .. + } = simulation_tests_initial_setup().await; + + let exception = swap_exception_rolling_stock(&app, &train_schedule, exception).await; + let swapped_rolling_stock = RollingStock::retrieve( + app.db_pool().get_ok(), + EXCEPTION_ROLLING_STOCK_NAME.to_string(), + ) + .await + .expect("Failed to retrieve rolling stock") + .expect("Swapped rolling stock not found"); + + // a user granted on the base rolling stock only, not on the one the exception swaps to + let user = app + .user("authorized", "Authorized") + .with_infra_grant(infra_id, authz::InfraGrant::Reader) + .with_rolling_stock_grant(rolling_stock_id, authz::RollingStockGrant::Reader) + .with_roles([authz::Role::OperationalStudies]) + .create() + .await; + + // WHEN let mut response: HashMap = app .post("/train_schedules/simulation_summary") .json(&json!({ - "infra_id": small_infra.id, + "infra_id": infra_id, "timetable_id": timetable.id, "ids": vec![train_schedule.id], })) + .by_user(&user.info) .await .assert_status_ok() .json(); - assert_eq!(response.len(), 1); - let train_schedule_summary = response + // THEN the base occurrence is simulated, the swapped one names the rolling stock it + // resolves to + let summary = response .remove(&train_schedule.id) .expect("missing simulation summary for train schedule"); - assert_eq!(train_schedule_summary.exceptions.len(), 4); + assert!(matches!( + summary.train_schedule, + SummaryResponse::Success { .. } + )); + assert_eq!( + summary.exceptions.get(&exception.id), + Some(&SummaryResponse::PathfindingInputError( + PathfindingInputError::UnauthorizedRollingStock { + rolling_stock_id: swapped_rolling_stock.id + } + )) + ); } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn paced_train_simulation_summary_not_found() { let SimulationTestsSetup { app, infra_id, .. } = simulation_tests_initial_setup().await; let timetable = create_timetable(&mut app.db_pool().get_ok()).await; + let user = app + .user("authorized", "authorized") + .with_infra_grant(infra_id, authz::InfraGrant::Reader) + .with_roles([authz::Role::OperationalStudies]) + .create() + .await; let response: InternalError = app .post("/train_schedules/simulation_summary") .json(&json!({ @@ -2766,6 +3517,7 @@ mod tests { "timetable_id": timetable.id, "ids": vec![0], })) + .by_user(&user.info) .await .assert_status_not_found() .json(); @@ -2778,17 +3530,19 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn get_paced_train_path_infra_not_found() { - let app = test_app!().skip_authz().build(); + let app = test_app!().build(); let pool = app.db_pool(); let train_schedule_set = create_train_schedule_set(&mut pool.get_ok()).await; let paced_train = create_simple_paced_train(&mut pool.get_ok(), train_schedule_set.id).await; + let user_no_grant = app.user("user", "User").create().await; let response: InternalError = app .get(&format!( "/train_schedules/{}/path?infra_id={}", paced_train.id, 0 )) + .by_user(user_no_grant.as_ref()) .await .assert_status_not_found() .json(); @@ -2801,15 +3555,21 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn get_paced_train_path_not_found() { - let app = test_app!().skip_authz().build(); + let app = test_app!().build(); let pool = app.db_pool(); let small_infra = create_small_infra(&mut pool.get_ok()).await; + let user = app + .user("user", "User") + .with_infra_grant(small_infra.id, InfraGrant::Reader) + .create() + .await; let response: InternalError = app .get(&format!( "/train_schedules/{}/path?infra_id={}", 0, small_infra.id )) + .by_user(user.as_ref()) .await .assert_status_not_found() .json(); @@ -2860,20 +3620,28 @@ mod tests { "status": "success" })) .finish(); - let app = test_app!().skip_authz().core_client(core.into()).build(); + let app = test_app!().core_client(core.into()).build(); let db_pool = app.db_pool(); - create_fast_rolling_stock(&mut db_pool.get_ok(), "R2D2").await; let train_schedule_set = create_train_schedule_set(&mut db_pool.get_ok()).await; let paced_train = create_simple_paced_train(&mut db_pool.get_ok(), train_schedule_set.id).await; + let rolling_stock = + create_fast_rolling_stock(&mut db_pool.get_ok(), &paced_train.rolling_stock_name).await; let small_infra = create_small_infra(&mut db_pool.get_ok()).await; + let user = app + .user("user", "User") + .with_rolling_stock_grant(rolling_stock.id, RollingStockGrant::Reader) + .with_infra_grant(small_infra.id, InfraGrant::Reader) + .create() + .await; let response = app .get(&format!( "/train_schedules/{}/path?infra_id={}", paced_train.id, small_infra.id )) + .by_user(user.as_ref()) .await .assert_status_ok() .json::(); @@ -2893,6 +3661,81 @@ mod tests { ) } + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn get_paced_train_path_requires_reader_grant_on_the_infra_and_rolling_stock() { + // Setup the app and the mocked core client + let mut core = MockingClient::new(); + for _ in 0..2 { + core.stub("/pathfinding/blocks") + .response(StatusCode::OK) + .json(json!({ + "path": { + "blocks":[], + "routes": [], + "track_section_ranges": [], + }, + "path_item_positions": [], + "backtrack_path_items": [], + "length": 1, + "status": "success" + })) + .finish(); + } + let app = test_app!().core_client(core.into()).build(); + + // Setup the rolling stock, train schedule and infra + let db_pool = app.db_pool(); + let rolling_stock = create_fast_rolling_stock(&mut db_pool.get_ok(), "R2D2").await; + let train_schedule_set = create_train_schedule_set(&mut db_pool.get_ok()).await; + let mut paced_train = + create_simple_paced_train(&mut db_pool.get_ok(), train_schedule_set.id).await; + paced_train.rolling_stock_name = rolling_stock.name; + let small_infra = create_small_infra(&mut db_pool.get_ok()).await; + + // Setup the users + let authorized_user = app + .user("user", "User") + .with_rolling_stock_grant(rolling_stock.id, RollingStockGrant::Reader) + .with_infra_grant(small_infra.id, InfraGrant::Reader) + .create() + .await; + let user_missing_infra_grant = app + .user("alice", "Alice") + .with_rolling_stock_grant(rolling_stock.id, RollingStockGrant::Reader) + .create() + .await; + let user_missing_rolling_stock_grant = app + .user("bob", "Bob") + .with_infra_grant(small_infra.id, InfraGrant::Reader) + .create() + .await; + + // Authorized user request succeeds + app.get(&format!( + "/train_schedules/{}/path?infra_id={}", + paced_train.id, small_infra.id + )) + .by_user(authorized_user.as_ref()) + .await + .assert_status_ok(); + + // Users missing grants requests fail with 403 Forbidden + app.get(&format!( + "/train_schedules/{}/path?infra_id={}", + paced_train.id, small_infra.id + )) + .by_user(user_missing_infra_grant.as_ref()) + .await + .assert_status_forbidden(); + app.get(&format!( + "/train_schedules/{}/path?infra_id={}", + paced_train.id, small_infra.id + )) + .by_user(user_missing_rolling_stock_grant.as_ref()) + .await + .assert_status_forbidden(); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn get_paced_train_path_with_bounds() { let mut core = MockingClient::new(); @@ -2928,19 +3771,27 @@ mod tests { "status": "success" })) .finish(); - let app = test_app!().skip_authz().core_client(core.into()).build(); + let app = test_app!().core_client(core.into()).build(); let db_pool = app.db_pool(); - create_fast_rolling_stock(&mut db_pool.get_ok(), "R2D2").await; let train_schedule_set = create_train_schedule_set(&mut db_pool.get_ok()).await; let paced_train = create_simple_paced_train(&mut db_pool.get_ok(), train_schedule_set.id).await; + let rolling_stock = + create_fast_rolling_stock(&mut db_pool.get_ok(), &paced_train.rolling_stock_name).await; let small_infra = create_small_infra(&mut db_pool.get_ok()).await; + let user = app + .user("user", "User") + .with_infra_grant(small_infra.id, InfraGrant::Reader) + .with_rolling_stock_grant(rolling_stock.id, RollingStockGrant::Reader) + .create() + .await; app.get(&format!( "/train_schedules/{}/path?infra_id={}&begin_index=1&end_index=2", paced_train.id, small_infra.id )) + .by_user(user.as_ref()) .await .assert_status_ok(); } @@ -2953,20 +3804,29 @@ mod tests { #[case] begin_index: usize, #[case] end_index: usize, ) { - let app = test_app!().skip_authz().build(); + let app = test_app!().build(); let db_pool = app.db_pool(); - create_fast_rolling_stock(&mut db_pool.get_ok(), "R2D2").await; let train_schedule_set = create_train_schedule_set(&mut db_pool.get_ok()).await; - let paced_train = + let mut paced_train = create_simple_paced_train(&mut db_pool.get_ok(), train_schedule_set.id).await; + let rolling_stock = + create_fast_rolling_stock(&mut db_pool.get_ok(), &paced_train.rolling_stock_name).await; + paced_train.rolling_stock_name = rolling_stock.name; let small_infra = create_small_infra(&mut db_pool.get_ok()).await; + let user = app + .user("user", "User") + .with_infra_grant(small_infra.id, InfraGrant::Reader) + .with_rolling_stock_grant(rolling_stock.id, RollingStockGrant::Reader) + .create() + .await; let response: InternalError = app .get(&format!( "/train_schedules/{}/path?infra_id={}&begin_index={}&end_index={}", paced_train.id, small_infra.id, begin_index, end_index )) + .by_user(user.as_ref()) .await .assert_status_bad_request() .json(); @@ -2991,15 +3851,17 @@ mod tests { "status": "success" })) .finish(); - let app = test_app!().skip_authz().core_client(core.into()).build(); + let app = test_app!().core_client(core.into()).build(); let db_pool = app.db_pool(); - create_fast_rolling_stock(&mut db_pool.get_ok(), "R2D2").await; let (timetable, train_schedule_set) = create_timetable_with_train_schedule_set(&mut db_pool.get_ok()).await; let train_schedule = create_simple_paced_train(&mut db_pool.get_ok(), train_schedule_set.id).await; + let rolling_stock = + create_fast_rolling_stock(&mut db_pool.get_ok(), &train_schedule.rolling_stock_name) + .await; let change_rolling_stock_exception = create_train_schedule_exception( &mut db_pool.get_ok(), @@ -3021,12 +3883,19 @@ mod tests { .await; let small_infra = create_small_infra(&mut db_pool.get_ok()).await; + let user_no_grant = app + .user("user", "User") + .with_rolling_stock_grant(rolling_stock.id, RollingStockGrant::Reader) + .with_infra_grant(small_infra.id, InfraGrant::Reader) + .create() + .await; let response = app .get(&format!( "/train_schedules/{}/path?infra_id={}&exception_id={}", train_schedule.id, small_infra.id, change_rolling_stock_exception.id )) + .by_user(user_no_grant.as_ref()) .await .assert_status_ok() .json::(); @@ -3112,15 +3981,16 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn paced_train_project_path() { - // SETUP let db_pool = DbConnectionPoolV2::for_tests(); let small_infra = create_small_infra(&mut db_pool.get_ok()).await; let (timetable, train_schedule_set) = create_timetable_with_train_schedule_set(&mut db_pool.get_ok()).await; - let _ = create_fast_rolling_stock(&mut db_pool.get_ok(), "R2D2").await; let paced_train_valid = create_simple_paced_train(&mut db_pool.get_ok(), train_schedule_set.id).await; + let rolling_stock = + create_fast_rolling_stock(&mut db_pool.get_ok(), &paced_train_valid.rolling_stock_name) + .await; let paced_train_fail = simple_paced_train_changeset(train_schedule_set.id) .rolling_stock_name("fail".to_string()) .start_time(millisecond::i64::new(0)) @@ -3130,12 +4000,18 @@ mod tests { let core = mocked_core_pathfinding_sim_and_proj(); let app = test_app!() - .skip_authz() .db_pool(db_pool) .core_client(core.into()) .build(); - // TEST + let user = app + .user("authorized", "Authorized") + .with_infra_grant(small_infra.id, authz::InfraGrant::Reader) + .with_rolling_stock_grant(rolling_stock.id, authz::RollingStockGrant::Reader) + .with_roles([authz::Role::OperationalStudies]) + .create() + .await; + let response: HashMap = app .post("/train_schedules/project_path") .json(&json!({ @@ -3152,6 +4028,7 @@ mod tests { } ], })) + .by_user(&user.info) .await .assert_status_ok() .json(); @@ -3160,15 +4037,155 @@ mod tests { assert_eq!(response.len(), 2); } + /// Projecting a train whose rolling stock the user cannot read is forbidden + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn project_path_without_rolling_stock_permission() { + // SETUP + let app = test_app!() + .core_client(mocked_core_pathfinding_sim_and_proj().into()) + .build(); + let db_pool = app.db_pool(); + + let small_infra = create_small_infra(&mut db_pool.get_ok()).await; + let (timetable, train_schedule_set) = + create_timetable_with_train_schedule_set(&mut db_pool.get_ok()).await; + let paced_train = + create_simple_paced_train(&mut db_pool.get_ok(), train_schedule_set.id).await; + create_fast_rolling_stock(&mut db_pool.get_ok(), &paced_train.rolling_stock_name).await; + + // a user that can read the infra, but not the rolling stock of the train + let user = app + .user("unauthorized", "Unauthorized") + .with_infra_grant(small_infra.id, authz::InfraGrant::Reader) + .with_roles([authz::Role::OperationalStudies]) + .create() + .await; + + // TEST + app.post("/train_schedules/project_path") + .json(&json!({ + "infra_id": small_infra.id, + "timetable_id": timetable.id, + "electrical_profile_set_id": null, + "ids": vec![paced_train.id], + "track_section_ranges": [ + { + "track_section": "TA1", + "begin": 0, + "end": 100, + "direction": "START_TO_STOP" + } + ], + })) + .by_user(&user.info) + .await + .assert_status_forbidden(); + } + + /// With a simulation, projecting a train whose rolling stock the user cannot read is forbidden + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn project_path_op_without_rolling_stock_permission() { + let app = test_app!() + .core_client(mocked_core_pathfinding_sim_and_proj().into()) + .build(); + let db_pool = app.db_pool(); + + let small_infra = create_small_infra(&mut db_pool.get_ok()).await; + let (timetable, train_schedule_set) = + create_timetable_with_train_schedule_set(&mut db_pool.get_ok()).await; + let paced_train = + create_simple_paced_train(&mut db_pool.get_ok(), train_schedule_set.id).await; + create_fast_rolling_stock(&mut db_pool.get_ok(), &paced_train.rolling_stock_name).await; + + // a user that can read the infra, but not the rolling stock of the train + let user = app + .user("unauthorized", "Unauthorized") + .with_infra_grant(small_infra.id, authz::InfraGrant::Reader) + .with_roles([authz::Role::OperationalStudies]) + .create() + .await; + + app.post("/train_schedules/project_path_op") + .json(&json!({ + "infra_id": small_infra.id, + "timetable_id": timetable.id, + "electrical_profile_set_id": null, + "train_ids": vec![paced_train.id], + "operational_points_refs": [ + { "type": "domestic", "country_code": "FR", "main_code": "MWS", "secondary_code": "BV" }, + { "type": "id", "operational_point": "Mid_East_station" }, + ], + "operational_points_distances": [10000], + "use_simulation": true, + })) + .by_user(&user.info) + .await + .assert_status_forbidden(); + } + + /// Without a simulation, no rolling stock is involved: projecting a train whose rolling stock + /// the user cannot read is allowed + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn project_path_op_without_simulation_skips_rolling_stock_permission() { + let app = test_app!() + .core_client(mocked_core_pathfinding_sim_and_proj().into()) + .build(); + let db_pool = app.db_pool(); + + let small_infra = create_small_infra(&mut db_pool.get_ok()).await; + let (timetable, train_schedule_set) = + create_timetable_with_train_schedule_set(&mut db_pool.get_ok()).await; + let paced_train = + create_simple_paced_train(&mut db_pool.get_ok(), train_schedule_set.id).await; + create_fast_rolling_stock(&mut db_pool.get_ok(), &paced_train.rolling_stock_name).await; + + // the very same user as above, without any grant on the rolling stock of the train + let user = app + .user("unauthorized", "Unauthorized") + .with_infra_grant(small_infra.id, authz::InfraGrant::Reader) + .with_roles([authz::Role::OperationalStudies]) + .create() + .await; + + let response: HashMap = app + .post("/train_schedules/project_path_op") + .json(&json!({ + "infra_id": small_infra.id, + "timetable_id": timetable.id, + "electrical_profile_set_id": null, + "train_ids": vec![paced_train.id], + "operational_points_refs": [ + { "type": "domestic", "country_code": "FR", "main_code": "MWS", "secondary_code": "BV" }, + { "type": "id", "operational_point": "Mid_East_station" }, + ], + "operational_points_distances": [10000], + "use_simulation": false, + })) + .by_user(&user.info) + .await + .assert_status_ok() + .json(); + + assert!(response.contains_key(&paced_train.id)); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn paced_train_occupancy_blocks() { let SimulationTestsSetup { app, infra_id, + rolling_stock_id, timetable, train_schedule, exception, } = simulation_tests_initial_setup().await; + let user = app + .user("authorized", "authorized") + .with_infra_grant(infra_id, authz::InfraGrant::Reader) + .with_rolling_stock_grant(rolling_stock_id, RollingStockGrant::Reader) + .with_roles([authz::Role::OperationalStudies]) + .create() + .await; let db_pool = app.db_pool(); // First remove all already generated exceptions @@ -3207,22 +4224,24 @@ mod tests { ) .await; + let json_payload = &json!({"ids": vec![train_schedule.id], + "infra_id": infra_id, + "timetable_id": timetable.id, + "path": { + "track_section_ranges": [{ + "track_section": "T1", + "begin": 0, + "end": 100, + "direction": "START_TO_STOP", + }], + "routes": [], + "blocks":[], + }, + }); let response = app .post("/train_schedules/occupancy_blocks") - .json(&json!({"ids": vec![train_schedule.id], - "infra_id": infra_id, - "timetable_id": timetable.id, - "path": { - "track_section_ranges": [{ - "track_section": "T1", - "begin": 0, - "end": 100, - "direction": "START_TO_STOP", - }], - "routes": [], - "blocks":[], - }, - })) + .json(&json_payload) + .by_user(&user.info) .await; let response: HashMap = response.assert_status_ok().json(); @@ -3240,6 +4259,22 @@ mod tests { response.get(&train_schedule.id).unwrap().exceptions.len(), 0 ); + + // User without rolling stock reader rights should have a filtered out response: + let user_missing_rs_grant = app + .user("bob", "Bob") + .with_infra_grant(infra_id, authz::InfraGrant::Reader) + .with_roles([authz::Role::OperationalStudies]) + .create() + .await; + let response_unauthorized: HashMap = app + .post("/train_schedules/occupancy_blocks") + .json(&json_payload) + .by_user(&user_missing_rs_grant.info) + .await + .assert_status_ok() + .json(); + assert!(response_unauthorized.is_empty()); } fn pathfinding_result_success() -> PathfindingResultSuccess { @@ -3258,12 +4293,13 @@ mod tests { } } - async fn init_paced_train_test( + async fn init_track_occupancy_test( with_exception: bool, path: Vec, schedule: Vec, operational_point_reference: OperationalPointReference, use_simulation: bool, + rolling_stock_grant: Option, ) -> TestResponse { let mut core = MockingClient::new(); core.stub("/pathfinding/blocks") @@ -3274,13 +4310,21 @@ mod tests { .response(StatusCode::OK) .json(simulation_empty_response(path.len())) .finish(); - let app = test_app!().skip_authz().core_client(core.into()).build(); + let app = test_app!().core_client(core.into()).build(); let db_pool = app.db_pool(); let small_infra = create_small_infra(&mut db_pool.get_ok()).await; let rolling_stock = create_fast_rolling_stock(&mut db_pool.get_ok(), "simulation_rolling_stock").await; let (timetable, train_schedule_set) = create_timetable_with_train_schedule_set(&mut db_pool.get_ok()).await; + let mut user_builder = app + .user("user", "User") + .with_infra_grant(small_infra.id, authz::InfraGrant::Reader) + .with_roles([authz::Role::OperationalStudies]); + if let Some(grant) = rolling_stock_grant { + user_builder = user_builder.with_rolling_stock_grant(rolling_stock.id, grant); + } + let user = user_builder.create().await; let train_schedule = editoast_models::TrainSchedule::default() .into_changeset() .train_schedule_set_id(train_schedule_set.id) @@ -3312,7 +4356,73 @@ mod tests { electrical_profile_set_id: None, use_simulation, }) + .by_user(&user.info) + .await + } + + /// [init_track_occupancy_test] as a user allowed to read the rolling stock of the train + async fn init_paced_train_test( + with_exception: bool, + path: Vec, + schedule: Vec, + operational_point_reference: OperationalPointReference, + use_simulation: bool, + ) -> TestResponse { + init_track_occupancy_test( + with_exception, + path, + schedule, + operational_point_reference, + use_simulation, + Some(authz::RollingStockGrant::Reader), + ) + .await + } + + /// The very same setup as [paced_train_track_occupancy_without_exceptions], but the user is + /// granted a read access on the infra only, not on the rolling stock of the train + async fn init_track_occupancy_test_without_rolling_stock_permission( + use_simulation: bool, + ) -> TestResponse { + init_track_occupancy_test( + false, + vec![ + PathItem::new_operational_point("Mid_West_station"), + PathItem::new_operational_point("Mid_East_station"), + ], + vec![ScheduleItem::new_with_stop( + "Mid_East_station", + Duration::new(0, 0).expect("Failed to parse duration"), + )], + OperationalPointReference::Id { + operational_point: "Mid_West_station".into(), + }, + use_simulation, + None, + ) + .await + } + + /// With a simulation, a train whose rolling stock the user cannot read is forbidden + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn track_occupancy_without_rolling_stock_permission() { + init_track_occupancy_test_without_rolling_stock_permission(true) .await + .assert_status_forbidden(); + } + + /// Without a simulation, no rolling stock is involved: the train is reported even though the + /// user cannot read its rolling stock + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn track_occupancy_without_simulation_skips_rolling_stock_permission() { + let track_occupancies: Vec = + init_track_occupancy_test_without_rolling_stock_permission(false) + .await + .assert_status_ok() + .json(); + + assert_eq!(track_occupancies.len(), 1); + assert_eq!(track_occupancies[0].trains.len(), 4); } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] diff --git a/front/public/locales/en/operational-studies.json b/front/public/locales/en/operational-studies.json index db44e1f83fc..3a5a4d7c5a3 100644 --- a/front/public/locales/en/operational-studies.json +++ b/front/public/locales/en/operational-studies.json @@ -235,6 +235,7 @@ "pathfinding_not_found": "Pathfinding not found", "rolling_stock_not_found": "RS not found", "simulation_failed": "Simulation failed", + "unauthorized_rolling_stock": "Unauthorized Rolling Stock", "zero_length_path": "Departure and arrival are identical" }, "invalidTrains": "Some trains are invalid", @@ -495,6 +496,7 @@ "not_found_in_tracks": "Missing track", "pathfinding_failure": "No path found", "rolling_stock_not_found": "Rolling stock not found", + "unauthorized_rolling_stock": "You are not authorized to use this rolling stock", "zero_length_path": "Departure and arrival are identical" }, "pathfindingInProgress": "Pathfinding in progress…", diff --git a/front/public/locales/fr/operational-studies.json b/front/public/locales/fr/operational-studies.json index c873f75a127..1633db78f9f 100644 --- a/front/public/locales/fr/operational-studies.json +++ b/front/public/locales/fr/operational-studies.json @@ -235,6 +235,7 @@ "pathfinding_not_found": "Chemin introuvable", "rolling_stock_not_found": "MR non trouvé", "simulation_failed": "Simulation impossible", + "unauthorized_rolling_stock": "Matériel roulant non autorisé", "zero_length_path": "L'origine et la destination sont identiques" }, "invalidTrains": "Certains trains sont invalides", @@ -495,6 +496,7 @@ "not_found_in_tracks": "Voie manquante", "pathfinding_failure": "Aucun chemin trouvé", "rolling_stock_not_found": "Matériel roulant non trouvé", + "unauthorized_rolling_stock": "Vous n'êtes pas autorisé à utiliser ce matériel roulant", "zero_length_path": "L'origine et la destination sont identiques" }, "pathfindingInProgress": "Recherche d’itinéraire en cours…", diff --git a/front/src/common/api/generatedEditoastApi.ts b/front/src/common/api/generatedEditoastApi.ts index 2ae8f98cb9c..c43ee30ee95 100644 --- a/front/src/common/api/generatedEditoastApi.ts +++ b/front/src/common/api/generatedEditoastApi.ts @@ -3807,6 +3807,10 @@ export type CorePathfindingInputError = | { error_type: 'not_enough_path_items'; } + | { + error_type: 'unauthorized_rolling_stock'; + rolling_stock_id: number; + } | { error_type: 'rolling_stock_not_found'; rolling_stock_name: string; diff --git a/osrd_schemas/osrd_schemas/models.py b/osrd_schemas/osrd_schemas/models.py index 76dfe7ab1ab..7d24cdf47f2 100644 --- a/osrd_schemas/osrd_schemas/models.py +++ b/osrd_schemas/osrd_schemas/models.py @@ -208,6 +208,11 @@ class PathfindingInputErrorNotEnoughPathItems(BaseModel): error_type: Literal["not_enough_path_items"] +class PathfindingInputErrorUnauthorizedRollingStock(BaseModel): + error_type: Literal["unauthorized_rolling_stock"] + rolling_stock_id: int + + class PathfindingInputErrorRollingStockNotFound(BaseModel): error_type: Literal["rolling_stock_not_found"] rolling_stock_name: str @@ -3179,12 +3184,19 @@ class PathfindingFailurePathfindingInputError3( class PathfindingFailurePathfindingInputError4( - PathfindingInputErrorRollingStockNotFound, PathfindingFailurePathfindingInputError1 + PathfindingInputErrorUnauthorizedRollingStock, + PathfindingFailurePathfindingInputError1, ): pass class PathfindingFailurePathfindingInputError5( + PathfindingInputErrorRollingStockNotFound, PathfindingFailurePathfindingInputError1 +): + pass + + +class PathfindingFailurePathfindingInputError6( PathfindingInputErrorZeroLengthPath, PathfindingFailurePathfindingInputError1 ): pass @@ -3218,24 +3230,31 @@ class PathfindingOutput(BaseModel): track_ranges: list[DirectionalTrackRange] -class PathfindingFailurePathfindingInputError7(BaseModel): +class PathfindingFailurePathfindingInputError8(BaseModel): failed_status: Literal["pathfinding_input_error"] -class PathfindingFailurePathfindingInputError9( - PathfindingInputErrorNotEnoughPathItems, PathfindingFailurePathfindingInputError7 +class PathfindingFailurePathfindingInputError10( + PathfindingInputErrorNotEnoughPathItems, PathfindingFailurePathfindingInputError8 ): pass -class PathfindingFailurePathfindingInputError10( - PathfindingInputErrorRollingStockNotFound, PathfindingFailurePathfindingInputError7 +class PathfindingFailurePathfindingInputError11( + PathfindingInputErrorUnauthorizedRollingStock, + PathfindingFailurePathfindingInputError8, ): pass -class PathfindingFailurePathfindingInputError11( - PathfindingInputErrorZeroLengthPath, PathfindingFailurePathfindingInputError7 +class PathfindingFailurePathfindingInputError12( + PathfindingInputErrorRollingStockNotFound, PathfindingFailurePathfindingInputError8 +): + pass + + +class PathfindingFailurePathfindingInputError13( + PathfindingInputErrorZeroLengthPath, PathfindingFailurePathfindingInputError8 ): pass @@ -3823,7 +3842,7 @@ class SummaryResponsePathfindingInputError3( class SummaryResponsePathfindingInputError4( - PathfindingInputErrorRollingStockNotFound, SummaryResponsePathfindingInputError1 + PathfindingInputErrorUnauthorizedRollingStock, SummaryResponsePathfindingInputError1 ): """ InputError @@ -3831,6 +3850,14 @@ class SummaryResponsePathfindingInputError4( class SummaryResponsePathfindingInputError5( + PathfindingInputErrorRollingStockNotFound, SummaryResponsePathfindingInputError1 +): + """ + InputError + """ + + +class SummaryResponsePathfindingInputError6( PathfindingInputErrorZeroLengthPath, SummaryResponsePathfindingInputError1 ): """ @@ -5531,25 +5558,27 @@ class PathfindingInputErrorInvalidPathItems2(BaseModel): items: list[Item] -class PathfindingFailurePathfindingInputError8( - PathfindingInputErrorInvalidPathItems2, PathfindingFailurePathfindingInputError7 +class PathfindingFailurePathfindingInputError9( + PathfindingInputErrorInvalidPathItems2, PathfindingFailurePathfindingInputError8 ): pass -class PathfindingFailurePathfindingInputError6( +class PathfindingFailurePathfindingInputError7( RootModel[ - PathfindingFailurePathfindingInputError8 - | PathfindingFailurePathfindingInputError9 + PathfindingFailurePathfindingInputError9 | PathfindingFailurePathfindingInputError10 | PathfindingFailurePathfindingInputError11 + | PathfindingFailurePathfindingInputError12 + | PathfindingFailurePathfindingInputError13 ] ): root: Annotated[ - PathfindingFailurePathfindingInputError8 - | PathfindingFailurePathfindingInputError9 + PathfindingFailurePathfindingInputError9 | PathfindingFailurePathfindingInputError10 - | PathfindingFailurePathfindingInputError11, + | PathfindingFailurePathfindingInputError11 + | PathfindingFailurePathfindingInputError12 + | PathfindingFailurePathfindingInputError13, Field(title="PathfindingFailurePathfindingInputError"), ] @@ -6149,6 +6178,7 @@ class CorePathfindingInputError( RootModel[ PathfindingInputErrorInvalidPathItems | PathfindingInputErrorNotEnoughPathItems + | PathfindingInputErrorUnauthorizedRollingStock | PathfindingInputErrorRollingStockNotFound | PathfindingInputErrorZeroLengthPath ] @@ -6156,6 +6186,7 @@ class CorePathfindingInputError( root: ( PathfindingInputErrorInvalidPathItems | PathfindingInputErrorNotEnoughPathItems + | PathfindingInputErrorUnauthorizedRollingStock | PathfindingInputErrorRollingStockNotFound | PathfindingInputErrorZeroLengthPath ) @@ -6798,6 +6829,7 @@ class ResponsePathfindingFailed(BaseModel): | PathfindingFailurePathfindingInputError3 | PathfindingFailurePathfindingInputError4 | PathfindingFailurePathfindingInputError5 + | PathfindingFailurePathfindingInputError6 | PathfindingFailurePathfindingNotFound2 | PathfindingFailurePathfindingNotFound3 | PathfindingFailurePathfindingNotFound4 @@ -6890,7 +6922,8 @@ class TrainScheduleSimulationSummaryResult(BaseModel): | SummaryResponsePathfindingInputError2 | SummaryResponsePathfindingInputError3 | SummaryResponsePathfindingInputError4 - | SummaryResponsePathfindingInputError5, + | SummaryResponsePathfindingInputError5 + | SummaryResponsePathfindingInputError6, ] """ The key is the `exception_id` @@ -6906,6 +6939,7 @@ class TrainScheduleSimulationSummaryResult(BaseModel): | SummaryResponsePathfindingInputError3 | SummaryResponsePathfindingInputError4 | SummaryResponsePathfindingInputError5 + | SummaryResponsePathfindingInputError6 )