diff --git a/.ci/deploy/localenv/data/keycloak/provision.sh b/.ci/deploy/localenv/data/keycloak/provision.sh index 8548bac66..326329ca9 100755 --- a/.ci/deploy/localenv/data/keycloak/provision.sh +++ b/.ci/deploy/localenv/data/keycloak/provision.sh @@ -59,6 +59,15 @@ main() { create_secret_client "opendut-cleo-client" "$OPENDUT_CLEO_NETWORK_OIDC_CLIENT_SECRET" "$REALM_OPENDUT" create_secret_client "opendut-edgar-client" "$OPENDUT_EDGAR_NETWORK_OIDC_CLIENT_SECRET" "$REALM_OPENDUT" + # Create API access scopes for authorization + create_client_scope "opendut-admin-api" "none" "$REALM_OPENDUT" + create_client_scope "opendut-edge-api" "none" "$REALM_OPENDUT" + + # Assign API scopes to clients + add_client_scope_to_client "opendut-lea-client" "opendut-admin-api" "$REALM_OPENDUT" + add_client_scope_to_client "opendut-cleo-client" "opendut-admin-api" "$REALM_OPENDUT" + add_client_scope_to_client "opendut-edgar-client" "opendut-edge-api" "$REALM_OPENDUT" + # Create keycloak client privileges for openDuT-CARL create_realm_role carl-admin "$REALM_OPENDUT" # Add role carl-admin to client opendut-carl-client diff --git a/opendut-auth/src/registration/client.rs b/opendut-auth/src/registration/client.rs index 50d4a0de3..1aaf73761 100644 --- a/opendut-auth/src/registration/client.rs +++ b/opendut-auth/src/registration/client.rs @@ -203,6 +203,73 @@ impl RegistrationClient { .map_err(|error| RegistrationClientError::RequestError { error: error.to_string(), cause: error.into() }) } + pub async fn assign_scope_to_client(&self, keycloak_client_uuid: &str, scope_name: &str) -> Result<(), RegistrationClientError> { + let scopes = self.list_client_scopes().await?; + + let scope_id = scopes.iter() + .find(|s| s.name == scope_name) + .map(|s| s.id.clone()) + .ok_or_else(|| RegistrationClientError::InvalidConfiguration { + error: format!("Client scope '{scope_name}' not found in Keycloak") + })?; + + let assign_uri = format!("clients/{keycloak_client_uuid}/default-client-scopes/{scope_id}"); + let assign_url = self.config.issuer_admin_url.value().join(&assign_uri) + .map_err(|cause| RegistrationClientError::InvalidConfiguration { + error: format!("Invalid admin api endpoint for scope assignment. {cause}") + })?; + + let request = self.create_http_request_with_auth_token(&assign_url, http::Method::PUT).await?; + let response = async_http_client(&self.inner.reqwest_client, request).await + .map_err(|error| RegistrationClientError::RequestError { + error: format!("Failed to assign scope '{scope_name}' to client '{keycloak_client_uuid}'"), + cause: Box::new(error) + })?; + + if response.status().is_success() { + Ok(()) + } else { + Err(RegistrationClientError::RequestError { + error: format!("Failed to assign scope '{scope_name}' to client '{keycloak_client_uuid}': HTTP {}", response.status()), + cause: format!("HTTP {}", response.status()).into() + }) + } + } + + pub async fn list_client_scopes(&self) -> Result, RegistrationClientError> { + let scopes_url = self.config.issuer_admin_url.value().join("client-scopes/") + .map_err(|cause| RegistrationClientError::InvalidConfiguration { + error: format!("Invalid admin api endpoint for client scopes. {cause}") + })?; + + let request = self.create_http_request_with_auth_token(&scopes_url, http::Method::GET).await?; + let response = async_http_client(&self.inner.reqwest_client, request).await + .map_err(|error| RegistrationClientError::RequestError { + error: "Failed to list client scopes".to_string(), + cause: Box::new(error) + })?; + + let scopes: Vec = serde_json::from_slice(response.body()) + .map_err(|cause| { + error!("Could not deserialize client scopes from keycloak: {:?}\nBody:\n{}", cause, String::from_utf8_lossy(response.body())); + RegistrationClientError::InvalidConfiguration { + error: format!("Could not deserialize client scopes response. {cause}") + } + })?; + + Ok(scopes) + } + + /// Find the Keycloak internal UUID for use in admin API paths, by its OAuth client_id + pub async fn find_client_uuid(&self, oauth_client_id: &str) -> Result { + let clients = self.list_clients().await?; + clients.value() + .into_iter() + .find(|c| c.client_id == oauth_client_id) + .map(|c| c.id) + .ok_or(RegistrationClientError::ClientNotFound) + } + async fn create_http_request_with_auth_token(&self, issuer_remote_url: &Url, http_method: http::Method) -> Result { let access_token = self.inner.get_token().await .map_err(|error| RegistrationClientError::RequestError { error: error.to_string(), cause: error.into() })?; @@ -250,6 +317,13 @@ impl Clients { #[derive(Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct Client { + pub id: String, pub client_id: String, base_url: Option, } + +#[derive(Deserialize, Debug, Clone)] +pub struct KeycloakClientScope { + pub id: String, + pub name: String, +} diff --git a/opendut-auth/src/types.rs b/opendut-auth/src/types.rs index 7f13f3055..2da8f367a 100644 --- a/opendut-auth/src/types.rs +++ b/opendut-auth/src/types.rs @@ -2,6 +2,11 @@ use std::fmt::Debug; use cfg_if::cfg_if; use serde::{Deserialize, Serialize}; +/// OAuth scope required to access the management API (ClusterManager, PeerManager, etc.) +pub const SCOPE_ADMIN_API: &str = "opendut-admin-api"; +/// OAuth scope required to access the edge API (PeerMessagingBroker) +pub const SCOPE_EDGE_API: &str = "opendut-edge-api"; + #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(untagged)] pub enum Audience { @@ -44,6 +49,9 @@ pub struct MyAdditionalClaims { /// Groups of the user (custom claim) may be omitted by identity provider, so we need a default value #[serde(default = "MyAdditionalClaims::empty_vector")] pub groups: Vec, + /// OAuth scopes (space-separated list transmitted as "scope" claim in JWT) + #[serde(default, rename = "scope")] + pub scopes: String, } impl MyAdditionalClaims { @@ -54,6 +62,9 @@ impl MyAdditionalClaims { pub fn has_role(&self, role: &str) -> bool { self.roles.contains(&role.to_string()) } + pub fn has_scope(&self, required_scope: &str) -> bool { + self.scopes.split_whitespace().any(|s| s == required_scope) + } } cfg_if! { diff --git a/opendut-carl/src/auth/grpc_auth_layer.rs b/opendut-carl/src/auth/grpc_auth_layer.rs index ceb053d85..85071c19b 100644 --- a/opendut-carl/src/auth/grpc_auth_layer.rs +++ b/opendut-carl/src/auth/grpc_auth_layer.rs @@ -19,7 +19,7 @@ pub enum GrpcAuthenticationLayer { } impl GrpcAuthenticationLayer { - pub async fn auth_interceptor(self, mut request: tonic::Request<()>, reqwest_client: reqwest::Client) -> anyhow::Result, Status> { + pub async fn auth_interceptor(self, mut request: tonic::Request<()>, reqwest_client: reqwest::Client, accepted_scopes: &'static [&'static str]) -> anyhow::Result, Status> { match self { GrpcAuthenticationLayer::AuthDisabled => { @@ -38,6 +38,17 @@ impl GrpcAuthenticationLayer { match authorize_current_user(auth_header, issuer_url, issuer_remote_url, cache, reqwest_client).await { Ok(user) => { + let has_required_scope = accepted_scopes + .iter() + .any(|scope| user.claims.additional_claims().has_scope(scope)); + + if !has_required_scope { + debug!("Blocking request: none of the required scopes {accepted_scopes:?} were present"); + return Err(Status::permission_denied( + format!("CARL says, missing one of the required scopes: {}", accepted_scopes.join(", ")) + )); + } + request.extensions_mut().insert(user); Ok(request) } diff --git a/opendut-carl/src/lib.rs b/opendut-carl/src/lib.rs index c0ac3b305..275d0f2bc 100644 --- a/opendut-carl/src/lib.rs +++ b/opendut-carl/src/lib.rs @@ -151,7 +151,6 @@ async fn run(settings: LoadedConfig, get_resource_manager_ref: bool) -> anyhow:: }; let mut routes_builder = Routes::builder(); - routes_builder .add_service(grpc_facades.cluster_manager_facade.into_grpc_service()) .add_service(grpc_facades.metadata_provider_facade.into_grpc_service()) @@ -166,8 +165,38 @@ async fn run(settings: LoadedConfig, get_resource_manager_ref: bool) -> anyhow:: routes_builder .routes() .into_axum_router() - .layer(async_interceptor(move |request| { - Clone::clone(&grpc_auth_layer).auth_interceptor(request, reqwest_client.clone()) + .layer(async_interceptor(move |request: tonic::Request<()>| { + let is_edge = request + .extensions() + .get::() + .map(|u| { + let path = u.path(); + path.starts_with("/opendut.carl.services.peer_messaging_broker.") || + path.contains("/opendut.carl.services.peer_messaging_broker.") + }) + .unwrap_or(false); + + // metadata_provider serves a single Version() endpoint used by all clients + // (EDGAR, CLEO, LEA) on startup. EDGAR only holds opendut-edge-api while + // CLEO/LEA only hold opendut-admin-api, so both scopes must be accepted. + let is_metadata_provider = request + .extensions() + .get::() + .map(|u| { + let path = u.path(); + path.starts_with("/opendut.carl.services.metadata_provider.") || + path.contains("/opendut.carl.services.metadata_provider.") + }) + .unwrap_or(false); + + let accepted_scopes: &'static [&'static str] = if is_metadata_provider { + &[opendut_auth::types::SCOPE_EDGE_API, opendut_auth::types::SCOPE_ADMIN_API] + } else if is_edge { + &[opendut_auth::types::SCOPE_EDGE_API] + } else { + &[opendut_auth::types::SCOPE_ADMIN_API] + }; + Clone::clone(&grpc_auth_layer).auth_interceptor(request, reqwest_client.clone(), accepted_scopes) })) }; diff --git a/opendut-carl/src/manager/peer_manager/generate_cleo_setup.rs b/opendut-carl/src/manager/peer_manager/generate_cleo_setup.rs index c92e5a326..f4a0c590f 100644 --- a/opendut-carl/src/manager/peer_manager/generate_cleo_setup.rs +++ b/opendut-carl/src/manager/peer_manager/generate_cleo_setup.rs @@ -1,7 +1,8 @@ use opendut_auth::registration::client::RegistrationClientRef; use opendut_auth::registration::resources::UserId; +use opendut_auth::types::SCOPE_ADMIN_API; use opendut_model::cleo::{CleoId, CleoSetup}; -use opendut_model::util::net::{AuthConfig, Certificate}; +use opendut_model::util::net::{AuthConfig, Certificate, OAuthScope}; use tracing::debug; use url::Url; use opendut_util::pem::Pem; @@ -32,7 +33,17 @@ pub async fn generate_cleo_setup(params: GenerateCleoSetupParams) -> Result. OIDC client_id='{}'.", client_credentials.client_id.clone().value()); - AuthConfig::from_credentials(issuer_url, client_credentials) + + // Assign admin API scope to the CLEO client + let keycloak_client_uuid = registration_client.find_client_uuid(&client_credentials.client_id.clone().value()) + .await + .map_err(|cause| GenerateCleoSetupError::Internal { cause: cause.to_string() })?; + registration_client.assign_scope_to_client(&keycloak_client_uuid, SCOPE_ADMIN_API) + .await + .map_err(|cause| GenerateCleoSetupError::Internal { cause: cause.to_string() })?; + debug!("Assigned scope '{SCOPE_ADMIN_API}' to CLEO client <{cleo_id}>."); + + AuthConfig::from_credentials(issuer_url, client_credentials, vec![OAuthScope(SCOPE_ADMIN_API.to_string())]) } }; diff --git a/opendut-carl/src/manager/peer_manager/generate_peer_setup.rs b/opendut-carl/src/manager/peer_manager/generate_peer_setup.rs index fe6fff0a2..7f7bc1b44 100644 --- a/opendut-carl/src/manager/peer_manager/generate_peer_setup.rs +++ b/opendut-carl/src/manager/peer_manager/generate_peer_setup.rs @@ -2,8 +2,9 @@ use crate::resource::manager::error::PersistenceError; use crate::settings::vpn::Vpn; use opendut_auth::registration::client::RegistrationClientRef; use opendut_auth::registration::resources::UserId; +use opendut_auth::types::SCOPE_EDGE_API; use opendut_model::peer::{PeerDescriptor, PeerId, PeerName, PeerSetup}; -use opendut_model::util::net::{AuthConfig, Certificate}; +use opendut_model::util::net::{AuthConfig, Certificate, OAuthScope}; use opendut_model::vpn::VpnPeerConfiguration; use tracing::{debug, info, warn}; use url::Url; @@ -57,7 +58,17 @@ impl Resources<'_> { .await .map_err(|cause| GeneratePeerSetupError::Internal { peer_id, peer_name: Clone::clone(&peer_name), cause: cause.to_string() })?; debug!("Successfully generated peer setup for peer '{peer_name}' <{peer_id}>. OIDC client_id='{}'.", client_credentials.client_id.clone().value()); - AuthConfig::from_credentials(issuer_url, client_credentials) + + // Assign edge API scope to the EDGAR client + let keycloak_client_uuid = registration_client.find_client_uuid(&client_credentials.client_id.clone().value()) + .await + .map_err(|cause| GeneratePeerSetupError::Internal { peer_id, peer_name: Clone::clone(&peer_name), cause: cause.to_string() })?; + registration_client.assign_scope_to_client(&keycloak_client_uuid, SCOPE_EDGE_API) + .await + .map_err(|cause| GeneratePeerSetupError::Internal { peer_id, peer_name: Clone::clone(&peer_name), cause: cause.to_string() })?; + debug!("Assigned scope '{SCOPE_EDGE_API}' to EDGAR client for peer '{peer_name}' <{peer_id}>."); + + AuthConfig::from_credentials(issuer_url, client_credentials, vec![OAuthScope(SCOPE_EDGE_API.to_string())]) } }; diff --git a/opendut-cleo/src/commands/setup.rs b/opendut-cleo/src/commands/setup.rs index 5cf56afd8..0d83d8bd6 100644 --- a/opendut-cleo/src/commands/setup.rs +++ b/opendut-cleo/src/commands/setup.rs @@ -63,15 +63,16 @@ impl SetupCli { OPENDUT_CLEO_NETWORK_OIDC_ENABLED=false ").as_str()); } - AuthConfig::Enabled { issuer_url, client_id, client_secret, .. } => { + AuthConfig::Enabled { issuer_url, client_id, client_secret, scopes } => { let id = client_id.value(); let secret = client_secret.value(); + let scopes_str = scopes.into_iter().map(|s| s.0).collect::>().join(","); environment_variables.push_str(formatdoc!(" OPENDUT_CLEO_NETWORK_OIDC_ENABLED=true OPENDUT_CLEO_NETWORK_OIDC_CLIENT_ISSUER_URL={issuer_url} OPENDUT_CLEO_NETWORK_OIDC_CLIENT_ID={id} OPENDUT_CLEO_NETWORK_OIDC_CLIENT_SECRET={secret} - OPENDUT_CLEO_NETWORK_OIDC_CLIENT_SCOPES=\"\" + OPENDUT_CLEO_NETWORK_OIDC_CLIENT_SCOPES=\"{scopes_str}\" ").as_str()); } } diff --git a/opendut-model/src/util/net.rs b/opendut-model/src/util/net.rs index d2e7a2bf2..1793153f4 100644 --- a/opendut-model/src/util/net.rs +++ b/opendut-model/src/util/net.rs @@ -429,13 +429,12 @@ pub enum AuthConfig { } impl AuthConfig { - pub fn from_credentials(issuer_url: Url, client_credentials: ClientCredentials) -> Self { - + pub fn from_credentials(issuer_url: Url, client_credentials: ClientCredentials, scopes: Vec) -> Self { Self::Enabled { issuer_url, client_id: client_credentials.client_id, client_secret: client_credentials.client_secret, - scopes: vec![], + scopes, } } } @@ -455,7 +454,7 @@ mod tests { let client_credentials = ClientCredentials { client_id: client_id.clone(), client_secret: client_secret.clone() }; let expected_scopes: Vec = vec![]; let issuer_url = Url::parse("https://some-address-idk.com").unwrap(); - let auth_config = AuthConfig::from_credentials(issuer_url.clone(), client_credentials); + let auth_config = AuthConfig::from_credentials(issuer_url.clone(), client_credentials, vec![]); assert_that!(auth_config, eq(&AuthConfig::Enabled { issuer_url,