Skip to content

Commit eaccf3a

Browse files
refactor(user-info-fetcher): move initialization into backends (#782)
* refactor: move initialization into backends * chore: changelog --------- Co-authored-by: Nick <10092581+NickLarsenNZ@users.noreply.github.com>
1 parent 1752fe0 commit eaccf3a

File tree

6 files changed

+624
-414
lines changed

6 files changed

+624
-414
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@ All notable changes to this project will be documented in this file.
88

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

11+
### Changed
12+
13+
- user-info-fetcher: Move backend initialization and credential resolution into backend-specific implementations ([#782]).
14+
1115
[#779]: https://github.com/stackabletech/opa-operator/pull/779
16+
[#782]: https://github.com/stackabletech/opa-operator/pull/782
1217

1318
## [25.11.0] - 2025-11-07
1419

rust/user-info-fetcher/src/backend/entra.rs

Lines changed: 155 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
1-
use std::collections::HashMap;
1+
use std::{collections::HashMap, path::Path};
22

33
use hyper::StatusCode;
4+
use reqwest::ClientBuilder;
45
use serde::Deserialize;
56
use snafu::{ResultExt, Snafu};
67
use stackable_opa_operator::crd::user_info_fetcher::v1alpha1;
78
use stackable_operator::commons::{networking::HostName, tls_verification::TlsClientDetails};
89
use url::Url;
910

10-
use crate::{Credentials, UserInfo, UserInfoRequest, http_error, utils::http::send_json_request};
11+
use crate::{
12+
UserInfo, UserInfoRequest, http_error,
13+
utils::{self, http::send_json_request},
14+
};
1115

1216
#[derive(Snafu, Debug)]
1317
pub enum Error {
@@ -40,6 +44,24 @@ pub enum Error {
4044
source: url::ParseError,
4145
endpoint: String,
4246
},
47+
48+
#[snafu(display("failed to construct HTTP client"))]
49+
ConstructHttpClient { source: reqwest::Error },
50+
51+
#[snafu(display("failed to configure TLS"))]
52+
ConfigureTls { source: utils::tls::Error },
53+
54+
#[snafu(display("failed to read client ID from {path:?}"))]
55+
ReadClientId {
56+
source: std::io::Error,
57+
path: String,
58+
},
59+
60+
#[snafu(display("failed to read client secret from {path:?}"))]
61+
ReadClientSecret {
62+
source: std::io::Error,
63+
path: String,
64+
},
4365
}
4466

4567
impl http_error::Error for Error {
@@ -50,6 +72,10 @@ impl http_error::Error for Error {
5072
Self::UserNotFoundById { .. } => StatusCode::NOT_FOUND,
5173
Self::RequestUserGroups { .. } => StatusCode::BAD_GATEWAY,
5274
Self::BuildEntraEndpointFailed { .. } => StatusCode::BAD_REQUEST,
75+
Self::ConstructHttpClient { .. } => StatusCode::SERVICE_UNAVAILABLE,
76+
Self::ConfigureTls { .. } => StatusCode::SERVICE_UNAVAILABLE,
77+
Self::ReadClientId { .. } => StatusCode::SERVICE_UNAVAILABLE,
78+
Self::ReadClientSecret { .. } => StatusCode::SERVICE_UNAVAILABLE,
5379
}
5480
}
5581
}
@@ -80,81 +106,134 @@ struct GroupMembership {
80106
display_name: Option<String>,
81107
}
82108

83-
pub(crate) async fn get_user_info(
84-
req: &UserInfoRequest,
85-
http: &reqwest::Client,
86-
credentials: &Credentials,
87-
config: &v1alpha1::EntraBackend,
88-
) -> Result<UserInfo, Error> {
89-
let v1alpha1::EntraBackend {
90-
client_credentials_secret: _,
91-
token_hostname,
92-
user_info_hostname,
93-
port,
94-
tenant_id,
95-
tls,
96-
} = config;
97-
98-
let entra_backend = EntraBackend::try_new(
99-
token_hostname,
100-
user_info_hostname,
101-
*port,
102-
tenant_id,
103-
TlsClientDetails { tls: tls.clone() }.uses_tls(),
104-
)?;
105-
106-
let token_url = entra_backend.oauth2_token();
107-
let authn = send_json_request::<OAuthResponse>(http.post(token_url).form(&[
108-
("client_id", credentials.client_id.as_str()),
109-
("client_secret", credentials.client_secret.as_str()),
110-
("scope", "https://graph.microsoft.com/.default"),
111-
("grant_type", "client_credentials"),
112-
]))
113-
.await
114-
.context(AccessTokenSnafu)?;
115-
116-
let user_info = match req {
117-
UserInfoRequest::UserInfoRequestById(req) => {
118-
let user_id = &req.id;
119-
send_json_request::<UserMetadata>(
120-
http.get(entra_backend.user_info(user_id))
121-
.bearer_auth(&authn.access_token),
122-
)
123-
.await
124-
.with_context(|_| UserNotFoundByIdSnafu {
125-
user_id: user_id.clone(),
126-
})?
127-
}
128-
UserInfoRequest::UserInfoRequestByName(req) => {
129-
let username = &req.username;
130-
send_json_request::<UserMetadata>(
131-
http.get(entra_backend.user_info(username))
132-
.bearer_auth(&authn.access_token),
133-
)
109+
/// Entra backend with resolved credentials.
110+
///
111+
/// This struct combines the CRD configuration with credentials loaded from the filesystem.
112+
/// Credentials and the HTTP client are initialized once at startup and stored internally.
113+
pub struct ResolvedEntraBackend {
114+
config: v1alpha1::EntraBackend,
115+
client_id: String,
116+
client_secret: String,
117+
http_client: reqwest::Client,
118+
}
119+
120+
impl ResolvedEntraBackend {
121+
/// Resolves an Entra backend by loading credentials from the filesystem.
122+
///
123+
/// Reads `clientId` and `clientSecret` from the credentials directory and initializes
124+
/// the HTTP client with appropriate TLS configuration.
125+
pub async fn resolve(
126+
config: v1alpha1::EntraBackend,
127+
credentials_dir: &Path,
128+
) -> Result<Self, Error> {
129+
let client_id_path = credentials_dir.join("clientId");
130+
let client_secret_path = credentials_dir.join("clientSecret");
131+
132+
let client_id =
133+
tokio::fs::read_to_string(&client_id_path)
134+
.await
135+
.context(ReadClientIdSnafu {
136+
path: client_id_path.display().to_string(),
137+
})?;
138+
let client_secret = tokio::fs::read_to_string(&client_secret_path)
134139
.await
135-
.with_context(|_| SearchForUserSnafu {
136-
username: username.clone(),
137-
})?
138-
}
139-
};
140-
141-
let groups = send_json_request::<GroupMembershipResponse>(
142-
http.get(entra_backend.group_info(&user_info.id))
143-
.bearer_auth(&authn.access_token),
144-
)
145-
.await
146-
.with_context(|_| RequestUserGroupsSnafu {
147-
username: user_info.user_principal_name.clone(),
148-
user_id: user_info.id.clone(),
149-
})?
150-
.value;
151-
152-
Ok(UserInfo {
153-
id: Some(user_info.id),
154-
username: Some(user_info.user_principal_name),
155-
groups: groups.into_iter().filter_map(|g| g.display_name).collect(),
156-
custom_attributes: user_info.attributes,
157-
})
140+
.context(ReadClientSecretSnafu {
141+
path: client_secret_path.display().to_string(),
142+
})?;
143+
144+
let mut client_builder = ClientBuilder::new();
145+
client_builder = utils::tls::configure_reqwest(
146+
&TlsClientDetails {
147+
tls: config.tls.clone(),
148+
},
149+
client_builder,
150+
)
151+
.await
152+
.context(ConfigureTlsSnafu)?;
153+
let http_client = client_builder.build().context(ConstructHttpClientSnafu)?;
154+
155+
Ok(Self {
156+
config,
157+
client_id,
158+
client_secret,
159+
http_client,
160+
})
161+
}
162+
163+
pub(crate) async fn get_user_info(&self, req: &UserInfoRequest) -> Result<UserInfo, Error> {
164+
let v1alpha1::EntraBackend {
165+
client_credentials_secret: _,
166+
token_hostname,
167+
user_info_hostname,
168+
port,
169+
tenant_id,
170+
tls,
171+
} = &self.config;
172+
173+
let entra_backend = EntraBackend::try_new(
174+
token_hostname,
175+
user_info_hostname,
176+
*port,
177+
tenant_id,
178+
TlsClientDetails { tls: tls.clone() }.uses_tls(),
179+
)?;
180+
181+
let token_url = entra_backend.oauth2_token();
182+
let authn = send_json_request::<OAuthResponse>(self.http_client.post(token_url).form(&[
183+
("client_id", self.client_id.as_str()),
184+
("client_secret", self.client_secret.as_str()),
185+
("scope", "https://graph.microsoft.com/.default"),
186+
("grant_type", "client_credentials"),
187+
]))
188+
.await
189+
.context(AccessTokenSnafu)?;
190+
191+
let user_info = match req {
192+
UserInfoRequest::UserInfoRequestById(req) => {
193+
let user_id = &req.id;
194+
send_json_request::<UserMetadata>(
195+
self.http_client
196+
.get(entra_backend.user_info(user_id))
197+
.bearer_auth(&authn.access_token),
198+
)
199+
.await
200+
.with_context(|_| UserNotFoundByIdSnafu {
201+
user_id: user_id.clone(),
202+
})?
203+
}
204+
UserInfoRequest::UserInfoRequestByName(req) => {
205+
let username = &req.username;
206+
send_json_request::<UserMetadata>(
207+
self.http_client
208+
.get(entra_backend.user_info(username))
209+
.bearer_auth(&authn.access_token),
210+
)
211+
.await
212+
.with_context(|_| SearchForUserSnafu {
213+
username: username.clone(),
214+
})?
215+
}
216+
};
217+
218+
let groups = send_json_request::<GroupMembershipResponse>(
219+
self.http_client
220+
.get(entra_backend.group_info(&user_info.id))
221+
.bearer_auth(&authn.access_token),
222+
)
223+
.await
224+
.with_context(|_| RequestUserGroupsSnafu {
225+
username: user_info.user_principal_name.clone(),
226+
user_id: user_info.id.clone(),
227+
})?
228+
.value;
229+
230+
Ok(UserInfo {
231+
id: Some(user_info.id),
232+
username: Some(user_info.user_principal_name),
233+
groups: groups.into_iter().filter_map(|g| g.display_name).collect(),
234+
custom_attributes: user_info.attributes,
235+
})
236+
}
158237
}
159238

160239
struct EntraBackend {

0 commit comments

Comments
 (0)