Skip to content

Commit 5970759

Browse files
committed
fix: respect next link in uif entra backend requests
1 parent b581c1d commit 5970759

4 files changed

Lines changed: 237 additions & 15 deletions

File tree

Cargo.lock

Lines changed: 68 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ tokio = { version = "1.52", features = ["full"] }
4848
tracing = "0.1"
4949
url = "2.5"
5050
uuid = "1.10"
51+
wiremock = "0.6"
5152

5253
[patch."https://github.com/stackabletech/operator-rs.git"]
5354
# stackable-operator = { git = "https://github.com/stackabletech//operator-rs.git", branch = "main" }

rust/user-info-fetcher/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,5 +34,8 @@ tracing.workspace = true
3434
url.workspace = true
3535
uuid.workspace = true
3636

37+
[dev-dependencies]
38+
wiremock.workspace = true
39+
3740
[build-dependencies]
3841
built.workspace = true

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

Lines changed: 165 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,10 @@ struct UserMetadata {
9898
#[serde(rename_all = "camelCase")]
9999
struct GroupMembershipResponse {
100100
value: Vec<GroupMembership>,
101+
102+
/// Set by Graph when further pages of group memberships are available.
103+
#[serde(rename = "@odata.nextLink")]
104+
next_link: Option<String>,
101105
}
102106

103107
#[derive(Deserialize)]
@@ -215,22 +219,31 @@ impl ResolvedEntraBackend {
215219
}
216220
};
217221

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;
222+
let mut groups = Vec::new();
223+
let mut next_url = Some(entra_backend.group_info(&user_info.id));
224+
225+
while let Some(url) = next_url {
226+
let response = send_json_request::<GroupMembershipResponse>(
227+
self.http_client.get(url).bearer_auth(&authn.access_token),
228+
)
229+
.await
230+
.with_context(|_| RequestUserGroupsSnafu {
231+
username: user_info.user_principal_name.clone(),
232+
user_id: user_info.id.clone(),
233+
})?;
234+
235+
groups.extend(response.value.into_iter().filter_map(|g| g.display_name));
236+
237+
next_url = response
238+
.next_link
239+
.map(|next_link| entra_backend.next_page(&next_link))
240+
.transpose()?;
241+
}
229242

230243
Ok(UserInfo {
231244
id: Some(user_info.id),
232245
username: Some(user_info.user_principal_name),
233-
groups: groups.into_iter().filter_map(|g| g.display_name).collect(),
246+
groups,
234247
custom_attributes: user_info.attributes,
235248
})
236249
}
@@ -282,18 +295,142 @@ impl EntraBackend {
282295
user_info_url
283296
}
284297

298+
/// Requests the first page of the user's group memberships.
299+
///
300+
/// The `microsoft.graph.group` segment restricts the result to groups. Without it, Graph also
301+
/// returns the directory roles and administrative units the user belongs to.
285302
pub fn group_info(&self, user: &str) -> Url {
286303
let mut user_info_url = self.user_info_endpoint_url.clone();
287-
user_info_url.set_path(&format!("/v1.0/users/{user}/memberOf"));
304+
user_info_url.set_path(&format!(
305+
"/v1.0/users/{user}/memberOf/microsoft.graph.group"
306+
));
307+
// 999 is the largest page size Graph accepts, and keeps the number of round-trips down.
308+
user_info_url.set_query(Some("$select=displayName&$top=999"));
288309
user_info_url
289310
}
311+
312+
/// Rebases an `@odata.nextLink` onto the configured user info endpoint.
313+
///
314+
/// Graph reports the link against its public `graph.microsoft.com` host, so following it
315+
/// verbatim would ignore a configured endpoint and send the access token to whichever host
316+
/// the response names. Only the path and query are taken from the link itself.
317+
pub fn next_page(&self, next_link: &str) -> Result<Url, Error> {
318+
let next_link_url = Url::parse(next_link).context(BuildEntraEndpointFailedSnafu {
319+
endpoint: next_link,
320+
})?;
321+
322+
let mut next_page_url = self.user_info_endpoint_url.clone();
323+
next_page_url.set_path(next_link_url.path());
324+
next_page_url.set_query(next_link_url.query());
325+
Ok(next_page_url)
326+
}
290327
}
291328

292329
#[cfg(test)]
293330
mod tests {
294331
use std::str::FromStr;
295332

333+
use stackable_operator::v2::types::kubernetes::SecretName;
334+
use wiremock::{
335+
Mock, MockServer, ResponseTemplate,
336+
matchers::{method, path, query_param},
337+
};
338+
296339
use super::*;
340+
use crate::UserInfoRequestById;
341+
342+
const TENANT_ID: &str = "1234-5678-1234-5678";
343+
const USER_ID: &str = "8765-4321-8765-4321";
344+
345+
/// Builds a backend pointing at `mock_server`, bypassing [`ResolvedEntraBackend::resolve`]
346+
/// so that no credentials have to be read from disk.
347+
fn backend_for(mock_server: &MockServer) -> ResolvedEntraBackend {
348+
let host = HostName::from_str(&mock_server.address().ip().to_string()).unwrap();
349+
350+
ResolvedEntraBackend {
351+
config: v1alpha2::EntraBackend {
352+
token_hostname: host.clone(),
353+
user_info_hostname: host,
354+
port: Some(mock_server.address().port()),
355+
tenant_id: TENANT_ID.to_owned(),
356+
tls: None,
357+
client_credentials_secret: SecretName::from_str("entra-credentials").unwrap(),
358+
},
359+
client_id: "client-id".to_owned(),
360+
client_secret: "client-secret".to_owned(),
361+
http_client: reqwest::Client::new(),
362+
}
363+
}
364+
365+
/// Mocks the OAuth2 token and user metadata endpoints, which every `get_user_info` call hits
366+
/// before it gets to the group memberships we actually care about.
367+
async fn mock_token_and_user(mock_server: &MockServer) {
368+
Mock::given(method("POST"))
369+
.and(path(format!("/{TENANT_ID}/oauth2/v2.0/token")))
370+
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
371+
"access_token": "access-token",
372+
})))
373+
.mount(mock_server)
374+
.await;
375+
376+
Mock::given(method("GET"))
377+
.and(path(format!("/v1.0/users/{USER_ID}")))
378+
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
379+
"id": USER_ID,
380+
"userPrincipalName": "alice@example.com",
381+
})))
382+
.mount(mock_server)
383+
.await;
384+
}
385+
386+
async fn get_user_info_by_id(backend: &ResolvedEntraBackend) -> UserInfo {
387+
backend
388+
.get_user_info(&UserInfoRequest::UserInfoRequestById(UserInfoRequestById {
389+
id: USER_ID.to_owned(),
390+
}))
391+
.await
392+
.unwrap()
393+
}
394+
395+
#[tokio::test]
396+
async fn test_entra_follows_paginated_group_memberships() {
397+
let mock_server = MockServer::start().await;
398+
mock_token_and_user(&mock_server).await;
399+
400+
let group_path = format!("/v1.0/users/{USER_ID}/memberOf/microsoft.graph.group");
401+
let next_link_path = "/v1.0/paged-groups";
402+
403+
// First page: two groups, plus a link to the second page.
404+
Mock::given(method("GET"))
405+
.and(path(group_path))
406+
.respond_with(
407+
ResponseTemplate::new(200).set_body_json(serde_json::json!({
408+
"value": [
409+
{"displayName": "group-1"},
410+
{"displayName": "group-2"},
411+
],
412+
"@odata.nextLink": format!("https://graph.microsoft.com{next_link_path}?$skiptoken=abc"),
413+
})),
414+
)
415+
.mount(&mock_server)
416+
.await;
417+
418+
// Second (final) page: one more group and no further link.
419+
Mock::given(method("GET"))
420+
.and(path(next_link_path))
421+
.and(query_param("$skiptoken", "abc"))
422+
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
423+
"value": [
424+
{"displayName": "group-3"},
425+
],
426+
})))
427+
.mount(&mock_server)
428+
.await;
429+
430+
let user_info = get_user_info_by_id(&backend_for(&mock_server)).await;
431+
432+
assert_eq!(user_info.groups, vec!["group-1", "group-2", "group-3"]);
433+
}
297434

298435
#[test]
299436
fn test_entra_defaults_id() {
@@ -323,10 +460,16 @@ mod tests {
323460
assert_eq!(
324461
entra.group_info(user),
325462
Url::parse(&format!(
326-
"https://graph.microsoft.com/v1.0/users/{user}/memberOf"
463+
"https://graph.microsoft.com/v1.0/users/{user}/memberOf/microsoft.graph.group?$select=displayName&$top=999"
327464
))
328465
.unwrap()
329466
);
467+
assert_eq!(
468+
entra
469+
.next_page("https://graph.microsoft.com/v1.0/paged-groups?$skiptoken=abc")
470+
.unwrap(),
471+
Url::parse("https://graph.microsoft.com/v1.0/paged-groups?$skiptoken=abc").unwrap()
472+
);
330473
}
331474

332475
#[test]
@@ -357,9 +500,16 @@ mod tests {
357500
assert_eq!(
358501
entra.group_info(user),
359502
Url::parse(&format!(
360-
"http://graph.mock.com:8080/v1.0/users/{user}/memberOf"
503+
"http://graph.mock.com:8080/v1.0/users/{user}/memberOf/microsoft.graph.group?$select=displayName&$top=999"
361504
))
362505
.unwrap()
363506
);
507+
// The link Graph returns names its own public host, but the configured endpoint wins.
508+
assert_eq!(
509+
entra
510+
.next_page("https://graph.microsoft.com/v1.0/paged-groups?$skiptoken=abc")
511+
.unwrap(),
512+
Url::parse("http://graph.mock.com:8080/v1.0/paged-groups?$skiptoken=abc").unwrap()
513+
);
364514
}
365515
}

0 commit comments

Comments
 (0)