Skip to main content

elasticctl_api/
profiles.rs

1use elasticctl_core::{Error, ErrorKind, Flavor, Result, Transport, urlencode};
2use serde_json::{Value, json};
3
4pub const UID_PREFIX: &str = "uid:";
5
6pub const PUBLIC_SUGGEST_PATH: &str = "/_security/profile/_suggest";
7
8pub fn internal_find_path(term: &str) -> String {
9    format!(
10        "/internal/detection_engine/users/_find?searchTerm={}",
11        urlencode(term)
12    )
13}
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct UserProfile {
17    pub uid: String,
18    pub username: String,
19    pub realm: Option<String>,
20}
21
22fn decode_profile(entry: &Value, context: &str) -> Result<UserProfile> {
23    let uid = entry
24        .get("uid")
25        .and_then(Value::as_str)
26        .ok_or_else(|| Error::new(ErrorKind::Http, format!("decoding {context} field `uid`")))?;
27    let username = entry
28        .pointer("/user/username")
29        .and_then(Value::as_str)
30        .ok_or_else(|| {
31            Error::new(
32                ErrorKind::Http,
33                format!("decoding {context} field `user.username`"),
34            )
35        })?;
36    Ok(UserProfile {
37        uid: uid.to_string(),
38        username: username.to_string(),
39        realm: entry
40            .pointer("/user/realm_name")
41            .and_then(Value::as_str)
42            .map(str::to_owned),
43    })
44}
45
46pub fn decode_public(value: &Value) -> Result<Vec<UserProfile>> {
47    value
48        .get("profiles")
49        .and_then(Value::as_array)
50        .ok_or_else(|| Error::new(ErrorKind::Http, "decoding profile suggest field `profiles`"))?
51        .iter()
52        .map(|p| decode_profile(p, "profile suggest entry"))
53        .collect()
54}
55
56pub fn decode_internal(value: &Value) -> Result<Vec<UserProfile>> {
57    value
58        .as_array()
59        .ok_or_else(|| {
60            Error::new(
61                ErrorKind::Http,
62                "decoding users find response: expected an array",
63            )
64        })?
65        .iter()
66        .map(|p| decode_profile(p, "users find entry"))
67        .collect()
68}
69
70fn downgrade_unavailable(e: Error, flavor: Flavor, missing_es_url: bool) -> Error {
71    let unavailable = matches!(e.http_status, Some(404) | Some(410))
72        || e.kind == ErrorKind::Permission
73        || (e.http_status == Some(400) && e.message.contains("is not available"));
74    if unavailable {
75        let cause = if missing_es_url {
76            "; this profile has no es_url configured, so the request went to the Kibana host \
77             instead — set es_url if the Elasticsearch endpoint differs"
78        } else {
79            ""
80        };
81        Error::new(
82            ErrorKind::Unsupported,
83            format!(
84                "profile suggestion is unavailable on {} ({}){cause}; pass uid:<profile_uid> to bypass resolution",
85                flavor.as_str(),
86                e.message
87            ),
88        )
89    } else {
90        e
91    }
92}
93
94pub async fn suggest(t: &Transport, flavor: Flavor, name: &str) -> Result<Vec<UserProfile>> {
95    match flavor {
96        Flavor::Serverless => {
97            let body = t
98                .get_internal(&internal_find_path(name))
99                .await
100                .map_err(|e| downgrade_unavailable(e, flavor, false))?;
101            decode_internal(&body)
102        }
103        Flavor::ElasticCloudHosted | Flavor::SelfManaged => {
104            let body = t
105                .post_absolute_es(PUBLIC_SUGGEST_PATH, &json!({ "name": name, "size": 10 }))
106                .await
107                .map_err(|e| downgrade_unavailable(e, flavor, !t.has_es_url()))?;
108            decode_public(&body)
109        }
110    }
111}
112
113pub fn pick_exact(candidates: &[UserProfile], username: &str) -> Result<String> {
114    let matches: Vec<&UserProfile> = candidates
115        .iter()
116        .filter(|p| p.username == username)
117        .collect();
118    match matches.as_slice() {
119        [] => Err(Error::new(
120            ErrorKind::NotFound,
121            format!(
122                "no user profile for '{username}': the user must have logged into Kibana at least \
123                 once to activate a profile, and an API-key identity never has one"
124            ),
125        )),
126        [one] => Ok(one.uid.clone()),
127        many => {
128            let listed: Vec<String> = many
129                .iter()
130                .map(|p| {
131                    format!(
132                        "{} ({})",
133                        p.username,
134                        p.realm.as_deref().unwrap_or("unknown realm")
135                    )
136                })
137                .collect();
138            Err(Error::new(
139                ErrorKind::Conflict,
140                format!(
141                    "username '{username}' is ambiguous across realms: {}",
142                    listed.join(", ")
143                ),
144            ))
145        }
146    }
147}
148
149pub async fn resolve_assignee(t: &Transport, input: &str) -> Result<String> {
150    if let Some(uid) = input.strip_prefix(UID_PREFIX) {
151        if uid.is_empty() {
152            return Err(Error::new(
153                ErrorKind::Error,
154                "empty profile uid after 'uid:'",
155            ));
156        }
157        return Ok(uid.to_string());
158    }
159    let flavor = t.capabilities().await?.flavor;
160    let candidates = suggest(t, flavor, input).await?;
161    pick_exact(&candidates, input)
162}