Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 list transmitted as "scope" claim in JWT)
#[serde(default, rename = "scope")]
pub scopes: String,
}

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.scopes.split_whitespace().any(|s| s == required_scope)
}
}

cfg_if! {
Expand Down
13 changes: 12 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, accepted_scopes: &'static [&'static str]) -> anyhow::Result<tonic::Request<()>, Status> {

match self {
GrpcAuthenticationLayer::AuthDisabled => {
Expand All @@ -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)
}
Expand Down
35 changes: 32 additions & 3 deletions opendut-carl/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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::<axum::extract::OriginalUri>()
.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::<axum::extract::OriginalUri>()
.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)
}))
};

Expand Down
15 changes: 13 additions & 2 deletions opendut-carl/src/manager/peer_manager/generate_cleo_setup.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -32,7 +33,17 @@ 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());
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())])
}
};

Expand Down
15 changes: 13 additions & 2 deletions opendut-carl/src/manager/peer_manager/generate_peer_setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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())])
}
};

Expand Down
5 changes: 3 additions & 2 deletions opendut-cleo/src/commands/setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>().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());
}
}
Expand Down
7 changes: 3 additions & 4 deletions opendut-model/src/util/net.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<OAuthScope>) -> Self {
Self::Enabled {
issuer_url,
client_id: client_credentials.client_id,
client_secret: client_credentials.client_secret,
scopes: vec![],
scopes,
}
}
}
Expand All @@ -455,7 +454,7 @@ mod tests {
let client_credentials = ClientCredentials { client_id: client_id.clone(), client_secret: client_secret.clone() };
let expected_scopes: Vec<OAuthScope> = 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,
Expand Down
Loading