diff --git a/policy/federation/idp/identity_provider_list.rego b/policy/federation/idp/identity_provider_list.rego index dfa1830e..8a4b3eb0 100644 --- a/policy/federation/idp/identity_provider_list.rego +++ b/policy/federation/idp/identity_provider_list.rego @@ -6,6 +6,12 @@ import data.identity default allow := false +default can_see_other_domain_resources := false + +can_see_other_domain_resources := true if { + "admin" in input.credentials.roles +} + allow if { identity.own_idp "reader" in input.credentials.roles diff --git a/src/api/v4/federation/identity_provider.rs b/src/api/v4/federation/identity_provider.rs index 2d4d4b80..e6e6fc9e 100644 --- a/src/api/v4/federation/identity_provider.rs +++ b/src/api/v4/federation/identity_provider.rs @@ -51,6 +51,7 @@ mod tests { use crate::config::Config; use crate::federation::MockFederationProvider; + use crate::identity::types::UserResponse; use crate::keystone::{Service, ServiceState}; use crate::policy::{MockPolicy, MockPolicyFactory, PolicyError, PolicyEvaluationResult}; use crate::provider::Provider; @@ -59,6 +60,7 @@ mod tests { pub(crate) fn get_mocked_state( federation_mock: MockFederationProvider, policy_allowed: bool, + policy_allowed_see_other_domains: Option, ) -> ServiceState { let mut token_mock = MockTokenProvider::default(); token_mock.expect_validate_token().returning(|_, _, _| { @@ -72,6 +74,11 @@ mod tests { .returning(|_, _, _| { Ok(Token::Unscoped(UnscopedPayload { user_id: "bar".into(), + user: Some(UserResponse { + id: "bar".into(), + domain_id: "udid".into(), + ..Default::default() + }), ..Default::default() })) }); @@ -84,11 +91,17 @@ mod tests { let mut policy_factory_mock = MockPolicyFactory::default(); if policy_allowed { - policy_factory_mock.expect_instantiate().returning(|| { + policy_factory_mock.expect_instantiate().returning(move || { let mut policy_mock = MockPolicy::default(); - policy_mock - .expect_enforce() - .returning(|_, _, _, _| Ok(PolicyEvaluationResult::allowed())); + if policy_allowed_see_other_domains.is_some_and(|x| x) { + policy_mock + .expect_enforce() + .returning(|_, _, _, _| Ok(PolicyEvaluationResult::allowed_admin())); + } else { + policy_mock + .expect_enforce() + .returning(|_, _, _, _| Ok(PolicyEvaluationResult::allowed())); + } Ok(policy_mock) }); } else { diff --git a/src/api/v4/federation/identity_provider/create.rs b/src/api/v4/federation/identity_provider/create.rs index 6b213dc7..fbba51fc 100644 --- a/src/api/v4/federation/identity_provider/create.rs +++ b/src/api/v4/federation/identity_provider/create.rs @@ -107,7 +107,7 @@ mod tests { }) }); - let state = get_mocked_state(federation_mock, true); + let state = get_mocked_state(federation_mock, true, None); let mut api = openapi_router() .layer(TraceLayer::new_for_http()) diff --git a/src/api/v4/federation/identity_provider/delete.rs b/src/api/v4/federation/identity_provider/delete.rs index a446d6f3..b75edd29 100644 --- a/src/api/v4/federation/identity_provider/delete.rs +++ b/src/api/v4/federation/identity_provider/delete.rs @@ -143,7 +143,7 @@ mod tests { .withf(|_: &DatabaseConnection, id: &'_ str| id == "bar") .returning(|_, _| Ok(())); - let state = get_mocked_state(federation_mock, true); + let state = get_mocked_state(federation_mock, true, None); let mut api = openapi_router() .layer(TraceLayer::new_for_http()) diff --git a/src/api/v4/federation/identity_provider/list.rs b/src/api/v4/federation/identity_provider/list.rs index 2e0031d1..7b5aa270 100644 --- a/src/api/v4/federation/identity_provider/list.rs +++ b/src/api/v4/federation/identity_provider/list.rs @@ -19,11 +19,14 @@ use axum::{ }; use mockall_double::double; use serde_json::to_value; +use std::collections::HashSet; use crate::api::auth::Auth; use crate::api::error::KeystoneApiError; use crate::api::v4::federation::types::*; -use crate::federation::FederationApi; +use crate::federation::{ + FederationApi, types::IdentityProviderListParameters as ProviderIdentityProviderListParameters, +}; use crate::keystone::ServiceState; #[double] use crate::policy::Policy; @@ -59,7 +62,7 @@ pub(super) async fn list( Query(query): Query, State(state): State, ) -> Result { - policy + let res = policy .enforce( "identity/identity_provider_list", &user_auth, @@ -68,10 +71,27 @@ pub(super) async fn list( ) .await?; + let mut provider_list_params = ProviderIdentityProviderListParameters { + name: query.name, + ..Default::default() + }; + if query.domain_id.as_ref().is_none() { + if !res.can_see_other_domain_resources.is_some_and(|x| x) { + let domain_ids: HashSet> = HashSet::from([ + None, + // TODO: perhaps we should first look at the domain_scope and than user domain. + user_auth.user().as_ref().map(|val| val.domain_id.clone()), + ]); + provider_list_params.domain_ids = Some(domain_ids); + } + } else { + provider_list_params.domain_ids = Some(HashSet::from([query.domain_id])); + } + let identity_providers: Vec = state .provider .get_federation_provider() - .list_identity_providers(&state.db, &query.try_into()?) + .list_identity_providers(&state.db, &provider_list_params) .await .map_err(KeystoneApiError::federation)? .into_iter() @@ -88,6 +108,7 @@ mod tests { }; use http_body_util::BodyExt; // for `collect` use sea_orm::DatabaseConnection; + use std::collections::HashSet; use tower::ServiceExt; // for `call`, `oneshot`, and `ready` use tower_http::trace::TraceLayer; @@ -117,7 +138,7 @@ mod tests { ..Default::default() }]) }); - let state = get_mocked_state(federation_mock, true); + let state = get_mocked_state(federation_mock, true, None); let mut api = openapi_router() .layer(TraceLayer::new_for_http()) @@ -168,7 +189,7 @@ mod tests { |_: &DatabaseConnection, qp: &provider_types::IdentityProviderListParameters| { provider_types::IdentityProviderListParameters { name: Some("name".into()), - domain_id: Some("did".into()), + domain_ids: Some(HashSet::from([Some("did".into())])), } == *qp }, ) @@ -181,7 +202,7 @@ mod tests { }]) }); - let state = get_mocked_state(federation_mock, true); + let state = get_mocked_state(federation_mock, true, None); let mut api = openapi_router() .layer(TraceLayer::new_for_http()) @@ -223,7 +244,7 @@ mod tests { ..Default::default() }]) }); - let state = get_mocked_state(federation_mock, false); + let state = get_mocked_state(federation_mock, false, None); let mut api = openapi_router() .layer(TraceLayer::new_for_http()) @@ -243,4 +264,100 @@ mod tests { assert_eq!(response.status(), StatusCode::FORBIDDEN); } + + #[tokio::test] + #[traced_test] + async fn test_list_shared_and_own() { + let mut federation_mock = MockFederationProvider::default(); + federation_mock + .expect_list_identity_providers() + .withf( + |_: &DatabaseConnection, qp: &provider_types::IdentityProviderListParameters| { + provider_types::IdentityProviderListParameters { + name: Some("name".into()), + domain_ids: Some(HashSet::from([None, Some("udid".into())])), + } == *qp + }, + ) + .returning(|_, _| { + Ok(vec![provider_types::IdentityProvider { + id: "id".into(), + name: "name".into(), + domain_id: Some("did".into()), + ..Default::default() + }]) + }); + + let state = get_mocked_state(federation_mock, true, Some(false)); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/?name=name") + .header("x-auth-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let _res: IdentityProviderList = serde_json::from_slice(&body).unwrap(); + } + + #[tokio::test] + #[traced_test] + async fn test_list_all() { + // Test listing ALL idps when the user does not specify the domain_id and is allowed to see + // IDP of other domains (admin) + let mut federation_mock = MockFederationProvider::default(); + federation_mock + .expect_list_identity_providers() + .withf( + |_: &DatabaseConnection, qp: &provider_types::IdentityProviderListParameters| { + provider_types::IdentityProviderListParameters { + name: Some("name".into()), + domain_ids: None, + } == *qp + }, + ) + .returning(|_, _| { + Ok(vec![provider_types::IdentityProvider { + id: "id".into(), + name: "name".into(), + domain_id: Some("did".into()), + ..Default::default() + }]) + }); + + let state = get_mocked_state(federation_mock, true, Some(true)); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/?name=name") + .header("x-auth-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let _res: IdentityProviderList = serde_json::from_slice(&body).unwrap(); + } } diff --git a/src/api/v4/federation/identity_provider/show.rs b/src/api/v4/federation/identity_provider/show.rs index 322928d1..ca5dcf5d 100644 --- a/src/api/v4/federation/identity_provider/show.rs +++ b/src/api/v4/federation/identity_provider/show.rs @@ -121,7 +121,7 @@ mod tests { })) }); - let state = get_mocked_state(federation_mock, true); + let state = get_mocked_state(federation_mock, true, None); let mut api = openapi_router() .layer(TraceLayer::new_for_http()) @@ -193,7 +193,7 @@ mod tests { })) }); - let state = get_mocked_state(federation_mock, false); + let state = get_mocked_state(federation_mock, false, None); let mut api = openapi_router() .layer(TraceLayer::new_for_http()) diff --git a/src/api/v4/federation/identity_provider/update.rs b/src/api/v4/federation/identity_provider/update.rs index 7364809d..96d45088 100644 --- a/src/api/v4/federation/identity_provider/update.rs +++ b/src/api/v4/federation/identity_provider/update.rs @@ -135,7 +135,7 @@ mod tests { }) }); - let state = get_mocked_state(federation_mock, true); + let state = get_mocked_state(federation_mock, true, None); let mut api = openapi_router() .layer(TraceLayer::new_for_http()) diff --git a/src/api/v4/federation/types/identity_provider.rs b/src/api/v4/federation/types/identity_provider.rs index 8ed93066..5d2f048a 100644 --- a/src/api/v4/federation/types/identity_provider.rs +++ b/src/api/v4/federation/types/identity_provider.rs @@ -357,7 +357,7 @@ impl TryFrom for types::IdentityProviderListPara fn try_from(value: IdentityProviderListParameters) -> Result { Ok(Self { name: value.name, - domain_id: value.domain_id, + domain_ids: None, //value.domain_id, }) } } diff --git a/src/federation/backends/sql/identity_provider/list.rs b/src/federation/backends/sql/identity_provider/list.rs index 6fddd637..d718e1c7 100644 --- a/src/federation/backends/sql/identity_provider/list.rs +++ b/src/federation/backends/sql/identity_provider/list.rs @@ -35,8 +35,18 @@ pub async fn list( select = select.filter(db_federated_identity_provider::Column::Name.eq(val)); } - if let Some(val) = ¶ms.domain_id { - select = select.filter(db_federated_identity_provider::Column::DomainId.eq(val)); + if let Some(val) = ¶ms.domain_ids { + let filter = + db_federated_identity_provider::Column::DomainId.is_in(val.iter().flatten()); + select = if val.contains(&None) { + select.filter( + Condition::any() + .add(filter) + .add(db_federated_identity_provider::Column::DomainId.is_null()), + ) + } else { + select.filter(filter) + }; } let db_entities: Vec = select.all(db).await?; @@ -51,6 +61,7 @@ pub async fn list( #[cfg(test)] mod tests { use sea_orm::{DatabaseBackend, MockDatabase, Transaction}; + use std::collections::HashSet; use crate::config::Config; @@ -75,7 +86,7 @@ mod tests { &db, &IdentityProviderListParameters { name: Some("idp_name".into()), - domain_id: Some("did".into()), + domain_ids: Some(HashSet::from([Some("did".into())])), } ) .await @@ -99,10 +110,66 @@ mod tests { ), Transaction::from_sql_and_values( DatabaseBackend::Postgres, - r#"SELECT "federated_identity_provider"."id", "federated_identity_provider"."name", "federated_identity_provider"."domain_id", "federated_identity_provider"."oidc_discovery_url", "federated_identity_provider"."oidc_client_id", "federated_identity_provider"."oidc_client_secret", "federated_identity_provider"."oidc_response_mode", "federated_identity_provider"."oidc_response_types", "federated_identity_provider"."jwks_url", "federated_identity_provider"."jwt_validation_pubkeys", "federated_identity_provider"."bound_issuer", "federated_identity_provider"."default_mapping_name", "federated_identity_provider"."provider_config" FROM "federated_identity_provider" WHERE "federated_identity_provider"."name" = $1 AND "federated_identity_provider"."domain_id" = $2"#, + r#"SELECT "federated_identity_provider"."id", "federated_identity_provider"."name", "federated_identity_provider"."domain_id", "federated_identity_provider"."oidc_discovery_url", "federated_identity_provider"."oidc_client_id", "federated_identity_provider"."oidc_client_secret", "federated_identity_provider"."oidc_response_mode", "federated_identity_provider"."oidc_response_types", "federated_identity_provider"."jwks_url", "federated_identity_provider"."jwt_validation_pubkeys", "federated_identity_provider"."bound_issuer", "federated_identity_provider"."default_mapping_name", "federated_identity_provider"."provider_config" FROM "federated_identity_provider" WHERE "federated_identity_provider"."name" = $1 AND "federated_identity_provider"."domain_id" IN ($2)"#, ["idp_name".into(), "did".into()] ), ] ); } + + #[tokio::test] + async fn test_list_with_null_domain_id() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([vec![get_idp_mock("1")]]) + .into_connection(); + let config = Config::default(); + list( + &config, + &db, + &IdentityProviderListParameters { + name: Some("idp_name".into()), + domain_ids: Some(HashSet::from([None, Some("did".into())])), + }, + ) + .await + .unwrap(); + + // Checking transaction log + assert_eq!( + db.into_transaction_log(), + [Transaction::from_sql_and_values( + DatabaseBackend::Postgres, + r#"SELECT "federated_identity_provider"."id", "federated_identity_provider"."name", "federated_identity_provider"."domain_id", "federated_identity_provider"."oidc_discovery_url", "federated_identity_provider"."oidc_client_id", "federated_identity_provider"."oidc_client_secret", "federated_identity_provider"."oidc_response_mode", "federated_identity_provider"."oidc_response_types", "federated_identity_provider"."jwks_url", "federated_identity_provider"."jwt_validation_pubkeys", "federated_identity_provider"."bound_issuer", "federated_identity_provider"."default_mapping_name", "federated_identity_provider"."provider_config" FROM "federated_identity_provider" WHERE "federated_identity_provider"."name" = $1 AND ("federated_identity_provider"."domain_id" IN ($2) OR "federated_identity_provider"."domain_id" IS NULL)"#, + ["idp_name".into(), "did".into()] + ),] + ); + } + + #[tokio::test] + async fn test_list_without_domain_id() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([vec![get_idp_mock("1")]]) + .into_connection(); + let config = Config::default(); + list( + &config, + &db, + &IdentityProviderListParameters { + name: Some("idp_name".into()), + domain_ids: None, + }, + ) + .await + .unwrap(); + + // Checking transaction log + assert_eq!( + db.into_transaction_log(), + [Transaction::from_sql_and_values( + DatabaseBackend::Postgres, + r#"SELECT "federated_identity_provider"."id", "federated_identity_provider"."name", "federated_identity_provider"."domain_id", "federated_identity_provider"."oidc_discovery_url", "federated_identity_provider"."oidc_client_id", "federated_identity_provider"."oidc_client_secret", "federated_identity_provider"."oidc_response_mode", "federated_identity_provider"."oidc_response_types", "federated_identity_provider"."jwks_url", "federated_identity_provider"."jwt_validation_pubkeys", "federated_identity_provider"."bound_issuer", "federated_identity_provider"."default_mapping_name", "federated_identity_provider"."provider_config" FROM "federated_identity_provider" WHERE "federated_identity_provider"."name" = $1"#, + ["idp_name".into()] + ),] + ); + } } diff --git a/src/federation/types/identity_provider.rs b/src/federation/types/identity_provider.rs index 4f150f1d..20c0f804 100644 --- a/src/federation/types/identity_provider.rs +++ b/src/federation/types/identity_provider.rs @@ -101,6 +101,7 @@ pub struct IdentityProviderUpdate { pub struct IdentityProviderListParameters { /// Filters the response by IDP name. pub name: Option, - /// Filters the response by a domain_id ID. - pub domain_id: Option, + /// Filters the response by a domain_id ID. It is an optional list of optional strings to + /// represent fetching of null and non-null values in a single request. + pub domain_ids: Option>>, } diff --git a/src/policy.rs b/src/policy.rs index a364dd92..081d931d 100644 --- a/src/policy.rs +++ b/src/policy.rs @@ -296,6 +296,7 @@ impl Policy { debug!("not enforcing policy due to the absence of initialized WASM data"); PolicyEvaluationResult { allow: true, + can_see_other_domain_resources: None, violations: None, } }; @@ -326,7 +327,12 @@ pub struct OpaResponse { /// The result of a policy evaluation. #[derive(Clone, Deserialize, Debug, Serialize)] pub struct PolicyEvaluationResult { + /// Whether the user is allowed to perform the request or not. pub allow: bool, + /// Whether the user is allowed to see resources of other domains. + #[serde(default)] + pub can_see_other_domain_resources: Option, + /// List of violations. #[serde(rename = "violation")] pub violations: Option>, } @@ -367,6 +373,16 @@ impl PolicyEvaluationResult { pub fn allowed() -> Self { Self { allow: true, + can_see_other_domain_resources: None, + violations: None, + } + } + + #[cfg(test)] + pub fn allowed_admin() -> Self { + Self { + allow: true, + can_see_other_domain_resources: Some(true), violations: None, } } @@ -375,6 +391,7 @@ impl PolicyEvaluationResult { pub fn forbidden() -> Self { Self { allow: false, + can_see_other_domain_resources: Some(false), violations: None, } } diff --git a/src/token/error.rs b/src/token/error.rs index 8b9b3453..9d6d3414 100644 --- a/src/token/error.rs +++ b/src/token/error.rs @@ -160,6 +160,13 @@ pub enum TokenProviderError { source: crate::auth::AuthenticationError, }, + #[error(transparent)] + IndentityProvider { + /// The source of the error. + #[from] + source: crate::identity::error::IdentityProviderError, + }, + #[error(transparent)] ResourceProvider { /// The source of the error. diff --git a/src/token/mod.rs b/src/token/mod.rs index 7b5248da..cfcc18af 100644 --- a/src/token/mod.rs +++ b/src/token/mod.rs @@ -39,6 +39,7 @@ use crate::assignment::{ }; use crate::auth::{AuthenticatedInfo, AuthenticationError, AuthzInfo}; use crate::config::{Config, TokenProvider as TokenProviderType}; +use crate::identity::IdentityApi; use crate::provider::Provider; use crate::resource::{ ResourceApi, @@ -244,6 +245,44 @@ impl TokenProvider { Err(TokenProviderError::FederatedPayloadMissingData) } } + + async fn expand_user_information( + &self, + token: &mut Token, + db: &DatabaseConnection, + provider: &Provider, + ) -> Result<(), TokenProviderError> { + if token.user().is_none() { + let user = provider + .get_identity_provider() + .get_user(db, token.user_id()) + .await?; + match token { + Token::ApplicationCredential(data) => { + data.user = user; + } + Token::Unscoped(data) => { + data.user = user; + } + Token::ProjectScope(data) => { + data.user = user; + } + Token::DomainScope(data) => { + data.user = user; + } + Token::FederationUnscoped(data) => { + data.user = user; + } + Token::FederationProjectScope(data) => { + data.user = user; + } + Token::FederationDomainScope(data) => { + data.user = user; + } + } + } + Ok(()) + } } #[async_trait] @@ -330,6 +369,7 @@ impl TokenApi for TokenProvider { { return Err(TokenProviderError::Expired); } + Ok(token) } @@ -536,6 +576,8 @@ impl TokenApi for TokenProvider { _ => {} }; + self.expand_user_information(&mut new_token, db, provider) + .await?; self.populate_role_assignments(&mut new_token, db, provider) .await?; Ok(new_token)