elasticctl_api/
profiles.rs1use 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}