Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
687f79d
editoast: implement authorizers::require utility function
younesschrifi Jun 23, 2026
de3a501
editoast: adapt get_by_id and get_by_name rolling stock endpoints
younesschrifi Jun 23, 2026
dc737b1
editoast: list rolling_stocks privileges
younesschrifi Jun 16, 2026
a006534
editoast: views: split a few tests in smaller ones
Sh099078 Jun 24, 2026
59c066e
editoast: add check privilege to the update rolling stock endpoint
younesschrifi Jun 28, 2026
77da91c
editoast: add check privilege to delete rolling stock endpoint
younesschrifi Jun 28, 2026
949832f
editoast: add privileges check for patch /rolling_stock/{id}/locked e…
younesschrifi Jun 25, 2026
48fdcb0
editoast: add authorization to POST:/rolling_stock/{id}/livery
Sh099078 Jun 25, 2026
6ac353a
editoast: add authorization to `GET:rolling_stock/{id}/usage`
Sh099078 Jun 24, 2026
9273b66
editoast: check consists permission in POST/timetable/id/stdcm endpoint
Sh099078 Jun 29, 2026
5bf16a7
editoast: use v2 infra_privileges
younesschrifi Jul 16, 2026
8009226
editoast: add rolling_stock check for simulation endpoint
younesschrifi Jul 16, 2026
3de0a8a
fixup simulation authz
Sh099078 Jul 26, 2026
63cdde1
editoast: add check rolling stock for simulation_summary endpoint
younesschrifi Jul 23, 2026
cd297f7
editoast: give to POST/rolling_stock issuer the rolling stock ownership
Sh099078 Jul 26, 2026
33e4de7
editoast: authorize the rolling stock in GET/train_schedule/{id}/path
Sh099078 Aug 5, 2026
d4876a7
editoast: add authorization to /train_schedule/{id}/etcs_braking_curves
Sh099078 Aug 6, 2026
b7e11f8
editoast: only authorize admins in GET/timetable/{id}/requirements
Sh099078 Aug 6, 2026
30dbc05
editoast: add authorisation check on project_path_op
younesschrifi Aug 5, 2026
94a8f11
editoast: add privilege check for track_occupancy endpoint
younesschrifi Aug 5, 2026
9275bab
editoast: add authorisation checks to level crossing occupancy endpoint
younesschrifi Aug 4, 2026
a3af82a
editoast: add CanRead check for project path endpoint
younesschrifi Aug 6, 2026
d0063aa
editoast: filter out unauthorized trains in GET/timetable/id/conflicts
Sh099078 Aug 6, 2026
be798e2
editoast: filter forbidden trains in /train_schedule/occupancy_blocks
Sh099078 Aug 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion editoast/authz/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ pub enum InfraGrant {
Eq,
Hash,
)]
#[cfg_attr(test, derive(PartialOrd, Ord))]
#[fga(name = "rolling_stock")]
pub struct RollingStock(pub i64);

Expand Down Expand Up @@ -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")]
Expand Down
6 changes: 6 additions & 0 deletions editoast/authz/src/v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,12 @@ impl<T> Protected<T> {
}
}

impl Protected<()> {
pub fn check(check: Check) -> Self {
Protected::<()>::default().with_check_iter([check])
}
}

impl<T: Send + 'static> Protected<T> {
/// A [Protected] value that always succeeds with the provided value
pub fn value(t: T) -> Self {
Expand Down
138 changes: 137 additions & 1 deletion editoast/authz/src/v2/rolling_stock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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<ResourcesList<RollingStock>> {
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;
Expand Down Expand Up @@ -781,7 +840,6 @@ mod tests {
.execute()
.await
.unwrap();

assert_eq!(
openfga
.rolling_stock_direct_grant(Subject::user(1), RollingStock(1))
Expand Down Expand Up @@ -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,
Expand All @@ -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<Check>,
#[case] expected_checks: &[Check],
Expand Down
19 changes: 19 additions & 0 deletions editoast/authz/src/v2/test_client_ext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,11 @@ pub trait TestClientExt {
async fn project_privileges(&self, user: User, project: Project) -> HashSet<ProjectPrivilege>;
async fn project_list(&self, user: User) -> ResourcesList<Project>;
async fn project_granted_subjects(&self, project: Project) -> Vec<Subject>;
async fn rolling_stock_list(
&self,
user: User,
privilege: RollingStockPrivilege,
) -> ResourcesList<RollingStock>;
}

impl TestClientExt for fga::Client {
Expand Down Expand Up @@ -360,4 +365,18 @@ impl TestClientExt for fga::Client {
.await
.unwrap()
}

async fn rolling_stock_list(
&self,
user: User,
privilege: RollingStockPrivilege,
) -> ResourcesList<RollingStock> {
let authorize = special_authorizers::Authorize(self);
authorize
.access_value(crate::v2::rolling_stock::rolling_stock_list(
user, privilege,
))
.await
.unwrap()
}
}
3 changes: 3 additions & 0 deletions editoast/core_client/src/pathfinding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,9 @@ pub enum PathfindingInputError {
items: Vec<InvalidPathItem>,
},
NotEnoughPathItems,
UnauthorizedRollingStock {
rolling_stock_id: i64,
},
RollingStockNotFound {
rolling_stock_name: String,
},
Expand Down
13 changes: 13 additions & 0 deletions editoast/openapi.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

66 changes: 66 additions & 0 deletions editoast/src/authorizers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 _;
Expand Down Expand Up @@ -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<I, U>(
authorizer: &U,
protected: Protected<I>,
required: &<I as IntoIterator>::Item,
) -> Result<(), AuthorizationError>
where
I: IntoIterator,
<I as IntoIterator>::Item: PartialEq,
U: Authorizer<Error = Error>,
{
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<Item = String>,
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::<std::collections::HashSet<_>>();
let rolling_stocks: Vec<editoast_models::RollingStock> =
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;
Expand Down
Loading