Skip to main content

elasticctl_core/
auth.rs

1//! Credential selection and `Authorization` header construction.
2
3use crate::config::Profile;
4use crate::error::{Error, ErrorKind, Result};
5use base64::Engine as _;
6use base64::engine::general_purpose::STANDARD;
7
8#[derive(Clone, PartialEq, Eq)]
9pub enum Credential {
10    /// An Elastic API key, already Base64-encoded as `id:key`.
11    ApiKey(String),
12    Basic {
13        username: String,
14        password: String,
15    },
16}
17
18impl std::fmt::Debug for Credential {
19    /// Redacts keys and passwords. Derived `Debug` could expose them in logs
20    /// or panic messages.
21    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        match self {
23            Credential::ApiKey(_) => f.write_str("Credential::ApiKey(***)"),
24            Credential::Basic { username, .. } => {
25                write!(
26                    f,
27                    "Credential::Basic {{ username: {username:?}, password: *** }}"
28                )
29            }
30        }
31    }
32}
33
34impl Credential {
35    /// Prefer an API key when both credential types are configured. It is the
36    /// documented default and works on all deployment flavors.
37    pub fn from_profile(p: &Profile) -> Result<Credential> {
38        if let Some(key) = &p.api_key
39            && !key.trim().is_empty()
40        {
41            return Ok(Credential::ApiKey(key.clone()));
42        }
43        match (&p.username, &p.password) {
44            (Some(u), Some(pw)) => Ok(Credential::Basic {
45                username: u.clone(),
46                password: pw.clone(),
47            }),
48            _ => Err(Error::new(
49                ErrorKind::Auth,
50                "No credential configured. Set api_key, or both username and password.",
51            )),
52        }
53    }
54
55    /// Whether the profile has a usable credential. Reuse credential selection
56    /// so this rule does not diverge from `from_profile`.
57    pub fn is_configured(profile: &Profile) -> bool {
58        Self::from_profile(profile).is_ok()
59    }
60
61    pub fn header_value(&self) -> String {
62        match self {
63            Credential::ApiKey(k) => format!("ApiKey {k}"),
64            Credential::Basic { username, password } => {
65                format!(
66                    "Basic {}",
67                    STANDARD.encode(format!("{username}:{password}"))
68                )
69            }
70        }
71    }
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    fn profile() -> Profile {
79        Profile {
80            kibana_url: "https://kb.example.com".into(),
81            es_url: None,
82            api_key: None,
83            username: None,
84            password: None,
85            space: "default".into(),
86            verify: true,
87            timeout_secs: 30,
88        }
89    }
90
91    #[test]
92    fn api_key_is_sent_verbatim_because_elastic_keys_are_already_encoded() {
93        let c = Credential::ApiKey("essu_abc123".into());
94        assert_eq!(c.header_value(), "ApiKey essu_abc123");
95    }
96
97    #[test]
98    fn basic_auth_is_base64_encoded() {
99        let c = Credential::Basic {
100            username: "elastic".into(),
101            password: "changeme".into(),
102        };
103        // Base64 of "elastic:changeme".
104        assert_eq!(c.header_value(), "Basic ZWxhc3RpYzpjaGFuZ2VtZQ==");
105    }
106
107    #[test]
108    fn api_key_wins_when_both_credentials_are_configured() {
109        let mut p = profile();
110        p.api_key = Some("essu_abc".into());
111        p.username = Some("elastic".into());
112        p.password = Some("changeme".into());
113        assert!(matches!(
114            Credential::from_profile(&p).unwrap(),
115            Credential::ApiKey(_)
116        ));
117    }
118
119    #[test]
120    fn a_profile_with_no_credential_is_an_auth_error() {
121        let err = Credential::from_profile(&profile()).unwrap_err();
122        assert_eq!(err.kind, ErrorKind::Auth);
123    }
124
125    #[test]
126    fn a_username_without_a_password_is_an_auth_error() {
127        let mut p = profile();
128        p.username = Some("elastic".into());
129        assert_eq!(
130            Credential::from_profile(&p).unwrap_err().kind,
131            ErrorKind::Auth
132        );
133    }
134
135    #[test]
136    fn is_configured_true_for_a_valid_api_key() {
137        let mut p = profile();
138        p.api_key = Some("essu_abc".into());
139        assert!(Credential::is_configured(&p));
140    }
141
142    #[test]
143    fn is_configured_false_for_an_empty_api_key_with_no_basic_auth() {
144        let mut p = profile();
145        p.api_key = Some("".into());
146        assert!(!Credential::is_configured(&p));
147    }
148
149    #[test]
150    fn is_configured_false_for_a_username_without_a_password() {
151        let mut p = profile();
152        p.username = Some("elastic".into());
153        assert!(!Credential::is_configured(&p));
154    }
155
156    #[test]
157    fn debug_redacts_api_key_material() {
158        let c = Credential::ApiKey("essu_secret".into());
159        let debug_str = format!("{:?}", c);
160        assert!(!debug_str.contains("essu_secret"));
161        assert!(debug_str.contains("***"));
162    }
163
164    #[test]
165    fn debug_redacts_password_but_shows_username() {
166        let c = Credential::Basic {
167            username: "elastic".into(),
168            password: "changeme".into(),
169        };
170        let debug_str = format!("{:?}", c);
171        assert!(!debug_str.contains("changeme"));
172        assert!(debug_str.contains("elastic"));
173        assert!(debug_str.contains("***"));
174    }
175}