mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-03 08:09:11 +00:00
fix: generate and store the ext auth salt on a new install instead of refusing to start (#15191)
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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")))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user