diff --git a/editoast/authz/src/regulator.rs b/editoast/authz/src/regulator.rs index a752f956205..6a8a5069035 100644 --- a/editoast/authz/src/regulator.rs +++ b/editoast/authz/src/regulator.rs @@ -131,25 +131,6 @@ impl Regulator { Ok(groups.users.into_iter().collect()) } - /// Returns the IDs of the users which are members of the provided group - #[tracing::instrument(skip_all, fields(group), ret(level = Level::DEBUG), err)] - pub async fn group_members(&self, group: &Group) -> Result, Error> { - if !self.group_exists(group.0).await? { - return Err(Error::UnknownSubject(group.0)); - } - let members = self - .openfga - .list_users(Group::member().query_users(group)) - .await - .map_err(QueryError::parsing_ok)?; - - debug_assert!( - members.public_access.is_none(), - "we don't write public accesses for groups" - ); - Ok(members.users.into_iter().collect()) - } - #[tracing::instrument(skip(self), ret(level = Level::DEBUG), err)] pub async fn user_roles(&self, user: &User) -> Result, Error> { // no need to check for user inexistence, an empty set will be returned in this case diff --git a/editoast/authz/src/v2.rs b/editoast/authz/src/v2.rs index 9676177ed08..5c5de9b6ee9 100644 --- a/editoast/authz/src/v2.rs +++ b/editoast/authz/src/v2.rs @@ -1,15 +1,20 @@ +mod group; +mod infra; +mod roles; +mod test_client_ext; + +pub use group::*; +pub use infra::*; +pub use roles::*; +pub use test_client_ext::TestClientExt; + use std::collections::HashSet; -use fga::client::QueryError; -use fga::client::UserList; -use fga::model::Relation as _; use futures::FutureExt; use futures::future::BoxFuture; -use itertools::Itertools; use crate::Group; use crate::Infra; -use crate::InfraGrant; use crate::InfraPrivilege; use crate::Role; use crate::Subject; @@ -105,8 +110,8 @@ pub trait Authorizer { /// do so concurrently as well. You can use [Access::access_all] for this. /// /// Also note that you'll have to then deal with each potential rejection - /// individually. If they're all the same, the upcoming transformation from - /// `Vec>` to `Protected>` will serve this purpose. Stay tuned! + /// individually. If they're all the same to you, consider using + /// [Protected::from_iter] instead. async fn authorize_all<'a, T>( &'a self, data: impl IntoIterator>, @@ -224,6 +229,39 @@ impl Default for Protected { } } +impl FromIterator> for Protected> { + /// Concatenates a bunch of protected operations into one + /// + /// If you need to handle individual rejections, consider using + /// [Authorizer::authorize_all] instead. + fn from_iter>>(iter: I) -> Self { + let mut guardrails = HashSet::new(); + let mut sanity_checks = HashSet::new(); + let mut ops = Vec::new(); + for Protected { + op, + guardrails: gd, + sanity_checks: sc, + } in iter + { + guardrails.extend(gd); + sanity_checks.extend(sc); + ops.push(op); + } + Self { + op: Box::new(move |openfga| { + async move { + let futs = ops.into_iter().map(|op| op(openfga)); + futures::future::try_join_all(futs).await + } + .boxed() + }), + guardrails, + sanity_checks, + } + } +} + impl<'a, T, R> Access<'a, T, R> { /// Awaits the authorized operation future if authorized or yields the rejection if not /// @@ -243,8 +281,8 @@ impl<'a, T, R> Access<'a, T, R> { /// Concurrently awaits all accesses and factorizes OpenFGA errors /// - /// If you don't need to handle individual rejections, stay tuned for the - /// upcoming `Protected>` transformation. + /// If you don't need to handle individual rejections, consider using + /// [Protected::from_iter] beforehand. pub async fn access_all( accesses: impl IntoIterator>, ) -> Result>, OpenFgaError> { @@ -262,322 +300,6 @@ impl<'a, T, R: std::fmt::Debug> Access<'a, T, R> { } } -pub fn group_members(group: Group) -> Protected> { - Protected::new(move |openfga| { - async move { - let UserList { - users, - public_access, - } = openfga - .list_users(Group::member().query_users(&group)) - .await - .map_err(QueryError::parsing_ok)?; - debug_assert!( - public_access.is_none(), - "we don't write public accesses for groups" - ); - Ok(users) - } - .boxed() - }) - .with_check(SanityCheck::group(group)) -} - -// TODO: move somewhere more appropriate -/// Adds some members to a group -/// -/// Idempotent but not atomic due to the lack of transactions in OpenFGA. -pub fn add_members(group: Group, members: HashSet) -> Protected<()> { - let user_exists_checks = members - .iter() - .map(|user| SanityCheck::user(*user)) - .collect_vec(); // members is moved in Protected - - group_members(group) - .map(move |openfga, existing_members| { - async move { - let existing_members = HashSet::from_iter(existing_members); - let new_members = members.difference(&existing_members); - let mut writes = openfga.prepare_writes(); - for user in new_members { - writes.push(&Group::member().tuple(user, &group)); - writes.push(&User::group().tuple(&group, user)); - } - writes.execute().await?; - Ok(()) - } - .boxed() - }) - .with_check_iter(user_exists_checks) - .with_guardrail(Guardrail::IssuerHasRole(Role::Admin)) -} - -pub fn subject_roles(subject: Subject) -> Protected> { - Protected::new(move |openfga| { - async move { - match &subject { - Subject::User(user) => Role::list_roles(openfga, User::role(), user).await, - Subject::Group(group) => Role::list_roles(openfga, Group::role(), group).await, - } - } - .boxed() - }) - .with_check(SanityCheck::SubjectExists(subject)) -} - -// TODO: move somewhere more appropriate -/// Gives the subject the specified roles -/// -/// Idempotent but not atomic due to the lack of transactions in OpenFGA. -pub fn add_roles(subject: Subject, roles: HashSet) -> Protected<()> { - subject_roles(subject) - .map(move |openfga, existing_roles| { - async move { - let existing_roles = HashSet::from_iter(existing_roles); - let new_roles = roles.difference(&existing_roles); - let mut writes = openfga.prepare_writes(); - match subject { - Subject::User(user) => { - for role in new_roles { - writes.push(&User::role().tuple(role, &user)); - } - } - Subject::Group(group) => { - for role in new_roles { - writes.push(&Group::role().tuple(role, &group)); - } - } - } - writes.execute().await?; - Ok(()) - } - .boxed() - }) - .with_guardrail(Guardrail::IssuerHasRole(Role::Admin)) -} - -/// Removes some members from a group -/// -/// Idempotent but not atomic due to the lack of transactions in OpenFGA. -pub fn remove_members(group: Group, members: HashSet) -> Protected<()> { - let user_exists_checks = members - .iter() - .map(|user| SanityCheck::user(*user)) - .collect_vec(); // members is moved in Protected - - group_members(group) - .map(move |openfga, existing_members| { - async move { - let existing_members = HashSet::from_iter(existing_members); - let expelled = members.intersection(&existing_members); - let mut writes = openfga.prepare_deletes(); - for user in expelled { - writes.push(&Group::member().tuple(user, &group)); - writes.push(&User::group().tuple(&group, user)); - } - writes.execute().await?; - Ok(()) - } - .boxed() - }) - .with_check_iter(user_exists_checks) - .with_guardrail(Guardrail::IssuerHasRole(Role::Admin)) -} - -// TODO: move somewhere more appropriate -/// Removes the specified roles from the subject -/// -/// Idempotent but not atomic due to the lack of transactions in OpenFGA. -pub fn remove_roles(subject: Subject, roles: HashSet) -> Protected<()> { - subject_roles(subject) - .map(move |openfga, existing_roles| { - async move { - let existing_roles = HashSet::from_iter(existing_roles); - let removed_roles = roles.intersection(&existing_roles); - let mut writes = openfga.prepare_deletes(); - match subject { - Subject::User(user) => { - for role in removed_roles { - writes.push(&User::role().tuple(role, &user)); - } - } - Subject::Group(group) => { - for role in removed_roles { - writes.push(&Group::role().tuple(role, &group)); - } - } - } - writes.execute().await?; - Ok(()) - } - .boxed() - }) - .with_guardrail(Guardrail::IssuerHasRole(Role::Admin)) -} - -/// Returns the *direct grant* a subject has on an [Infra], if any -/// -/// A user can have *indirect grants* on a resource through group membership. -/// For those, use [`infra_effective_grant`]. -/// -/// A subject can have at most one direct grant on any resource. Should this -/// invariant be violated, the protected operation will panic. -pub fn infra_direct_grant(subject: Subject, infra: Infra) -> Protected> { - Protected::new(move |openfga| { - async move { - let (is_reader, is_writer, is_owner) = match &subject { - Subject::User(user) => tokio::try_join!( - openfga - .tuple_exists(Infra::reader().tuple(user, &infra)), - openfga - .tuple_exists(Infra::writer().tuple(user, &infra)), - openfga.tuple_exists(Infra::owner().tuple(user, &infra)), - )?, - Subject::Group(group) => tokio::try_join!( - openfga - .tuple_exists(Infra::reader().tuple(Group::member().userset(group), &infra)), - openfga - .tuple_exists(Infra::writer().tuple(Group::member().userset(group), &infra)), - openfga - .tuple_exists(Infra::owner().tuple(Group::member().userset(group), &infra)), - )?, - }; - - match (is_reader, is_writer, is_owner) { - (true, false, false) => Ok(Some(InfraGrant::Reader)), - (false, true, false) => Ok(Some(InfraGrant::Writer)), - (false, false, true) => Ok(Some(InfraGrant::Owner)), - (false, false, false) => Ok(None), - _ => { - tracing::error!( - is_reader, - is_writer, - is_owner, - ?subject, - resource = ?infra, - "Subject has multiple direct grants on the same resource" - ); - panic!( - "Subject '{subject:?}' has multiple direct grants on the same resource '{infra:?}', which is not supposed to happen by design. \n\ - Detected direct grants: reader: {is_reader}, writer: {is_writer}, owner: {is_owner}" - ) - } - } - } - .boxed() - }) - .with_check(SanityCheck::SubjectExists(subject)) - .with_check(SanityCheck::InfraExists(infra)) -} - -/// Returns the effective (maximum) grant a subject has on an [Infra], if any -/// -/// A given user may have multiple grants on the same resource. This can happen -/// if a user inherits a grant from one of its groups and also has a direct grant. -/// Inherited grants are not the same thing as privileges: they do not have the same semantic, -/// are not represented by the same enum, do no work on the same scale nor in the same way. -/// -/// For direct grants, see [`infra_direct_grant`]. -/// -/// Groups only have direct grants. If multiple direct grants are found, this protected operation will panic. -pub fn infra_effective_grant(subject: Subject, infra: Infra) -> Protected> { - Protected::new(move |openfga| { - async move { - let (is_reader, is_writer, is_owner) = match &subject { - Subject::User(user) => { - openfga - .checks(( - Infra::reader().check(user, &infra), - Infra::writer().check(user, &infra), - Infra::owner().check(user, &infra), - )) - .await? - } - Subject::Group(group) => { - let (is_reader, is_writer, is_owner) = openfga - .checks(( - Infra::reader().check(Group::member().userset(group), &infra), - Infra::writer().check(Group::member().userset(group), &infra), - Infra::owner().check(Group::member().userset(group), &infra), - )) - .await?; - if matches!( - (is_reader, is_writer, is_owner), - (true, true, _) | (true, _, true) | (_, true, true) - ) { - tracing::error!( - is_reader, - is_writer, - is_owner, - ?subject, - resource = ?infra, - "Group has multiple direct grants on the same resource" - ); - panic!( - "Group {subject:?} has multiple direct grants on the same resource {infra:?}, which is not supposed to happen by design. \n\ - While a user may have inherited grants from one of their groups, groups do not have inherited grants. \n\ - Detected direct grants: reader: {is_reader}, writer: {is_writer}, owner: {is_owner}" - ); - } - (is_reader, is_writer, is_owner) - } - }; - - Ok(is_owner - .then_some(InfraGrant::Owner) - .or_else(|| is_writer.then_some(InfraGrant::Writer)) - .or_else(|| is_reader.then_some(InfraGrant::Reader))) - } - .boxed() - }) - .with_check(SanityCheck::SubjectExists(subject)) - .with_check(SanityCheck::InfraExists(infra)) - .with_guardrail(Guardrail::IssuerHasInfraPrivilege(InfraPrivilege::CanRead, infra)) -} - -pub fn infra_privileges(user: User, infra: Infra) -> Protected> { - Protected::new(move |openfga| { - async move { - let ( - admin, - can_read, - can_share_read, - can_write, - can_share_write, - can_delete, - can_share_ownership, - ) = openfga - .checks(( - User::role().check(&Role::Admin, &user), - Infra::can_read().check(&user, &infra), - Infra::can_share_read().check(&user, &infra), - Infra::can_write().check(&user, &infra), - Infra::can_share_write().check(&user, &infra), - Infra::can_delete().check(&user, &infra), - Infra::can_share_ownership().check(&user, &infra), - )) - .await?; - let mut privileges = HashSet::new(); - privileges.extend((admin || can_read).then_some(InfraPrivilege::CanRead)); - privileges.extend((admin || can_share_read).then_some(InfraPrivilege::CanShareRead)); - privileges.extend((admin || can_write).then_some(InfraPrivilege::CanWrite)); - privileges.extend((admin || can_share_write).then_some(InfraPrivilege::CanShareWrite)); - privileges.extend((admin || can_delete).then_some(InfraPrivilege::CanDelete)); - privileges.extend( - (admin || can_share_ownership).then_some(InfraPrivilege::CanShareOwnership), - ); - Ok(privileges) - } - .boxed() - }) - .with_check(SanityCheck::InfraExists(infra)) - .with_check(SanityCheck::SubjectExists(Subject::user(user))) - .with_guardrail(Guardrail::IssuerHasInfraPrivilege( - InfraPrivilege::CanRead, - infra, - )) -} - pub mod special_authorizers { use std::convert::Infallible; @@ -626,592 +348,3 @@ pub mod special_authorizers { } } } - -pub trait TestClientExt { - async fn subject_roles(&self, subject: &Subject) -> HashSet; - async fn group_members(&self, group: &Group) -> HashSet; - async fn infra_effective_grant(&self, subject: Subject, infra: Infra) -> Option; - async fn infra_direct_grant( - &self, - subject: impl Into, - infra: Infra, - ) -> Option; - async fn infra_privileges(&self, user: User, infra: Infra) -> HashSet; -} - -impl TestClientExt for fga::Client { - async fn subject_roles(&self, subject: &Subject) -> HashSet { - match subject { - Subject::User(user) => Role::list_roles(self, User::role(), user).await, - Subject::Group(group) => Role::list_roles(self, Group::role(), group).await, - } - .unwrap() - .into_iter() - .collect() - } - - async fn group_members(&self, group: &Group) -> HashSet { - self.list_users(Group::member().query_users(group)) - .await - .unwrap() - .users - .into_iter() - .collect() - } - - async fn infra_effective_grant(&self, subject: Subject, infra: Infra) -> Option { - let authorize = special_authorizers::Authorize(self); - authorize - .access_value(infra_effective_grant(subject, infra)) - .await - .unwrap() - } - - async fn infra_direct_grant( - &self, - subject: impl Into, - infra: Infra, - ) -> Option { - let authorize = special_authorizers::Authorize(self); - authorize - .access_value(infra_direct_grant(subject.into(), infra)) - .await - .unwrap() - } - - async fn infra_privileges(&self, user: User, infra: Infra) -> HashSet { - let authorize = special_authorizers::Authorize(self); - authorize - .access_value(infra_privileges(user, infra)) - .await - .unwrap() - } -} - -#[cfg(test)] -mod tests { - use crate::v2::special_authorizers::Authorize; - - use super::*; - - #[tokio::test] - async fn add_members_idempotent() { - let openfga = crate::authz_client!(); - let authorize = Authorize(&openfga); - - add_members(Group(1), HashSet::from_iter([User(1), User(2)])) - .authorize(&authorize) - .await - .unwrap() - .unwrap_authorized() - .await; - assert_eq!( - openfga.group_members(&Group(1)).await, - HashSet::from_iter([User(1), User(2)]) - ); - - add_members(Group(1), HashSet::from_iter([User(1), User(2)])) - .authorize(&authorize) - .await - .unwrap() - .unwrap_authorized() - .await; - assert_eq!( - openfga.group_members(&Group(1)).await, - HashSet::from_iter([User(1), User(2)]) - ); - } - - #[tokio::test] - async fn add_members_intersecting_calls() { - let openfga = crate::authz_client!(); - let authorize = Authorize(&openfga); - - add_members(Group(1), HashSet::from_iter([User(1), User(2)])) - .authorize(&authorize) - .await - .unwrap() - .unwrap_authorized() - .await; - assert_eq!( - openfga.group_members(&Group(1)).await, - HashSet::from_iter([User(1), User(2)]) - ); - - add_members(Group(1), HashSet::from_iter([User(1), User(3)])) - .authorize(&authorize) - .await - .unwrap() - .unwrap_authorized() - .await; - assert_eq!( - openfga.group_members(&Group(1)).await, - HashSet::from_iter([User(1), User(2), User(3)]) - ); - } - - #[tokio::test] - async fn remove_members_idempotent() { - let openfga = crate::authz_client!(); - let authorize = Authorize(&openfga); - - add_members(Group(1), HashSet::from_iter([User(1), User(2)])) - .authorize(&authorize) - .await - .unwrap() - .unwrap_authorized() - .await; - assert_eq!( - openfga.group_members(&Group(1)).await, - HashSet::from_iter([User(1), User(2)]) - ); - - remove_members(Group(1), HashSet::from_iter([User(1), User(2)])) - .authorize(&authorize) - .await - .unwrap() - .unwrap_authorized() - .await; - assert_eq!( - openfga.group_members(&Group(1)).await, - HashSet::from_iter([]) - ); - - remove_members(Group(1), HashSet::from_iter([User(1), User(2)])) - .authorize(&authorize) - .await - .unwrap() - .unwrap_authorized() - .await; - assert_eq!( - openfga.group_members(&Group(1)).await, - HashSet::from_iter([]) - ); - } - - #[tokio::test] - async fn remove_members_intersecting_calls() { - let openfga = crate::authz_client!(); - let authorize = Authorize(&openfga); - - add_members(Group(1), HashSet::from_iter([User(1), User(2), User(3)])) - .authorize(&authorize) - .await - .unwrap() - .unwrap_authorized() - .await; - assert_eq!( - openfga.group_members(&Group(1)).await, - HashSet::from_iter([User(1), User(2), User(3)]) - ); - - remove_members(Group(1), HashSet::from_iter([User(1), User(2)])) - .authorize(&authorize) - .await - .unwrap() - .unwrap_authorized() - .await; - assert_eq!( - openfga.group_members(&Group(1)).await, - HashSet::from_iter([User(3)]) - ); - - remove_members(Group(1), HashSet::from_iter([User(1), User(3)])) - .authorize(&authorize) - .await - .unwrap() - .unwrap_authorized() - .await; - assert_eq!( - openfga.group_members(&Group(1)).await, - HashSet::from_iter([]) - ); - } - - #[tokio::test] - async fn add_roles_idempotent() { - let openfga = crate::authz_client!(); - let authorize = Authorize(&openfga); - - add_roles( - Subject::user(1), - HashSet::from_iter([Role::Admin, Role::Stdcm]), - ) - .authorize(&authorize) - .await - .unwrap() - .unwrap_authorized() - .await; - assert_eq!( - openfga.subject_roles(&Subject::user(1)).await, - HashSet::from_iter([Role::Admin, Role::Stdcm]) - ); - - add_roles( - Subject::user(1), - HashSet::from_iter([Role::Admin, Role::Stdcm]), - ) - .authorize(&authorize) - .await - .unwrap() - .unwrap_authorized() - .await; - assert_eq!( - openfga.subject_roles(&Subject::user(1)).await, - HashSet::from_iter([Role::Admin, Role::Stdcm]) - ); - } - - #[tokio::test] - async fn add_roles_intersecting_calls() { - let openfga = crate::authz_client!(); - let authorize = Authorize(&openfga); - - add_roles( - Subject::user(1), - HashSet::from_iter([Role::Admin, Role::Stdcm]), - ) - .authorize(&authorize) - .await - .unwrap() - .unwrap_authorized() - .await; - assert_eq!( - openfga.subject_roles(&Subject::user(1)).await, - HashSet::from_iter([Role::Admin, Role::Stdcm]) - ); - - add_roles( - Subject::user(1), - HashSet::from_iter([Role::Admin, Role::OperationalStudies]), - ) - .authorize(&authorize) - .await - .unwrap() - .unwrap_authorized() - .await; - assert_eq!( - openfga.subject_roles(&Subject::user(1)).await, - HashSet::from_iter([Role::Admin, Role::Stdcm, Role::OperationalStudies]) - ); - } - - #[tokio::test] - async fn remove_roles_idempotent() { - let openfga = crate::authz_client!(); - let authorize = Authorize(&openfga); - - add_roles( - Subject::user(1), - HashSet::from_iter([Role::Admin, Role::Stdcm]), - ) - .authorize(&authorize) - .await - .unwrap() - .unwrap_authorized() - .await; - assert_eq!( - openfga.subject_roles(&Subject::user(1)).await, - HashSet::from_iter([Role::Admin, Role::Stdcm]) - ); - - remove_roles( - Subject::user(1), - HashSet::from_iter([Role::Admin, Role::Stdcm]), - ) - .authorize(&authorize) - .await - .unwrap() - .unwrap_authorized() - .await; - assert_eq!( - openfga.subject_roles(&Subject::user(1)).await, - HashSet::from_iter([]) - ); - - remove_roles( - Subject::user(1), - HashSet::from_iter([Role::Admin, Role::Stdcm]), - ) - .authorize(&authorize) - .await - .unwrap() - .unwrap_authorized() - .await; - assert_eq!( - openfga.subject_roles(&Subject::user(1)).await, - HashSet::from_iter([]) - ); - } - - #[tokio::test] - async fn remove_roles_intersecting_calls() { - let openfga = crate::authz_client!(); - let authorize = Authorize(&openfga); - - add_roles( - Subject::user(1), - HashSet::from_iter([Role::Admin, Role::Stdcm, Role::OperationalStudies]), - ) - .authorize(&authorize) - .await - .unwrap() - .unwrap_authorized() - .await; - assert_eq!( - openfga.subject_roles(&Subject::user(1)).await, - HashSet::from_iter([Role::Admin, Role::Stdcm, Role::OperationalStudies]) - ); - - remove_roles( - Subject::user(1), - HashSet::from_iter([Role::Admin, Role::Stdcm]), - ) - .authorize(&authorize) - .await - .unwrap() - .unwrap_authorized() - .await; - assert_eq!( - openfga.subject_roles(&Subject::user(1)).await, - HashSet::from_iter([Role::OperationalStudies]) - ); - - remove_roles( - Subject::user(1), - HashSet::from_iter([Role::Admin, Role::OperationalStudies]), - ) - .authorize(&authorize) - .await - .unwrap() - .unwrap_authorized() - .await; - assert_eq!( - openfga.subject_roles(&Subject::user(1)).await, - HashSet::from_iter([]) - ); - } - - #[tokio::test] - async fn infra_effective_grant_direct_and_inherited() { - let openfga = crate::authz_client!(); - let authorize = Authorize(&openfga); - - openfga - .write_tuples(&[Infra::reader().tuple(&User(1), &Infra(1))]) - .await - .unwrap(); - - let grant = infra_effective_grant(Subject::user(1), Infra(1)) - .authorize(&authorize) - .await - .unwrap() - .unwrap_authorized() - .await; - assert_eq!(grant, Some(InfraGrant::Reader)); - - openfga - .prepare_writes() - .write(&Group::member().tuple(&User(1), &Group(1))) - .write(&Infra::owner().tuple(Group::member().userset(&Group(1)), &Infra(1))) - .execute() - .await - .unwrap(); - - let grant = infra_effective_grant(Subject::user(1), Infra(1)) - .authorize(&authorize) - .await - .unwrap() - .unwrap_authorized() - .await; - assert_eq!(grant, Some(InfraGrant::Owner)); - } - - #[tokio::test] - async fn user_infra_direct_grant() { - let openfga = crate::authz_client!(); - let authorize = Authorize(&openfga); - - let infra_grant = async |user_id: i64| { - infra_direct_grant(Subject::user(user_id), Infra(1)) - .authorize(&authorize) - .await - .unwrap() - .unwrap_authorized() - .await - }; - - assert_eq!(infra_grant(1).await, None); - - openfga - .prepare_writes() - .write(&Infra::reader().tuple(&User(1), &Infra(1))) - .write(&Infra::writer().tuple(&User(2), &Infra(1))) - .write(&Infra::owner().tuple(&User(3), &Infra(1))) - .execute() - .await - .unwrap(); - - assert_eq!(infra_grant(1).await, Some(InfraGrant::Reader)); - assert_eq!(infra_grant(2).await, Some(InfraGrant::Writer)); - assert_eq!(infra_grant(3).await, Some(InfraGrant::Owner)); - } - - #[tokio::test] - async fn group_infra_direct_grant() { - let openfga = crate::authz_client!(); - let authorize = Authorize(&openfga); - - let infra_grant = async |group_id: i64| { - infra_direct_grant(Subject::group(group_id), Infra(1)) - .authorize(&authorize) - .await - .unwrap() - .unwrap_authorized() - .await - }; - - assert_eq!(infra_grant(1).await, None); - - openfga - .prepare_writes() - .write(&Infra::reader().tuple(Group::member().userset(&Group(1)), &Infra(1))) - .write(&Infra::writer().tuple(Group::member().userset(&Group(2)), &Infra(1))) - .write(&Infra::owner().tuple(Group::member().userset(&Group(3)), &Infra(1))) - .execute() - .await - .unwrap(); - - assert_eq!(infra_grant(1).await, Some(InfraGrant::Reader)); - assert_eq!(infra_grant(2).await, Some(InfraGrant::Writer)); - assert_eq!(infra_grant(3).await, Some(InfraGrant::Owner)); - } - - #[tokio::test] - async fn no_inference_infra_direct_grant() { - let openfga = crate::authz_client!(); - let authorize = Authorize(&openfga); - - openfga - .prepare_writes() - .write(&Group::member().tuple(&User(1), &Group(1))) - .write(&Group::member().tuple(&User(2), &Group(2))) - .write(&Group::member().tuple(&User(3), &Group(3))) - .write(&Infra::reader().tuple(&User(1), &Infra(1))) - .write(&Infra::writer().tuple(Group::member().userset(&Group(2)), &Infra(1))) - .write(&Infra::owner().tuple(&User(3), &Infra(1))) - .write(&Infra::reader().tuple(Group::member().userset(&Group(3)), &Infra(1))) - .execute() - .await - .unwrap(); - - let user_direct_grant = async |user_id: i64| { - infra_direct_grant(Subject::user(user_id), Infra(1)) - .authorize(&authorize) - .await - .unwrap() - .unwrap_authorized() - .await - }; - - let group_direct_grant = async |group_id: i64| { - infra_direct_grant(Subject::group(group_id), Infra(1)) - .authorize(&authorize) - .await - .unwrap() - .unwrap_authorized() - .await - }; - - assert_eq!(user_direct_grant(1).await, Some(InfraGrant::Reader)); - assert_eq!(group_direct_grant(1).await, None); - - assert_eq!(user_direct_grant(2).await, None); - assert_eq!(group_direct_grant(2).await, Some(InfraGrant::Writer)); - - assert_eq!(user_direct_grant(3).await, Some(InfraGrant::Owner)); - assert_eq!(group_direct_grant(3).await, Some(InfraGrant::Reader)); - } - - #[tokio::test] - #[should_panic] - async fn infra_direct_grant_inconsistent_state_panics() { - let openfga = crate::authz_client!(); - let authorize = Authorize(&openfga); - - openfga - .prepare_writes() - .write(&Infra::reader().tuple(&User(1), &Infra(1))) - .write(&Infra::writer().tuple(&User(1), &Infra(1))) - .execute() - .await - .unwrap(); - - infra_direct_grant(Subject::user(1), Infra(1)) - .authorize(&authorize) - .await - .unwrap() - .unwrap_authorized() - .await; - } - - #[tokio::test] - async fn infra_privileges_non_admin() { - let openfga = crate::authz_client!(); - - openfga - .write_tuples(&[Infra::writer().tuple(&User(1), &Infra(1))]) - .await - .unwrap(); - - assert_eq!( - openfga.infra_privileges(User(1), Infra(1)).await, - HashSet::from_iter([ - InfraPrivilege::CanRead, - InfraPrivilege::CanShareRead, - InfraPrivilege::CanWrite, - InfraPrivilege::CanShareWrite, - ]) - ); - } - - #[tokio::test] - async fn infra_privileges_admin() { - let openfga = crate::authz_client!(); - - openfga - .prepare_writes() - .write(&User::role().tuple(&Role::Admin, &User(1))) - .write(&Infra::reader().tuple(&User(2), &Infra(1))) - .execute() - .await - .unwrap(); - - assert_eq!( - openfga.infra_privileges(User(1), Infra(1)).await, - HashSet::from_iter([ - InfraPrivilege::CanRead, - InfraPrivilege::CanShareRead, - InfraPrivilege::CanWrite, - InfraPrivilege::CanShareWrite, - InfraPrivilege::CanDelete, - InfraPrivilege::CanShareOwnership, - ]) - ); - } - - #[tokio::test] - async fn no_infra_privileges() { - let openfga = crate::authz_client!(); - - openfga - .write_tuples(&[Infra::reader().tuple(&User(2), &Infra(1))]) - .await - .unwrap(); - - assert_eq!( - openfga.infra_privileges(User(1), Infra(1)).await, - HashSet::new(), - ); - } -} diff --git a/editoast/authz/src/v2/group.rs b/editoast/authz/src/v2/group.rs new file mode 100644 index 00000000000..89b0c60c86a --- /dev/null +++ b/editoast/authz/src/v2/group.rs @@ -0,0 +1,233 @@ +use std::collections::HashSet; + +use fga::client::QueryError; +use fga::client::UserList; +use fga::model::Relation as _; +use futures::FutureExt as _; +use itertools::Itertools as _; + +use crate::Group; +use crate::Role; +use crate::User; +use crate::v2::Guardrail; +use crate::v2::Protected; +use crate::v2::SanityCheck; + +pub fn group_members(group: Group) -> Protected> { + Protected::new(move |openfga| { + async move { + let UserList { + users, + public_access, + } = openfga + .list_users(Group::member().query_users(&group)) + .await + .map_err(QueryError::parsing_ok)?; + debug_assert!( + public_access.is_none(), + "we don't write public accesses for groups" + ); + Ok(users) + } + .boxed() + }) + .with_check(SanityCheck::group(group)) +} + +/// Adds some members to a group +/// +/// Idempotent but not atomic due to the lack of transactions in OpenFGA. +pub fn add_members(group: Group, members: HashSet) -> Protected<()> { + let user_exists_checks = members + .iter() + .map(|user| SanityCheck::user(*user)) + .collect_vec(); // members is moved in Protected + + group_members(group) + .map(move |openfga, existing_members| { + async move { + let existing_members = HashSet::from_iter(existing_members); + let new_members = members.difference(&existing_members); + let mut writes = openfga.prepare_writes(); + for user in new_members { + writes.push(&Group::member().tuple(user, &group)); + writes.push(&User::group().tuple(&group, user)); + } + writes.execute().await?; + Ok(()) + } + .boxed() + }) + .with_check_iter(user_exists_checks) + .with_guardrail(Guardrail::IssuerHasRole(Role::Admin)) +} + +/// Removes some members from a group +/// +/// Idempotent but not atomic due to the lack of transactions in OpenFGA. +pub fn remove_members(group: Group, members: HashSet) -> Protected<()> { + let user_exists_checks = members + .iter() + .map(|user| SanityCheck::user(*user)) + .collect_vec(); // members is moved in Protected + + group_members(group) + .map(move |openfga, existing_members| { + async move { + let existing_members = HashSet::from_iter(existing_members); + let expelled = members.intersection(&existing_members); + let mut writes = openfga.prepare_deletes(); + for user in expelled { + writes.push(&Group::member().tuple(user, &group)); + writes.push(&User::group().tuple(&group, user)); + } + writes.execute().await?; + Ok(()) + } + .boxed() + }) + .with_check_iter(user_exists_checks) + .with_guardrail(Guardrail::IssuerHasRole(Role::Admin)) +} + +#[cfg(test)] +mod tests { + use crate::v2::TestClientExt as _; + use crate::v2::special_authorizers::Authorize; + + use super::*; + + #[tokio::test] + async fn add_members_idempotent() { + let openfga = crate::authz_client!(); + let authorize = Authorize(&openfga); + + add_members(Group(1), HashSet::from_iter([User(1), User(2)])) + .authorize(&authorize) + .await + .unwrap() + .unwrap_authorized() + .await; + assert_eq!( + openfga.group_members(&Group(1)).await, + HashSet::from_iter([User(1), User(2)]) + ); + + add_members(Group(1), HashSet::from_iter([User(1), User(2)])) + .authorize(&authorize) + .await + .unwrap() + .unwrap_authorized() + .await; + assert_eq!( + openfga.group_members(&Group(1)).await, + HashSet::from_iter([User(1), User(2)]) + ); + } + + #[tokio::test] + async fn add_members_intersecting_calls() { + let openfga = crate::authz_client!(); + let authorize = Authorize(&openfga); + + add_members(Group(1), HashSet::from_iter([User(1), User(2)])) + .authorize(&authorize) + .await + .unwrap() + .unwrap_authorized() + .await; + assert_eq!( + openfga.group_members(&Group(1)).await, + HashSet::from_iter([User(1), User(2)]) + ); + + add_members(Group(1), HashSet::from_iter([User(1), User(3)])) + .authorize(&authorize) + .await + .unwrap() + .unwrap_authorized() + .await; + assert_eq!( + openfga.group_members(&Group(1)).await, + HashSet::from_iter([User(1), User(2), User(3)]) + ); + } + + #[tokio::test] + async fn remove_members_idempotent() { + let openfga = crate::authz_client!(); + let authorize = Authorize(&openfga); + + add_members(Group(1), HashSet::from_iter([User(1), User(2)])) + .authorize(&authorize) + .await + .unwrap() + .unwrap_authorized() + .await; + assert_eq!( + openfga.group_members(&Group(1)).await, + HashSet::from_iter([User(1), User(2)]) + ); + + remove_members(Group(1), HashSet::from_iter([User(1), User(2)])) + .authorize(&authorize) + .await + .unwrap() + .unwrap_authorized() + .await; + assert_eq!( + openfga.group_members(&Group(1)).await, + HashSet::from_iter([]) + ); + + remove_members(Group(1), HashSet::from_iter([User(1), User(2)])) + .authorize(&authorize) + .await + .unwrap() + .unwrap_authorized() + .await; + assert_eq!( + openfga.group_members(&Group(1)).await, + HashSet::from_iter([]) + ); + } + + #[tokio::test] + async fn remove_members_intersecting_calls() { + let openfga = crate::authz_client!(); + let authorize = Authorize(&openfga); + + add_members(Group(1), HashSet::from_iter([User(1), User(2), User(3)])) + .authorize(&authorize) + .await + .unwrap() + .unwrap_authorized() + .await; + assert_eq!( + openfga.group_members(&Group(1)).await, + HashSet::from_iter([User(1), User(2), User(3)]) + ); + + remove_members(Group(1), HashSet::from_iter([User(1), User(2)])) + .authorize(&authorize) + .await + .unwrap() + .unwrap_authorized() + .await; + assert_eq!( + openfga.group_members(&Group(1)).await, + HashSet::from_iter([User(3)]) + ); + + remove_members(Group(1), HashSet::from_iter([User(1), User(3)])) + .authorize(&authorize) + .await + .unwrap() + .unwrap_authorized() + .await; + assert_eq!( + openfga.group_members(&Group(1)).await, + HashSet::from_iter([]) + ); + } +} diff --git a/editoast/authz/src/v2/infra.rs b/editoast/authz/src/v2/infra.rs new file mode 100644 index 00000000000..1cf3235f8a3 --- /dev/null +++ b/editoast/authz/src/v2/infra.rs @@ -0,0 +1,409 @@ +use std::collections::HashSet; + +use fga::model::Relation as _; +use futures::FutureExt as _; + +use crate::Group; +use crate::Infra; +use crate::InfraGrant; +use crate::InfraPrivilege; +use crate::Role; +use crate::Subject; +use crate::User; +use crate::v2::Guardrail; +use crate::v2::Protected; +use crate::v2::SanityCheck; + +/// Returns the *direct grant* a subject has on an [Infra], if any +/// +/// A user can have *indirect grants* on a resource through group membership. +/// For those, use [`infra_effective_grant`]. +/// +/// A subject can have at most one direct grant on any resource. Should this +/// invariant be violated, the protected operation will panic. +pub fn infra_direct_grant(subject: Subject, infra: Infra) -> Protected> { + Protected::new(move |openfga| { + async move { + let (is_reader, is_writer, is_owner) = match &subject { + Subject::User(user) => tokio::try_join!( + openfga + .tuple_exists(Infra::reader().tuple(user, &infra)), + openfga + .tuple_exists(Infra::writer().tuple(user, &infra)), + openfga.tuple_exists(Infra::owner().tuple(user, &infra)), + )?, + Subject::Group(group) => tokio::try_join!( + openfga + .tuple_exists(Infra::reader().tuple(Group::member().userset(group), &infra)), + openfga + .tuple_exists(Infra::writer().tuple(Group::member().userset(group), &infra)), + openfga + .tuple_exists(Infra::owner().tuple(Group::member().userset(group), &infra)), + )?, + }; + + match (is_reader, is_writer, is_owner) { + (true, false, false) => Ok(Some(InfraGrant::Reader)), + (false, true, false) => Ok(Some(InfraGrant::Writer)), + (false, false, true) => Ok(Some(InfraGrant::Owner)), + (false, false, false) => Ok(None), + _ => { + tracing::error!( + is_reader, + is_writer, + is_owner, + ?subject, + resource = ?infra, + "Subject has multiple direct grants on the same resource" + ); + panic!( + "Subject '{subject:?}' has multiple direct grants on the same resource '{infra:?}', which is not supposed to happen by design. \n\ + Detected direct grants: reader: {is_reader}, writer: {is_writer}, owner: {is_owner}" + ) + } + } + } + .boxed() + }) + .with_check(SanityCheck::SubjectExists(subject)) + .with_check(SanityCheck::InfraExists(infra)) +} + +/// Returns the effective (maximum) grant a subject has on an [Infra], if any +/// +/// A given user may have multiple grants on the same resource. This can happen +/// if a user inherits a grant from one of its groups and also has a direct grant. +/// Inherited grants are not the same thing as privileges: they do not have the same semantic, +/// are not represented by the same enum, do no work on the same scale nor in the same way. +/// +/// For direct grants, see [`infra_direct_grant`]. +/// +/// Groups only have direct grants. If multiple direct grants are found, this protected operation will panic. +pub fn infra_effective_grant(subject: Subject, infra: Infra) -> Protected> { + Protected::new(move |openfga| { + async move { + let (is_reader, is_writer, is_owner) = match &subject { + Subject::User(user) => { + openfga + .checks(( + Infra::reader().check(user, &infra), + Infra::writer().check(user, &infra), + Infra::owner().check(user, &infra), + )) + .await? + } + Subject::Group(group) => { + let (is_reader, is_writer, is_owner) = openfga + .checks(( + Infra::reader().check(Group::member().userset(group), &infra), + Infra::writer().check(Group::member().userset(group), &infra), + Infra::owner().check(Group::member().userset(group), &infra), + )) + .await?; + if matches!( + (is_reader, is_writer, is_owner), + (true, true, _) | (true, _, true) | (_, true, true) + ) { + tracing::error!( + is_reader, + is_writer, + is_owner, + ?subject, + resource = ?infra, + "Group has multiple direct grants on the same resource" + ); + panic!( + "Group {subject:?} has multiple direct grants on the same resource {infra:?}, which is not supposed to happen by design. \n\ + While a user may have inherited grants from one of their groups, groups do not have inherited grants. \n\ + Detected direct grants: reader: {is_reader}, writer: {is_writer}, owner: {is_owner}" + ); + } + (is_reader, is_writer, is_owner) + } + }; + + Ok(is_owner + .then_some(InfraGrant::Owner) + .or_else(|| is_writer.then_some(InfraGrant::Writer)) + .or_else(|| is_reader.then_some(InfraGrant::Reader))) + } + .boxed() + }) + .with_check(SanityCheck::SubjectExists(subject)) + .with_check(SanityCheck::InfraExists(infra)) + .with_guardrail(Guardrail::IssuerHasInfraPrivilege(InfraPrivilege::CanRead, infra)) +} + +pub fn infra_privileges(user: User, infra: Infra) -> Protected> { + Protected::new(move |openfga| { + async move { + let ( + admin, + can_read, + can_share_read, + can_write, + can_share_write, + can_delete, + can_share_ownership, + ) = openfga + .checks(( + User::role().check(&Role::Admin, &user), + Infra::can_read().check(&user, &infra), + Infra::can_share_read().check(&user, &infra), + Infra::can_write().check(&user, &infra), + Infra::can_share_write().check(&user, &infra), + Infra::can_delete().check(&user, &infra), + Infra::can_share_ownership().check(&user, &infra), + )) + .await?; + let mut privileges = HashSet::new(); + privileges.extend((admin || can_read).then_some(InfraPrivilege::CanRead)); + privileges.extend((admin || can_share_read).then_some(InfraPrivilege::CanShareRead)); + privileges.extend((admin || can_write).then_some(InfraPrivilege::CanWrite)); + privileges.extend((admin || can_share_write).then_some(InfraPrivilege::CanShareWrite)); + privileges.extend((admin || can_delete).then_some(InfraPrivilege::CanDelete)); + privileges.extend( + (admin || can_share_ownership).then_some(InfraPrivilege::CanShareOwnership), + ); + Ok(privileges) + } + .boxed() + }) + .with_check(SanityCheck::InfraExists(infra)) + .with_check(SanityCheck::SubjectExists(Subject::user(user))) + .with_guardrail(Guardrail::IssuerHasInfraPrivilege( + InfraPrivilege::CanRead, + infra, + )) +} + +#[cfg(test)] +mod tests { + use crate::v2::TestClientExt as _; + use crate::v2::special_authorizers::Authorize; + + use super::*; + + #[tokio::test] + async fn infra_effective_grant_direct_and_inherited() { + let openfga = crate::authz_client!(); + let authorize = Authorize(&openfga); + + openfga + .write_tuples(&[Infra::reader().tuple(&User(1), &Infra(1))]) + .await + .unwrap(); + + let grant = infra_effective_grant(Subject::user(1), Infra(1)) + .authorize(&authorize) + .await + .unwrap() + .unwrap_authorized() + .await; + assert_eq!(grant, Some(InfraGrant::Reader)); + + openfga + .prepare_writes() + .write(&Group::member().tuple(&User(1), &Group(1))) + .write(&Infra::owner().tuple(Group::member().userset(&Group(1)), &Infra(1))) + .execute() + .await + .unwrap(); + + let grant = infra_effective_grant(Subject::user(1), Infra(1)) + .authorize(&authorize) + .await + .unwrap() + .unwrap_authorized() + .await; + assert_eq!(grant, Some(InfraGrant::Owner)); + } + + #[tokio::test] + async fn user_infra_direct_grant() { + let openfga = crate::authz_client!(); + let authorize = Authorize(&openfga); + + let infra_grant = async |user_id: i64| { + infra_direct_grant(Subject::user(user_id), Infra(1)) + .authorize(&authorize) + .await + .unwrap() + .unwrap_authorized() + .await + }; + + assert_eq!(infra_grant(1).await, None); + + openfga + .prepare_writes() + .write(&Infra::reader().tuple(&User(1), &Infra(1))) + .write(&Infra::writer().tuple(&User(2), &Infra(1))) + .write(&Infra::owner().tuple(&User(3), &Infra(1))) + .execute() + .await + .unwrap(); + + assert_eq!(infra_grant(1).await, Some(InfraGrant::Reader)); + assert_eq!(infra_grant(2).await, Some(InfraGrant::Writer)); + assert_eq!(infra_grant(3).await, Some(InfraGrant::Owner)); + } + + #[tokio::test] + async fn group_infra_direct_grant() { + let openfga = crate::authz_client!(); + let authorize = Authorize(&openfga); + + let infra_grant = async |group_id: i64| { + infra_direct_grant(Subject::group(group_id), Infra(1)) + .authorize(&authorize) + .await + .unwrap() + .unwrap_authorized() + .await + }; + + assert_eq!(infra_grant(1).await, None); + + openfga + .prepare_writes() + .write(&Infra::reader().tuple(Group::member().userset(&Group(1)), &Infra(1))) + .write(&Infra::writer().tuple(Group::member().userset(&Group(2)), &Infra(1))) + .write(&Infra::owner().tuple(Group::member().userset(&Group(3)), &Infra(1))) + .execute() + .await + .unwrap(); + + assert_eq!(infra_grant(1).await, Some(InfraGrant::Reader)); + assert_eq!(infra_grant(2).await, Some(InfraGrant::Writer)); + assert_eq!(infra_grant(3).await, Some(InfraGrant::Owner)); + } + + #[tokio::test] + async fn no_inference_infra_direct_grant() { + let openfga = crate::authz_client!(); + let authorize = Authorize(&openfga); + + openfga + .prepare_writes() + .write(&Group::member().tuple(&User(1), &Group(1))) + .write(&Group::member().tuple(&User(2), &Group(2))) + .write(&Group::member().tuple(&User(3), &Group(3))) + .write(&Infra::reader().tuple(&User(1), &Infra(1))) + .write(&Infra::writer().tuple(Group::member().userset(&Group(2)), &Infra(1))) + .write(&Infra::owner().tuple(&User(3), &Infra(1))) + .write(&Infra::reader().tuple(Group::member().userset(&Group(3)), &Infra(1))) + .execute() + .await + .unwrap(); + + let user_direct_grant = async |user_id: i64| { + infra_direct_grant(Subject::user(user_id), Infra(1)) + .authorize(&authorize) + .await + .unwrap() + .unwrap_authorized() + .await + }; + + let group_direct_grant = async |group_id: i64| { + infra_direct_grant(Subject::group(group_id), Infra(1)) + .authorize(&authorize) + .await + .unwrap() + .unwrap_authorized() + .await + }; + + assert_eq!(user_direct_grant(1).await, Some(InfraGrant::Reader)); + assert_eq!(group_direct_grant(1).await, None); + + assert_eq!(user_direct_grant(2).await, None); + assert_eq!(group_direct_grant(2).await, Some(InfraGrant::Writer)); + + assert_eq!(user_direct_grant(3).await, Some(InfraGrant::Owner)); + assert_eq!(group_direct_grant(3).await, Some(InfraGrant::Reader)); + } + + #[tokio::test] + #[should_panic] + async fn infra_direct_grant_inconsistent_state_panics() { + let openfga = crate::authz_client!(); + let authorize = Authorize(&openfga); + + openfga + .prepare_writes() + .write(&Infra::reader().tuple(&User(1), &Infra(1))) + .write(&Infra::writer().tuple(&User(1), &Infra(1))) + .execute() + .await + .unwrap(); + + infra_direct_grant(Subject::user(1), Infra(1)) + .authorize(&authorize) + .await + .unwrap() + .unwrap_authorized() + .await; + } + + #[tokio::test] + async fn infra_privileges_non_admin() { + let openfga = crate::authz_client!(); + + openfga + .write_tuples(&[Infra::writer().tuple(&User(1), &Infra(1))]) + .await + .unwrap(); + + assert_eq!( + openfga.infra_privileges(User(1), Infra(1)).await, + HashSet::from_iter([ + InfraPrivilege::CanRead, + InfraPrivilege::CanShareRead, + InfraPrivilege::CanWrite, + InfraPrivilege::CanShareWrite, + ]) + ); + } + + #[tokio::test] + async fn infra_privileges_admin() { + let openfga = crate::authz_client!(); + + openfga + .prepare_writes() + .write(&User::role().tuple(&Role::Admin, &User(1))) + .write(&Infra::reader().tuple(&User(2), &Infra(1))) + .execute() + .await + .unwrap(); + + assert_eq!( + openfga.infra_privileges(User(1), Infra(1)).await, + HashSet::from_iter([ + InfraPrivilege::CanRead, + InfraPrivilege::CanShareRead, + InfraPrivilege::CanWrite, + InfraPrivilege::CanShareWrite, + InfraPrivilege::CanDelete, + InfraPrivilege::CanShareOwnership, + ]) + ); + } + + #[tokio::test] + async fn no_infra_privileges() { + let openfga = crate::authz_client!(); + + openfga + .write_tuples(&[Infra::reader().tuple(&User(2), &Infra(1))]) + .await + .unwrap(); + + assert_eq!( + openfga.infra_privileges(User(1), Infra(1)).await, + HashSet::new(), + ); + } +} diff --git a/editoast/authz/src/v2/roles.rs b/editoast/authz/src/v2/roles.rs new file mode 100644 index 00000000000..f6e1d222eaa --- /dev/null +++ b/editoast/authz/src/v2/roles.rs @@ -0,0 +1,257 @@ +use std::collections::HashSet; + +use fga::model::Relation as _; +use futures::FutureExt as _; + +use crate::Group; +use crate::Role; +use crate::Subject; +use crate::User; +use crate::v2::Guardrail; +use crate::v2::Protected; +use crate::v2::SanityCheck; + +pub fn subject_roles(subject: Subject) -> Protected> { + Protected::new(move |openfga| { + async move { + match &subject { + Subject::User(user) => Role::list_roles(openfga, User::role(), user).await, + Subject::Group(group) => Role::list_roles(openfga, Group::role(), group).await, + } + } + .boxed() + }) + .with_check(SanityCheck::SubjectExists(subject)) +} + +/// Gives the subject the specified roles +/// +/// Idempotent but not atomic due to the lack of transactions in OpenFGA. +pub fn add_roles(subject: Subject, roles: HashSet) -> Protected<()> { + subject_roles(subject) + .map(move |openfga, existing_roles| { + async move { + let existing_roles = HashSet::from_iter(existing_roles); + let new_roles = roles.difference(&existing_roles); + let mut writes = openfga.prepare_writes(); + match subject { + Subject::User(user) => { + for role in new_roles { + writes.push(&User::role().tuple(role, &user)); + } + } + Subject::Group(group) => { + for role in new_roles { + writes.push(&Group::role().tuple(role, &group)); + } + } + } + writes.execute().await?; + Ok(()) + } + .boxed() + }) + .with_guardrail(Guardrail::IssuerHasRole(Role::Admin)) +} + +/// Removes the specified roles from the subject +/// +/// Idempotent but not atomic due to the lack of transactions in OpenFGA. +pub fn remove_roles(subject: Subject, roles: HashSet) -> Protected<()> { + subject_roles(subject) + .map(move |openfga, existing_roles| { + async move { + let existing_roles = HashSet::from_iter(existing_roles); + let removed_roles = roles.intersection(&existing_roles); + let mut writes = openfga.prepare_deletes(); + match subject { + Subject::User(user) => { + for role in removed_roles { + writes.push(&User::role().tuple(role, &user)); + } + } + Subject::Group(group) => { + for role in removed_roles { + writes.push(&Group::role().tuple(role, &group)); + } + } + } + writes.execute().await?; + Ok(()) + } + .boxed() + }) + .with_guardrail(Guardrail::IssuerHasRole(Role::Admin)) +} + +#[cfg(test)] +mod tests { + use crate::v2::TestClientExt as _; + use crate::v2::special_authorizers::Authorize; + + use super::*; + + #[tokio::test] + async fn add_roles_idempotent() { + let openfga = crate::authz_client!(); + let authorize = Authorize(&openfga); + + add_roles( + Subject::user(1), + HashSet::from_iter([Role::Admin, Role::Stdcm]), + ) + .authorize(&authorize) + .await + .unwrap() + .unwrap_authorized() + .await; + assert_eq!( + openfga.subject_roles(&Subject::user(1)).await, + HashSet::from_iter([Role::Admin, Role::Stdcm]) + ); + + add_roles( + Subject::user(1), + HashSet::from_iter([Role::Admin, Role::Stdcm]), + ) + .authorize(&authorize) + .await + .unwrap() + .unwrap_authorized() + .await; + assert_eq!( + openfga.subject_roles(&Subject::user(1)).await, + HashSet::from_iter([Role::Admin, Role::Stdcm]) + ); + } + + #[tokio::test] + async fn add_roles_intersecting_calls() { + let openfga = crate::authz_client!(); + let authorize = Authorize(&openfga); + + add_roles( + Subject::user(1), + HashSet::from_iter([Role::Admin, Role::Stdcm]), + ) + .authorize(&authorize) + .await + .unwrap() + .unwrap_authorized() + .await; + assert_eq!( + openfga.subject_roles(&Subject::user(1)).await, + HashSet::from_iter([Role::Admin, Role::Stdcm]) + ); + + add_roles( + Subject::user(1), + HashSet::from_iter([Role::Admin, Role::OperationalStudies]), + ) + .authorize(&authorize) + .await + .unwrap() + .unwrap_authorized() + .await; + assert_eq!( + openfga.subject_roles(&Subject::user(1)).await, + HashSet::from_iter([Role::Admin, Role::Stdcm, Role::OperationalStudies]) + ); + } + + #[tokio::test] + async fn remove_roles_idempotent() { + let openfga = crate::authz_client!(); + let authorize = Authorize(&openfga); + + add_roles( + Subject::user(1), + HashSet::from_iter([Role::Admin, Role::Stdcm]), + ) + .authorize(&authorize) + .await + .unwrap() + .unwrap_authorized() + .await; + assert_eq!( + openfga.subject_roles(&Subject::user(1)).await, + HashSet::from_iter([Role::Admin, Role::Stdcm]) + ); + + remove_roles( + Subject::user(1), + HashSet::from_iter([Role::Admin, Role::Stdcm]), + ) + .authorize(&authorize) + .await + .unwrap() + .unwrap_authorized() + .await; + assert_eq!( + openfga.subject_roles(&Subject::user(1)).await, + HashSet::from_iter([]) + ); + + remove_roles( + Subject::user(1), + HashSet::from_iter([Role::Admin, Role::Stdcm]), + ) + .authorize(&authorize) + .await + .unwrap() + .unwrap_authorized() + .await; + assert_eq!( + openfga.subject_roles(&Subject::user(1)).await, + HashSet::from_iter([]) + ); + } + + #[tokio::test] + async fn remove_roles_intersecting_calls() { + let openfga = crate::authz_client!(); + let authorize = Authorize(&openfga); + + add_roles( + Subject::user(1), + HashSet::from_iter([Role::Admin, Role::Stdcm, Role::OperationalStudies]), + ) + .authorize(&authorize) + .await + .unwrap() + .unwrap_authorized() + .await; + assert_eq!( + openfga.subject_roles(&Subject::user(1)).await, + HashSet::from_iter([Role::Admin, Role::Stdcm, Role::OperationalStudies]) + ); + + remove_roles( + Subject::user(1), + HashSet::from_iter([Role::Admin, Role::Stdcm]), + ) + .authorize(&authorize) + .await + .unwrap() + .unwrap_authorized() + .await; + assert_eq!( + openfga.subject_roles(&Subject::user(1)).await, + HashSet::from_iter([Role::OperationalStudies]) + ); + + remove_roles( + Subject::user(1), + HashSet::from_iter([Role::Admin, Role::OperationalStudies]), + ) + .authorize(&authorize) + .await + .unwrap() + .unwrap_authorized() + .await; + assert_eq!( + openfga.subject_roles(&Subject::user(1)).await, + HashSet::from_iter([]) + ); + } +} diff --git a/editoast/authz/src/v2/test_client_ext.rs b/editoast/authz/src/v2/test_client_ext.rs new file mode 100644 index 00000000000..0c7c6de3097 --- /dev/null +++ b/editoast/authz/src/v2/test_client_ext.rs @@ -0,0 +1,76 @@ +use std::collections::HashSet; + +use fga::model::Relation as _; + +use crate::Group; +use crate::Infra; +use crate::InfraGrant; +use crate::InfraPrivilege; +use crate::Role; +use crate::Subject; +use crate::User; +use crate::v2::infra_direct_grant; +use crate::v2::infra_effective_grant; +use crate::v2::infra_privileges; +use crate::v2::special_authorizers; + +pub trait TestClientExt { + async fn subject_roles(&self, subject: &Subject) -> HashSet; + async fn group_members(&self, group: &Group) -> HashSet; + async fn infra_effective_grant(&self, subject: Subject, infra: Infra) -> Option; + async fn infra_direct_grant( + &self, + subject: impl Into, + infra: Infra, + ) -> Option; + async fn infra_privileges(&self, user: User, infra: Infra) -> HashSet; +} + +impl TestClientExt for fga::Client { + async fn subject_roles(&self, subject: &Subject) -> HashSet { + match subject { + Subject::User(user) => Role::list_roles(self, User::role(), user).await, + Subject::Group(group) => Role::list_roles(self, Group::role(), group).await, + } + .unwrap() + .into_iter() + .collect() + } + + async fn group_members(&self, group: &Group) -> HashSet { + self.list_users(Group::member().query_users(group)) + .await + .unwrap() + .users + .into_iter() + .collect() + } + + async fn infra_effective_grant(&self, subject: Subject, infra: Infra) -> Option { + let authorize = special_authorizers::Authorize(self); + authorize + .access_value(infra_effective_grant(subject, infra)) + .await + .unwrap() + } + + async fn infra_direct_grant( + &self, + subject: impl Into, + infra: Infra, + ) -> Option { + let authorize = special_authorizers::Authorize(self); + authorize + .access_value(infra_direct_grant(subject.into(), infra)) + .await + .unwrap() + } + + async fn infra_privileges(&self, user: User, infra: Infra) -> HashSet { + let authorize = special_authorizers::Authorize(self); + authorize + .access_value(infra_privileges(user, infra)) + .await + .unwrap() + } +} diff --git a/editoast/src/client/group.rs b/editoast/src/client/group.rs index f5ca32e039d..069a544f7f9 100644 --- a/editoast/src/client/group.rs +++ b/editoast/src/client/group.rs @@ -100,8 +100,12 @@ pub async fn group_info( openfga_config: OpenfgaConfig, pool: Arc, ) -> anyhow::Result<()> { - let regulator = openfga_config.into_regulator(pool).await?; + let regulator = openfga_config.into_regulator(pool.clone()).await?; let driver = regulator.driver(); + let system = SystemAuthorizer { + openfga: regulator.openfga(), + conn: pool.get().await?, + }; let Some(group_id) = driver.get_group_id(&name).await? else { tracing::error!(name, "No such group"); return Ok(()); @@ -110,7 +114,16 @@ pub async fn group_info( tracing::error!(group.id = group_id, "No such group"); return Ok(()); }; - let user_ids = regulator.group_members(&authz::Group(group_id)).await?; + let user_ids = match system + .authorize(authz::v2::group_members(authz::Group(group_id))) + .await? + .access() + .await? + { + Ok(user_ids) => user_ids, + Err(Rejection::NoSuchGroup(_)) => unreachable!("tested above"), + Err(rejection) => impossible!(rejection), + }; println!("id : {group_id}"); println!("name : {name}"); @@ -227,7 +240,16 @@ pub async fn delete_group( let group = Group(group_id); // Delete the relationships between the group to be deleted and its members - let users_in_group = regulator.group_members(&group).await?; + let users_in_group = match system + .authorize(authz::v2::group_members(group)) + .await? + .access() + .await? + { + Ok(user_ids) => HashSet::from_iter(user_ids), + Err(Rejection::NoSuchGroup(_)) => unreachable!("tested above"), + Err(rejection) => impossible!(rejection), + }; let remove_member = authz::v2::remove_members(group, users_in_group); match system.authorize(remove_member).await?.access().await? { Ok(()) => {} diff --git a/editoast/src/client/user.rs b/editoast/src/client/user.rs index d893ff78234..2007c1add7f 100644 --- a/editoast/src/client/user.rs +++ b/editoast/src/client/user.rs @@ -13,10 +13,13 @@ use editoast_models::authn::user::AddIdentitiesError; use editoast_models::authn::user::UserWithIdentities; use editoast_models::prelude::*; use futures::TryStreamExt as _; -use futures::future::try_join_all; use std::collections::HashSet; use std::sync::Arc; +use crate::authorizers::Rejection; +use crate::authorizers::SystemAuthorizer; +use crate::authorizers::impossible; + use super::openfga_config::OpenfgaConfig; #[derive(Debug, Subcommand)] @@ -79,7 +82,11 @@ pub async fn list_user( openfga_config: OpenfgaConfig, pool: Arc, ) -> anyhow::Result<()> { - let regulator = openfga_config.into_regulator(pool.clone()).await?; + let openfga = openfga_config.into_client().await?; + let system = SystemAuthorizer { + openfga: &openfga, + conn: pool.get().await?, + }; let (users, groups) = tokio::join!( async { @@ -97,16 +104,20 @@ pub async fn list_user( } ); let users = if without_groups { - let group_members = - try_join_all(groups?.into_iter().zip(std::iter::repeat(regulator)).map( - |(group, regulator)| async move { - regulator.group_members(&authz::Group(group.id)).await - }, - )) - .await? - .into_iter() - .flatten() - .collect::>(); + let group_member_access = authz::v2::Protected::from_iter( + groups? + .into_iter() + .map(|group| authz::v2::group_members(authz::Group(group.id))), + ) + .authorize(&system) + .await?; + let group_members = match group_member_access.access().await? { + Ok(users) => users.into_iter().flatten().collect::>(), + Err(Rejection::NoSuchGroup(_)) => { + unreachable!("groups were listed from the database") + } + Err(rejection) => impossible!(rejection), + }; users? .into_iter() .filter(|user| !group_members.contains(&authz::User(user.user.id))) diff --git a/editoast/src/views/timetable.rs b/editoast/src/views/timetable.rs index b026b077e52..6d925845ebb 100644 --- a/editoast/src/views/timetable.rs +++ b/editoast/src/views/timetable.rs @@ -41,10 +41,11 @@ use editoast_models::timetable::Timetable; use editoast_models::timetable::TimetableWithTrains; use itertools::Itertools; use itertools::izip; +use schemas::rolling_stock::EtcsBrakeParams; +use schemas::rolling_stock::LoadingGaugeType; use schemas::rolling_stock::RollingResistance; use schemas::rolling_stock::RollingStock; use schemas::rolling_stock::TowedRollingStock; -use schemas::rolling_stock::{EtcsBrakeParams, LoadingGaugeType}; use schemas::train_schedule::TrainScheduleLike; use serde::Deserialize; use serde::Serialize;