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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ All notable changes to this project will be documented in this file.

- Add support for OpenLDAP backend to user-info-fetcher ([#779]).

### Changed

- user-info-fetcher: Move backend initialization and credential resolution into backend-specific implementations ([#782]).

[#779]: https://github.com/stackabletech/opa-operator/pull/779
[#782]: https://github.com/stackabletech/opa-operator/pull/782

## [25.11.0] - 2025-11-07

Expand Down
231 changes: 155 additions & 76 deletions rust/user-info-fetcher/src/backend/entra.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
use std::collections::HashMap;
use std::{collections::HashMap, path::Path};

use hyper::StatusCode;
use reqwest::ClientBuilder;
use serde::Deserialize;
use snafu::{ResultExt, Snafu};
use stackable_opa_operator::crd::user_info_fetcher::v1alpha1;
use stackable_operator::commons::{networking::HostName, tls_verification::TlsClientDetails};
use url::Url;

use crate::{Credentials, UserInfo, UserInfoRequest, http_error, utils::http::send_json_request};
use crate::{
UserInfo, UserInfoRequest, http_error,
utils::{self, http::send_json_request},
};

#[derive(Snafu, Debug)]
pub enum Error {
Expand Down Expand Up @@ -40,6 +44,24 @@ pub enum Error {
source: url::ParseError,
endpoint: String,
},

#[snafu(display("failed to construct HTTP client"))]
ConstructHttpClient { source: reqwest::Error },

#[snafu(display("failed to configure TLS"))]
ConfigureTls { source: utils::tls::Error },

#[snafu(display("failed to read client ID from {path:?}"))]
ReadClientId {
source: std::io::Error,
path: String,
},

#[snafu(display("failed to read client secret from {path:?}"))]
ReadClientSecret {
source: std::io::Error,
path: String,
},
}

impl http_error::Error for Error {
Expand All @@ -50,6 +72,10 @@ impl http_error::Error for Error {
Self::UserNotFoundById { .. } => StatusCode::NOT_FOUND,
Self::RequestUserGroups { .. } => StatusCode::BAD_GATEWAY,
Self::BuildEntraEndpointFailed { .. } => StatusCode::BAD_REQUEST,
Self::ConstructHttpClient { .. } => StatusCode::SERVICE_UNAVAILABLE,
Self::ConfigureTls { .. } => StatusCode::SERVICE_UNAVAILABLE,
Self::ReadClientId { .. } => StatusCode::SERVICE_UNAVAILABLE,
Self::ReadClientSecret { .. } => StatusCode::SERVICE_UNAVAILABLE,
}
}
}
Expand Down Expand Up @@ -80,81 +106,134 @@ struct GroupMembership {
display_name: Option<String>,
}

pub(crate) async fn get_user_info(
req: &UserInfoRequest,
http: &reqwest::Client,
credentials: &Credentials,
config: &v1alpha1::EntraBackend,
) -> Result<UserInfo, Error> {
let v1alpha1::EntraBackend {
client_credentials_secret: _,
token_hostname,
user_info_hostname,
port,
tenant_id,
tls,
} = config;

let entra_backend = EntraBackend::try_new(
token_hostname,
user_info_hostname,
*port,
tenant_id,
TlsClientDetails { tls: tls.clone() }.uses_tls(),
)?;

let token_url = entra_backend.oauth2_token();
let authn = send_json_request::<OAuthResponse>(http.post(token_url).form(&[
("client_id", credentials.client_id.as_str()),
("client_secret", credentials.client_secret.as_str()),
("scope", "https://graph.microsoft.com/.default"),
("grant_type", "client_credentials"),
]))
.await
.context(AccessTokenSnafu)?;

let user_info = match req {
UserInfoRequest::UserInfoRequestById(req) => {
let user_id = &req.id;
send_json_request::<UserMetadata>(
http.get(entra_backend.user_info(user_id))
.bearer_auth(&authn.access_token),
)
.await
.with_context(|_| UserNotFoundByIdSnafu {
user_id: user_id.clone(),
})?
}
UserInfoRequest::UserInfoRequestByName(req) => {
let username = &req.username;
send_json_request::<UserMetadata>(
http.get(entra_backend.user_info(username))
.bearer_auth(&authn.access_token),
)
/// Entra backend with resolved credentials.
///
/// This struct combines the CRD configuration with credentials loaded from the filesystem.
/// Credentials and the HTTP client are initialized once at startup and stored internally.
pub struct ResolvedEntraBackend {
config: v1alpha1::EntraBackend,
client_id: String,
client_secret: String,
http_client: reqwest::Client,
}

impl ResolvedEntraBackend {
/// Resolves an Entra backend by loading credentials from the filesystem.
///
/// Reads `clientId` and `clientSecret` from the credentials directory and initializes
/// the HTTP client with appropriate TLS configuration.
pub async fn resolve(
config: v1alpha1::EntraBackend,
credentials_dir: &Path,
) -> Result<Self, Error> {
let client_id_path = credentials_dir.join("clientId");
let client_secret_path = credentials_dir.join("clientSecret");

let client_id =
tokio::fs::read_to_string(&client_id_path)
.await
.context(ReadClientIdSnafu {
path: client_id_path.display().to_string(),
})?;
let client_secret = tokio::fs::read_to_string(&client_secret_path)
.await
.with_context(|_| SearchForUserSnafu {
username: username.clone(),
})?
}
};

let groups = send_json_request::<GroupMembershipResponse>(
http.get(entra_backend.group_info(&user_info.id))
.bearer_auth(&authn.access_token),
)
.await
.with_context(|_| RequestUserGroupsSnafu {
username: user_info.user_principal_name.clone(),
user_id: user_info.id.clone(),
})?
.value;

Ok(UserInfo {
id: Some(user_info.id),
username: Some(user_info.user_principal_name),
groups: groups.into_iter().filter_map(|g| g.display_name).collect(),
custom_attributes: user_info.attributes,
})
.context(ReadClientSecretSnafu {
path: client_secret_path.display().to_string(),
})?;

let mut client_builder = ClientBuilder::new();
client_builder = utils::tls::configure_reqwest(
&TlsClientDetails {
tls: config.tls.clone(),
},
client_builder,
)
.await
.context(ConfigureTlsSnafu)?;
let http_client = client_builder.build().context(ConstructHttpClientSnafu)?;

Ok(Self {
config,
client_id,
client_secret,
http_client,
})
}

pub(crate) async fn get_user_info(&self, req: &UserInfoRequest) -> Result<UserInfo, Error> {
let v1alpha1::EntraBackend {
client_credentials_secret: _,
token_hostname,
user_info_hostname,
port,
tenant_id,
tls,
} = &self.config;

let entra_backend = EntraBackend::try_new(
token_hostname,
user_info_hostname,
*port,
tenant_id,
TlsClientDetails { tls: tls.clone() }.uses_tls(),
)?;

let token_url = entra_backend.oauth2_token();
let authn = send_json_request::<OAuthResponse>(self.http_client.post(token_url).form(&[
("client_id", self.client_id.as_str()),
("client_secret", self.client_secret.as_str()),
("scope", "https://graph.microsoft.com/.default"),
("grant_type", "client_credentials"),
]))
.await
.context(AccessTokenSnafu)?;

let user_info = match req {
UserInfoRequest::UserInfoRequestById(req) => {
let user_id = &req.id;
send_json_request::<UserMetadata>(
self.http_client
.get(entra_backend.user_info(user_id))
.bearer_auth(&authn.access_token),
)
.await
.with_context(|_| UserNotFoundByIdSnafu {
user_id: user_id.clone(),
})?
}
UserInfoRequest::UserInfoRequestByName(req) => {
let username = &req.username;
send_json_request::<UserMetadata>(
self.http_client
.get(entra_backend.user_info(username))
.bearer_auth(&authn.access_token),
)
.await
.with_context(|_| SearchForUserSnafu {
username: username.clone(),
})?
}
};

let groups = send_json_request::<GroupMembershipResponse>(
self.http_client
.get(entra_backend.group_info(&user_info.id))
.bearer_auth(&authn.access_token),
)
.await
.with_context(|_| RequestUserGroupsSnafu {
username: user_info.user_principal_name.clone(),
user_id: user_info.id.clone(),
})?
.value;

Ok(UserInfo {
id: Some(user_info.id),
username: Some(user_info.user_principal_name),
groups: groups.into_iter().filter_map(|g| g.display_name).collect(),
custom_attributes: user_info.attributes,
})
}
}

struct EntraBackend {
Expand Down
Loading
Loading