use crate::cli::GlobalArgs;
use crate::context::{self, Context};
use elasticctl_api::rules::{self, RuleFilter};
use elasticctl_core::{Config, Result};
use serde_json::{Value, json};
fn check(name: &str, status: &str, message: impl Into<String>) -> Value {
json!({"check": name, "status": status, "message": message.into()})
}
fn key_scope_check(realm: &str) -> Value {
match realm {
"_es_api_key" => check("key_scope", "ok", "project-scoped Elasticsearch API key"),
"_cloud_api_key" => check(
"key_scope",
"warn",
"Organization-level API key: reads and deletes work, but enabling a \
rule will fail. Create a project-scoped Elasticsearch API key in \
Kibana under Management > API keys.",
),
other => check(
"key_scope",
"ok",
format!("authenticated via the '{other}' realm, not an Elasticsearch API key"),
),
}
}
const MAX_IDENTITY_CHARS: usize = 12;
fn short_identity(value: &str) -> String {
if value.chars().count() <= MAX_IDENTITY_CHARS {
return value.to_string();
}
let head: String = value.chars().take(6).collect();
format!("{head}...")
}
pub async fn run(global: &GlobalArgs) -> Result<Value> {
let mut checks = Vec::new();
let path = context::config_path(global);
if let Some(message) = Config::permission_warning(&path) {
checks.push(check("config_permissions", "warn", message));
}
let ctx = match Context::build(global) {
Ok(ctx) => match ctx.require_credential() {
Ok(()) => {
checks.push(check(
"config",
"ok",
format!("profile '{}'", ctx.resolved.name),
));
Some(ctx)
}
Err(e) => {
checks.push(check("config", "fail", e.message));
None
}
},
Err(e) => {
checks.push(check("config", "fail", e.message));
None
}
};
let Some(ctx) = ctx else {
return Ok(json!({"checks": checks, "ok": false}));
};
let caps = match ctx.capabilities().await {
Ok(c) => {
checks.push(check(
"connectivity",
"ok",
ctx.resolved.profile.kibana_url.clone(),
));
checks.push(check(
"flavor",
"ok",
format!("{} {}", c.flavor.as_str(), c.version),
));
Some(c)
}
Err(e) => {
checks.push(check("connectivity", "fail", e.message));
None
}
};
if caps.is_some() {
match identity(&ctx).await {
Ok((username, realm)) => {
checks.push(check(
"auth",
"ok",
format!("{} via {realm}", short_identity(&username)),
));
checks.push(key_scope_check(&realm));
}
Err(e) => checks.push(check("auth", "fail", e.message)),
}
match ctx.transport().await {
Ok(transport) => {
match rules::find_page(transport, &RuleFilter::default(), 1, 1).await {
Ok((_, total)) => checks.push(check(
"rules_access",
"ok",
format!("{total} rules visible"),
)),
Err(e) => checks.push(check("rules_access", "fail", e.message)),
}
}
Err(e) => checks.push(check("rules_access", "fail", e.message)),
}
}
let ok = checks.iter().all(|c| c["status"] != "fail");
Ok(json!({"checks": checks, "ok": ok}))
}
async fn identity(ctx: &Context) -> Result<(String, String)> {
let transport = ctx.transport().await?;
let body = transport
.get_absolute_es("/_security/_authenticate")
.await?;
let username = body["username"].as_str().unwrap_or("unknown").to_string();
let realm = body["authentication_realm"]["type"]
.as_str()
.unwrap_or("unknown")
.to_string();
Ok((username, realm))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn key_scope_check_reports_ok_for_a_project_scoped_es_api_key() {
let c = key_scope_check("_es_api_key");
assert_eq!(c["status"], "ok");
assert!(c["message"].as_str().unwrap().contains("project-scoped"));
}
#[test]
fn key_scope_check_warns_for_an_organization_cloud_api_key() {
let c = key_scope_check("_cloud_api_key");
assert_eq!(c["status"], "warn");
assert!(
c["message"]
.as_str()
.unwrap()
.contains("Organization-level")
);
}
#[test]
fn key_scope_check_names_an_unrecognized_realm_rather_than_calling_it_an_api_key() {
let c = key_scope_check("native");
assert_eq!(c["status"], "ok");
let msg = c["message"].as_str().unwrap();
assert!(msg.contains("native"), "message must name the realm: {msg}");
assert!(
!msg.contains("project-scoped Elasticsearch API key"),
"must not claim a non-API-key realm is a project-scoped API key: {msg}"
);
}
#[test]
fn key_scope_check_names_the_unknown_realm_from_a_parse_failure() {
let c = key_scope_check("unknown");
assert_eq!(c["status"], "ok");
assert!(c["message"].as_str().unwrap().contains("unknown"));
}
#[test]
fn a_key_id_is_truncated_in_the_auth_check() {
let full = "2XTe9p8BLjNicQlhfc9W";
let short = short_identity(full);
assert_eq!(short, "2XTe9p...");
assert!(
!full.starts_with(&short),
"sanity: the id must be shortened"
);
}
#[test]
fn a_human_username_is_left_readable() {
assert_eq!(short_identity("elastic"), "elastic");
assert_eq!(short_identity("admin"), "admin");
assert_eq!(short_identity("unknown"), "unknown");
}
#[test]
fn truncation_never_splits_a_multibyte_character() {
let s = short_identity("ααααααααααααααααα");
assert!(s.ends_with("..."));
assert_eq!(s.chars().count(), 9);
}
}