Skip to content

Commit bf067ac

Browse files
committed
editoast: add authorisation check for rolling stock
Signed-off-by: Youness CHRIFI ALAOUI <youness.chrifi@gmail.com>
1 parent b8ff39c commit bf067ac

4 files changed

Lines changed: 225 additions & 7 deletions

File tree

editoast/src/authentication.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,4 +124,15 @@ impl State {
124124
Mode::Skip { .. } => Ok(State::Skip { user, roles }),
125125
}
126126
}
127+
128+
/// The authenticated user if authorization is **not** skipped
129+
///
130+
/// If this function returns a user, then it is subject to permissions,
131+
/// otherwise they likely can be bypassed.
132+
pub fn regular_user(&self) -> Option<authz::User> {
133+
match self {
134+
State::Authenticated { user, .. } => Some(*user),
135+
State::Skip { .. } => None,
136+
}
137+
}
127138
}

editoast/src/authorizers.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
use std::convert::Infallible;
22

3+
use crate::views::AuthorizationError;
4+
use crate::views::AuthorizerError;
35
use authz::v2::Access;
46
use authz::v2::Actor;
57
use authz::v2::Authorizer;
@@ -209,6 +211,38 @@ macro_rules! impossible {
209211
}
210212
pub(crate) use impossible;
211213

214+
/// Ensures the issuer holds a privilege satisfying `required`.
215+
/// `protected` is an operation yielding the set of privileges the issuer holds on a resource.
216+
/// Access is granted when the operation is authorized and the issuer holds a privilege equal to `required`.
217+
pub async fn require<I, U>(
218+
authorizer: &U,
219+
protected: Protected<I>,
220+
required: &<I as IntoIterator>::Item,
221+
) -> Result<(), AuthorizationError>
222+
where
223+
I: IntoIterator,
224+
<I as IntoIterator>::Item: PartialEq,
225+
U: Authorizer<Error = Error>,
226+
{
227+
let access = authorizer.authorize(protected).await.map_err(|e| match e {
228+
Error::Database(error) => {
229+
AuthorizationError::AuthError(AuthorizerError::Storage(error.into()))
230+
}
231+
Error::OpenFga(error) => error.into(),
232+
})?;
233+
let Ok(privileges) = access.access().await? else {
234+
return Err(AuthorizationError::Forbidden);
235+
};
236+
if privileges
237+
.into_iter()
238+
.any(|privilege| privilege == *required)
239+
{
240+
Ok(())
241+
} else {
242+
Err(AuthorizationError::Forbidden)
243+
}
244+
}
245+
212246
#[cfg(test)]
213247
mod tests {
214248
use authz::InfraGrant;

editoast/src/views/rolling_stock.rs

Lines changed: 86 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
pub(in crate::views) mod light;
22
pub(in crate::views) mod towed;
33

4+
use authz::RollingStockPrivilege;
5+
use authz::v2::rolling_stock_privileges;
6+
use axum::Extension;
47
use schemas::RollingStock as RollingStockForm;
58

69
use std::io::Cursor;
@@ -36,6 +39,7 @@ use thiserror::Error;
3639
use utoipa::IntoParams;
3740
use utoipa::ToSchema;
3841

42+
use crate::AppState;
3943
use crate::error::InternalError;
4044
use crate::error::Result;
4145

@@ -180,9 +184,22 @@ pub struct RollingStockNameParam {
180184
)
181185
)]
182186
pub(in crate::views) async fn get(
183-
State(db_pool): State<Arc<DbConnectionPoolV2>>,
187+
State(AppState {
188+
regulator, db_pool, ..
189+
}): State<AppState>,
190+
Extension(authn_state): Extension<crate::authentication::State>,
184191
Path(rolling_stock_id): Path<i64>,
185192
) -> Result<Json<RollingStockWithLiveries>> {
193+
if let Some(user) = authn_state.regular_user() {
194+
let authorizer = authn_state.authorizer(regulator.openfga(), db_pool.get().await?);
195+
crate::authorizers::require(
196+
&authorizer,
197+
rolling_stock_privileges(user, authz::RollingStock(rolling_stock_id)),
198+
&RollingStockPrivilege::CanRead,
199+
)
200+
.await?;
201+
}
202+
186203
let rolling_stock = retrieve_existing_rolling_stock(
187204
&mut db_pool.get().await?,
188205
RollingStockKey::Id(rolling_stock_id),
@@ -204,14 +221,28 @@ pub(in crate::views) async fn get(
204221
)
205222
)]
206223
pub(in crate::views) async fn get_by_name(
207-
State(db_pool): State<Arc<DbConnectionPoolV2>>,
224+
State(AppState {
225+
regulator, db_pool, ..
226+
}): State<AppState>,
227+
Extension(authn_state): Extension<crate::authentication::State>,
208228
Path(rolling_stock_name): Path<String>,
209229
) -> Result<Json<RollingStockWithLiveries>> {
210230
let rolling_stock = retrieve_existing_rolling_stock(
211231
&mut db_pool.get().await?,
212232
RollingStockKey::Name(rolling_stock_name),
213233
)
214234
.await?;
235+
236+
if let Some(user) = authn_state.regular_user() {
237+
let authorizer = authn_state.authorizer(regulator.openfga(), db_pool.get().await?);
238+
crate::authorizers::require(
239+
&authorizer,
240+
rolling_stock_privileges(user, authz::RollingStock(rolling_stock.id)),
241+
&RollingStockPrivilege::CanRead,
242+
)
243+
.await?;
244+
}
245+
215246
let rolling_stock_with_liveries =
216247
RollingStockWithLiveries::try_fetch(&mut db_pool.get().await?, rolling_stock).await?;
217248
Ok(Json(rolling_stock_with_liveries))
@@ -689,6 +720,7 @@ pub mod tests {
689720
use crate::fixtures::simple_paced_train_changeset;
690721
use crate::views::test_app;
691722
use crate::views::test_app::TestApp;
723+
use crate::views::test_app::TestRequestExt;
692724
use editoast_models::rolling_stock::RollingStock;
693725

694726
impl TestApp {
@@ -1012,15 +1044,24 @@ pub mod tests {
10121044
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
10131045
async fn get_rolling_stock_by_id() {
10141046
// GIVEN
1015-
let app = test_app!().skip_authz().build();
1047+
let app = test_app!().build();
10161048
let db_pool = app.db_pool();
10171049

10181050
let rs_name = "fast_rolling_stock_name";
10191051
let fast_rolling_stock = create_fast_rolling_stock(&mut db_pool.get_ok(), rs_name).await;
10201052

1053+
// a user with the role to reach the endpoint and a read grant on the rolling stock
1054+
let user = app
1055+
.user("authorized", "Authorized")
1056+
.with_roles([Role::OperationalStudies])
1057+
.with_rolling_stock_grant(fast_rolling_stock.id, authz::RollingStockGrant::Reader)
1058+
.create()
1059+
.await;
1060+
10211061
// WHEN
10221062
let raw_response = app
10231063
.rolling_stock_get_by_id_request(fast_rolling_stock.id)
1064+
.by_user(&user.info)
10241065
.await;
10251066

10261067
// THEN
@@ -1029,6 +1070,48 @@ pub mod tests {
10291070
assert_eq!(response, fast_rolling_stock);
10301071
}
10311072

1073+
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
1074+
async fn get_rolling_stock_by_id_with_privilege_and_no_roles() {
1075+
let app = test_app!().build();
1076+
let db_pool = app.db_pool();
1077+
1078+
let fast_rolling_stock =
1079+
create_fast_rolling_stock(&mut db_pool.get_ok(), "fast_rolling_stock_name").await;
1080+
1081+
// a user that does not have the role to reach the endpoint but has a read grant on the rolling stock
1082+
let user = app
1083+
.user("unauthorized", "Unauthorized")
1084+
.with_rolling_stock_grant(fast_rolling_stock.id, authz::RollingStockGrant::Reader)
1085+
.create()
1086+
.await;
1087+
1088+
app.rolling_stock_get_by_id_request(fast_rolling_stock.id)
1089+
.by_user(&user.info)
1090+
.await
1091+
.assert_status_forbidden();
1092+
}
1093+
1094+
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
1095+
async fn get_rolling_stock_by_id_without_permission() {
1096+
let app = test_app!().build();
1097+
let db_pool = app.db_pool();
1098+
1099+
let fast_rolling_stock =
1100+
create_fast_rolling_stock(&mut db_pool.get_ok(), "fast_rolling_stock_name").await;
1101+
1102+
// a user that has the role to reach the endpoint but no read grant on the rolling stock
1103+
let user = app
1104+
.user("unauthorized", "Unauthorized")
1105+
.with_roles([Role::OperationalStudies])
1106+
.create()
1107+
.await;
1108+
1109+
app.rolling_stock_get_by_id_request(fast_rolling_stock.id)
1110+
.by_user(&user.info)
1111+
.await
1112+
.assert_status_forbidden();
1113+
}
1114+
10321115
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
10331116
async fn get_rolling_stock_by_name() {
10341117
// GIVEN

editoast/src/views/rolling_stock/light.rs

Lines changed: 94 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
use authz::RollingStockPrivilege;
2+
use authz::v2::rolling_stock_privileges;
3+
use axum::Extension;
14
use axum::extract::Json;
25
use axum::extract::Path;
36
use axum::extract::Query;
@@ -28,6 +31,7 @@ use super::RollingStockError;
2831
use super::RollingStockIdParam;
2932
use super::RollingStockKey;
3033
use super::RollingStockNameParam;
34+
use crate::AppState;
3135
use crate::error::Result;
3236
use crate::views::pagination::PaginatedList;
3337
use crate::views::pagination::PaginationQueryParams;
@@ -118,9 +122,22 @@ pub(in crate::views) async fn list(
118122
)
119123
)]
120124
pub(in crate::views) async fn get(
121-
State(db_pool): State<Arc<DbConnectionPoolV2>>,
125+
State(AppState {
126+
regulator, db_pool, ..
127+
}): State<AppState>,
128+
Extension(authn_state): Extension<crate::authentication::State>,
122129
Path(light_rolling_stock_id): Path<i64>,
123130
) -> Result<Json<LightRollingStockWithLiveries>> {
131+
if let Some(user) = authn_state.regular_user() {
132+
let authorizer = authn_state.authorizer(regulator.openfga(), db_pool.get().await?);
133+
crate::authorizers::require(
134+
&authorizer,
135+
rolling_stock_privileges(user, authz::RollingStock(light_rolling_stock_id)),
136+
&RollingStockPrivilege::CanRead,
137+
)
138+
.await?;
139+
}
140+
124141
let rolling_stock =
125142
RollingStock::retrieve_or_fail(db_pool.get().await?, light_rolling_stock_id, || {
126143
RollingStockError::KeyNotFound {
@@ -144,7 +161,10 @@ pub(in crate::views) async fn get(
144161
)
145162
)]
146163
pub(in crate::views) async fn get_by_name(
147-
State(db_pool): State<Arc<DbConnectionPoolV2>>,
164+
State(AppState {
165+
regulator, db_pool, ..
166+
}): State<AppState>,
167+
Extension(authn_state): Extension<crate::authentication::State>,
148168
Path(light_rolling_stock_name): Path<String>,
149169
) -> Result<Json<LightRollingStockWithLiveries>> {
150170
let rolling_stock = RollingStock::retrieve_or_fail(
@@ -155,6 +175,17 @@ pub(in crate::views) async fn get_by_name(
155175
},
156176
)
157177
.await?;
178+
179+
if let Some(user) = authn_state.regular_user() {
180+
let authorizer = authn_state.authorizer(regulator.openfga(), db_pool.get().await?);
181+
crate::authorizers::require(
182+
&authorizer,
183+
rolling_stock_privileges(user, authz::RollingStock(rolling_stock.id)),
184+
&RollingStockPrivilege::CanRead,
185+
)
186+
.await?;
187+
}
188+
158189
let light_rolling_stock_with_liveries =
159190
LightRollingStockWithLiveries::try_fetch(&mut db_pool.get().await?, rolling_stock).await?;
160191
Ok(Json(light_rolling_stock_with_liveries))
@@ -281,6 +312,7 @@ mod tests {
281312
use crate::error::InternalError;
282313
use crate::fixtures::create_fast_rolling_stock;
283314
use crate::views::test_app;
315+
use crate::views::test_app::TestRequestExt;
284316

285317
fn is_sorted(data: &[i64]) -> bool {
286318
for elem in data.windows(2) {
@@ -300,15 +332,23 @@ mod tests {
300332
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
301333
async fn get_light_rolling_stock() {
302334
// GIVEN
303-
let app = test_app!().skip_authz().build();
335+
let app = test_app!().build();
304336
let db_pool = app.db_pool();
305337

306338
let rs_name = "fast_rolling_stock_name";
307339
let fast_rolling_stock = create_fast_rolling_stock(&mut db_pool.get_ok(), rs_name).await;
308340

341+
// a user with a read grant on the rolling stock
342+
let user = app
343+
.user("authorized", "Authorized")
344+
.with_rolling_stock_grant(fast_rolling_stock.id, authz::RollingStockGrant::Reader)
345+
.create()
346+
.await;
347+
309348
// WHEN
310349
let response: LightRollingStockWithLiveries = app
311350
.get(format!("/light_rolling_stock/{}", fast_rolling_stock.id).as_str())
351+
.by_user(&user.info)
312352
.await
313353
.assert_status_ok()
314354
.json();
@@ -320,15 +360,23 @@ mod tests {
320360
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
321361
async fn get_light_rolling_stock_by_name() {
322362
// GIVEN
323-
let app = test_app!().skip_authz().build();
363+
let app = test_app!().build();
324364
let db_pool = app.db_pool();
325365

326366
let rs_name = "fast_rolling_stock_name";
327367
let fast_rolling_stock = create_fast_rolling_stock(&mut db_pool.get_ok(), rs_name).await;
328368

369+
// a user with a read grant on the rolling stock
370+
let user = app
371+
.user("authorized", "Authorized")
372+
.with_rolling_stock_grant(fast_rolling_stock.id, authz::RollingStockGrant::Reader)
373+
.create()
374+
.await;
375+
329376
// WHEN
330377
let response: LightRollingStockWithLiveries = app
331378
.get(format!("/light_rolling_stock/name/{rs_name}").as_str())
379+
.by_user(&user.info)
332380
.await
333381
.assert_status_ok()
334382
.json();
@@ -347,6 +395,48 @@ mod tests {
347395
.assert_status_not_found();
348396
}
349397

398+
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
399+
async fn get_light_rolling_stock_without_permission() {
400+
let app = test_app!().build();
401+
let db_pool = app.db_pool();
402+
403+
let fast_rolling_stock =
404+
create_fast_rolling_stock(&mut db_pool.get_ok(), "fast_rolling_stock_name").await;
405+
406+
// a user that has the role to reach the endpoint but no read grant on the rolling stock
407+
let user = app
408+
.user("unauthorized", "Unauthorized")
409+
.with_roles([authz::Role::OperationalStudies])
410+
.create()
411+
.await;
412+
413+
app.get(format!("/light_rolling_stock/{}", fast_rolling_stock.id).as_str())
414+
.by_user(&user.info)
415+
.await
416+
.assert_status_forbidden();
417+
}
418+
419+
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
420+
async fn get_light_rolling_stock_by_name_without_permission() {
421+
let app = test_app!().build();
422+
let db_pool = app.db_pool();
423+
424+
let rs_name = "fast_rolling_stock_name";
425+
create_fast_rolling_stock(&mut db_pool.get_ok(), rs_name).await;
426+
427+
// a user that has the role to reach the endpoint but no read grant on the rolling stock
428+
let user = app
429+
.user("unauthorized", "Unauthorized")
430+
.with_roles([authz::Role::OperationalStudies])
431+
.create()
432+
.await;
433+
434+
app.get(format!("/light_rolling_stock/name/{rs_name}").as_str())
435+
.by_user(&user.info)
436+
.await
437+
.assert_status_forbidden();
438+
}
439+
350440
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
351441
async fn list_light_rolling_stock_increasing_ids() {
352442
let app = test_app!().skip_authz().build();

0 commit comments

Comments
 (0)