Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .ci/deploy/localenv/data/keycloak/provision.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
74 changes: 74 additions & 0 deletions opendut-auth/src/registration/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<KeycloakClientScope>, 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<KeycloakClientScope> = 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<String, RegistrationClientError> {
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<HttpRequest, RegistrationClientError> {
let access_token = self.inner.get_token().await
.map_err(|error| RegistrationClientError::RequestError { error: error.to_string(), cause: error.into() })?;
Expand Down Expand Up @@ -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<String>,
}

#[derive(Deserialize, Debug, Clone)]
pub struct KeycloakClientScope {
pub id: String,
pub name: String,
}
11 changes: 11 additions & 0 deletions opendut-auth/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<String>,
/// OAuth scopes (space-separated string in the JWT)
#[serde(default)]
pub scope: String,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As written in the comment, there can be multiple scopes encoded. The field should also be named scopes.

}

impl MyAdditionalClaims {
Expand All @@ -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.scope.split_whitespace().any(|s| s == required_scope)
}
}

cfg_if! {
Expand Down
9 changes: 8 additions & 1 deletion opendut-carl/src/auth/grpc_auth_layer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<tonic::Request<()>, Status> {
pub async fn auth_interceptor(self, mut request: tonic::Request<()>, reqwest_client: reqwest::Client, required_scope: &'static str) -> anyhow::Result<tonic::Request<()>, Status> {

match self {
GrpcAuthenticationLayer::AuthDisabled => {
Expand All @@ -38,6 +38,13 @@ impl GrpcAuthenticationLayer {

match authorize_current_user(auth_header, issuer_url, issuer_remote_url, cache, reqwest_client).await {
Ok(user) => {
if !user.claims.additional_claims().has_scope(required_scope) {
debug!("Blocking request: missing required scope '{required_scope}'");
return Err(Status::permission_denied(
format!("CARL says, missing required scope: {required_scope}")
));
}

request.extensions_mut().insert(user);
Ok(request)
}
Expand Down
17 changes: 14 additions & 3 deletions opendut-carl/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,9 @@ async fn run(settings: LoadedConfig, get_resource_manager_ref: bool) -> anyhow::
}
};

let mut routes_builder = Routes::builder();
let reqwest_client = reqwest_client::oidc::create_from_config(&settings)?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line is a duplicate of the same line below.


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())
Expand All @@ -166,8 +167,18 @@ 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::<axum::extract::OriginalUri>()
.map(|u| u.path().contains("PeerMessagingBroker"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This match is too broad; it should use an exact/prefix match on the fully-qualified gRPC service path (on OriginalUri) to avoid accidental scope misclassification if other paths ever include that substring.

.unwrap_or(false);
let required_scope = 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(), required_scope)
}))
};

Expand Down
11 changes: 11 additions & 0 deletions opendut-carl/src/manager/peer_manager/generate_cleo_setup.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
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 tracing::debug;
Expand Down Expand Up @@ -32,6 +33,16 @@ pub async fn generate_cleo_setup(params: GenerateCleoSetupParams) -> Result<Cleo
.await
.map_err(|cause| GenerateCleoSetupError::Internal { cause: cause.to_string() })?;
debug!("Successfully generated CLEO setup with id <{cleo_id}>. OIDC client_id='{}'.", client_credentials.client_id.clone().value());

// 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)
}
};
Expand Down
11 changes: 11 additions & 0 deletions opendut-carl/src/manager/peer_manager/generate_peer_setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ 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::vpn::VpnPeerConfiguration;
Expand Down Expand Up @@ -57,6 +58,16 @@ 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());

// 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)
}
};
Expand Down
Loading