1- use std:: collections:: HashMap ;
1+ use std:: { collections:: HashMap , path :: Path } ;
22
33use hyper:: StatusCode ;
4+ use reqwest:: ClientBuilder ;
45use serde:: Deserialize ;
56use snafu:: { ResultExt , Snafu } ;
67use stackable_opa_operator:: crd:: user_info_fetcher:: v1alpha1;
78use stackable_operator:: commons:: { networking:: HostName , tls_verification:: TlsClientDetails } ;
89use 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 ) ]
1317pub 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
4567impl 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
160239struct EntraBackend {
0 commit comments