osauth/loading/
cloud.rs

1// Copyright 2021 Dmitry Tantsur <dtantsur@protonmail.com>
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Cloud configuration structure.
16
17use std::collections::HashMap;
18use std::convert::TryFrom;
19use std::str::FromStr;
20use std::sync::Arc;
21
22use reqwest::Url;
23use serde::{Deserialize, Serialize};
24
25use super::config::from_config;
26use super::env::from_env;
27use crate::client::AuthenticatedClient;
28use crate::common::IdOrName;
29use crate::identity::{ApplicationCredential, Password, Scope, Token};
30use crate::{AuthType, BasicAuth, Error, ErrorKind, InterfaceType, NoAuth, Session};
31
32#[derive(Debug, Clone, Default, Deserialize, Serialize)]
33#[cfg_attr(test, derive(PartialEq, Eq))]
34pub(crate) struct Auth {
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub(crate) auth_url: Option<String>,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub(crate) endpoint: Option<String>,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub(crate) password: Option<String>,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub(crate) project_id: Option<String>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub(crate) project_name: Option<String>,
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub(crate) project_domain_id: Option<String>,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub(crate) project_domain_name: Option<String>,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub(crate) token: Option<String>,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub(crate) username: Option<String>,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub(crate) user_domain_name: Option<String>,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub(crate) user_id: Option<String>,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub(crate) application_credential_id: Option<String>,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub(crate) application_credential_secret: Option<String>,
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub(crate) application_credential_name: Option<String>,
63}
64
65/// Cloud configuration.
66///
67/// This is a source from which sessions and authentications can be created.
68/// It can be loaded from a `clouds.yaml` configuration file or from environment variables.
69/// Additionally, the configuration can be serialized and deserialized.
70#[derive(Debug, Clone, Default, Deserialize, Serialize)]
71#[cfg_attr(test, derive(PartialEq, Eq))]
72pub struct CloudConfig {
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub(crate) auth: Option<Auth>,
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub(crate) auth_type: Option<String>,
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub(crate) cacert: Option<String>,
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub(crate) interface: Option<String>,
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub(crate) region_name: Option<String>,
83    #[serde(flatten)]
84    pub(crate) options: HashMap<String, serde_yaml::Value>,
85}
86
87#[inline]
88fn require(value: Option<String>, message: &str) -> Result<String, Error> {
89    value.ok_or_else(|| Error::new(ErrorKind::InvalidConfig, message))
90}
91
92fn project_scope(
93    project_id: Option<String>,
94    project_name: Option<String>,
95    project_domain_id: Option<String>,
96    project_domain_name: Option<String>,
97) -> Option<Scope> {
98    let project_domain = project_domain_id
99        .map(IdOrName::Id)
100        .or_else(|| project_domain_name.map(IdOrName::Name))
101        .unwrap_or_else(|| IdOrName::from_name("Default"));
102    project_id
103        .map(IdOrName::Id)
104        .or_else(|| project_name.map(IdOrName::Name))
105        .map(|project| Scope::Project {
106            project,
107            domain: Some(project_domain),
108        })
109}
110
111impl Auth {
112    fn create_basic_auth(self) -> Result<BasicAuth, Error> {
113        let endpoint = require(
114            self.endpoint,
115            "HTTP basic authentication requires an endpoint",
116        )?;
117        let username = require(
118            self.username,
119            "HTTP basic authentication requires a username",
120        )?;
121        let password = require(
122            self.password,
123            "HTTP basic authentication requires a password",
124        )?;
125        BasicAuth::new(endpoint, username, password)
126    }
127
128    fn create_none_auth(self) -> Result<NoAuth, Error> {
129        if let Some(endpoint) = self.endpoint {
130            NoAuth::new(endpoint)
131        } else {
132            Ok(NoAuth::new_without_endpoint())
133        }
134    }
135
136    fn create_password_auth(self) -> Result<Password, Error> {
137        let auth_url = require(
138            self.auth_url,
139            "Password authentication requires an authentication URL",
140        )?;
141        let username = require(self.username, "Password authentication requires a username")?;
142        let password = require(self.password, "Password authentication requires a password")?;
143        let user_domain = self
144            .user_domain_name
145            .unwrap_or_else(|| String::from("Default"));
146        let mut id = Password::new(auth_url, username, password, user_domain)?;
147
148        if let Some(scope) = project_scope(
149            self.project_id,
150            self.project_name,
151            self.project_domain_id,
152            self.project_domain_name,
153        ) {
154            id.set_scope(scope);
155        }
156
157        Ok(id)
158    }
159
160    fn create_token_auth(self) -> Result<Token, Error> {
161        let auth_url = require(
162            self.auth_url,
163            "Token authentication requires an authentication URL",
164        )?;
165        let token = require(self.token, "Token authentication requires a token")?;
166        let mut id = Token::new(auth_url, token)?;
167
168        if let Some(scope) = project_scope(
169            self.project_id,
170            self.project_name,
171            self.project_domain_id,
172            self.project_domain_name,
173        ) {
174            id.set_scope(scope);
175        }
176
177        Ok(id)
178    }
179
180    fn create_application_credential(self) -> Result<ApplicationCredential, Error> {
181        let auth_url = require(
182            self.auth_url,
183            "Password authentication requires an authentication URL",
184        )?;
185        let app_secret = require(
186            self.application_credential_secret,
187            "Application credential requires a secret",
188        )?;
189
190        if let Some(app_id) = self.application_credential_id {
191            ApplicationCredential::new(auth_url, app_id, app_secret)
192        } else if let Some(app_name) = self.application_credential_name {
193            if self.username.is_some() && self.user_id.is_none() {
194                return Err(Error::new(
195                    ErrorKind::InvalidConfig,
196                    "Specifying Application Credential by name currently requires specifying the user ID",
197                ));
198            }
199            let user_id = require(
200                self.user_id,
201                "Application Credential authentication by name requires the user ID",
202            )?;
203            ApplicationCredential::with_user_id(auth_url, app_name, app_secret, user_id)
204        } else {
205            Err(Error::new(
206                ErrorKind::InvalidConfig,
207                "Application Credential requires an id or a name",
208            ))
209        }
210    }
211
212    fn create_auth(self, auth_type: Option<String>) -> Result<Arc<dyn AuthType>, Error> {
213        let auth_type = auth_type.unwrap_or_else(|| {
214            if self.token.is_some() {
215                "v3token"
216            } else {
217                "password"
218            }
219            .into()
220        });
221
222        Ok(if auth_type == "password" {
223            Arc::new(self.create_password_auth()?)
224        } else if auth_type == "v3token" {
225            Arc::new(self.create_token_auth()?)
226        } else if auth_type == "http_basic" {
227            Arc::new(self.create_basic_auth()?)
228        } else if auth_type == "v3applicationcredential" {
229            Arc::new(self.create_application_credential()?)
230        } else if auth_type == "none" {
231            Arc::new(self.create_none_auth()?)
232        } else {
233            return Err(Error::new(
234                ErrorKind::InvalidInput,
235                format!("Unsupported authentication type: {}", auth_type),
236            ));
237        })
238    }
239}
240
241// This structure is not strictly necessary but very handy for unit tests.
242#[derive(Debug)]
243pub(crate) struct SessionConfig {
244    pub(crate) client: AuthenticatedClient,
245    pub(crate) endpoint_overrides: HashMap<String, Url>,
246    pub(crate) interface: Option<InterfaceType>,
247    pub(crate) region_name: Option<String>,
248}
249
250impl CloudConfig {
251    /// Create a cloud config from the configuration file.
252    pub fn from_config<S: AsRef<str>>(cloud_name: S) -> Result<CloudConfig, Error> {
253        from_config(cloud_name.as_ref())
254    }
255
256    /// Create a cloud config from environment variables.
257    pub fn from_env() -> Result<CloudConfig, Error> {
258        from_env()
259    }
260
261    fn create_endpoint_overrides(&self) -> Result<HashMap<String, Url>, Error> {
262        let mut result = HashMap::with_capacity(self.options.len());
263        for (ref key, ref value) in &self.options {
264            if let Some(service_type) = key.strip_suffix("_endpoint_override") {
265                if let serde_yaml::Value::String(value) = value {
266                    let url = Url::parse(value).map_err(|e| {
267                        Error::new(
268                            ErrorKind::InvalidConfig,
269                            format!("Invalid {} `{}`: {}", key, value, e),
270                        )
271                    })?;
272                    let _ = result.insert(service_type.to_string(), url.clone());
273                    // Handle types like baremetal-introspection
274                    let with_dashes = service_type.replace('_', "-");
275                    let _ = result.insert(with_dashes, url);
276                } else {
277                    return Err(Error::new(
278                        ErrorKind::InvalidConfig,
279                        format!("{} must be a string, got {:?}", key, value),
280                    ));
281                }
282            }
283        }
284        Ok(result)
285    }
286
287    #[inline]
288    pub(crate) fn create_session_config(self) -> Result<SessionConfig, Error> {
289        let endpoint_overrides = self.create_endpoint_overrides()?;
290        let auth = if let Some(auth_info) = self.auth {
291            auth_info.create_auth(self.auth_type)?
292        } else if self.auth_type.map(|x| x == "none").unwrap_or(false) {
293            Arc::new(NoAuth::new_without_endpoint())
294        } else {
295            return Err(Error::new(
296                ErrorKind::InvalidInput,
297                "Credentials can be missing only for none authentication",
298            ));
299        };
300        let client = AuthenticatedClient::new_internal(super::get_client(self.cacert)?, auth);
301        let interface = if let Some(interface) = self.interface {
302            Some(InterfaceType::from_str(&interface)?)
303        } else {
304            None
305        };
306
307        Ok(SessionConfig {
308            client,
309            endpoint_overrides,
310            interface,
311            region_name: self.region_name,
312        })
313    }
314
315    /// Create a session from this configuration.
316    pub async fn create_session(self) -> Result<Session, Error> {
317        let mut config = self.create_session_config()?;
318        config.client.refresh().await?;
319        let mut result = Session::new_with_authenticated_client(config.client)
320            .with_endpoint_overrides(config.endpoint_overrides);
321        result.endpoint_filters_mut().region = config.region_name;
322        if let Some(interface) = config.interface {
323            result.endpoint_filters_mut().set_interfaces(interface);
324        }
325        Ok(result)
326    }
327
328    fn check_auth_type(&self, expected: &str) -> Result<(), Error> {
329        if let Some(ref auth_type) = self.auth_type {
330            if auth_type != expected {
331                return Err(Error::new(
332                    ErrorKind::InvalidInput,
333                    format!(
334                        "Invalid authentication type, excepted {}, got {}",
335                        expected, auth_type
336                    ),
337                ));
338            }
339        }
340        Ok(())
341    }
342}
343
344impl TryFrom<CloudConfig> for NoAuth {
345    type Error = Error;
346
347    fn try_from(value: CloudConfig) -> Result<NoAuth, Error> {
348        value.check_auth_type("none")?;
349        if let Some(auth) = value.auth {
350            auth.create_none_auth()
351        } else {
352            Ok(NoAuth::new_without_endpoint())
353        }
354    }
355}
356
357impl TryFrom<CloudConfig> for BasicAuth {
358    type Error = Error;
359
360    fn try_from(value: CloudConfig) -> Result<BasicAuth, Error> {
361        value.check_auth_type("http_basic")?;
362        if let Some(auth) = value.auth {
363            auth.create_basic_auth()
364        } else {
365            Err(Error::new(
366                ErrorKind::InvalidInput,
367                "Credentials can be missing only for none authentication",
368            ))
369        }
370    }
371}
372
373impl TryFrom<CloudConfig> for Password {
374    type Error = Error;
375
376    fn try_from(value: CloudConfig) -> Result<Password, Error> {
377        value.check_auth_type("password")?;
378        if let Some(auth) = value.auth {
379            auth.create_password_auth()
380        } else {
381            Err(Error::new(
382                ErrorKind::InvalidInput,
383                "Credentials can be missing only for none authentication",
384            ))
385        }
386    }
387}
388
389impl TryFrom<CloudConfig> for Token {
390    type Error = Error;
391
392    fn try_from(value: CloudConfig) -> Result<Token, Error> {
393        value.check_auth_type("v3token")?;
394        if let Some(auth) = value.auth {
395            auth.create_token_auth()
396        } else {
397            Err(Error::new(
398                ErrorKind::InvalidInput,
399                "Credentials can be missing only for none authentication",
400            ))
401        }
402    }
403}
404
405#[cfg(test)]
406mod test_cloud_config {
407    #[cfg(any(feature = "native-tls", feature = "rustls"))]
408    use std::io::Write;
409
410    use maplit::hashmap;
411    use reqwest::Url;
412
413    use super::{Auth, CloudConfig};
414
415    #[test]
416    fn test_endpoint_overrides_empty() {
417        let cfg = CloudConfig::default();
418        let result = cfg.create_endpoint_overrides().unwrap();
419        assert!(result.is_empty());
420    }
421
422    #[test]
423    fn test_endpoint_overrides_valid() {
424        let options = hashmap! {
425            "baremetal_endpoint_override".into() => "http://127.0.0.1/baremetal".into(),
426            "baremetal_introspection_endpoint_override".into() => "http://127.0.0.1:5050/".into(),
427            "something unrelated".into() => "banana".into(),
428        };
429        let cfg = CloudConfig {
430            options,
431            ..CloudConfig::default()
432        };
433        let result = cfg.create_endpoint_overrides().unwrap();
434        assert_eq!(
435            result,
436            hashmap! {
437                "baremetal".into() => Url::parse("http://127.0.0.1/baremetal").unwrap(),
438                "baremetal_introspection".into() => Url::parse("http://127.0.0.1:5050/").unwrap(),
439                "baremetal-introspection".into() => Url::parse("http://127.0.0.1:5050/").unwrap(),
440            }
441        );
442    }
443
444    #[test]
445    fn test_endpoint_overrides_wrong_type() {
446        let options = hashmap! {
447            "baremetal_endpoint_override".into() => "http://127.0.0.1/baremetal".into(),
448            "baremetal_introspection_endpoint_override".into() => 42.into(),
449        };
450        let cfg = CloudConfig {
451            options,
452            ..CloudConfig::default()
453        };
454        assert!(cfg.create_endpoint_overrides().is_err());
455    }
456
457    #[test]
458    fn test_endpoint_overrides_wrong_url() {
459        let options = hashmap! {
460            "baremetal_endpoint_override".into() => "http://127.0.0.1/baremetal".into(),
461            "baremetal_introspection_endpoint_override".into() => "?! banana".into(),
462        };
463        let cfg = CloudConfig {
464            options,
465            ..CloudConfig::default()
466        };
467        assert!(cfg.create_endpoint_overrides().is_err());
468    }
469
470    #[test]
471    fn test_create_session_config_no_auth() {
472        let cfg = CloudConfig::default();
473        assert!(cfg.create_session_config().is_err());
474    }
475
476    #[tokio::test]
477    async fn test_create_session_config_none_auth() {
478        let options = hashmap! {
479            "baremetal_endpoint_override".into() => "http://127.0.0.1/baremetal".into(),
480        };
481        let cfg = CloudConfig {
482            auth_type: Some("none".into()),
483            options,
484            ..CloudConfig::default()
485        };
486        let sscfg = cfg.create_session_config().unwrap();
487        assert!(sscfg
488            .client
489            .get_endpoint("baremetal", &Default::default())
490            .await
491            .is_err());
492    }
493
494    #[tokio::test]
495    async fn test_create_session_config_basic_auth() {
496        let cfg = CloudConfig {
497            auth_type: Some("http_basic".into()),
498            auth: Some(Auth {
499                username: Some("vasya".into()),
500                password: Some("hacker".into()),
501                endpoint: Some("http://127.0.0.1".into()),
502                ..Auth::default()
503            }),
504            ..CloudConfig::default()
505        };
506        let sscfg = cfg.create_session_config().unwrap();
507        assert_eq!(
508            sscfg
509                .client
510                .get_endpoint("baremetal", &Default::default())
511                .await
512                .unwrap()
513                .as_str(),
514            "http://127.0.0.1/"
515        );
516    }
517
518    #[test]
519    #[cfg(any(feature = "native-tls", feature = "rustls"))]
520    fn test_create_session_config_with_region_and_cacert() {
521        let mut cacert = tempfile::NamedTempFile::new().unwrap();
522        write!(
523            cacert,
524            r#"-----BEGIN CERTIFICATE-----
525MIIBYzCCAQqgAwIBAgIUJcTlPhsFyWG9S0pAAElKuSFEPBYwCgYIKoZIzj0EAwIw
526FDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTIwMTAwMjExNTU1NloXDTIwMTEwMTEx
527NTU1NlowFDESMBAGA1UEAwwJbG9jYWxob3N0MFkwEwYHKoZIzj0CAQYIKoZIzj0D
528AQcDQgAEsfpkV9dAThk54U1K+rXUnNbpwuNo5wCRrKpk+cNR/2HBO8VydNj7dkxs
529VBUvI7M9hY8dgg1jBVoPcCf0GSOvuqM6MDgwFAYDVR0RBA0wC4IJbG9jYWxob3N0
530MAsGA1UdDwQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDATAKBggqhkjOPQQDAgNH
531ADBEAiAdjF7484kjb3XJoLbgqnZh4V1yHKs57eBVuil9/V0YugIgLwb/vSUAPowb
532hK9jLBzNvo8qzKqaGfnGieuLeXCqFDA=
533-----END CERTIFICATE-----"#
534        )
535        .unwrap();
536        cacert.flush().unwrap();
537
538        let cfg = CloudConfig {
539            auth_type: Some("password".into()),
540            auth: Some(Auth {
541                auth_url: Some("http://127.0.0.1".into()),
542                username: Some("vasya".into()),
543                password: Some("hacker".into()),
544                project_name: Some("admin".into()),
545                ..Auth::default()
546            }),
547            cacert: Some(cacert.path().to_str().unwrap().into()),
548            region_name: Some("Lapland".into()),
549            ..CloudConfig::default()
550        };
551        let sscfg = cfg.create_session_config().unwrap();
552        assert_eq!(sscfg.region_name.as_ref().unwrap(), "Lapland");
553    }
554
555    #[test]
556    #[cfg(any(feature = "native-tls", feature = "rustls"))]
557    fn test_create_session_config_cacert_not_found() {
558        let cfg = CloudConfig {
559            auth_type: Some("password".into()),
560            auth: Some(Auth {
561                auth_url: Some("http://127.0.0.1".into()),
562                username: Some("vasya".into()),
563                password: Some("hacker".into()),
564                project_name: Some("admin".into()),
565                ..Auth::default()
566            }),
567            cacert: Some("/I/do/not/exist".into()),
568            region_name: Some("Lapland".into()),
569            ..CloudConfig::default()
570        };
571        assert!(cfg.create_session_config().is_err());
572    }
573}