fix: generate and store the ext auth salt on a new install instead of refusing to start (#15191)

This commit is contained in:
Hengfei Yang
2026-10-08 09:56:01 +00:00
committed by GitHub
parent 1bf81734c5
commit 610cd70a13
23 changed files with 320 additions and 274 deletions
-2
View File
@@ -1,7 +1,5 @@
ZO_ROOT_USER_EMAIL = "root@example.com"
ZO_ROOT_USER_PASSWORD = "Complexpass#123"
# required on a new install: 16 to 48 random bytes, e.g. from `openssl rand -hex 24`
ZO_EXT_AUTH_SALT = ""
ZO_MAX_FILE_SIZE_ON_DISK = 32
ZO_FILE_PUSH_INTERVAL = 10
ZO_S3_BUCKET_NAME = "zinc-dev"
-1
View File
@@ -20,7 +20,6 @@ env:
COLUMNS: 150
ZO_ROOT_USER_EMAIL: root@example.com
ZO_ROOT_USER_PASSWORD: Complexpass#123
ZO_EXT_AUTH_SALT: ci-only-ext-auth-salt-0123456789abcdef
ZO_BASE_URL: http://localhost:5080/
ZO_BASE_URL_SC: http://localhost:5080/
WS_ZO_BASE_URL: ws://localhost:5080/
-1
View File
@@ -20,7 +20,6 @@ env:
COLUMNS: 150
ZO_ROOT_USER_EMAIL: root@example.com
ZO_ROOT_USER_PASSWORD: Complexpass#123
ZO_EXT_AUTH_SALT: ci-only-ext-auth-salt-0123456789abcdef
ZO_BASE_URL: http://localhost:5080/
ZO_INTERNAL_GRPC_TOKEN: fake_token_for_testing
ZO_MEM_PERSIST_INTERVAL: 1
-1
View File
@@ -84,7 +84,6 @@ env:
# Non-secret defaults for a fresh local OpenObserve (identical to the other test workflows).
ZO_ROOT_USER_EMAIL: root@example.com
ZO_ROOT_USER_PASSWORD: Complexpass#123
ZO_EXT_AUTH_SALT: ci-only-ext-auth-salt-0123456789abcdef
# RUM-ingestion parity with the proven playwright.yml suite. Without these a fresh
# OpenObserve can silently reject/misfile RUM ingest (loopback SSRF guard, missing org,
# dynamic schema, past-timestamp window). The iOS binary inherits these from the job env;
-1
View File
@@ -21,7 +21,6 @@ env:
COLUMNS: 150
ZO_ROOT_USER_EMAIL: root@example.com
ZO_ROOT_USER_PASSWORD: Complexpass#123
ZO_EXT_AUTH_SALT: ci-only-ext-auth-salt-0123456789abcdef
ZO_BASE_URL: http://localhost:5080
WS_ZO_BASE_URL: ws://localhost:5080
ZO_BASE_URL_SC: http://localhost:5080
@@ -18,7 +18,6 @@ env:
COLUMNS: 150
ZO_ROOT_USER_EMAIL: root@example.com
ZO_ROOT_USER_PASSWORD: Complexpass#123
ZO_EXT_AUTH_SALT: ci-only-ext-auth-salt-0123456789abcdef
ZO_BASE_URL: http://localhost:5080
WS_ZO_BASE_URL: ws://localhost:5080
ZO_BASE_URL_SC: http://localhost:5080
-2
View File
@@ -112,7 +112,6 @@ jobs:
contents: read
env:
ZO_INTERNAL_GRPC_TOKEN: fake
ZO_EXT_AUTH_SALT: ci-only-ext-auth-salt-0123456789abcdef
ZO_TEXT_DATA_TYPE: text
ZO_META_STORE: postgres
ZO_META_POSTGRES_DSN: postgres://postgres:password@localhost:5432/postgres
@@ -187,7 +186,6 @@ jobs:
contents: read
env:
ZO_INTERNAL_GRPC_TOKEN: fake
ZO_EXT_AUTH_SALT: ci-only-ext-auth-salt-0123456789abcdef
ZO_TEXT_DATA_TYPE: text
ZO_META_STORE: sqlite
ZO_META_POSTGRES_DSN: ""
+2 -3
View File
@@ -89,18 +89,17 @@ Create a `.env` file in the root `openobserve` directory (if not already present
```env
ZO_WEB_URL="http://localhost:8081"
ZO_CORS_ALLOWED_ORIGINS="http://localhost:3000, http://localhost:5173, http://localhost:8081"
ZO_EXT_AUTH_SALT="<output of openssl rand -hex 24, kept the same across restarts>"
```
Start the backend server:
```shell
ZO_ROOT_USER_EMAIL="root@example.com" ZO_ROOT_USER_PASSWORD="Complexpass#123" ZO_EXT_AUTH_SALT="<16 to 48 random bytes, e.g. from openssl rand -hex 24>" cargo run
ZO_ROOT_USER_EMAIL="root@example.com" ZO_ROOT_USER_PASSWORD="Complexpass#123" cargo run
```
This will start the openobserve API server on port 5080
environment variables ZO_ROOT_USER_EMAIL and ZO_ROOT_USER_PASSWORD can be used first time to configure default admin user when openobserve is started. A new install refuses to start until ZO_EXT_AUTH_SALT is set to a random secret of 16 to 48 bytes (for example `openssl rand -hex 24`); keep the same value across restarts and nodes.
environment variables ZO_ROOT_USER_EMAIL and ZO_ROOT_USER_PASSWORD can be used first time to configure default admin user when openobserve is started. A new install generates the salt for presigned and ext-token logins and stores it in the meta db; set ZO_EXT_AUTH_SALT to a random secret of 16 to 48 bytes (for example `openssl rand -hex 24`) only if you want it kept out of the db, and then keep the same value across restarts and nodes.
### UI
-2
View File
@@ -81,14 +81,12 @@ Get started in minutes without managing infrastructure. The free tier includes u
### 🐳 Docker
```bash
export ZO_EXT_AUTH_SALT="$(openssl rand -hex 24)" # save it: the same value must be used on every restart
docker run -d \
--name openobserve \
-v $PWD/data:/data \
-p 5080:5080 \
-e ZO_ROOT_USER_EMAIL="root@example.com" \
-e ZO_ROOT_USER_PASSWORD="Complexpass#123" \
-e ZO_EXT_AUTH_SALT="$ZO_EXT_AUTH_SALT" \
public.ecr.aws/zinclabs/openobserve:latest
```
-3
View File
@@ -51,9 +51,6 @@ spec:
value: root@example.com
- name: ZO_ROOT_USER_PASSWORD
value: Complexpass#123
# required on a new install: 16 to 48 random bytes, e.g. from `openssl rand -hex 24`
- name: ZO_EXT_AUTH_SALT
value: ""
- name: ZO_DATA_DIR
value: /data
# command: ["/bin/bash", "-c", "while true; do sleep 1; done"]
+12 -8
View File
@@ -757,8 +757,7 @@ pub async fn validate_credentials_ext(
auth_token: AuthTokensExt,
method: &str,
) -> Result<TokenValidationResponse, AuthError> {
let cfg = get_config();
let password_ext_salt = cfg.auth.ext_auth_salt.as_str();
let password_ext_salt = &config::get_ext_auth_salt();
// Strip leading slash if present
let path = path.strip_prefix('/').unwrap_or(path);
let mut path_columns = path.split('/').collect::<Vec<&str>>();
@@ -1042,8 +1041,14 @@ pub async fn validate_user(
let db_user = db::user::get_user_record(user_id)
.await
.map(|user| DBUser::from(&user));
let cfg = get_config();
validate_user_from_db(db_user, user_password, None, 0, &cfg.auth.ext_auth_salt).await
validate_user_from_db(
db_user,
user_password,
None,
0,
&config::get_ext_auth_salt(),
)
.await
}
pub async fn validate_user_for_query_params(
@@ -1053,13 +1058,12 @@ pub async fn validate_user_for_query_params(
exp_in: i64,
) -> Result<TokenValidationResponse, AuthError> {
let db_user = db::user::get_db_user(user_id).await;
let cfg = get_config();
validate_user_from_db(
db_user,
user_password,
req_time,
exp_in,
&cfg.auth.ext_auth_salt,
&config::get_ext_auth_salt(),
)
.await
}
@@ -2097,7 +2101,7 @@ mod tests {
.is_valid
);
assert!(validate_user(init_user, pwd).await.unwrap().is_valid);
let stored_ext = get_hash(pwd, &get_config().auth.ext_auth_salt);
let stored_ext = get_hash(pwd, &config::get_ext_auth_salt());
for (path, method) in [
("default/_bulk", Method::POST),
("default/streams", Method::GET),
@@ -2253,7 +2257,7 @@ mod tests {
request_time: 1_700_000_000,
expires_in: 300,
};
let salt = get_config().auth.ext_auth_salt.clone();
let salt = config::get_ext_auth_salt();
let unbacked = password_ext_credential("", "1700000000", 300, &salt);
assert!(
!validate_credentials_ext(email, &unbacked, "default/streams", auth_token, "GET")
+1 -1
View File
@@ -848,7 +848,7 @@ pub async fn get_presigned_url(
let cfg = get_config();
let time = chrono::Utc::now().timestamp();
let password_ext_salt = cfg.auth.ext_auth_salt.as_str();
let password_ext_salt = &config::get_ext_auth_salt();
let base_url = format!("{}{}", cfg.common.web_url, cfg.common.base_uri);
let url = generate_presigned_url(
+36 -2
View File
@@ -412,6 +412,9 @@ pub static NATS_KV_WATCH_MODULES: Lazy<HashSet<String>> = Lazy::new(|| {
pub static CONFIG: Lazy<ArcSwap<Config>> = Lazy::new(|| ArcSwap::from(Arc::new(init())));
static INSTANCE_ID: Lazy<RwHashMap<String, String>> = Lazy::new(Default::default);
static STORED_GRPC_TOKEN: Lazy<ArcSwap<String>> = Lazy::new(Default::default);
static STORED_EXT_AUTH_SALT: Lazy<ArcSwap<String>> = Lazy::new(Default::default);
/// Installs older than the stored salt hash with this value: rotating it breaks their logins.
pub const LEGACY_EXT_AUTH_SALT: &str = "openobserve";
pub fn get_config() -> Arc<Config> {
CONFIG.load().clone()
@@ -577,6 +580,30 @@ pub fn get_stored_grpc_token() -> String {
STORED_GRPC_TOKEN.load().to_string()
}
/// Caches the ext auth salt stored in the meta db; empty means none is stored.
pub fn cache_stored_ext_auth_salt(salt: &str) {
STORED_EXT_AUTH_SALT.store(Arc::new(salt.to_owned()));
}
pub fn get_stored_ext_auth_salt() -> String {
STORED_EXT_AUTH_SALT.load().to_string()
}
pub fn get_ext_auth_salt() -> String {
select_ext_auth_salt(
&get_config().auth.ext_auth_salt,
&get_stored_ext_auth_salt(),
)
}
fn select_ext_auth_salt(env_salt: &str, stored_salt: &str) -> String {
[env_salt, stored_salt]
.into_iter()
.find(|s| !s.is_empty())
.unwrap_or(LEGACY_EXT_AUTH_SALT)
.to_string()
}
pub fn calculate_config_file_hash(path: &PathBuf) -> Result<String, anyhow::Error> {
let content = std::fs::read_to_string(path)?;
Ok(digest(content))
@@ -1488,8 +1515,8 @@ pub struct Auth {
pub cookie_same_site_lax: bool,
#[env_config(name = "ZO_COOKIE_SECURE_ONLY", default = false)]
pub cookie_secure_only: bool,
/// Secret for presigned and ext-token logins; a new install refuses to start with the default.
#[env_config(name = "ZO_EXT_AUTH_SALT", default = "openobserve")]
/// Empty: a new install generates one and stores it in the meta db.
#[env_config(name = "ZO_EXT_AUTH_SALT", default = "")]
pub ext_auth_salt: String,
#[env_config(
name = "ZO_ALERT_CHART_SIGNING_KEY",
@@ -6362,4 +6389,11 @@ mod tests {
let result = deverbatim(p);
assert_eq!(result, r"C:\data\openobserve");
}
#[test]
fn test_select_ext_auth_salt_prefers_env_then_stored_then_legacy() {
assert_eq!(select_ext_auth_salt("env", "stored"), "env");
assert_eq!(select_ext_auth_salt("", "stored"), "stored");
assert_eq!(select_ext_auth_salt("", ""), LEGACY_EXT_AUTH_SALT);
}
}
@@ -52,7 +52,7 @@ pub async fn signing_key() -> Option<&'static [u8]> {
SIGNING_KEY
.get_or_try_init(|| async {
let cfg = config::get_config();
let salt = cfg.auth.ext_auth_salt.as_str();
let salt = &config::get_ext_auth_salt();
if !cfg.auth.alert_chart_signing_key.is_empty() {
return Ok(payload::derive_signing_key(
&cfg.auth.alert_chart_signing_key,
@@ -73,12 +73,7 @@ pub struct ChartPayload {
pub trigger_ts: u64,
}
/// Derive the chart-signing key from per-instance secret material (IKM) and
/// the deployment salt. HKDF-extract (one HMAC application, salt as key) with
/// a versioned domain-separation label — sufficient without a new dependency.
///
/// Callers must fail closed on empty IKM: `ZO_EXT_AUTH_SALT` defaults to the
/// public constant "openobserve", so the salt alone MUST NOT produce a key.
/// Callers must fail closed on empty IKM: the public legacy salt alone must not produce a key.
pub fn derive_signing_key(ikm: &str, salt: &str) -> Vec<u8> {
let mut mac =
Hmac::<Sha256>::new_from_slice(salt.as_bytes()).expect("HMAC accepts any key length");
+224 -24
View File
@@ -16,16 +16,25 @@
use std::sync::Arc;
use ::common::infra::wal;
use config::{cache_instance_id, cache_stored_grpc_token, get_config, ider};
use config::{
LEGACY_EXT_AUTH_SALT, cache_instance_id, cache_stored_ext_auth_salt, cache_stored_grpc_token,
get_config, ider,
};
use db::metas;
#[cfg(feature = "enterprise")]
use crate::self_reporting::CoreAuditPublisher;
use crate::self_reporting::persistence::CoreBatchPublisher;
const MIN_EXT_AUTH_SALT_LEN: usize = 16;
// argon2 hashes with a salt of 8 to 48 bytes and get_hash panics outside that
const MIN_ARGON2_SALT_LEN: usize = 8;
const MAX_EXT_AUTH_SALT_LEN: usize = 48;
struct InstanceIdentity {
instance_id: String,
grpc_token: Option<String>,
ext_auth_salt: Option<String>,
}
struct CoreOrganizationProvisioner;
@@ -60,13 +69,21 @@ impl schema::OrganizationProvisioner for CoreOrganizationProvisioner {
/// The instance id must stay stable for the lifetime of the deployment:
/// generate a new one only when it is genuinely absent, never because the db
/// errored on the read.
async fn get_or_create_instance_identity() -> Result<InstanceIdentity, anyhow::Error> {
async fn get_or_create_instance_identity(
env_ext_auth_salt: &str,
) -> Result<InstanceIdentity, anyhow::Error> {
const MAX_RETRIES: usize = 5;
let mut last_err = None;
for attempt in 1..=MAX_RETRIES {
match read_instance_identity().await {
Ok(Some(identity)) => return Ok(identity),
Ok(None) => return create_instance_identity().await,
Ok(Some(identity)) => {
check_ext_auth_salt(false, env_ext_auth_salt)?;
return Ok(identity);
}
Ok(None) => {
check_ext_auth_salt(true, env_ext_auth_salt)?;
return create_instance_identity(env_ext_auth_salt).await;
}
Err(e) => {
log::warn!(
"error in getting instance id (attempt {attempt}/{MAX_RETRIES}): {e}, retrying..."
@@ -86,33 +103,88 @@ async fn read_instance_identity() -> infra::errors::Result<Option<InstanceIdenti
let Some(instance_id) = metas::instance::get().await? else {
return Ok(None);
};
let (grpc_token, ext_auth_salt) =
tokio::try_join!(metas::grpc_token::get(), metas::ext_auth_salt::get())?;
Ok(Some(InstanceIdentity {
instance_id,
grpc_token: metas::grpc_token::get().await?,
grpc_token,
ext_auth_salt,
}))
}
async fn create_instance_identity() -> Result<InstanceIdentity, anyhow::Error> {
/// Existing installs only warn on a weak usable salt: rotating it breaks logins derived from it.
fn check_ext_auth_salt(fresh_install: bool, salt: &str) -> anyhow::Result<()> {
// unset: a new install generates and stores one, an existing one keeps the legacy salt
if salt.is_empty() {
return Ok(());
}
if salt.len() > MAX_EXT_AUTH_SALT_LEN {
return Err(anyhow::anyhow!(
"ZO_EXT_AUTH_SALT is {} bytes, but argon2 accepts at most {MAX_EXT_AUTH_SALT_LEN}; set it to a random secret of {MIN_EXT_AUTH_SALT_LEN} to {MAX_EXT_AUTH_SALT_LEN} bytes (for example `openssl rand -hex 24`)",
salt.len()
));
}
if salt != LEGACY_EXT_AUTH_SALT && salt.len() >= MIN_EXT_AUTH_SALT_LEN {
return Ok(());
}
if fresh_install {
return Err(anyhow::anyhow!(
"ZO_EXT_AUTH_SALT is the public legacy salt or shorter than {MIN_EXT_AUTH_SALT_LEN} bytes; on a new install set a random secret of {MIN_EXT_AUTH_SALT_LEN} to {MAX_EXT_AUTH_SALT_LEN} bytes (for example `openssl rand -hex 24`) kept the same on every node and restart, or leave it unset to have one generated and stored in the meta db"
));
}
if salt.len() < MIN_ARGON2_SALT_LEN {
return Err(anyhow::anyhow!(
"ZO_EXT_AUTH_SALT is {} bytes, but argon2 needs at least {MIN_ARGON2_SALT_LEN}; set it to a random secret of {MIN_EXT_AUTH_SALT_LEN} to {MAX_EXT_AUTH_SALT_LEN} bytes (for example `openssl rand -hex 24`)",
salt.len()
));
}
log::warn!(
"ZO_EXT_AUTH_SALT is the public legacy salt or shorter than {MIN_EXT_AUTH_SALT_LEN} bytes; set a random secret of {MIN_EXT_AUTH_SALT_LEN} to {MAX_EXT_AUTH_SALT_LEN} bytes before the first presigned or ext-token login"
);
Ok(())
}
/// With `ZO_EXT_AUTH_SALT` set the operator keeps the salt out of the meta db: nothing is stored.
async fn create_instance_identity(
env_ext_auth_salt: &str,
) -> Result<InstanceIdentity, anyhow::Error> {
log::info!("Generating new instance id");
// stored before the instance id, so a node that reads the id also reads the secrets
let ext_auth_salt = if env_ext_auth_salt.is_empty() {
let candidate = config::utils::rand::random_hex(24);
Some(metas::ext_auth_salt::get_or_create(&candidate).await?)
} else {
None
};
let candidate = config::utils::rand::random_hex(32);
// stored before the instance id, so a node that reads the id also reads the token
let grpc_token = metas::grpc_token::get_or_create(&candidate).await?;
let instance_id = ider::generate();
metas::instance::set(&instance_id).await?;
Ok(InstanceIdentity {
instance_id,
grpc_token: Some(grpc_token),
ext_auth_salt,
})
}
fn cache_instance_identity(identity: &InstanceIdentity) {
fn cache_instance_identity(identity: &InstanceIdentity, env_ext_auth_salt: &str) {
cache_instance_id(&identity.instance_id);
cache_stored_grpc_token(identity.grpc_token.as_deref().unwrap_or_default());
cache_stored_ext_auth_salt(identity.ext_auth_salt.as_deref().unwrap_or_default());
if identity.grpc_token.is_none() && get_config().grpc.internal_grpc_token.is_empty() {
log::warn!(
"ZO_INTERNAL_GRPC_TOKEN is not set and this install has no stored internal gRPC token, so the instance id is used as the token; set ZO_INTERNAL_GRPC_TOKEN to the same secret on every node"
);
}
match identity.ext_auth_salt.as_deref() {
None if env_ext_auth_salt.is_empty() => log::warn!(
"ZO_EXT_AUTH_SALT is not set and this install has no stored ext auth salt, so the public legacy salt is used; set ZO_EXT_AUTH_SALT to the same random secret of 16 to 48 bytes on every node"
),
Some(stored) if !env_ext_auth_salt.is_empty() && stored != env_ext_auth_salt => log::warn!(
"ZO_EXT_AUTH_SALT differs from the ext auth salt stored in the meta db; the env value is used, and presigned or ext-token logins derived from the stored salt will fail"
),
_ => {}
}
}
pub async fn init() -> Result<(), anyhow::Error> {
@@ -124,7 +196,11 @@ pub async fn init() -> Result<(), anyhow::Error> {
audit::set_audit_publisher(Arc::new(CoreAuditPublisher))
.map_err(|_| anyhow::anyhow!("audit publisher is already initialized"))?;
cache_instance_identity(&get_or_create_instance_identity().await?);
let env_ext_auth_salt = get_config().auth.ext_auth_salt.clone();
cache_instance_identity(
&get_or_create_instance_identity(&env_ext_auth_salt).await?,
&env_ext_auth_salt,
);
// _meta used to appear only once self-reporting wrote its first stream into it,
// so a deployment that reports nothing never had the org its history pages read.
@@ -149,7 +225,7 @@ mod tests {
async fn reset_identity() {
infra::db::create_table().await.unwrap();
let db = infra::db::get_db().await;
for key in ["/instance/", "/internal_grpc_token/"] {
for key in ["/instance/", "/internal_grpc_token/", "/ext_auth_salt/"] {
db.delete_if_exists(key, false, false).await.unwrap();
}
}
@@ -158,7 +234,7 @@ mod tests {
async fn test_fresh_install_gets_a_random_grpc_token() {
let _guard = META_KEYS.lock().await;
reset_identity().await;
let identity = get_or_create_instance_identity().await.unwrap();
let identity = get_or_create_instance_identity("").await.unwrap();
let token = identity.grpc_token.expect("a fresh install stores a token");
assert_eq!(token.len(), 64);
assert!(token.chars().all(|c| c.is_ascii_hexdigit()));
@@ -169,7 +245,7 @@ mod tests {
);
reset_identity().await;
let other = get_or_create_instance_identity().await.unwrap();
let other = get_or_create_instance_identity("").await.unwrap();
assert_ne!(other.grpc_token, Some(token));
}
@@ -177,11 +253,13 @@ mod tests {
async fn test_grpc_token_is_stable_across_restarts() {
let _guard = META_KEYS.lock().await;
reset_identity().await;
let first = get_or_create_instance_identity().await.unwrap();
let second = get_or_create_instance_identity().await.unwrap();
let first = get_or_create_instance_identity("").await.unwrap();
let second = get_or_create_instance_identity("").await.unwrap();
assert!(first.grpc_token.is_some());
assert!(first.ext_auth_salt.is_some());
assert_eq!(first.instance_id, second.instance_id);
assert_eq!(first.grpc_token, second.grpc_token);
assert_eq!(first.ext_auth_salt, second.ext_auth_salt);
}
#[tokio::test]
@@ -189,33 +267,155 @@ mod tests {
let _guard = META_KEYS.lock().await;
reset_identity().await;
metas::instance::set("existing-instance").await.unwrap();
let identity = get_or_create_instance_identity().await.unwrap();
let identity = get_or_create_instance_identity("").await.unwrap();
assert_eq!(identity.instance_id, "existing-instance");
assert_eq!(identity.grpc_token, None);
assert_eq!(metas::grpc_token::get().await.unwrap(), None);
cache_stored_grpc_token("left-over");
cache_instance_identity(&identity);
cache_stored_ext_auth_salt("left-over");
cache_instance_identity(&identity, "");
assert_eq!(config::get_stored_grpc_token(), "");
assert_eq!(config::get_stored_ext_auth_salt(), "");
assert_eq!(config::get_instance_id(), "existing-instance");
}
#[tokio::test]
async fn test_fresh_install_without_env_salt_stores_a_random_one() {
let _guard = META_KEYS.lock().await;
reset_identity().await;
let identity = get_or_create_instance_identity("").await.unwrap();
let salt = identity
.ext_auth_salt
.expect("a fresh install stores a salt");
assert_eq!(salt.len(), 48);
assert!(salt.chars().all(|c| c.is_ascii_hexdigit()));
assert!(
::config::utils::hash::try_get_passcode_hash("p", &salt).is_some(),
"argon2 must accept the generated salt"
);
assert_eq!(
metas::ext_auth_salt::get().await.unwrap().as_deref(),
Some(salt.as_str())
);
}
#[tokio::test]
async fn test_fresh_install_with_env_salt_stores_none() {
let _guard = META_KEYS.lock().await;
reset_identity().await;
let identity = get_or_create_instance_identity("env-salt-0123456789abcdef")
.await
.unwrap();
assert_eq!(identity.ext_auth_salt, None);
assert_eq!(metas::ext_auth_salt::get().await.unwrap(), None);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn test_concurrent_first_boots_agree_on_the_grpc_token() {
async fn test_concurrent_first_boots_agree_on_the_stored_secrets() {
let _guard = META_KEYS.lock().await;
reset_identity().await;
let boots = (0..4)
.map(|_| tokio::spawn(get_or_create_instance_identity()))
.map(|_| tokio::spawn(get_or_create_instance_identity("")))
.collect::<Vec<_>>();
let mut tokens = Vec::new();
let mut secrets = Vec::new();
for boot in boots {
tokens.push(boot.await.unwrap().unwrap().grpc_token);
let identity = boot.await.unwrap().unwrap();
secrets.push((identity.grpc_token, identity.ext_auth_salt));
}
let stored = metas::grpc_token::get().await.unwrap();
assert!(stored.is_some());
let stored = (
metas::grpc_token::get().await.unwrap(),
metas::ext_auth_salt::get().await.unwrap(),
);
assert!(stored.0.is_some() && stored.1.is_some());
assert!(
tokens.iter().all(|t| *t == stored),
"{tokens:?} vs {stored:?}"
secrets.iter().all(|s| *s == stored),
"{secrets:?} vs {stored:?}"
);
}
#[test]
fn a_new_install_refuses_the_legacy_or_a_short_ext_auth_salt() {
for salt in [LEGACY_EXT_AUTH_SALT, "short-secret"] {
let err = check_ext_auth_salt(true, salt).unwrap_err().to_string();
assert!(err.contains("ZO_EXT_AUTH_SALT"), "{salt}: {err}");
}
}
#[test]
fn an_unset_ext_auth_salt_is_accepted_on_any_install() {
for fresh_install in [true, false] {
assert!(check_ext_auth_salt(fresh_install, "").is_ok());
}
}
#[test]
fn a_new_install_accepts_a_random_ext_auth_salt() {
assert!(check_ext_auth_salt(true, "3f9c2a7e5b1d8c4f6a0e2b9d7c5a3f1e").is_ok());
}
#[test]
fn an_accepted_ext_auth_salt_is_one_argon2_can_hash_with() {
// 0 is "unset": the install hashes with a generated or legacy salt instead
for len in 1..=96 {
let salt = "a".repeat(len);
let hashable =
::config::utils::hash::try_get_passcode_hash("Complexpass#123", &salt).is_some();
for fresh_install in [true, false] {
let accepted = check_ext_auth_salt(fresh_install, &salt).is_ok();
assert!(
!accepted || hashable,
"len {len}, fresh {fresh_install}: accepted but argon2 refuses it"
);
}
}
}
#[test]
fn the_recommended_ext_auth_salt_is_accepted_and_a_hex_32_one_is_refused() {
let hex_24 = "9f2c4e6a8b0d1f3e5c7a9b1d3f5e7c9a0b2d4f6e8a1c3e5f";
assert_eq!(hex_24.len(), 48);
assert!(check_ext_auth_salt(true, hex_24).is_ok());
assert!(::config::utils::hash::try_get_passcode_hash("p", hex_24).is_some());
let hex_32 = "9f2c4e6a8b0d1f3e5c7a9b1d3f5e7c9a0b2d4f6e8a1c3e5f7a9b0c1d2e3f4a5b";
assert_eq!(hex_32.len(), 64);
for fresh_install in [true, false] {
let err = check_ext_auth_salt(fresh_install, hex_32)
.unwrap_err()
.to_string();
assert!(err.contains("ZO_EXT_AUTH_SALT"), "{err}");
assert!(err.contains("openssl rand -hex 24"), "{err}");
}
}
#[test]
fn the_new_install_refusal_recommends_a_salt_that_fits() {
let err = check_ext_auth_salt(true, LEGACY_EXT_AUTH_SALT)
.unwrap_err()
.to_string();
assert!(err.contains("openssl rand -hex 24"), "{err}");
}
#[test]
fn an_existing_install_keeps_starting_with_the_default_or_a_short_usable_ext_auth_salt() {
for salt in [LEGACY_EXT_AUTH_SALT, "eight888", "short-secret"] {
assert!(check_ext_auth_salt(false, salt).is_ok(), "{salt:?}");
}
}
#[test]
fn an_existing_install_refuses_an_ext_auth_salt_argon2_cannot_use() {
let too_long = "a".repeat(MAX_EXT_AUTH_SALT_LEN + 1);
for salt in ["a", "abc", "seven77", too_long.as_str()] {
assert!(
::config::utils::hash::try_get_passcode_hash("p", salt).is_none(),
"{salt:?} is usable"
);
let err = check_ext_auth_salt(false, salt)
.expect_err(salt)
.to_string();
assert!(err.contains("ZO_EXT_AUTH_SALT"), "{salt:?}: {err}");
assert!(err.contains("openssl rand -hex 24"), "{salt:?}: {err}");
}
}
}
+5 -9
View File
@@ -28,7 +28,7 @@ use common::meta::user::{InviteStatus, UserInvite, UserInviteList};
#[cfg(feature = "enterprise")]
use config::meta::ratelimit::CachedUserRoles;
use config::{
DEFAULT_ORG, META_ORG_ID, get_config, ider,
DEFAULT_ORG, META_ORG_ID, ider,
meta::user::{DBUser, User, UserOrg, UserRole},
utils::rand::generate_random_string,
};
@@ -81,7 +81,6 @@ pub async fn post_user(
if !is_valid_email(&usr_req.email) {
return Ok(MetaHttpResponse::bad_request("Invalid email"));
}
let cfg = get_config();
usr_req.email = usr_req.email.to_lowercase();
if let Some(_custom_roles) = &usr_req.role.custom_role {
#[cfg(not(feature = "enterprise"))]
@@ -165,7 +164,7 @@ pub async fn post_user(
}
let salt = ider::uuid();
let password = get_hash(&usr_req.password, &salt);
let password_ext = get_hash(&usr_req.password, &cfg.auth.ext_auth_salt);
let password_ext = get_hash(&usr_req.password, &config::get_ext_auth_salt());
let token = generate_random_string(16);
let token_for_response = token.clone();
let rum_token = format!("rum{}", generate_random_string(16));
@@ -309,8 +308,7 @@ pub async fn update_user(
let mut old_role = None;
let mut new_role = None;
let conf = get_config();
let password_ext_salt = conf.auth.ext_auth_salt.as_str();
let password_ext_salt = &config::get_ext_auth_salt();
let Ok(existing_user) = existing_user else {
return Ok(MetaHttpResponse::not_found("User not found"));
@@ -1342,10 +1340,9 @@ pub async fn list_user_invites(user_id: &str, only_pending: bool) -> Result<Resp
}
pub async fn create_root_user(org_id: &str, user_req: UserRequest) -> Result<(), anyhow::Error> {
let cfg = get_config();
let salt = ider::uuid();
let password = get_hash(&user_req.password, &salt);
let password_ext = get_hash(&user_req.password, &cfg.auth.ext_auth_salt);
let password_ext = get_hash(&user_req.password, &config::get_ext_auth_salt());
let token = user_req
.token
.clone()
@@ -1457,8 +1454,7 @@ pub async fn create_service_account_if_not_exists(email: &str) -> Result<(), any
let random_password = generate_random_string(32);
let salt = ider::uuid();
let password_hash = get_hash(&random_password, &salt);
let cfg = get_config();
let password_ext = get_hash(&random_password, &cfg.auth.ext_auth_salt);
let password_ext = get_hash(&random_password, &config::get_ext_auth_salt());
let now = chrono::Utc::now().timestamp_micros();
let user_record = infra::table::users::UserRecord {
email: email.to_string(),
+38 -11
View File
@@ -65,41 +65,68 @@ pub mod instance {
}
pub mod grpc_token {
use infra::errors::Result;
// its own module, not a key under /instance/: NATS get falls back to a prefix scan there
const KEY: &str = "/internal_grpc_token/";
pub async fn get() -> Result<Option<String>> {
super::secret::get(KEY).await
}
pub async fn get_or_create(candidate: &str) -> Result<String> {
super::secret::get_or_create(KEY, candidate).await
}
}
pub mod ext_auth_salt {
use infra::errors::Result;
const KEY: &str = "/ext_auth_salt/";
pub async fn get() -> Result<Option<String>> {
super::secret::get(KEY).await
}
pub async fn get_or_create(candidate: &str) -> Result<String> {
super::secret::get_or_create(KEY, candidate).await
}
}
mod secret {
use bytes::Bytes;
use infra::{
db::{NO_NEED_WATCH, get_db},
errors::{Error, Result},
};
// its own module, not a key under /instance/: NATS get falls back to a prefix scan there
const KEY: &str = "/internal_grpc_token/";
pub async fn get() -> Result<Option<String>> {
let value = get_db().await.get_if_exists(KEY).await?;
pub(super) async fn get(key: &str) -> Result<Option<String>> {
let value = get_db().await.get_if_exists(key).await?;
Ok(value
.map(|v| String::from_utf8_lossy(&v).trim().to_string())
.filter(|v| !v.is_empty()))
}
/// Stores `candidate` unless a token is already stored, and returns the stored token.
pub async fn get_or_create(candidate: &str) -> Result<String> {
/// Returns the stored value, which is `candidate` only when nothing was stored yet.
pub(super) async fn get_or_create(key: &str, candidate: &str) -> Result<String> {
let value = Bytes::from(candidate.to_string());
let owned_key = key.to_string();
get_db()
.await
.get_for_update(
KEY,
key,
NO_NEED_WATCH,
None,
Box::new(move |existing| {
Ok(match existing {
Some(_) => None,
None => Some((None, Some((KEY.to_string(), value, None)))),
None => Some((None, Some((owned_key, value, None)))),
})
}),
)
.await?;
get()
get(key)
.await?
.ok_or_else(|| Error::Message("internal grpc token is missing after create".into()))
.ok_or_else(|| Error::Message(format!("{key} is missing after create")))
}
}
-9
View File
@@ -59,15 +59,6 @@ pub async fn get_db_schema_version() -> Result<u64, anyhow::Error> {
Ok(k)
}
/// Whether a node already booted on this meta store: every boot since 2023 stores the instance id.
pub async fn db_has_data() -> Result<bool, anyhow::Error> {
match db::get_db().await.count("/instance/").await {
Ok(rows) => Ok(rows > 0),
Err(e) if is_db_schema_version_missing(&e) => Ok(false),
Err(e) => Err(e.into()),
}
}
/// Whether the error means the version is genuinely not stored yet (vs the
/// database being unreachable or overloaded).
fn is_db_schema_version_missing(e: &errors::Error) -> bool {
-137
View File
@@ -31,12 +31,6 @@ mod progress;
pub use config::MigrationConfig;
pub use migrator::{run_file_list, run_meta};
const DEFAULT_EXT_AUTH_SALT: &str = "openobserve";
const MIN_EXT_AUTH_SALT_LEN: usize = 16;
// argon2 hashes with a salt of 8 to 48 bytes and get_hash panics outside that
const MIN_ARGON2_SALT_LEN: usize = 8;
const MAX_EXT_AUTH_SALT_LEN: usize = 48;
pub async fn init_db() -> std::result::Result<(), anyhow::Error> {
// warm both pools before the migration starts hitting them
get_orm_client_ro().await;
@@ -67,9 +61,6 @@ pub async fn init_db() -> std::result::Result<(), anyhow::Error> {
"failed to get db schema version after {MAX_RETRIES} attempts: {e}; refusing to assume a fresh install and run the db upgrade against an unhealthy database"
));
}
// a missing version alone also matches installs older than the version key
let fresh_install = db_schema_version == 0 && !infra::db_has_data().await?;
check_ext_auth_salt(fresh_install, &::config::get_config().auth.ext_auth_salt)?;
if db_schema_version == DB_SCHEMA_VERSION {
// if version matches, we do not need to run update commands
log::info!("DB_SCHEMA_VERSION match, skipping db upgrade");
@@ -115,131 +106,3 @@ pub async fn init_db() -> std::result::Result<(), anyhow::Error> {
Ok(())
}
/// Existing installs only warn on a weak usable salt: rotating it breaks logins derived from it.
fn check_ext_auth_salt(fresh_install: bool, salt: &str) -> anyhow::Result<()> {
if salt.len() > MAX_EXT_AUTH_SALT_LEN {
return Err(anyhow::anyhow!(
"ZO_EXT_AUTH_SALT is {} bytes, but argon2 accepts at most {MAX_EXT_AUTH_SALT_LEN}; set it to a random secret of {MIN_EXT_AUTH_SALT_LEN} to {MAX_EXT_AUTH_SALT_LEN} bytes (for example `openssl rand -hex 24`)",
salt.len()
));
}
if salt != DEFAULT_EXT_AUTH_SALT && salt.len() >= MIN_EXT_AUTH_SALT_LEN {
return Ok(());
}
if fresh_install {
return Err(anyhow::anyhow!(
"ZO_EXT_AUTH_SALT must be set to a random secret of {MIN_EXT_AUTH_SALT_LEN} to {MAX_EXT_AUTH_SALT_LEN} bytes on a new install (for example `openssl rand -hex 24`), and kept the same on every node and restart"
));
}
if salt.len() < MIN_ARGON2_SALT_LEN {
return Err(anyhow::anyhow!(
"ZO_EXT_AUTH_SALT is {} bytes, but argon2 needs at least {MIN_ARGON2_SALT_LEN}; set it to a random secret of {MIN_EXT_AUTH_SALT_LEN} to {MAX_EXT_AUTH_SALT_LEN} bytes (for example `openssl rand -hex 24`)",
salt.len()
));
}
log::warn!(
"ZO_EXT_AUTH_SALT is the public default or shorter than {MIN_EXT_AUTH_SALT_LEN} bytes; set a random secret of {MIN_EXT_AUTH_SALT_LEN} to {MAX_EXT_AUTH_SALT_LEN} bytes before the first presigned or ext-token login"
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_new_install_refuses_a_missing_default_or_short_ext_auth_salt() {
for salt in ["", DEFAULT_EXT_AUTH_SALT, "short-secret"] {
let err = check_ext_auth_salt(true, salt).unwrap_err().to_string();
assert!(err.contains("ZO_EXT_AUTH_SALT"), "{salt}: {err}");
}
}
#[test]
fn a_new_install_accepts_a_random_ext_auth_salt() {
assert!(check_ext_auth_salt(true, "3f9c2a7e5b1d8c4f6a0e2b9d7c5a3f1e").is_ok());
}
#[test]
fn an_accepted_ext_auth_salt_is_one_argon2_can_hash_with() {
for len in 0..=96 {
let salt = "a".repeat(len);
let hashable =
::config::utils::hash::try_get_passcode_hash("Complexpass#123", &salt).is_some();
for fresh_install in [true, false] {
let accepted = check_ext_auth_salt(fresh_install, &salt).is_ok();
assert!(
!accepted || hashable,
"len {len}, fresh {fresh_install}: accepted but argon2 refuses it"
);
}
}
}
#[test]
fn the_recommended_ext_auth_salt_is_accepted_and_a_hex_32_one_is_refused() {
let hex_24 = "9f2c4e6a8b0d1f3e5c7a9b1d3f5e7c9a0b2d4f6e8a1c3e5f";
assert_eq!(hex_24.len(), 48);
assert!(check_ext_auth_salt(true, hex_24).is_ok());
assert!(::config::utils::hash::try_get_passcode_hash("p", hex_24).is_some());
let hex_32 = "9f2c4e6a8b0d1f3e5c7a9b1d3f5e7c9a0b2d4f6e8a1c3e5f7a9b0c1d2e3f4a5b";
assert_eq!(hex_32.len(), 64);
for fresh_install in [true, false] {
let err = check_ext_auth_salt(fresh_install, hex_32)
.unwrap_err()
.to_string();
assert!(err.contains("ZO_EXT_AUTH_SALT"), "{err}");
assert!(err.contains("openssl rand -hex 24"), "{err}");
}
}
#[test]
fn the_new_install_refusal_recommends_a_salt_that_fits() {
let err = check_ext_auth_salt(true, "").unwrap_err().to_string();
assert!(err.contains("openssl rand -hex 24"), "{err}");
}
#[test]
fn the_shipped_example_ext_auth_salts_are_refused_on_a_new_install() {
let env_example = include_str!("../../.env.example")
.lines()
.find_map(|line| line.trim().strip_prefix("ZO_EXT_AUTH_SALT"))
.and_then(|rest| rest.trim().strip_prefix('='))
.map(|value| value.trim().trim_matches('"').to_string())
.expect(".env.example sets ZO_EXT_AUTH_SALT");
let k8s = include_str!("../../deploy/k8s/statefulset.yaml");
let k8s = k8s
.lines()
.skip_while(|line| !line.contains("name: ZO_EXT_AUTH_SALT"))
.find_map(|line| line.trim().strip_prefix("value:"))
.map(|value| value.trim().trim_matches('"').to_string())
.expect("statefulset.yaml sets ZO_EXT_AUTH_SALT");
for salt in [env_example, k8s] {
assert!(check_ext_auth_salt(true, &salt).is_err(), "{salt:?}");
}
}
#[test]
fn an_existing_install_keeps_starting_with_the_default_or_a_short_usable_ext_auth_salt() {
for salt in [DEFAULT_EXT_AUTH_SALT, "eight888", "short-secret"] {
assert!(check_ext_auth_salt(false, salt).is_ok(), "{salt:?}");
}
}
#[test]
fn an_existing_install_refuses_an_ext_auth_salt_argon2_cannot_use() {
let too_long = "a".repeat(MAX_EXT_AUTH_SALT_LEN + 1);
for salt in ["", "a", "abc", "seven77", too_long.as_str()] {
assert!(
::config::utils::hash::try_get_passcode_hash("p", salt).is_none(),
"{salt:?} is usable"
);
let err = check_ext_auth_salt(false, salt)
.expect_err(salt)
.to_string();
assert!(err.contains("ZO_EXT_AUTH_SALT"), "{salt:?}: {err}");
assert!(err.contains("openssl rand -hex 24"), "{salt:?}: {err}");
}
}
}
@@ -40,7 +40,6 @@ class _Server:
env.update({
"ZO_ROOT_USER_EMAIL": self.auth[0],
"ZO_ROOT_USER_PASSWORD": self.auth[1],
"ZO_EXT_AUTH_SALT": "test-only-ext-auth-salt-0123456789",
"ZO_HTTP_PORT": str(self.http_port),
"ZO_GRPC_PORT": str(self.grpc_port),
"ZO_DATA_DIR": str(self.data_dir),
-47
View File
@@ -1,47 +0,0 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use openobserve::migration;
#[tokio::test]
async fn only_an_empty_db_counts_as_a_fresh_install_for_the_ext_auth_salt() {
let data_dir = tempfile::tempdir().unwrap();
unsafe {
std::env::set_var("ZO_DATA_DIR", format!("{}/", data_dir.path().display()));
std::env::set_var("ZO_META_STORE", "sqlite");
std::env::set_var("ZO_LOCAL_MODE", "true");
std::env::set_var("ZO_EXT_AUTH_SALT", "openobserve");
}
for attempt in 1..=2 {
let err = migration::init_db().await.unwrap_err().to_string();
assert!(err.contains("ZO_EXT_AUTH_SALT"), "attempt {attempt}: {err}");
}
// an install older than the schema-version key: meta rows, no version
infra::db::create_table().await.unwrap();
infra::db::get_db()
.await
.put("/instance/", "7f3a9c".into(), false, None)
.await
.unwrap();
assert_eq!(infra::get_db_schema_version().await.unwrap(), 0);
migration::init_db().await.unwrap();
assert_eq!(
infra::get_db_schema_version().await.unwrap(),
config::DB_SCHEMA_VERSION
);
}
-1
View File
@@ -86,7 +86,6 @@ mod tests {
START.call_once(|| unsafe {
env::set_var("ZO_ROOT_USER_EMAIL", "root@example.com");
env::set_var("ZO_ROOT_USER_PASSWORD", "Complexpass#123");
env::set_var("ZO_EXT_AUTH_SALT", "test-only-ext-auth-salt-0123456789");
env::set_var("ZO_LOCAL_MODE", "true");
env::set_var("ZO_MAX_FILE_SIZE_ON_DISK", "1");
env::set_var("ZO_FILE_PUSH_INTERVAL", "1");