elasticctl_api/
profiles.rs1use elasticctl_core::{Error, ErrorKind, Flavor, Result, Transport, urlencode};
12use serde_json::{Value, json};
13
14pub 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 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
59pub 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
70pub 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
85fn 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
120pub 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
141pub 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
179pub 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}