Skip to main content

elasticctl_api/
profiles.rs

1//! Username-to-profile-uid resolution for alert (and, in 0.4.1, case)
2//! assignment.
3//!
4//! The assignees routes take user profile uids, and a uid exists only after
5//! its user has activated a profile by logging into Kibana at least once.
6//! Resolution is flavor-dependent (triage spec section 7): Hosted and
7//! self-managed use the public suggest API; Serverless answers 410 there, so
8//! it uses the Security solution's own internal suggestion route — the one
9//! the assignee picker in the UI calls.
10
11use elasticctl_core::{Error, ErrorKind, Flavor, Result, Transport, urlencode};
12use serde_json::{Value, json};
13
14/// Prefix that bypasses resolution: `uid:<profile_uid>` is passed through.
15/// The escape hatch, not the primary interface.
16pub const UID_PREFIX: &str = "uid:";
17
18pub const PUBLIC_SUGGEST_PATH: &str = "/_security/profile/_suggest";
19
20pub fn internal_find_path(term: &str) -> String {
21    format!(
22        "/internal/detection_engine/users/_find?searchTerm={}",
23        urlencode(term)
24    )
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct UserProfile {
29    pub uid: String,
30    pub username: String,
31    /// The public route reports `realm_name`; the internal one does not.
32    pub realm: Option<String>,
33}
34
35fn decode_profile(entry: &Value, context: &str) -> Result<UserProfile> {
36    let uid = entry
37        .get("uid")
38        .and_then(Value::as_str)
39        .ok_or_else(|| Error::new(ErrorKind::Http, format!("decoding {context} field `uid`")))?;
40    let username = entry
41        .pointer("/user/username")
42        .and_then(Value::as_str)
43        .ok_or_else(|| {
44            Error::new(
45                ErrorKind::Http,
46                format!("decoding {context} field `user.username`"),
47            )
48        })?;
49    Ok(UserProfile {
50        uid: uid.to_string(),
51        username: username.to_string(),
52        realm: entry
53            .pointer("/user/realm_name")
54            .and_then(Value::as_str)
55            .map(str::to_owned),
56    })
57}
58
59/// Decode `POST /_security/profile/_suggest`: `{total, took, profiles: [...]}`.
60pub fn decode_public(value: &Value) -> Result<Vec<UserProfile>> {
61    value
62        .get("profiles")
63        .and_then(Value::as_array)
64        .ok_or_else(|| Error::new(ErrorKind::Http, "decoding profile suggest field `profiles`"))?
65        .iter()
66        .map(|p| decode_profile(p, "profile suggest entry"))
67        .collect()
68}
69
70/// Decode `GET /internal/detection_engine/users/_find`: a bare array.
71pub fn decode_internal(value: &Value) -> Result<Vec<UserProfile>> {
72    value
73        .as_array()
74        .ok_or_else(|| {
75            Error::new(
76                ErrorKind::Http,
77                "decoding users find response: expected an array",
78            )
79        })?
80        .iter()
81        .map(|p| decode_profile(p, "users find entry"))
82        .collect()
83}
84
85/// A resolution route that is missing, withdrawn, or forbidden is a definite
86/// refusal with a remedy, not an unclassified failure. The internal route
87/// family answers a 400 "exists but is not available" instead of a 404 when
88/// `x-elastic-internal-origin` is absent or the route is otherwise refused;
89/// message-scoped so a genuine bad request (a different 400) still surfaces
90/// as `http`.
91///
92/// `missing_es_url` names a likely cause on the public route: without an
93/// `es_url`, `post_absolute_es` silently falls back to the Kibana host, and
94/// the 404 that follows would otherwise blame the deployment ("unavailable
95/// on elastic-cloud-hosted") for what is really a profile misconfiguration.
96fn downgrade_unavailable(e: Error, flavor: Flavor, missing_es_url: bool) -> Error {
97    let unavailable = matches!(e.http_status, Some(404) | Some(410))
98        || e.kind == ErrorKind::Permission
99        || (e.http_status == Some(400) && e.message.contains("is not available"));
100    if unavailable {
101        let cause = if missing_es_url {
102            "; this profile has no es_url configured, so the request went to the Kibana host \
103             instead — set es_url if the Elasticsearch endpoint differs"
104        } else {
105            ""
106        };
107        Error::new(
108            ErrorKind::Unsupported,
109            format!(
110                "profile suggestion is unavailable on {} ({}){cause}; pass uid:<profile_uid> to bypass resolution",
111                flavor.as_str(),
112                e.message
113            ),
114        )
115    } else {
116        e
117    }
118}
119
120/// Suggest activated profiles matching `name`, on the route this flavor
121/// serves.
122pub async fn suggest(t: &Transport, flavor: Flavor, name: &str) -> Result<Vec<UserProfile>> {
123    match flavor {
124        Flavor::Serverless => {
125            let body = t
126                .get_internal(&internal_find_path(name))
127                .await
128                .map_err(|e| downgrade_unavailable(e, flavor, false))?;
129            decode_internal(&body)
130        }
131        Flavor::ElasticCloudHosted | Flavor::SelfManaged => {
132            let body = t
133                .post_absolute_es(PUBLIC_SUGGEST_PATH, &json!({ "name": name, "size": 10 }))
134                .await
135                .map_err(|e| downgrade_unavailable(e, flavor, !t.has_es_url()))?;
136            decode_public(&body)
137        }
138    }
139}
140
141/// Match the suggestion list exactly on `user.username`, mirroring rule-name
142/// resolution: never a prefix, never a silent first pick.
143pub fn pick_exact(candidates: &[UserProfile], username: &str) -> Result<String> {
144    let matches: Vec<&UserProfile> = candidates
145        .iter()
146        .filter(|p| p.username == username)
147        .collect();
148    match matches.as_slice() {
149        [] => Err(Error::new(
150            ErrorKind::NotFound,
151            format!(
152                "no user profile for '{username}': the user must have logged into Kibana at least \
153                 once to activate a profile, and an API-key identity never has one"
154            ),
155        )),
156        [one] => Ok(one.uid.clone()),
157        many => {
158            let listed: Vec<String> = many
159                .iter()
160                .map(|p| {
161                    format!(
162                        "{} ({})",
163                        p.username,
164                        p.realm.as_deref().unwrap_or("unknown realm")
165                    )
166                })
167                .collect();
168            Err(Error::new(
169                ErrorKind::Conflict,
170                format!(
171                    "username '{username}' is ambiguous across realms: {}",
172                    listed.join(", ")
173                ),
174            ))
175        }
176    }
177}
178
179/// Resolve an assignee argument to a profile uid. `uid:<uid>` bypasses
180/// resolution entirely; anything else is a username resolved per flavor.
181pub async fn resolve_assignee(t: &Transport, input: &str) -> Result<String> {
182    if let Some(uid) = input.strip_prefix(UID_PREFIX) {
183        if uid.is_empty() {
184            return Err(Error::new(
185                ErrorKind::Error,
186                "empty profile uid after 'uid:'",
187            ));
188        }
189        return Ok(uid.to_string());
190    }
191    let flavor = t.capabilities().await?.flavor;
192    let candidates = suggest(t, flavor, input).await?;
193    pick_exact(&candidates, input)
194}