@@ -98,6 +98,10 @@ struct UserMetadata {
9898#[ serde( rename_all = "camelCase" ) ]
9999struct 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) ]
293330mod 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