feat(iam): configurable password policy over api, enforced instance wide. (#14214)

Co-authored-by: omkark06 <omkar@zinclabs.io>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Bhargav <BJP232004@GMAIL.COM>
Co-authored-by: Shrinath Rao <shnath@openobserve.ai>
This commit is contained in:
Nikith Sai
2026-09-23 16:06:37 +00:00
committed by GitHub
co-authored by omkark06 github-actions[bot] Bhargav Shrinath Rao
parent dd278a645a
commit 951ded3684
102 changed files with 8976 additions and 393 deletions
@@ -16,6 +16,8 @@ Reference for the O2 display and content primitives under `@/lib/core/*`. Each e
- [OEmptyState](#oemptystate)
- [OIcon](#oicon)
- [OSeparator](#oseparator)
- [OSettingRow](#osettingrow)
- [OSettingRowPair](#osettingrowpair)
- [OShortcut](#oshortcut)
- [OText](#otext)
- [OVirtualScroll](#ovirtualscroll)
@@ -400,6 +402,61 @@ Note: delete/bin icon names render in the destructive (red) colour by default; o
---
### OSettingRow
**Import:** `@/lib/core/SettingRow/OSettingRow.vue`
**Use when:** One labelled setting inside a settings card — name and one-line description on the left, the control on the right, hairline rule between rows (the last row drops its own).
**Don't use for:** Form fields with a label above the control — use `OFormInput` directly.
**Key props:**
- `label` (`I18nText`, required — the setting's name)
- `description` (`I18nText` — one line saying what the setting does or what a special value means)
- `disabled` (boolean, default `false` — renders the row muted only; the control owns its own disabled state)
- `dataTest` (string)
**Slots:** `default` — the control on the right
**Emits:** none
**Example:**
```vue
<OSettingRow
:label="t('passwordPolicy.minLength')"
:description="t('passwordPolicy.minLengthDesc')"
data-test="settings-password-policy-min-length"
>
<OFormInput name="min_length" type="number" width="xs" />
</OSettingRow>
```
**Family:** Pair with `OFormSection`, which supplies the card.
---
### OSettingRowPair
**Import:** `@/lib/core/SettingRow/OSettingRowPair.vue`
**Use when:** Two related settings — a min/max, a period and its warning — on one row; the pair owns the hairline and padding so the two cells read as one row.
**Don't use for:** More than two controls, or unrelated settings that only happen to be adjacent.
**Key props:**
- `dataTest` (string)
**Slots:** `default` — exactly two `OSettingRow`; `footer` — a full-width message under both cells (a cross-field validation error)
**Emits:** none
**Example:**
```vue
<OSettingRowPair data-test="settings-password-policy-pair-length">
<OSettingRow :label="t('passwordPolicy.minLength')" data-test="settings-password-policy-min-length">
<OFormInput name="min_length" type="number" width="xs" />
</OSettingRow>
<OSettingRow :label="t('passwordPolicy.maxLength')" data-test="settings-password-policy-max-length">
<OFormInput name="max_length" type="number" width="xs">
<template #error />
</OFormInput>
</OSettingRow>
<template v-if="maxLengthError" #footer>
<p class="text-input-error-text text-xs" role="alert">{{ maxLengthError }}</p>
</template>
</OSettingRowPair>
```
**Family:** `OSettingRow`, `OFormSection`.
---
### OShortcut
**Import:** `@/lib/core/Shortcut/OShortcut.vue`
@@ -233,7 +233,7 @@ function copyLink(url: string) {
**Key props:**
- `value` (`number`, required) — between 0 and 1; clamped to that range internally.
- `variant` (`"default" | "warning" | "danger"` — default `"default"`) — semantic fill color.
- `variant` (`"default" | "success" | "warning" | "danger"` — default `"default"`) — semantic fill color.
- `size` (`"xs" | "sm" | "md" | "lg"` — default `"sm"`) — track height.
**Slots:** `default` — content rendered inside the filled bar (e.g. a percentage label).
**Emits:** none.
Generated
+1
View File
@@ -8051,6 +8051,7 @@ dependencies = [
"axum-tracing-opentelemetry",
"base64 0.22.1",
"bytes",
"chrono",
"common",
"config",
"db",
@@ -10105,6 +10105,7 @@
"getStartedHeading": "1b5da3f87ac69571",
"hearAboutUsLabel": "9e19a4c422a4a3e8",
"hearAboutUsPlaceholder": "c469844a403f50e8",
"lockedOut": "1060caa91c04760a",
"login": "9d6322c1f4d9d3f3",
"loginAsInternalUser": "3d7f06d8cac9fdbf",
"loginWithSso": "b31e8d76180ffb20",
@@ -13384,6 +13385,104 @@
"yAxisShort": "ce423a87ba8e56c8",
"zAxis": "34f0456880f88136"
},
"passwordPolicy": {
"applyToRoot": "0a27adcf897429e8",
"applyToRootDesc": "c8d83acd97724e5d",
"backoffExponential": "a58da51adb22134a",
"backoffLinear": "e6950b45d25c2027",
"complexity": "2dcf78dcabca38ef",
"confirmMessage": "ca65c9b4a2336f8e",
"confirmTitle": "67a730b70ad0da95",
"cookieMaxAge": "da126ac3f32196d3",
"cookieMaxAgeDesc": "b4a7944c62ff3ff0",
"historyCount": "eab8fd303add5d3f",
"historyCountDesc": "6611b5bbd9dadcc9",
"historyMaxRetained": "783cc24a04edcadb",
"historyMaxRetainedDesc": "27f0506d99c7e75f",
"historyRetainedTooSmall": "5d7c01fed5a6937b",
"loadFailed": "12c65dde7f781c7d",
"loadFailedTitle": "952d2eb4da269774",
"lockout": "227f0a54feea5a1d",
"lockoutBackoff": "35615b824542ec09",
"lockoutBackoffDesc": "adf08d10f48d9bd0",
"lockoutBucketSize": "22b77578c0185c28",
"lockoutBucketSizeDesc": "5411ea069ecdcd84",
"lockoutMaxSecs": "bf0f723587aac403",
"lockoutMaxSecsDesc": "21341a74edabdadc",
"lockoutPreview": "0e8839cf25f3930e",
"lockoutStartSecs": "ea8e1c531279ce42",
"lockoutStartSecsDesc": "381a8e9699052f3a",
"lockoutStartTooLong": "bcac86bc0bc7a296",
"lockoutThreshold": "913a3fde70150265",
"lockoutThresholdDesc": "f7c1f5889f89349a",
"maxLength": "ee29a87ba3bcc885",
"maxLengthDesc": "6bc01631f9d2919e",
"maxLengthTooSmall": "452e190a1b903c5f",
"minLength": "54fda5553dbf64a5",
"minLengthDesc": "915b3233195489a8",
"notAdmin": "b0f0b825ed9434b4",
"notAdminTitle": "031cc7a3ec828ec6",
"requireDigit": "9197281f4fc025f5",
"requireDigitDesc": "2bffa7b2368ae230",
"requireLowercase": "55c8caf4480ba611",
"requireLowercaseDesc": "3b958c6978e94656",
"requireSpecial": "eb4f44a8a521ddcf",
"requireSpecialDesc": "a6310c0ffee47fa8",
"requireUppercase": "a37bc863b0f0ceab",
"requireUppercaseDesc": "c0fbb97ca200e291",
"reuse": "daa3bf3e0114e60d",
"reuseExplainer": "38cf28a18b4317d2",
"rootDialogAck": "440a002568e585e5",
"rootDialogConfirm": "818cd4edb7bc578e",
"rootDialogMessage": "332cc78439926bfe",
"rootDialogTitle": "5d625660815e8c46",
"rootWarning": "9d7a0d2463eaa59a",
"rotation": "57b5e2fc1bba18bb",
"rotationDays": "34e836a3ef8674ed",
"rotationDaysDesc": "cb8e6a261f8bf40a",
"rotationPreview": "2217f92fc05d1b0c",
"rotationPreviewNoWarning": "fa428702b60e2519",
"rotationWarningDays": "b4053bcd33c82776",
"rotationWarningDaysDesc": "2724c970274ec68d",
"saveFailed": "236183f574bf30da",
"saved": "58d1f34e50b808ed",
"sessionEnforcement": "da37ca7035052020",
"specialCharSet": "369a52ccc06c2441",
"specialCharSetDesc": "4a18a809de2e773e",
"warningDaysTooLong": "c1bc355067a8f170"
},
"passwordReset": {
"confirmPassword": "bf000421aeb39493",
"confirmPasswordRequired": "3923eddc70463c0d",
"currentPassword": "72ed2bd767cef627",
"currentPasswordRequired": "5000b74e76ec8b67",
"expiryBanner": "2b22707578ee98bd",
"expiryBannerAction": "63bf045213cebbaf",
"expiryBannerDismiss": "48845bff334a50a5",
"mismatch": "6c6e178ad2a856ca",
"newPassword": "3dd9df4441fb348f",
"newPasswordRequired": "1e1a49c16c534200",
"reasonPolicyTightened": "85fb2f2924b00fad",
"reasonRotationExpired": "c2c002c87236d686",
"reasonRotationWarning": "99bb2721ab40ac67",
"req": {
"digit": "c0fd6b3daf2da4bf",
"lowercase": "8ec64ccbfdaa27bb",
"maxLength": "f03a2d1060168e17",
"minLength": "aac08e05efe4d0f8",
"special": "de9e9fe904b1b6eb",
"specialFromSet": "79a200ab53243d88",
"unmet": "7d67e0ecc8c7d318",
"uppercase": "ef5dd11c37f98d21"
},
"reused": "02e39b827a0f71e5",
"signOut": "48f0d3d397d49f13",
"signedInAs": "7d271b6b1f2c6f78",
"submit": "fe45b4014135b70f",
"title": "badc2317da60bf68",
"updateFailed": "f62efd27ccdd3734",
"updated": "494401fd94d27fd3"
},
"pipeline": {
"actionCannotBeUndone": "a44f66e8e66488ae",
"addPipeline": "43604bb124f1605c",
@@ -14228,6 +14327,7 @@
"loginCallback": "abc3ccb8b27e3974",
"modelPricingEditor": "bcb792875fefde91",
"notFound": "2d857a3660e0240b",
"passwordPolicy": "11831de5b6d62b7c",
"pipelineBackfillJobs": "54a8767a761a58e0",
"regexPatterns": "83a58bf362610444",
"searchScheduler": "ff78cae7d0ccaa32",
@@ -15438,6 +15538,8 @@
"settingsUpdated": "7e077bab54d72f3b"
},
"organizationSettingsUpdated": "71623dbd4398b6a3",
"passwordPolicy": "11831de5b6d62b7c",
"passwordPolicyDesc": "30bbc73af19cf402",
"pickCustomColor": "01cdff89f6bfcc46",
"pipelineDestinationsDesc": "98402418a1408ae2",
"platformSettings": "b18b122ea1b3815a",
@@ -18024,6 +18126,9 @@
"successfullyDeletedEnrichmentTables": "27033a8179732aa4",
"view": "dcc839a4015c4b7d"
},
"iam": {
"userSaveFailed": "8e7e4de89efdf90a"
},
"layouts": {
"ingestionNotStarted": "06854b793335768c"
},
@@ -18879,6 +18984,15 @@
"inviteByEmailPlaceholder": "f728553b59c5d29d",
"inviteEmailInvalid": "e16dabef04db970d",
"lastName": "f55563b386a39dc3",
"lockout": {
"autoUnlock": "274abcca94693921",
"confirmMessage": "c2245482165d19a1",
"confirmTitle": "85d8814ccbd655d2",
"locked": "6f7f4e56672cd3ab",
"staged": "26bf1357f873133f",
"undo": "6d4af35fe1199479",
"unlock": "4ac709aa58bcfe5d"
},
"name": "b82e11da3bc16786",
"newPassword": "7c451e0f436d04a6",
"newPasswordRequired": "1e1a49c16c534200",
+371 -11
View File
@@ -16,7 +16,7 @@
use axum::{
body::Body,
extract::Request,
http::{HeaderMap, Method, StatusCode, Uri},
http::{HeaderMap, Method, StatusCode, Uri, header},
response::{IntoResponse, Response},
};
use config::{
@@ -28,6 +28,8 @@ use db::{self, user::is_root_user};
#[cfg(feature = "enterprise")]
use o2_dex::config::get_config as get_dex_config;
#[cfg(feature = "enterprise")]
use o2_enterprise::enterprise::password_policy::lockout::{self, LoginAttemptOutcome};
#[cfg(feature = "enterprise")]
pub use openobserve_core::auth::get_user_email_from_auth_str;
pub use openobserve_core::authz::{check_permissions, list_objects_for_user};
use openobserve_core::{
@@ -67,6 +69,12 @@ pub enum AuthError {
Unauthorized(String),
Forbidden(String),
NotFound(String),
/// Refused by the failed-login lockout rather than by the credential itself. Only the seconds
/// left are disclosed: the thresholds behind them stay admin-only, since a brute-forcer who
/// learns them knows exactly how to pace attempts underneath.
Locked {
retry_after_secs: i64,
},
}
impl std::fmt::Display for AuthError {
@@ -75,6 +83,9 @@ impl std::fmt::Display for AuthError {
AuthError::Unauthorized(msg) => write!(f, "Unauthorized: {}", msg),
AuthError::Forbidden(msg) => write!(f, "Forbidden: {}", msg),
AuthError::NotFound(msg) => write!(f, "NotFound: {}", msg),
AuthError::Locked { retry_after_secs } => {
write!(f, "Locked: {}", lockout_message(*retry_after_secs))
}
}
}
}
@@ -109,10 +120,25 @@ impl IntoResponse for AuthError {
}
AuthError::Forbidden(msg) => (StatusCode::FORBIDDEN, msg).into_response(),
AuthError::NotFound(msg) => (StatusCode::NOT_FOUND, msg).into_response(),
// 429 rather than 423: `Retry-After` is canonical on it, and clients and proxies
// already know to back off rather than retry immediately.
AuthError::Locked { retry_after_secs } => Response::builder()
.status(StatusCode::TOO_MANY_REQUESTS)
.header(header::RETRY_AFTER, retry_after_secs)
.body(Body::from(lockout_message(retry_after_secs)))
.unwrap(),
}
}
}
/// The plain-English rejection for a locked account.
///
/// Carries the seconds rather than a rendered duration: the console composes its own localized
/// sentence from `lockout_retry_after_secs`, and this is what everything else sees.
pub fn lockout_message(retry_after_secs: i64) -> String {
format!("Too many failed login attempts, please try again in {retry_after_secs} seconds")
}
/// Result of auth validation - contains user info and modified request
pub struct AuthValidationResult {
pub user_email: String,
@@ -120,6 +146,30 @@ pub struct AuthValidationResult {
pub is_internal_user: bool,
}
/// What a password comparison decided, once the lockout policy has had its say.
///
/// `Locked` is distinct from `Mismatch` because the two owe the caller different answers: one is
/// "that is not your password", the other is "stop asking for now".
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PasswordCheck {
Matched,
Mismatch,
#[cfg(feature = "enterprise")]
Locked {
retry_after_secs: i64,
},
}
impl PasswordCheck {
fn from_comparison(matched: bool) -> Self {
if matched {
PasswordCheck::Matched
} else {
PasswordCheck::Mismatch
}
}
}
/// Helper function to build a successful token validation response
fn build_token_validation_response(user: &User) -> TokenValidationResponse {
TokenValidationResponse {
@@ -245,6 +295,89 @@ async fn blocked_external(user: &config::meta::user::User) -> bool {
)
}
/// Reads whose answer is instance-wide, so membership in the org named by the path is irrelevant.
///
/// `license` carries no org at all. `password_complexity` carries one but ignores it: the policy is
/// the same for every organization, and the caller who most needs it is a user blocked for a forced
/// password reset — who has to be told what password will satisfy the policy regardless of which
/// org the console happens to be pointed at. Neither response contains org-scoped data, so nothing
/// cross-tenant leaks by admitting a non-member.
fn is_org_agnostic_read(path: &str) -> bool {
if path == "license" {
return true;
}
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
matches!(
segments.as_slice(),
[_org, "password_complexity"] | ["api", _org, "password_complexity"]
)
}
/// Compare a password under the lockout policy, recording the attempt.
///
/// `compare` is whatever "this credential matches" means at the call site, and it runs only once
/// the lockout check has passed: an attacker is then rejected without the Argon2 work they were
/// trying to buy, and a locked account rejects in the same time whatever the candidate.
///
/// Root is exempt unless `apply_to_root` says otherwise: it is the one account with no recovery
/// path from inside the product, and anyone who knows its address can deny it access without ever
/// guessing the password. External users are out of scope because their credentials are verified
/// elsewhere, whatever the policy says.
///
/// A lockout is reported as itself rather than as a mismatch, so the caller can tell the user how
/// long to wait. Only the remaining seconds are disclosed — never the thresholds behind them.
async fn enforce_lockout_and_compare_password<F>(
user_email: &str,
is_internal: bool,
compare: F,
) -> PasswordCheck
where
F: FnOnce() -> bool,
{
#[cfg(not(feature = "enterprise"))]
{
let _ = (user_email, is_internal);
PasswordCheck::from_comparison(compare())
}
#[cfg(feature = "enterprise")]
{
let policy = db::password_policy::get_effective_policy().await;
let lockout = policy.lockout;
let root_exempt = is_root_user(user_email) && !policy.apply_to_root;
if !lockout.is_enabled() || !is_internal || root_exempt {
return PasswordCheck::from_comparison(compare());
}
match lockout::check_lockout(user_email, &lockout).await {
LoginAttemptOutcome::Locked { retry_after_secs } => {
log::warn!(
"Rejected a login for locked-out account {user_email}, {retry_after_secs}s remaining"
);
PasswordCheck::Locked { retry_after_secs }
}
outcome => {
if !compare() {
// The failure that trips the lock reports it immediately, rather than leaving
// the user to discover it on an attempt they have no reason to expect to fail.
return match lockout::record_failed_attempt(user_email, &lockout).await {
LoginAttemptOutcome::Locked { retry_after_secs } => {
PasswordCheck::Locked { retry_after_secs }
}
_ => PasswordCheck::Mismatch,
};
}
// Only a user with failures needs the write; the steady state stays read-only.
if outcome == LoginAttemptOutcome::AllowedWithFailures
&& let Err(e) = lockout::record_successful_login(user_email).await
{
log::error!("{e}");
}
PasswordCheck::Matched
}
}
}
}
pub async fn validate_credentials(
user_id: &str,
user_password: &str,
@@ -440,7 +573,7 @@ pub async fn validate_credentials(
// rest of api calls will get blocked anyways, but without this,
// native users get stuck in logout loop if they go to any page calling license
// api call
if path == "license"
if is_org_agnostic_read(path)
&& let Ok(v) = db::user::get_user_record(user_id).await
{
// we set the record manually with minimal permission,
@@ -584,13 +717,23 @@ pub async fn validate_credentials(
});
}
}
let in_pass = get_hash(user_password, &user.salt);
if !user.password.eq(&in_pass)
&& !user
.password_ext
.unwrap_or("".to_string())
.eq(&user_password)
{
let password_check: PasswordCheck =
enforce_lockout_and_compare_password(&user.email, !user.is_external, || {
user.password.eq(&get_hash(user_password, &user.salt))
|| user
.password_ext
.as_deref()
.unwrap_or_default()
.eq(user_password)
})
.await;
// A lockout is the one refusal that carries an answer, so it is the one that does not collapse
// into the shared invalid-credentials response.
#[cfg(feature = "enterprise")]
if let PasswordCheck::Locked { retry_after_secs } = password_check {
return Err(AuthError::Locked { retry_after_secs });
}
if password_check != PasswordCheck::Matched {
return Ok(TokenValidationResponse {
is_valid: false,
user_email: "".to_string(),
@@ -838,17 +981,32 @@ async fn validate_user_from_db(
// let db_user = db::user::get_db_user(user_id).await;
match db_user {
Ok(mut user) => {
let in_pass = get_hash(user_password, &user.salt);
if req_time.is_none() && user.password.eq(&in_pass) {
// Only this branch is a raw password guess; the password_ext branches below are not.
let password_check = if req_time.is_none() {
enforce_lockout_and_compare_password(&user.email, !user.is_external, || {
user.password.eq(&get_hash(user_password, &user.salt))
})
.await
} else {
PasswordCheck::Mismatch
};
#[cfg(feature = "enterprise")]
if let PasswordCheck::Locked { retry_after_secs } = password_check {
return Err(AuthError::Locked { retry_after_secs });
}
if password_check == PasswordCheck::Matched {
if user.password_ext.is_none() {
let password_ext = get_hash(user_password, password_ext_salt);
user.password_ext = Some(password_ext);
// Backfilling the derived hash is not a password change: bumping the rotation
// clock here would restart it on every login and expiry would never arrive.
let _ = db::user::update(
&user.email,
&user.first_name,
&user.last_name,
&user.password,
user.password_ext.clone(),
false,
)
.await;
}
@@ -1281,6 +1439,8 @@ mod tests {
db::{get_orm_client_ro, get_orm_client_rw},
table as infra_table,
};
#[cfg(feature = "enterprise")]
use o2_enterprise::enterprise::password_policy::meta::{LockoutPolicy, PasswordPolicy};
use openobserve_core::{organization, users};
use super::*;
@@ -1837,6 +1997,183 @@ mod tests {
.is_valid
);
assert!(validate_user(init_user, pwd).await.unwrap().is_valid);
#[cfg(feature = "enterprise")]
exercise_lockout(org_id, init_user, pwd).await;
}
/// Root survives any number of wrong passwords, and a locked-out user is refused even once they
/// present the right one.
///
/// Folded into `test_validate` rather than standing alone: it needs that fixture's root user
/// and caches, and a second test clearing the same tables would race with it.
#[cfg(feature = "enterprise")]
async fn exercise_lockout(org_id: &str, root_user: &str, root_pwd: &str) {
let locked_user = "lockme@example.com";
let pwd = "Complexpass#123";
let _ = infra_table::system_settings::create_table().await;
// The fixture truncates the user tables but not this one, which outlives the process.
let _ = infra::table::user_auth_state::delete(locked_user).await;
let _ = infra::table::user_auth_state::delete(root_user).await;
// `is_root_user` reads the org-user cache, which nothing populates under test.
ORG_USERS.insert(
format!("{DEFAULT_ORG}/{root_user}"),
infra::table::org_users::OrgUserRecord {
role: config::meta::user::UserRole::Root,
token: "root_token".to_string(),
rum_token: None,
org_id: DEFAULT_ORG.to_string(),
email: root_user.to_string(),
created_at: 0,
allow_static_token: true,
},
);
assert!(is_root_user(root_user), "fixture must have a root user");
let _ = users::post_user(
org_id,
UserRequest {
email: locked_user.to_string(),
password: pwd.to_string(),
role: common::meta::user::UserOrgRole {
base_role: config::meta::user::UserRole::Admin,
custom_role: None,
},
first_name: "locked".to_owned(),
last_name: "".to_owned(),
is_external: false,
token: None,
},
root_user,
)
.await;
db::password_policy::set_policy(&PasswordPolicy {
lockout: LockoutPolicy {
threshold: 2,
..Default::default()
},
..Default::default()
})
.await
.unwrap();
for _ in 0..3 {
assert_eq!(
refuse(root_user, "Wrongpass#123").await,
None,
"root's failures never carry a retry-after, because they never lock"
);
}
assert!(
infra::table::user_auth_state::get(root_user)
.await
.unwrap()
.is_none(),
"root must never acquire lockout state"
);
assert!(
validate_credentials(root_user, root_pwd, "default/_bulk", &Method::POST, false)
.await
.unwrap()
.is_valid,
"root is never locked out"
);
// Without this the final assertion would also hold for a user who was never created.
assert!(
validate_credentials(locked_user, pwd, "default/_bulk", &Method::POST, false)
.await
.unwrap()
.is_valid,
"the fixture's user must authenticate before being locked out"
);
assert_eq!(
refuse(locked_user, "Wrongpass#123").await,
None,
"a wrong password below the threshold is a plain mismatch"
);
let tripped = refuse(locked_user, "Wrongpass#123")
.await
.expect("the failure that trips the lock reports it, rather than the next attempt");
assert!((1..=60).contains(&tripped), "{tripped}s is out of range");
let refused = refuse(locked_user, pwd)
.await
.expect("a locked account is refused even with the right password");
assert!((1..=60).contains(&refused), "{refused}s is out of range");
exercise_root_lockout(root_user, root_pwd).await;
db::password_policy::set_policy(&PasswordPolicy::default())
.await
.unwrap();
}
/// The same instance with `apply_to_root` on: root loses the exemption it holds above.
///
/// Nothing clears the lockout early — a password change does not touch it — so a locked root
/// waits out `locked_until` unless an admin clears the row.
#[cfg(feature = "enterprise")]
async fn exercise_root_lockout(root_user: &str, root_pwd: &str) {
db::password_policy::set_policy(&PasswordPolicy {
lockout: LockoutPolicy {
threshold: 2,
..Default::default()
},
apply_to_root: true,
..Default::default()
})
.await
.unwrap();
assert_eq!(refuse(root_user, "Wrongpass#123").await, None);
let tripped = refuse(root_user, "Wrongpass#123")
.await
.expect("root locks like anyone else once the policy applies to it");
assert!((1..=60).contains(&tripped), "{tripped}s is out of range");
assert!(
refuse(root_user, root_pwd).await.is_some(),
"a locked root is refused even with the right password"
);
assert!(
infra::table::user_auth_state::get(root_user)
.await
.unwrap()
.is_some_and(|state| state.lockout_level == 1),
"root now carries the lockout state it is exempt from by default"
);
let _ = infra::table::user_auth_state::delete(root_user).await;
}
/// The seconds a refused login is told to wait, or `None` when it was refused as a plain
/// mismatch. Panics if the credentials are accepted.
#[cfg(feature = "enterprise")]
async fn refuse(user: &str, password: &str) -> Option<i64> {
match validate_credentials(user, password, "default/_bulk", &Method::POST, false).await {
Ok(response) => {
assert!(!response.is_valid, "{user} was expected to be refused");
None
}
Err(AuthError::Locked { retry_after_secs }) => Some(retry_after_secs),
Err(e) => panic!("{user} was refused with an unexpected error: {e}"),
}
}
#[test]
fn a_lockout_answers_429_with_a_retry_after_header() {
let response = AuthError::Locked {
retry_after_secs: 42,
}
.into_response();
assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
assert_eq!(
response.headers().get(header::RETRY_AFTER).unwrap(),
"42",
"clients and proxies back off on the header, not on the sentence"
);
}
#[test]
@@ -1982,4 +2319,27 @@ mod tests {
};
assert_eq!(org_id_normal, "default");
}
#[test]
fn org_agnostic_reads_cover_license_and_password_complexity() {
assert!(is_org_agnostic_read("license"));
// The path reaching here is relative and nest-stripped, but accept both shapes.
assert!(is_org_agnostic_read("acme/password_complexity"));
assert!(is_org_agnostic_read("api/acme/password_complexity"));
assert!(is_org_agnostic_read("_meta/password_complexity"));
// An org literally named "api" still resolves as an org.
assert!(is_org_agnostic_read("api/password_complexity"));
}
#[test]
fn org_agnostic_reads_do_not_widen_anything_else() {
// Authoring the policy keeps its org-membership requirement.
assert!(!is_org_agnostic_read("acme/settings/password_policy"));
assert!(!is_org_agnostic_read("_meta/settings/password_policy"));
// Neither do neighbouring or deeper paths.
assert!(!is_org_agnostic_read("acme/password_complexity/detail"));
assert!(!is_org_agnostic_read("acme/streams"));
assert!(!is_org_agnostic_read("password_complexity"));
assert!(!is_org_agnostic_read("license/keys"));
}
}
+1
View File
@@ -69,6 +69,7 @@ http-body-util.workspace = true
anyhow.workspace = true
base64.workspace = true
bytes.workspace = true
chrono.workspace = true
futures.workspace = true
hashbrown.workspace = true
log.workspace = true
@@ -14,5 +14,7 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
mod org_blocking;
mod password_policy;
pub use org_blocking::blocked_orgs_middleware;
pub use password_policy::password_policy_middleware;
@@ -0,0 +1,367 @@
// 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/>.
//! The axum layer that applies the instance password policy.
//!
//! The decision itself is `o2_enterprise::enterprise::password_policy::enforcement`; this is the
//! glue that finds the user, reads the live policy, and turns a refusal into a response. Without
//! the enterprise feature there is no policy to apply and the layer passes everything through,
//! so the routers can install it unconditionally.
use axum::{extract::Request, middleware::Next, response::Response};
#[cfg(feature = "enterprise")]
use {
axum::{
body::Body,
http::{Method, StatusCode, Uri, header},
},
chrono::Utc,
common::infra::config::{ORG_USERS, USERS},
o2_enterprise::enterprise::password_policy::enforcement::{PolicyDecision, decide},
openobserve_core::auth::V2_API_PREFIX,
};
/// The header `auth_middleware` writes the authenticated email into. Reading it here rather than
/// re-validating is what keeps this a layer of its own; `audit_middleware` consumes the same one.
#[cfg(feature = "enterprise")]
const USER_ID_HEADER: &str = "user_id";
/// Refuse a request whose user owes the instance a new password.
///
/// Must be layered *inside* `auth_middleware`: the email comes from the header that authentication
/// writes, and a request that never authenticated has no user to hold to a policy. A request
/// arriving without that header is passed straight through — this layer authenticates nobody and
/// must not appear to.
pub async fn password_policy_middleware(request: Request, next: Next) -> Response {
#[cfg(feature = "enterprise")]
if let Some(user_email) = request
.headers()
.get(USER_ID_HEADER)
.and_then(|value| value.to_str().ok())
.map(str::to_string)
&& let Some(blocked) = check_request(&user_email, request.uri(), request.method()).await
{
return blocked;
}
next.run(request).await
}
/// Look the user up and apply the policy, returning the refusal when there is one.
#[cfg(feature = "enterprise")]
async fn check_request(user_email: &str, uri: &Uri, method: &Method) -> Option<Response> {
// Served from the cluster-consistent users cache, so this costs no database round trip on the
// authenticated hot path.
// Authentication succeeding against something this cache does not hold — a token or an
// enterprise identity — means there is nothing here that applies to it.
let user_email = user_email.to_lowercase();
let user = USERS.get(&user_email)?;
// Root's exemption is settled inside `decide`, which is the only place that holds both the user
// and the policy. An early return here could only guess at the policy it has not read yet.
let policy = db::password_policy::get_effective_policy().await;
let is_service_account = is_service_account(&user_email, uri);
match decide(&user, is_service_account, uri, method, &policy, Utc::now()) {
PolicyDecision::Allow => None,
PolicyDecision::Block { code, reason } => Some(blocked_response(code, &reason)),
}
}
/// The role lives on `org_users`, keyed by the org the validator also reads off the path.
#[cfg(feature = "enterprise")]
fn is_service_account(user_email: &str, uri: &Uri) -> bool {
let mut segments = uri.path().split('/').filter(|s| !s.is_empty());
let org = match segments.next() {
Some(V2_API_PREFIX) => segments.next(),
org => org,
};
org.and_then(|org| ORG_USERS.get(&format!("{org}/{user_email}")))
.is_some_and(|org_user| org_user.role.is_service_account())
}
#[cfg(feature = "enterprise")]
fn blocked_response(code: &str, reason: &str) -> Response {
let body = serde_json::json!({
"code": code,
"reason": reason,
"message": "Your password must be updated before you can continue.",
});
Response::builder()
.status(StatusCode::FORBIDDEN)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(body.to_string()))
.unwrap_or_else(|_| StatusCode::FORBIDDEN.into_response_fallback())
}
/// Infallible fallback so a malformed header can never turn a policy block into a panic.
#[cfg(feature = "enterprise")]
trait InfallibleResponse {
fn into_response_fallback(self) -> Response;
}
#[cfg(feature = "enterprise")]
impl InfallibleResponse for StatusCode {
fn into_response_fallback(self) -> Response {
let mut response = Response::new(Body::empty());
*response.status_mut() = self;
response
}
}
#[cfg(test)]
mod tests {
use axum::{Router, body::Body, http::StatusCode, routing::get};
use tower::ServiceExt;
use super::*;
/// Layered outside the gate, so the gate sees exactly what `auth_middleware` leaves behind.
#[cfg(feature = "enterprise")]
async fn stub_auth(email: &'static str, mut request: Request, next: Next) -> Response {
request
.headers_mut()
.insert(USER_ID_HEADER, email.parse().unwrap());
next.run(request).await
}
/// An unauthenticated request must come out the other side untouched: this layer decides
/// nothing about identity, and a 401 here would hide whatever the route itself answers.
#[tokio::test]
async fn a_request_carrying_no_authenticated_email_passes_through() {
let app = Router::new()
.route("/default/streams", get(|| async { "ok" }))
.layer(axum::middleware::from_fn(password_policy_middleware));
let response = app
.oneshot(
Request::builder()
.uri("/default/streams")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
/// Authentication succeeded against an identity the users cache does not hold — a token, or an
/// enterprise login. There is no local password to have a policy about.
#[cfg(feature = "enterprise")]
#[tokio::test]
async fn a_user_the_cache_does_not_hold_passes_through() {
let app = Router::new()
.route("/default/streams", get(|| async { "ok" }))
.layer(axum::middleware::from_fn(password_policy_middleware))
.layer(axum::middleware::from_fn(|request, next| async move {
stub_auth("nobody@b.com", request, next).await
}));
let response = app
.oneshot(
Request::builder()
.uri("/default/streams")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
/// `decide` blocking an expired password is unit-tested in the enterprise crate, but not the
/// step that reads the live policy and turns that into a response: dropping it would leave
/// those tests green. This one runs a real request through the layer.
#[cfg(feature = "enterprise")]
#[tokio::test]
async fn an_expired_password_is_refused_through_the_layer() {
use chrono::TimeDelta;
use common::infra::config::SYSTEM_SETTINGS;
use config::{
META_ORG_ID,
meta::{
system_settings::{SystemSetting, keys},
user::UserType,
},
};
use infra::table::users::UserRecord;
use o2_enterprise::enterprise::password_policy::meta::PasswordPolicy;
let email = "expired@b.com";
USERS.insert(
email.to_string(),
UserRecord {
email: email.to_string(),
first_name: "T".to_string(),
last_name: "U".to_string(),
password: "hash".to_string(),
salt: "salt".to_string(),
is_root: false,
password_ext: None,
user_type: UserType::Internal,
created_at: 0,
updated_at: 0,
must_reset_password: false,
password_reset_reason: None,
flagged_at: None,
// Against the wall clock: the layer reads `Utc::now()`.
password_updated_at: Some((Utc::now() - TimeDelta::days(91)).timestamp_micros()),
},
);
// Seeding the settings cache keeps the policy read off the database. The key format is
// db::system_settings::cache_key's; a drift there fails this test rather than silencing it,
// since the read would then fall back to a policy with rotation off.
let policy_key = format!("org:{META_ORG_ID}:_:{}", keys::PASSWORD_POLICY);
SYSTEM_SETTINGS.write().await.insert(
policy_key.clone(),
SystemSetting::new_org(
META_ORG_ID,
keys::PASSWORD_POLICY,
serde_json::to_value(PasswordPolicy {
rotation_days: 90,
rotation_warning_days: 7,
..PasswordPolicy::default()
})
.unwrap(),
),
);
let app = Router::new()
.route("/default/streams", get(|| async { "ok" }))
.layer(axum::middleware::from_fn(password_policy_middleware))
.layer(axum::middleware::from_fn(|request, next| async move {
stub_auth("expired@b.com", request, next).await
}));
let response = app
.oneshot(
Request::builder()
.uri("/default/streams")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
USERS.remove(email);
SYSTEM_SETTINGS.write().await.remove(&policy_key);
}
/// The enterprise unit tests feed `decide` a path string directly, so they cannot catch the
/// case where the layer receives a different string than expected. This one routes a real
/// request through `nest("/api", ..)` and asserts on what actually arrives.
#[tokio::test]
async fn nesting_strips_the_api_prefix_before_the_middleware_sees_it() {
use axum::{extract::Request, middleware, response::Response};
async fn capture(request: Request, next: middleware::Next) -> Response {
let seen = request.uri().path().to_string();
let mut response = next.run(request).await;
response
.headers_mut()
.insert("x-seen-path", seen.parse().unwrap());
response
}
let app = Router::new().nest(
"/api",
Router::new()
.route("/{org_id}/password_complexity", get(|| async { "ok" }))
.layer(middleware::from_fn(capture)),
);
let response = app
.oneshot(
Request::builder()
.uri("/api/default/password_complexity")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let seen = response.headers()["x-seen-path"].to_str().unwrap();
assert_eq!(seen, "/default/password_complexity");
// And the real matcher must let exactly that string through, for a user it would otherwise
// refuse. Asserting on the string alone is what would let the two drift apart.
#[cfg(feature = "enterprise")]
{
use config::meta::user::UserType;
use infra::table::users::UserRecord;
use o2_enterprise::enterprise::password_policy::meta::PasswordPolicy;
let flagged = UserRecord {
email: "a@b.com".to_string(),
first_name: "T".to_string(),
last_name: "U".to_string(),
password: "hash".to_string(),
salt: "salt".to_string(),
is_root: false,
password_ext: None,
user_type: UserType::Internal,
created_at: 0,
updated_at: 0,
must_reset_password: true,
password_reset_reason: Some("policy_tightened".to_string()),
flagged_at: Some(1),
password_updated_at: Some(1),
};
assert_eq!(
decide(
&flagged,
false,
&seen.parse().unwrap(),
&Method::GET,
&PasswordPolicy::default(),
Utc::now(),
),
PolicyDecision::Allow
);
}
}
/// The role is read off the org in the path, the same way the validator resolves it.
#[cfg(feature = "enterprise")]
#[test]
fn a_service_account_is_recognised_from_the_org_in_the_path() {
use config::meta::user::UserRole;
use infra::table::org_users::OrgUserRecord;
let email = "sa@b.com";
ORG_USERS.insert(
format!("default/{email}"),
OrgUserRecord::new("default", email, UserRole::ServiceAccount, "tok", None),
);
ORG_USERS.insert(
format!("other/{email}"),
OrgUserRecord::new("other", email, UserRole::Admin, "tok", None),
);
let uri = |s: &str| s.parse::<Uri>().unwrap();
assert!(is_service_account(email, &uri("/default/streams")));
assert!(is_service_account(email, &uri("/v2/default/streams")));
assert!(!is_service_account(email, &uri("/other/streams")));
assert!(!is_service_account(email, &uri("/organizations")));
ORG_USERS.remove(&format!("default/{email}"));
ORG_USERS.remove(&format!("other/{email}"));
}
}
+217 -3
View File
@@ -73,7 +73,7 @@ use crate::{
RequestData, oo_validator, validator_aws, validator_gcp, validator_proxy_url,
validator_rum,
},
router::middlewares::blocked_orgs_middleware,
router::middlewares::{blocked_orgs_middleware, password_policy_middleware},
},
};
@@ -250,7 +250,10 @@ fn attach_mcp_www_authenticate(_org_id: &str, resp: Response) -> Response {
resp
}
/// Authentication middleware for API routes
/// Authentication middleware for API routes.
///
/// Pair it with `password_policy_middleware` layered inside it on every router that uses it: this
/// one answers who the caller is, that one whether their password still entitles them to be here.
pub async fn auth_middleware(request: Request, next: Next) -> Response {
// Extract request data FIRST, before any async calls
// This ensures the future is Send because RequestData is Send + Sync
@@ -654,6 +657,8 @@ pub fn basic_routes() -> Router {
.route("/consistent_hash", post(status::consistent_hash))
.route("/refresh_nodes_list", get(status::refresh_nodes_list))
.route("/refresh_user_sessions", get(status::refresh_user_sessions))
// Listed first, so it wraps closer to the route and runs after authentication.
.layer(middleware::from_fn(password_policy_middleware))
.layer(middleware::from_fn(auth_middleware));
router = router.nest("/node", node_routes);
@@ -665,6 +670,7 @@ pub fn basic_routes() -> Router {
.route("/profile/memory", get(profiling::memory_profile))
.route("/profile/stats", get(profiling::jemalloc_stats))
.route("/profile/cpu", get(profiling::cpu_profile))
.layer(middleware::from_fn(password_policy_middleware))
.layer(middleware::from_fn(auth_middleware));
router = router.nest("/debug", debug_routes);
@@ -751,6 +757,7 @@ pub fn basic_routes() -> Router {
pub fn config_routes() -> Router {
Router::new()
.route("/reload", get(status::config_reload))
.route_layer(middleware::from_fn(password_policy_middleware))
.route_layer(middleware::from_fn(auth_middleware))
.route("/", get(status::zo_config_bootstrap))
.route("/logout", get(status::logout))
@@ -760,6 +767,7 @@ pub fn config_routes() -> Router {
pub fn config_routes() -> Router {
Router::new()
.route("/reload", get(status::config_reload))
.route_layer(middleware::from_fn(password_policy_middleware))
.route_layer(middleware::from_fn(auth_middleware))
.route("/", get(status::zo_config_bootstrap))
.route("/logout", get(status::logout))
@@ -788,8 +796,29 @@ pub fn service_routes() -> Router {
// `/config` bootstrap in config_routes()
router = router.route("/{org_id}/config", get(status::zo_config));
// Users
// Reading one member returns their lockout counters, which only an enterprise build has.
#[cfg(not(feature = "enterprise"))]
{
router = router.route(
"/{org_id}/users/{email_id}",
post(users::add_user_to_org)
.put(users::update)
.delete(users::delete),
);
}
#[cfg(feature = "enterprise")]
{
router = router.route(
"/{org_id}/users/{email_id}",
post(users::add_user_to_org)
.put(users::update)
.delete(users::delete)
.get(users::get),
);
}
router = router.route("/{org_id}/users", get(users::list).post(users::save))
.route("/{org_id}/users/{email_id}", post(users::add_user_to_org).put(users::update).delete(users::delete))
.route("/{org_id}/users/bulk", delete(users::delete_bulk))
.route("/{org_id}/users/roles", get(users::list_roles))
.route("/invites", get(users::list_invitations))
@@ -1089,6 +1118,12 @@ pub fn service_routes() -> Router {
#[cfg(feature = "enterprise")]
{
router = router
// Instance-wide native-user auth policy: authored on the meta org, enforced everywhere
.route("/{org_id}/settings/password_policy", get(organization::password_policy::get_policy).put(organization::password_policy::set_policy))
// Complexity requirements only: readable by any authenticated user, including one the
// policy middleware has blocked, who needs to know what password will satisfy it
.route("/{org_id}/password_complexity", get(organization::password_policy::get_password_complexity))
// Anomaly Detection
.route("/{org_id}/anomaly_detection", get(anomaly_detection::list_configs).post(anomaly_detection::create_config))
.route("/{org_id}/anomaly_detection/{config_id}", get(anomaly_detection::get_config).put(anomaly_detection::update_config).delete(anomaly_detection::delete_config))
@@ -1924,6 +1959,9 @@ pub fn service_routes() -> Router {
router
.layer(middleware::from_fn(blocked_orgs_middleware))
.layer(middleware::from_fn(audit_middleware))
// Between auth and audit: it needs the email authentication resolves, and a request it
// refuses never reached a handler, so there is nothing for the audit trail to record.
.layer(middleware::from_fn(password_policy_middleware))
.layer(middleware::from_fn(auth_middleware))
.layer(RequestDecompressionLayer::new())
.layer(middleware::from_fn(
@@ -2680,6 +2718,182 @@ mod tests {
// appears in the OpenAPI surface, and a minimal router carrying only this
// route dispatches GET to the handler and rejects other methods.
#[cfg(feature = "enterprise")]
#[tokio::test]
async fn password_policy_read_is_refused_outside_the_meta_org() {
// The full policy carries lockout thresholds and history depth. Gating only the write
// would leave those readable by any org-level settings holder — and in an OSS build,
// where check_permissions is a no-op, by any authenticated user at all.
let app = Router::new().route(
"/{org_id}/settings/password_policy",
get(organization::password_policy::get_policy),
);
// auth_middleware injects this header before the handler runs; the minimal router here
// bypasses that, so the test supplies it.
let refused = app
.oneshot(
Request::builder()
.uri("/acme/settings/password_policy")
.header("user_id", "someone@example.com")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
// Returns before touching the database, which is what makes this assertable here.
assert_eq!(refused.status(), StatusCode::FORBIDDEN);
}
#[cfg(feature = "enterprise")]
#[tokio::test]
async fn password_policy_read_is_refused_for_a_non_admin_on_the_meta_org() {
// Belonging to _meta is not enough. With OpenFGA off — or in an OSS build, where
// check_permissions is an unconditional true — membership would otherwise be the only
// gate, so the role check has to hold on its own.
let app = Router::new().route(
"/{org_id}/settings/password_policy",
get(organization::password_policy::get_policy),
);
let refused = app
.oneshot(
Request::builder()
.uri("/_meta/settings/password_policy")
// Not present in ORG_USERS, so the role lookup finds nothing to admit.
.header("user_id", "not-a-meta-admin@example.invalid")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(refused.status(), StatusCode::FORBIDDEN);
}
/// axum resolves the route table when the `Router` is built, panicking on two paths it cannot
/// tell apart. Nothing else here constructs the real table — the tests below use minimal
/// routers — so without this a conflicting path would first be discovered at startup.
#[test]
fn service_routes_table_has_no_conflicts() {
let _ = service_routes();
}
/// The lockout counters ride along with the user record, and reading them tells the account
/// exactly how to pace attempts underneath the threshold — so a caller who does not administer
/// the named org gets nothing. Which administrators *are* admitted is unit-tested against
/// `validate_user_admin` in the handler crate.
///
/// The caller is seeded as a real member of both organizations, holding a role that is not an
/// administrator's. An unseeded caller would be refused for having no row to read a role from,
/// which would pass this test against a check that admitted every user it could find.
#[cfg(feature = "enterprise")]
#[tokio::test]
async fn user_read_refuses_a_caller_who_administers_nothing() {
use common::infra::config::{ORG_USERS, USERS};
use config::meta::user::{UserRole, UserType};
let caller = "viewer@user-read-routes.test";
let target = "target@example.com";
USERS.insert(
caller.to_string(),
infra::table::users::UserRecord {
email: caller.to_string(),
first_name: "V".to_string(),
last_name: "R".to_string(),
password: "hash".to_string(),
salt: "salt".to_string(),
is_root: false,
password_ext: None,
user_type: UserType::Internal,
created_at: 0,
updated_at: 0,
must_reset_password: false,
password_reset_reason: None,
flagged_at: None,
password_updated_at: None,
},
);
// The target is seeded as a member too, so the caller's role is the ONLY thing left that
// can refuse these requests. Without it a check that admitted the caller would still
// answer — for the target not being a member — and this test could not tell the two apart.
for (org, email) in [("acme", caller), ("_meta", caller), ("acme", target)] {
ORG_USERS.insert(
format!("{org}/{email}"),
infra::table::org_users::OrgUserRecord {
role: UserRole::Viewer,
token: "token".to_string(),
rum_token: None,
org_id: org.to_string(),
email: email.to_string(),
created_at: 0,
allow_static_token: true,
},
);
}
let app = Router::new().route("/{org_id}/users/{email_id}", get(users::get));
for (uri, case) in [
(
"/acme/users/target@example.com",
"read on an org the caller does not administer",
),
(
"/_meta/users/target@example.com",
"read by a non-admin on the meta org",
),
] {
let refused = app
.clone()
.oneshot(
Request::builder()
.uri(uri)
.header("user_id", caller)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
// Answered from the caches above, so this never reaches the database.
assert_eq!(refused.status(), StatusCode::FORBIDDEN, "{case}");
}
USERS.remove(caller);
for (org, email) in [("acme", caller), ("_meta", caller), ("acme", target)] {
ORG_USERS.remove(&format!("{org}/{email}"));
}
}
#[cfg(feature = "enterprise")]
#[tokio::test]
async fn password_complexity_route_is_readable_by_any_org() {
// The counterpart to the test above: the projection is deliberately not org-gated, since
// a user blocked for a forced reset holds no settings grant anywhere.
let app = Router::new().route(
"/{org_id}/password_complexity",
get(organization::password_policy::get_password_complexity),
);
let response = app
.oneshot(
Request::builder()
.uri("/acme/password_complexity")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_ne!(
response.status(),
StatusCode::FORBIDDEN,
"complexity must stay readable outside _meta"
);
}
#[tokio::test]
async fn query_functions_route_dispatches_get_and_rejects_other_methods() {
let app = Router::new().route(
@@ -596,9 +596,15 @@ pub struct ApiDoc;
openobserve_api_management::request::remote_tasks::activate_remote_task_signing_candidate,
openobserve_api_management::request::remote_tasks::end_remote_task_signing_grace,
openobserve_api_management::request::remote_tasks::revoke_remote_task_signing_secret,
openobserve_api_management::request::users::get,
openobserve_api_management::request::organization::password_policy::get_policy,
openobserve_api_management::request::organization::password_policy::set_policy,
openobserve_api_management::request::organization::password_policy::get_password_complexity,
))]
#[openapi(components(schemas(
openobserve_api_management::models::experiments::ExperimentResultRowSortBody,
o2_enterprise::enterprise::password_policy::lockout::LockoutState,
openobserve_api_management::request::users::UserDetailsResponse,
)))]
struct EnterpriseExperimentApiDoc;
+2
View File
@@ -49,6 +49,8 @@ pub mod model_pricing;
pub mod oncall;
pub mod organization;
#[cfg(feature = "enterprise")]
pub mod password_policy;
#[cfg(feature = "enterprise")]
pub mod playground;
#[cfg(feature = "profiling")]
pub mod profiling;
@@ -18,6 +18,8 @@ pub mod billing_group;
pub mod es;
pub mod ingestion_tokens;
pub mod org;
#[cfg(feature = "enterprise")]
pub mod password_policy;
pub mod settings;
#[cfg(feature = "enterprise")]
pub mod storage;
@@ -0,0 +1,227 @@
// 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/>.
//! Instance-wide authentication policy HTTP handler.
//!
//! The policy governs every native user on the deployment, so it is authored on `_meta` and never
//! per-tenant. Both methods require an administrator of that org, on top of the ordinary `settings`
//! permission: the grant answers "may this caller edit settings for the org they named", and the
//! checks below answer "is that org the one org allowed to hold this policy" and "is this caller an
//! instance administrator".
//!
//! The role check is not redundant with OpenFGA. That grant is optional — an OSS build compiles
//! `check_permissions` as an unconditional `true`, and an enterprise build with `openfga.enabled =
//! false`, or one whose license reporting has failed, short-circuits to the same. In those
//! configurations org membership would otherwise be the only gate, leaving a Viewer who happens to
//! belong to `_meta` able to read the lockout thresholds or rewrite the policy for everyone.
use axum::{Json, extract::Path, response::Response};
use config::{META_ORG_ID, meta::user::UserRole};
use o2_enterprise::enterprise::password_policy::meta::{PasswordComplexity, PasswordPolicy};
use openobserve_api_common::extractors::Headers;
use openobserve_core::auth::UserEmail;
use crate::common::meta::http::HttpResponse as MetaHttpResponse;
/// A tightening write forces a password reset on every affected user, so the count comes back with
/// the policy rather than leaving the admin to discover the blast radius from support tickets.
#[derive(serde::Serialize, utoipa::ToSchema)]
pub struct SetPasswordPolicyResponse {
pub policy: PasswordPolicy,
pub users_flagged: u64,
}
fn validate_meta_org_access(org_id: &str) -> Result<(), String> {
if org_id != META_ORG_ID {
return Err(format!(
"The password policy can only be configured from the meta organization. Provided \
org_id: {org_id}, expected: {META_ORG_ID}"
));
}
Ok(())
}
/// Both methods additionally require Root or Admin on `_meta`. See the module docs for why this is
/// enforced here rather than left to the OpenFGA `settings` grant.
async fn validate_meta_admin(org_id: &str, user_id: &str) -> Result<(), String> {
validate_meta_org_access(org_id)?;
// Root's membership lives on the default org, so it will not always be found under `_meta`.
if db::user::is_root_user(user_id) {
return Ok(());
}
match openobserve_core::users::get_user(Some(META_ORG_ID), user_id).await {
Some(user) if matches!(user.role, UserRole::Root | UserRole::Admin) => Ok(()),
_ => Err(format!(
"Configuring the password policy requires an administrator of the {META_ORG_ID} \
organization"
)),
}
}
/// Get the instance-wide password policy
#[utoipa::path(
get,
path = "/{org_id}/settings/password_policy",
context_path = "/api",
tag = "Organizations",
operation_id = "GetPasswordPolicy",
summary = "Get the password policy",
description = "Returns the authentication policy enforced for native users across the whole \
deployment: password complexity, rotation, reuse prevention and account lockout. \
Requires Root or Admin on the meta organization, because the full policy \
includes lockout thresholds and history depth; callers who only need the \
password requirements should use /{org_id}/password_complexity. An instance that has \
never configured a policy returns the built-in defaults rather than an error, \
so this endpoint never 404s.",
security(("Authorization" = [])),
params(
("org_id" = String, Path, description = "Organization name (must be meta org; caller must be Root or Admin)"),
),
responses(
(status = 200, description = "Success", content_type = "application/json", body = PasswordPolicy),
(status = 403, description = "Forbidden", content_type = "application/json", body = ()),
),
extensions(
("x-o2-ratelimit" = json!({"module": "Settings", "operation": "get"})),
("x-o2-mcp" = json!({"description": "Get the instance password policy", "category": "system"}))
)
)]
pub async fn get_policy(
Path(org_id): Path<String>,
Headers(user_email): Headers<UserEmail>,
) -> Response {
// Gated like the write, not just like a read: the full policy carries lockout thresholds and
// history depth, which tell an attacker how to pace attempts. Non-admins who need to know what
// a password must look like read /{org_id}/password_complexity instead.
if let Err(e) = validate_meta_admin(&org_id, &user_email.user_id).await {
return MetaHttpResponse::forbidden(e);
}
MetaHttpResponse::json(db::password_policy::get_effective_policy().await)
}
/// Get the password complexity requirements
#[utoipa::path(
get,
path = "/{org_id}/password_complexity",
context_path = "/api",
tag = "Organizations",
operation_id = "GetPasswordComplexity",
summary = "Get password complexity requirements",
description = "Returns only what a password must look like — length bounds, required character \
classes, and the special-character set. Readable by any authenticated user in any \
organization, because the caller who most needs it is one who has just been \
forced to reset their password and holds no settings permission. Rotation, reuse \
and lockout settings are never included.",
security(("Authorization" = [])),
params(
("org_id" = String, Path, description = "Organization name"),
),
responses(
(status = 200, description = "Success", content_type = "application/json", body = PasswordComplexity),
),
extensions(
("x-o2-ratelimit" = json!({"module": "Settings", "operation": "get"})),
("x-o2-mcp" = json!({"description": "Get password complexity requirements", "category": "system"}))
)
)]
pub async fn get_password_complexity(Path(_org_id): Path<String>) -> Response {
let policy = db::password_policy::get_effective_policy().await;
MetaHttpResponse::json(PasswordComplexity::from(&policy))
}
/// Replace the instance-wide password policy
#[utoipa::path(
put,
path = "/{org_id}/settings/password_policy",
context_path = "/api",
tag = "Organizations",
operation_id = "SetPasswordPolicy",
summary = "Configure the password policy",
description = "Replaces the whole policy; any field omitted from the body falls back to its \
default rather than to its current value. Requires Root or Admin on the meta \
organization. If the complexity requirements grew, every native user is flagged \
for a forced password reset and the response reports how many were affected — \
root, external users and service accounts are never flagged.",
security(("Authorization" = [])),
request_body(content = PasswordPolicy, description = "The policy to enforce", content_type = "application/json"),
params(
("org_id" = String, Path, description = "Organization name (must be meta org; caller must be Root or Admin)"),
),
responses(
(status = 200, description = "Success", content_type = "application/json", body = SetPasswordPolicyResponse),
(status = 400, description = "Bad Request", content_type = "application/json", body = ()),
(status = 403, description = "Forbidden", content_type = "application/json", body = ()),
(status = 500, description = "Internal Server Error", content_type = "application/json", body = ()),
),
extensions(
("x-o2-ratelimit" = json!({"module": "Settings", "operation": "create"})),
("x-o2-mcp" = json!({"description": "Configure the instance password policy", "category": "system", "requires_confirmation": true}))
)
)]
pub async fn set_policy(
Path(org_id): Path<String>,
Headers(user_email): Headers<UserEmail>,
Json(policy): Json<PasswordPolicy>,
) -> Response {
if let Err(e) = validate_meta_admin(&org_id, &user_email.user_id).await {
return MetaHttpResponse::forbidden(e);
}
// Reject a self-contradictory policy before it can flag anyone: the sweep is not something an
// admin can undo by correcting the request.
if let Err(e) = policy.validate() {
return MetaHttpResponse::bad_request(e);
}
match db::password_policy::set_policy(&policy).await {
Ok(users_flagged) => MetaHttpResponse::json(SetPasswordPolicyResponse {
policy,
users_flagged,
}),
Err(e) => {
log::error!("Error saving the password policy: {e}");
MetaHttpResponse::internal_error(e)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_meta_org_access_allows_only_meta() {
assert!(validate_meta_org_access(META_ORG_ID).is_ok());
let err = validate_meta_org_access("acme").unwrap_err();
assert!(err.contains("acme"), "the error names the rejected org");
assert!(err.contains(META_ORG_ID), "and the expected one");
}
#[test]
fn test_response_serializes_flagged_count() {
let body = serde_json::to_value(SetPasswordPolicyResponse {
policy: PasswordPolicy::default(),
users_flagged: 12,
})
.unwrap();
assert_eq!(body["users_flagged"], 12);
assert_eq!(body["policy"]["min_length"], 8);
}
}
@@ -0,0 +1,16 @@
// 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/>.
pub mod password_rotation;
@@ -0,0 +1,172 @@
// 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/>.
//! The rotation countdown handed to a user when they sign in.
//!
//! Rotation itself is enforced at resource-access time, where an expired password is refused. This
//! is only the advance notice, and it is computed once per session rather than per request: the
//! number moves when the day ticks over, so repeating it on every response would restate a value
//! that had not changed.
use chrono::{DateTime, Utc};
use common::infra::config::USERS;
use o2_enterprise::enterprise::password_policy::meta::RotationStatus;
/// Days left before `user_email`'s password expires, or `None` when there is nothing to say.
///
/// `None` covers every case the console must not draw a banner for: rotation off, a password too
/// young to warn about, an identity with no local password, root unless the policy applies to it,
/// and an already-expired password — that one is not a warning but a block, delivered by the
/// enforcement middleware as a `password_reset_required` refusal on the next request.
pub async fn warning_days(user_email: &str) -> Option<i64> {
let user = USERS.get(&user_email.to_lowercase())?;
let policy = db::password_policy::get_effective_policy().await;
if user.is_root && !policy.apply_to_root {
return None;
}
// An unrepresentable stored timestamp lands on the same never-expired reading as no timestamp
// at all.
let set_at = user
.password_updated_at
.and_then(DateTime::from_timestamp_micros);
match policy.rotation_status(set_at, Utc::now()) {
RotationStatus::Warning { days_remaining } => Some(days_remaining),
RotationStatus::Current | RotationStatus::Expired => None,
}
}
#[cfg(test)]
mod tests {
use chrono::TimeDelta;
use common::infra::config::SYSTEM_SETTINGS;
use config::{
META_ORG_ID,
meta::{
system_settings::{SystemSetting, keys},
user::UserType,
},
};
use infra::table::users::UserRecord;
use o2_enterprise::enterprise::password_policy::meta::PasswordPolicy;
use super::*;
/// A user whose password was set `age_days` ago, with nothing else standing in rotation's way.
fn user(email: &str, age_days: i64) -> UserRecord {
UserRecord {
email: email.to_string(),
first_name: "T".to_string(),
last_name: "U".to_string(),
password: "hash".to_string(),
salt: "salt".to_string(),
is_root: false,
password_ext: None,
user_type: UserType::Internal,
created_at: 0,
updated_at: 0,
must_reset_password: false,
password_reset_reason: None,
flagged_at: None,
password_updated_at: Some((Utc::now() - TimeDelta::days(age_days)).timestamp_micros()),
}
}
/// Seeding the settings cache keeps the policy read off the database. The key format is
/// db::system_settings::cache_key's; a drift there fails these tests rather than silencing
/// them, since the read would then fall back to a policy with rotation off.
async fn set_policy(rotation_days: u32, rotation_warning_days: u32, apply_to_root: bool) {
let policy = PasswordPolicy {
rotation_days,
rotation_warning_days,
apply_to_root,
..PasswordPolicy::default()
};
SYSTEM_SETTINGS.write().await.insert(
format!("org:{META_ORG_ID}:_:{}", keys::PASSWORD_POLICY),
SystemSetting::new_org(
META_ORG_ID,
keys::PASSWORD_POLICY,
serde_json::to_value(policy).unwrap(),
),
);
}
/// One test per process-wide cache: the settings cache is shared, so the cases that need
/// different policies cannot run as separate `#[tokio::test]`s.
#[tokio::test]
async fn warns_only_inside_the_window() {
let inside = "inside@b.com";
let young = "young@b.com";
let expired = "expired@b.com";
let root = "root@b.com";
let root_inside = "root-inside@b.com";
USERS.insert(inside.to_string(), user(inside, 85));
USERS.insert(young.to_string(), user(young, 1));
USERS.insert(expired.to_string(), user(expired, 91));
let mut root_user = user(root, 10_000);
root_user.is_root = true;
USERS.insert(root.to_string(), root_user);
let mut root_inside_user = user(root_inside, 85);
root_inside_user.is_root = true;
USERS.insert(root_inside.to_string(), root_inside_user);
set_policy(90, 7, false).await;
assert_eq!(warning_days(inside).await, Some(5));
assert_eq!(warning_days(young).await, None);
// Expired is the middleware's block to report, not a countdown.
assert_eq!(warning_days(expired).await, None);
assert_eq!(warning_days(root).await, None);
// Authenticated against something the users cache does not hold — a token, or an
// enterprise identity. There is no local password to have a policy about.
assert_eq!(warning_days("absent@b.com").await, None);
// The cache is keyed lowercase, so a sign-in that cased the email differently must still
// find the row.
assert_eq!(warning_days("Inside@B.com").await, Some(5));
assert_eq!(
warning_days(root_inside).await,
None,
"root is out of scope while the policy exempts it"
);
// The same instance with root in scope. Re-seeded in this test rather than a second one:
// the settings cache is process-wide, so two tests holding different policies would race.
set_policy(90, 7, true).await;
assert_eq!(
warning_days(root_inside).await,
Some(5),
"the exemption was the only thing keeping the countdown from root"
);
assert_eq!(
warning_days(root).await,
None,
"an expired root password is the middleware's block, not a countdown"
);
assert_eq!(
warning_days(inside).await,
Some(5),
"unchanged for everyone else"
);
for email in [inside, young, expired, root, root_inside] {
USERS.remove(email);
}
SYSTEM_SETTINGS
.write()
.await
.remove(&format!("org:{META_ORG_ID}:_:{}", keys::PASSWORD_POLICY));
}
}
@@ -287,6 +287,7 @@ pub async fn update(
new_password: None,
role: None,
token: None,
remove_lockout: false,
};
let initiator_id = &user_email.user_id;
+52 -14
View File
@@ -1272,7 +1272,8 @@ pub async fn redirect(Query(query): Query<std::collections::HashMap<String, Stri
let mut auth_cookie = Cookie::new("auth_tokens", tokens);
auth_cookie.set_expires(
time::OffsetDateTime::now_utc() + time::Duration::seconds(cfg.auth.cookie_max_age),
time::OffsetDateTime::now_utc()
+ time::Duration::seconds(db::password_policy::cookie_max_age_secs().await),
);
auth_cookie.set_http_only(true);
auth_cookie.set_secure(cfg.auth.cookie_secure_only);
@@ -1430,7 +1431,9 @@ pub async fn refresh_token_with_dex(
let mut auth_cookie = Cookie::new("auth_tokens", cleared);
auth_cookie.set_expires(
time::OffsetDateTime::now_utc()
+ time::Duration::seconds(conf.auth.cookie_max_age),
+ time::Duration::seconds(
db::password_policy::cookie_max_age_secs().await,
),
);
auth_cookie.set_http_only(true);
auth_cookie.set_secure(conf.auth.cookie_secure_only);
@@ -1471,7 +1474,8 @@ pub async fn refresh_token_with_dex(
let mut auth_cookie = Cookie::new("auth_tokens", tokens);
auth_cookie.set_expires(
time::OffsetDateTime::now_utc() + time::Duration::seconds(conf.auth.cookie_max_age),
time::OffsetDateTime::now_utc()
+ time::Duration::seconds(db::password_policy::cookie_max_age_secs().await),
);
auth_cookie.set_http_only(true);
auth_cookie.set_secure(conf.auth.cookie_secure_only);
@@ -1495,7 +1499,8 @@ pub async fn refresh_token_with_dex(
let mut auth_cookie = Cookie::new("auth_tokens", tokens);
auth_cookie.set_expires(
time::OffsetDateTime::now_utc() + time::Duration::seconds(conf.auth.cookie_max_age),
time::OffsetDateTime::now_utc()
+ time::Duration::seconds(db::password_policy::cookie_max_age_secs().await),
);
auth_cookie.set_http_only(true);
auth_cookie.set_secure(conf.auth.cookie_secure_only);
@@ -1516,15 +1521,18 @@ pub async fn refresh_token_with_dex(
}
}
/// `max_age_secs` is passed in rather than read here: the policy read is async and this is not, and
/// logout builds two cookies from the one answer.
fn prepare_empty_cookie<'a, T: Serialize + ?Sized>(
cookie_name: &'a str,
token_struct: &T,
conf: &Arc<Config>,
max_age_secs: i64,
) -> Cookie<'a> {
let tokens = json::to_string(token_struct).unwrap();
let tokens = base64::encode(&tokens);
let mut auth_cookie = Cookie::new(cookie_name, tokens);
auth_cookie.set_max_age(time::Duration::seconds(conf.auth.cookie_max_age));
auth_cookie.set_max_age(time::Duration::seconds(max_age_secs));
auth_cookie.set_http_only(true);
auth_cookie.set_secure(conf.auth.cookie_secure_only);
auth_cookie.set_path("/");
@@ -1567,8 +1575,11 @@ pub async fn logout(
.await;
}
};
let auth_cookie = prepare_empty_cookie("auth_tokens", &AuthTokens::default(), &conf);
let auth_ext_cookie = prepare_empty_cookie("auth_ext", &AuthTokensExt::default(), &conf);
let max_age_secs = db::password_policy::cookie_max_age_secs().await;
let auth_cookie =
prepare_empty_cookie("auth_tokens", &AuthTokens::default(), &conf, max_age_secs);
let auth_ext_cookie =
prepare_empty_cookie("auth_ext", &AuthTokensExt::default(), &conf, max_age_secs);
#[cfg(feature = "enterprise")]
if let Some(user_email) = user_email {
@@ -1950,7 +1961,12 @@ mod tests {
};
let config = Arc::new(Config::default());
let cookie = prepare_empty_cookie("test_cookie", &test_token, &config);
let cookie = prepare_empty_cookie(
"test_cookie",
&test_token,
&config,
config.auth.cookie_max_age,
);
let cookie_str = cookie.to_string();
assert!(cookie_str.starts_with("test_cookie="));
@@ -1969,7 +1985,12 @@ mod tests {
let empty_token = EmptyToken {};
let config = Arc::new(Config::default());
let cookie = prepare_empty_cookie("auth_cookie", &empty_token, &config);
let cookie = prepare_empty_cookie(
"auth_cookie",
&empty_token,
&config,
config.auth.cookie_max_age,
);
let cookie_str = cookie.to_string();
assert!(cookie_str.contains("HttpOnly"));
@@ -1991,8 +2012,10 @@ mod tests {
let test_data = TestData { id: 42 };
let config = Arc::new(Config::default());
let cookie1 = prepare_empty_cookie("cookie1", &test_data, &config);
let cookie2 = prepare_empty_cookie("cookie2", &test_data, &config);
let cookie1 =
prepare_empty_cookie("cookie1", &test_data, &config, config.auth.cookie_max_age);
let cookie2 =
prepare_empty_cookie("cookie2", &test_data, &config, config.auth.cookie_max_age);
let cookie1_str = cookie1.to_string();
let cookie2_str = cookie2.to_string();
@@ -2106,7 +2129,12 @@ mod tests {
};
let config = Arc::new(Config::default());
let cookie = prepare_empty_cookie("complex_cookie", &complex_data, &config);
let cookie = prepare_empty_cookie(
"complex_cookie",
&complex_data,
&config,
config.auth.cookie_max_age,
);
assert_eq!(cookie.name(), "complex_cookie");
assert!(!cookie.value().is_empty());
@@ -2190,7 +2218,12 @@ mod tests {
let empty_data = EmptyStruct;
let config = Arc::new(Config::default());
let cookie = prepare_empty_cookie("empty_cookie", &empty_data, &config);
let cookie = prepare_empty_cookie(
"empty_cookie",
&empty_data,
&config,
config.auth.cookie_max_age,
);
assert_eq!(cookie.name(), "empty_cookie");
assert!(!cookie.value().is_empty()); // Even empty struct gets base64 encoded
@@ -2218,7 +2251,12 @@ mod tests {
value: "test".to_string(),
};
let config = Arc::new(Config::default());
let cookie = prepare_empty_cookie("valid_cookie", &valid_data, &config);
let cookie = prepare_empty_cookie(
"valid_cookie",
&valid_data,
&config,
config.auth.cookie_max_age,
);
// Should not panic and should produce valid cookie
assert_eq!(cookie.name(), "valid_cookie");
+228 -16
View File
@@ -27,24 +27,31 @@ use common::meta::user::UserList;
use config::{
Config, get_config,
meta::user::UserRole,
utils::{base64, json, password::validate_password_strength},
utils::{base64, json},
};
use openobserve_api_common::extractors::Headers;
use openobserve_api_common::{auth::validator::AuthError, extractors::Headers};
use openobserve_core::{
auth::{UserEmail, generate_presigned_url, is_valid_email},
users,
};
use serde::Serialize;
#[cfg(feature = "enterprise")]
use utoipa::ToSchema;
#[cfg(feature = "enterprise")]
use {
audit::audit,
config::utils::time::now_micros,
o2_dex::config::get_config as get_dex_config,
o2_enterprise::enterprise::common::auditor::{AuditMessage, Protocol, ResponseMeta},
o2_enterprise::enterprise::password_policy::lockout::{self, LockoutState},
o2_openfga::config::get_config as get_openfga_config,
openobserve_core::auth::check_permissions,
};
#[cfg(feature = "enterprise")]
use crate::common::meta::user::UserResponse;
#[cfg(feature = "enterprise")]
use crate::request::password_policy::password_rotation;
use crate::{
common::meta::{
self,
@@ -59,6 +66,19 @@ use crate::{
pub mod service_accounts;
/// One organization member, as the list endpoint reports them, plus their lockout state.
///
/// The counters are an administrator's view and must not be handed to the user they describe:
/// someone who can read the escalation level and the remaining seconds can derive the thresholds
/// behind them and pace attempts to stay underneath.
#[cfg(feature = "enterprise")]
#[derive(Serialize, ToSchema)]
pub struct UserDetailsResponse {
#[serde(flatten)]
pub user: UserResponse,
pub lockout: LockoutState,
}
/// ListUsers
#[utoipa::path(
get,
@@ -134,6 +154,80 @@ pub async fn list(
}
}
/// GetUser
#[cfg(feature = "enterprise")]
#[utoipa::path(
get,
path = "/{org_id}/users/{email_id}",
context_path = "/api",
tag = "Users",
operation_id = "UserGet",
summary = "Get a single organization user",
description = "Returns one member of the organization — the same record the list endpoint \
reports — together with their failed-login lockout state: whether they are \
currently locked out of password authentication, how long is left on the lock, \
and the failure counters behind it. Requires Root or Admin on the named \
organization, and the user must belong to it. A user who has never failed a \
login is reported as unlocked with zeroed counters rather than as missing.",
security(
("Authorization"= [])
),
params(
("org_id" = String, Path, description = "Organization name"),
("email_id" = String, Path, description = "User's email id"),
),
responses(
(status = 200, description = "Success", content_type = "application/json", body = UserDetailsResponse),
(status = 403, description = "Forbidden", content_type = "application/json", body = ()),
(status = 404, description = "Not Found", content_type = "application/json", body = ()),
),
extensions(
("x-o2-ratelimit" = json!({"module": "Users", "operation": "get"})),
("x-o2-mcp" = json!({"description": "Get a user's details and lockout state", "category": "users"}))
)
)]
pub async fn get(
Path((org_id, email_id)): Path<(String, String)>,
Headers(user_email): Headers<UserEmail>,
) -> Response {
let email_id = email_id.trim().to_lowercase();
if let Err(e) = validate_user_admin(&org_id, &user_email.user_id).await {
return MetaHttpResponse::forbidden(e);
}
let Some(user) = users::get_user_details(&org_id, &email_id).await else {
return MetaHttpResponse::not_found("User not found");
};
match lockout::lockout_state(&email_id).await {
Ok(lockout) => MetaHttpResponse::json(UserDetailsResponse { user, lockout }),
Err(e) => {
log::error!("{e}");
MetaHttpResponse::internal_error("Failed to read the lockout state")
}
}
}
/// Who may read another member's record, and the counters that come with it.
///
/// The same rule `update_user` applies before it will reset a password or clear a lockout: root
/// administers the instance, everyone else must administer the organization named in the path.
/// Whether the *target* belongs to that organization is not decided here — the record lookup is
/// org-scoped, so a user outside it is simply not found.
#[cfg(feature = "enterprise")]
async fn validate_user_admin(org_id: &str, initiator_id: &str) -> Result<(), String> {
if db::user::is_root_user(initiator_id) {
return Ok(());
}
match users::get_user(Some(org_id), initiator_id).await {
Some(user) if matches!(user.role, UserRole::Root | UserRole::Admin) => Ok(()),
_ => Err(format!(
"Reading a user's details requires Root or Admin on {org_id}"
)),
}
}
/// Rejects an `org_id` path segment that is empty or whitespace-only.
///
/// The `/{org_id}/users` route parameter can arrive as `""` (via a
@@ -156,9 +250,9 @@ fn validate_org_id(org_id: &str) -> Option<&'static str> {
operation_id = "UserSave",
summary = "Create new user",
description = "Creates a new user account within the organization with specified role and authentication credentials. \
The password must be at least 8 characters long and the email address must be valid. Users are \
automatically assigned to the organization with the specified role and can begin accessing resources \
immediately upon creation.",
The password must be at least 15 characters long by default or comply to the defined password policy \
and the email address must be valid. Users are automatically assigned to the organization with the \
specified role and can begin accessing resources immediately upon creation.",
security(
("Authorization"= [])
),
@@ -184,18 +278,19 @@ pub async fn save(
user.email = user.email.trim().to_lowercase();
let bad_req_msg = if let Some(msg) = validate_org_id(&org_id) {
Some(msg)
} else if let Err(msg) = validate_password_strength(&user.password) {
Some(msg.to_string())
} else if let Err(msg) = db::password_policy::validate_password(&user.password).await {
Some(msg)
} else if user.role.base_role == UserRole::Root {
Some("Not allowed")
Some("Not allowed".to_string())
} else if user.role.base_role == UserRole::SreAgent {
Some("SRE Agent role cannot be assigned via API")
Some("SRE Agent role cannot be assigned via API".to_string())
} else if !is_valid_email(user.email.as_str()) {
Some("Invalid Email address")
Some("Invalid Email address".to_string())
} else {
None
};
if let Some(msg) = bad_req_msg {
return Response::builder()
.status(StatusCode::BAD_REQUEST)
@@ -230,8 +325,10 @@ pub async fn save(
summary = "Update user account",
description = "Updates user account information including role assignments, password changes, or other profile details. \
Users can modify their own account settings, while administrators have broader permissions to update \
any user account. Password changes require the new password to be at least 8 characters long for \
security compliance.",
any user account. Password changes require the new password to be at least 15 characters long by default \
or comply to the defined password policy for security compliance. Setting remove_lockout releases an \
active failed-login lockout and resets the counters; it answers to the same gate as an administrative \
password reset, so Root or Admin on the organization, and never the locked-out user themselves.",
security(
("Authorization"= [])
),
@@ -271,7 +368,7 @@ pub async fn update(
}
if user.change_password
&& let Some(new_pw) = user.new_password.as_deref()
&& let Err(msg) = validate_password_strength(new_pw)
&& let Err(msg) = db::password_policy::validate_password(new_pw).await
{
return Response::builder()
.status(StatusCode::BAD_REQUEST)
@@ -641,6 +738,11 @@ pub async fn authentication(
return unauthorized_error(resp);
}
}
Err(AuthError::Locked { retry_after_secs }) => {
#[cfg(feature = "enterprise")]
audit_locked_error(audit_message).await;
return locked_error(resp, retry_after_secs);
}
Err(_e) => {
#[cfg(feature = "enterprise")]
audit_unauthorized_error(audit_message).await;
@@ -650,6 +752,11 @@ pub async fn authentication(
if resp.status {
let cfg = get_config();
#[cfg(feature = "enterprise")]
{
resp.password_rotation_warning = password_rotation::warning_days(&auth.name).await;
}
let access_token = format!(
"Basic {}",
base64::encode(&format!("{}:{}", auth.name, auth.password))
@@ -663,7 +770,8 @@ pub async fn authentication(
let tokens = base64::encode(&tokens);
let mut auth_cookie = Cookie::new("auth_tokens", tokens);
auth_cookie.set_expires(
time::OffsetDateTime::now_utc() + time::Duration::seconds(cfg.auth.cookie_max_age),
time::OffsetDateTime::now_utc()
+ time::Duration::seconds(db::password_policy::cookie_max_age_secs().await),
);
auth_cookie.set_http_only(true);
auth_cookie.set_secure(cfg.auth.cookie_secure_only);
@@ -1038,12 +1146,38 @@ fn unauthorized_error(mut resp: SignInResponse) -> Response {
.unwrap()
}
/// The lockout refusal, which unlike [`unauthorized_error`] has something to tell the caller: how
/// long to wait. 429 with `Retry-After` matches what the API path returns for the same state.
fn locked_error(mut resp: SignInResponse, retry_after_secs: i64) -> Response {
resp.status = false;
resp.message = openobserve_api_common::auth::validator::lockout_message(retry_after_secs);
resp.lockout_retry_after_secs = Some(retry_after_secs);
Response::builder()
.status(StatusCode::TOO_MANY_REQUESTS)
.header(header::CONTENT_TYPE, "application/json")
.header(header::RETRY_AFTER, retry_after_secs)
.body(Body::from(serde_json::to_string(&resp).unwrap()))
.unwrap()
}
#[cfg(feature = "enterprise")]
async fn audit_unauthorized_error(mut audit_message: AuditMessage) {
async fn audit_unauthorized_error(audit_message: AuditMessage) {
audit_login_failure(audit_message, 401).await;
}
/// A lockout is audited under its own status code: an audit trail that reported every refused login
/// as a 401 would hide the difference between a wrong password and an account under attack.
#[cfg(feature = "enterprise")]
async fn audit_locked_error(audit_message: AuditMessage) {
audit_login_failure(audit_message, 429).await;
}
#[cfg(feature = "enterprise")]
async fn audit_login_failure(mut audit_message: AuditMessage, http_response_code: u16) {
use chrono::Utc;
audit_message._timestamp = Utc::now().timestamp_micros();
audit_message.response_meta.http_response_code = 401;
audit_message.response_meta.http_response_code = http_response_code;
// Even if the user_email of audit_message is not set, still the event should be audited
audit(audit_message).await;
}
@@ -1244,4 +1378,82 @@ mod tests {
assert!(validate_org_id("my-org").is_none());
assert!(validate_org_id(" default ").is_none());
}
/// Seeds the caches a running node fills from the coordinator watcher. Nothing watches under
/// test, and every allow path below is answered from these two alone — a miss would reach for
/// the database.
#[cfg(feature = "enterprise")]
fn join(org_id: &str, email: &str, role: UserRole) {
common::infra::config::USERS.insert(
email.to_string(),
infra::table::users::UserRecord {
email: email.to_string(),
first_name: "F".to_string(),
last_name: "L".to_string(),
password: "hash".to_string(),
salt: "salt".to_string(),
is_root: role == UserRole::Root,
password_ext: None,
user_type: config::meta::user::UserType::Internal,
created_at: 0,
updated_at: 0,
must_reset_password: false,
password_reset_reason: None,
flagged_at: None,
password_updated_at: None,
},
);
common::infra::config::ORG_USERS.insert(
format!("{org_id}/{email}"),
infra::table::org_users::OrgUserRecord {
role,
token: "token".to_string(),
rum_token: None,
org_id: org_id.to_string(),
email: email.to_string(),
created_at: 0,
allow_static_token: true,
},
);
}
#[cfg(feature = "enterprise")]
#[tokio::test]
async fn an_org_admin_reads_its_own_org() {
let org = "user-get-acme";
let admin = "admin@user-get-acme.test";
join(org, admin, UserRole::Admin);
assert!(validate_user_admin(org, admin).await.is_ok());
}
#[cfg(feature = "enterprise")]
#[tokio::test]
async fn a_non_admin_is_refused_its_own_org() {
let org = "user-get-viewer";
let viewer = "viewer@user-get-viewer.test";
join(org, viewer, UserRole::Viewer);
assert!(validate_user_admin(org, viewer).await.is_err());
}
/// The org boundary is the whole point: administering one tenant must not reach into another.
#[cfg(feature = "enterprise")]
#[tokio::test]
async fn administering_one_org_does_not_reach_another() {
let (mine, theirs) = ("user-get-mine", "user-get-theirs");
let admin = "admin@user-get-mine.test";
join(mine, admin, UserRole::Admin);
assert!(validate_user_admin(theirs, admin).await.is_err());
}
#[cfg(feature = "enterprise")]
#[tokio::test]
async fn root_reads_any_org() {
let root = "root@user-get-root.test";
join(config::DEFAULT_ORG, root, UserRole::Root);
assert!(validate_user_admin("user-get-any", root).await.is_ok());
}
}
+4 -3
View File
@@ -227,9 +227,9 @@ pub async fn cli() -> Result<bool, anyhow::Error> {
let component = command.get_one::<String>("component").unwrap();
match component.as_str() {
"root" => {
if let Err(msg) = config::utils::password::validate_password_strength(
&cfg.auth.root_user_password,
) {
if let Err(msg) =
db::password_policy::validate_password(&cfg.auth.root_user_password).await
{
return Err(anyhow::anyhow!(
"ZO_ROOT_USER_PASSWORD does not meet policy: {msg}"
));
@@ -254,6 +254,7 @@ pub async fn cli() -> Result<bool, anyhow::Error> {
} else {
Some(cfg.auth.root_user_token.clone())
},
remove_lockout: false,
},
)
.await?;
+14
View File
@@ -126,6 +126,11 @@ pub struct UpdateUser {
pub role: Option<UserRoleRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub token: Option<String>,
/// Releases an active failed-login lockout and resets the counters. Nothing else ends one
/// early — not even a password change, since the lock records attempts rather than the
/// password — and it is gated like a password reset by an administrator.
#[serde(default)]
pub remove_lockout: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
@@ -261,6 +266,14 @@ pub struct SignInUser {
pub struct SignInResponse {
pub status: bool,
pub message: String,
/// Days left before the caller's password expires, present only inside the rotation warning
/// window. Advisory: a client that ignores it is never broken by one.
#[serde(skip_serializing_if = "Option::is_none")]
pub password_rotation_warning: Option<i64>,
/// Seconds until a locked-out account may try again, present only on that refusal. The number
/// rather than a sentence, so the console can render it in the viewer's own language.
#[serde(skip_serializing_if = "Option::is_none")]
pub lockout_retry_after_secs: Option<i64>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, ToSchema)]
@@ -613,6 +626,7 @@ mod tests {
custom: None,
}),
token: Some("token123".to_string()),
remove_lockout: false,
};
assert!(update.change_password);
+2 -1
View File
@@ -84,7 +84,8 @@ pub type RwBTreeMap<K, V> = tokio::sync::RwLock<BTreeMap<K, V>>;
// 85: create llm_experiment_slot_retries.
// 86: create synthetics shared variables tables; add env to synthetics_jobs.
// 87: add input_preview to llm_annotation_queue_items.
pub const DB_SCHEMA_VERSION: u64 = 87;
// 88: add iam password policy tables.
pub const DB_SCHEMA_VERSION: u64 = 88;
pub const DB_SCHEMA_KEY: &str = "/db_schema_version/";
// global version variables
+11
View File
@@ -96,6 +96,8 @@ pub enum SettingCategory {
Ingestion,
/// General/miscellaneous settings
General,
/// Authentication and access-control settings
Security,
}
impl SettingCategory {
@@ -108,6 +110,7 @@ impl SettingCategory {
SettingCategory::Search => "search",
SettingCategory::Ingestion => "ingestion",
SettingCategory::General => "general",
SettingCategory::Security => "security",
}
}
}
@@ -130,6 +133,7 @@ impl std::str::FromStr for SettingCategory {
"search" => Ok(SettingCategory::Search),
"ingestion" => Ok(SettingCategory::Ingestion),
"general" => Ok(SettingCategory::General),
"security" => Ok(SettingCategory::Security),
_ => Err(format!("Invalid setting category: {}", s)),
}
}
@@ -163,6 +167,8 @@ pub mod keys {
pub const LIGHT_MODE_THEME_COLOR: &str = "light_mode_theme_color";
/// Dark mode theme color
pub const DARK_MODE_THEME_COLOR: &str = "dark_mode_theme_color";
/// Instance-wide authentication policy for native users
pub const PASSWORD_POLICY: &str = "password_policy";
}
/// A system setting record
@@ -417,6 +423,7 @@ mod tests {
assert_eq!(SettingCategory::Search.as_str(), "search");
assert_eq!(SettingCategory::Ingestion.as_str(), "ingestion");
assert_eq!(SettingCategory::General.as_str(), "general");
assert_eq!(SettingCategory::Security.as_str(), "security");
}
#[test]
@@ -455,6 +462,10 @@ mod tests {
"general".parse::<SettingCategory>().unwrap(),
SettingCategory::General
);
assert_eq!(
"security".parse::<SettingCategory>().unwrap(),
SettingCategory::Security
);
assert!("unknown".parse::<SettingCategory>().is_err());
}
+641 -244
View File
@@ -36,6 +36,8 @@ use db::{self, user::is_root_user};
use hashbrown::HashMap;
use infra::table::org_users::OrgUserRecord;
#[cfg(feature = "enterprise")]
use o2_enterprise::enterprise::password_policy;
#[cfg(feature = "enterprise")]
use o2_openfga::{
authorizer::authz::delete_service_account_from_org, config::get_config as get_openfga_config,
};
@@ -309,266 +311,331 @@ pub async fn update_user(
let mut new_role = None;
let conf = get_config();
let password_ext_salt = conf.auth.ext_auth_salt.as_str();
if let Ok(existing_user) = existing_user {
let mut new_user;
let mut is_updated = false;
let mut is_org_updated = false;
let mut message = "";
#[cfg(feature = "enterprise")]
let mut custom_roles = vec![];
#[cfg(feature = "enterprise")]
let mut custom_roles_need_change = false;
match existing_user {
Some(local_user) => {
#[cfg(not(feature = "cloud"))]
if local_user.is_external {
return Ok(MetaHttpResponse::bad_request(
"Updates not allowed with external users, please update with source system",
));
}
if !update_mode.is_self_update() {
if is_root_user(initiator_id) {
allow_password_update = true
} else {
let initiating_user = match db::user::get(Some(org_id), initiator_id).await
{
Ok(Some(u)) => u,
Ok(None) => {
return Ok(MetaHttpResponse::unauthorized("Not Allowed"));
}
Err(e) => {
log::error!("Error fetching initiating user {initiator_id}: {e}");
return Ok(MetaHttpResponse::unauthorized("Not Allowed"));
}
};
if (local_user.role.eq(&UserRole::Root)
&& initiating_user.role.eq(&UserRole::Root))
|| (!local_user.role.eq(&UserRole::Root)
&& (initiating_user.role.eq(&UserRole::Admin)
|| initiating_user.role.eq(&UserRole::Root)))
{
allow_password_update = true
}
}
}
if local_user.role.eq(&UserRole::Root)
&& !update_mode.is_self_update()
&& !update_mode.is_cli_update()
{
return Ok(MetaHttpResponse::bad_request(
"Only root user can update its details",
));
}
new_user = local_user.clone();
if update_mode.is_self_update()
&& let Some(old_pass) = &user.old_password
&& let Some(new_pass) = &user.new_password
{
if local_user
.password
.eq(&get_hash(old_pass, &local_user.salt))
{
new_user.password = get_hash(new_pass, &local_user.salt);
new_user.password_ext = Some(get_hash(new_pass, password_ext_salt));
log::info!("Password self updated for user: {email}");
is_updated = true;
} else {
message = "Existing/old password mismatch, please provide valid existing password";
return Ok(MetaHttpResponse::bad_request(message));
}
} else if update_mode.is_self_update()
&& user.new_password.is_some()
&& user.old_password.is_none()
{
message = "Please provide existing password";
} else if !update_mode.is_self_update()
&& allow_password_update
&& !local_user.is_external
&& let Some(new_pass) = user.new_password
{
new_user.password = get_hash(&new_pass, &local_user.salt);
new_user.password_ext = Some(get_hash(&new_pass, password_ext_salt));
log::info!("Password by root updated for user: {email}");
is_updated = true;
} else if user.new_password.is_some() {
message = "You are not authorised to change the password";
let Ok(existing_user) = existing_user else {
return Ok(MetaHttpResponse::not_found("User not found"));
};
let mut new_user;
let mut is_updated = false;
let mut password_changed = false;
let mut is_org_updated = false;
let mut message = "";
#[cfg(feature = "enterprise")]
let mut custom_roles = vec![];
#[cfg(feature = "enterprise")]
let mut custom_roles_need_change = false;
let Some(local_user) = existing_user else {
return Ok(MetaHttpResponse::not_found("User not found"));
};
#[cfg(not(feature = "cloud"))]
if local_user.is_external {
return Ok(MetaHttpResponse::bad_request(
"Updates not allowed with external users, please update with source system",
));
}
if !update_mode.is_self_update() {
if is_root_user(initiator_id) {
allow_password_update = true
} else {
let initiating_user = match db::user::get(Some(org_id), initiator_id).await {
Ok(Some(u)) => u,
Ok(None) => {
return Ok(MetaHttpResponse::unauthorized("Not Allowed"));
}
if let Some(first_name) = user.first_name
&& !local_user.is_external
{
new_user.first_name = first_name;
is_updated = true;
Err(e) => {
log::error!("Error fetching initiating user {initiator_id}: {e}");
return Ok(MetaHttpResponse::unauthorized("Not Allowed"));
}
if let Some(last_name) = user.last_name
&& !local_user.is_external
{
new_user.last_name = last_name;
is_updated = true;
}
if let Some(role) = user.role
&& (!local_user.is_external || is_cloud)
&& (!update_mode.is_self_update()
|| (local_user.role.eq(&UserRole::Admin)
};
if (local_user.role.eq(&UserRole::Root) && initiating_user.role.eq(&UserRole::Root))
|| (!local_user.role.eq(&UserRole::Root)
&& (initiating_user.role.eq(&UserRole::Admin)
|| initiating_user.role.eq(&UserRole::Root)))
{
allow_password_update = true
}
}
}
if local_user.role.eq(&UserRole::Root)
&& !update_mode.is_self_update()
&& !update_mode.is_cli_update()
{
return Ok(MetaHttpResponse::bad_request(
"Only root user can update its details",
));
}
// Clearing a lockout hands the account back its remaining guesses, so it answers to the same
// gate as replacing the password outright: root, or an administrator of this org. Never the
// locked-out user themselves, which `allow_password_update` already excludes by staying false
// on a self-update.
#[cfg(feature = "enterprise")]
let remove_lockout = user.remove_lockout;
// The request field stays on the OSS wire so a mixed fleet agrees on the shape; without the
// lockout there is simply nothing to clear.
#[cfg(not(feature = "enterprise"))]
let remove_lockout = false;
if remove_lockout && !allow_password_update {
return Ok(MetaHttpResponse::unauthorized("Not Allowed"));
}
new_user = local_user.clone();
if update_mode.is_self_update()
&& let Some(old_pass) = &user.old_password
&& let Some(new_pass) = &user.new_password
{
if local_user
.password
.eq(&get_hash(old_pass, &local_user.salt))
{
// Validated here, not only at the HTTP layer: this is the single funnel
// every password change passes through, and clearing the reset flag below
// must never happen for a password the current policy would reject.
if let Err(msg) = db::password_policy::validate_password(new_pass).await {
return Ok(MetaHttpResponse::bad_request(msg));
}
let new_hash = get_hash(new_pass, &local_user.salt);
#[cfg(feature = "enterprise")]
if let Err(msg) = password_policy::history::check_reuse_and_record(
email,
&new_hash,
&local_user.password,
&db::password_policy::get_effective_policy().await,
)
.await
{
return Ok(MetaHttpResponse::bad_request(msg));
}
new_user.password = new_hash;
new_user.password_ext = Some(get_hash(new_pass, password_ext_salt));
log::info!("Password self updated for user: {email}");
is_updated = true;
password_changed = true;
} else {
message = "Existing/old password mismatch, please provide valid existing password";
return Ok(MetaHttpResponse::bad_request(message));
}
} else if update_mode.is_self_update()
&& user.new_password.is_some()
&& user.old_password.is_none()
{
message = "Please provide existing password";
} else if !update_mode.is_self_update()
&& allow_password_update
&& !local_user.is_external
&& let Some(new_pass) = user.new_password
{
if let Err(msg) = db::password_policy::validate_password(&new_pass).await {
return Ok(MetaHttpResponse::bad_request(msg));
}
let new_hash = get_hash(&new_pass, &local_user.salt);
#[cfg(feature = "enterprise")]
if let Err(msg) = password_policy::history::check_reuse_and_record(
email,
&new_hash,
&local_user.password,
&db::password_policy::get_effective_policy().await,
)
.await
{
return Ok(MetaHttpResponse::bad_request(msg));
}
new_user.password = new_hash;
new_user.password_ext = Some(get_hash(&new_pass, password_ext_salt));
log::info!("Password by root updated for user: {email}");
is_updated = true;
password_changed = true;
} else if user.new_password.is_some() {
message = "You are not authorised to change the password";
}
if let Some(first_name) = user.first_name
&& !local_user.is_external
{
new_user.first_name = first_name;
is_updated = true;
}
if let Some(last_name) = user.last_name
&& !local_user.is_external
{
new_user.last_name = last_name;
is_updated = true;
}
if let Some(role) = user.role
&& (!local_user.is_external || is_cloud)
&& (!update_mode.is_self_update()
|| (local_user.role.eq(&UserRole::Admin)
// Editor can update other's roles, but viewer can update only self
|| local_user.role.eq(&UserRole::Editor)
|| local_user.role.eq(&UserRole::Viewer)
|| local_user.role.eq(&UserRole::Root)))
// if the User Role is Root, we do not change the Role
// Admins Role can still be mutable.
{
let new_org_role = UserOrgRole::from(&role);
old_role = Some(new_user.role);
new_user.role = new_org_role.base_role;
new_role = Some(new_user.role.clone());
if local_user.role.eq(&UserRole::Root) && new_user.role.ne(&UserRole::Root) {
message = "Root user role cannot be changed";
} else if update_mode.is_self_update() && local_user.role < new_user.role {
message = "Self role cannot be upgraded";
} else {
is_org_updated |= local_user.role.ne(&new_user.role);
#[cfg(feature = "enterprise")]
if let Some(cr) = new_org_role.custom_role {
custom_roles_need_change = true;
custom_roles.extend(cr);
is_org_updated = true;
}
// if the User Role is Root, we do not change the Role
// Admins Role can still be mutable.
{
let new_org_role = UserOrgRole::from(&role);
old_role = Some(new_user.role);
new_user.role = new_org_role.base_role;
new_role = Some(new_user.role.clone());
if local_user.role.eq(&UserRole::Root) && new_user.role.ne(&UserRole::Root) {
message = "Root user role cannot be changed";
} else if update_mode.is_self_update() && local_user.role < new_user.role {
message = "Self role cannot be upgraded";
} else {
is_org_updated |= local_user.role.ne(&new_user.role);
#[cfg(feature = "enterprise")]
if let Some(cr) = new_org_role.custom_role {
custom_roles_need_change = true;
custom_roles.extend(cr);
is_org_updated = true;
}
}
}
// Token replacement is a privileged operation — only allow if the
// initiator is updating their own token OR has password-update rights
// (i.e. Root or Admin updating a non-root user).
if let Some(token) = user.token
&& (update_mode.is_self_update() || allow_password_update)
{
new_user.token = token;
is_org_updated = true;
}
if !message.is_empty() {
return Ok(MetaHttpResponse::bad_request(message));
}
if !is_updated && !is_org_updated && !remove_lockout {
return Ok(MetaHttpResponse::bad_request("No changes to update"));
}
// Ahead of the record writes below: an unlock that the caller asked for must not be skipped
// because a later write failed, and it is safe to repeat if they retry the whole update.
if remove_lockout {
if let Err(e) = infra::table::user_auth_state::reset(email).await {
log::error!("Error clearing the lockout for {email}: {e}");
return Ok(MetaHttpResponse::internal_error(
"Failed to clear the lockout",
));
}
log::info!("Lockout cleared for {email} by {initiator_id}");
}
// `password_changed` also clears whatever the user was flagged for and restarts
// their rotation clock, in the same statement as the new hash: the password has
// just been validated against the current policy, so there is nothing left to hold
// them to.
if is_updated
&& db::user::update(
email,
&new_user.first_name,
&new_user.last_name,
&new_user.password,
new_user.password_ext,
password_changed,
)
.await
.is_err()
{
return Ok(MetaHttpResponse::internal_error("Failed to update user"));
}
// Update the organization membership
if is_org_updated {
if db::org_users::get(org_id, email).await.is_ok() {
if let Err(e) = db::org_users::update(
org_id,
email,
new_user.role,
&new_user.token,
new_user.rum_token,
)
.await
{
log::error!("Error updating org user relation: {e}");
return Ok(MetaHttpResponse::internal_error(
"Failed to update organization membership for user",
));
}
} else if let Err(e) = db::org_users::add(
org_id,
email,
new_user.role,
&new_user.token,
new_user.rum_token,
)
.await
{
log::error!("Error adding org user relation: {e}");
return Ok(MetaHttpResponse::internal_error(
"Failed to add organization membership for user",
));
}
#[cfg(feature = "enterprise")]
{
use o2_openfga::authorizer::{
authz::{get_user_crole_tuple, update_tuples, update_user_role},
roles::{get_role_key, get_roles_for_org_user, get_user_crole_removal_tuples},
};
if get_openfga_config().enabled
&& let Some(old) = old_role
&& let Some(new) = new_role
{
if !old.eq(&new) {
let mut old_str = old.to_string();
let mut new_str = new.to_string();
if old.eq(&UserRole::User) || old.is_service_account() {
old_str = "allowed_user".to_string();
}
if new.eq(&UserRole::User) || new.is_service_account() {
new_str = "allowed_user".to_string();
}
if old_str != new_str {
log::debug!(
"updating openfga role for {email} from {old_str} to {new_str}"
);
update_user_role(&old_str, &new_str, email, org_id).await;
}
}
// Token replacement is a privileged operation — only allow if the
// initiator is updating their own token OR has password-update rights
// (i.e. Root or Admin updating a non-root user).
if let Some(token) = user.token
&& (update_mode.is_self_update() || allow_password_update)
{
new_user.token = token;
is_org_updated = true;
}
if !message.is_empty() {
return Ok(MetaHttpResponse::bad_request(message));
}
if !is_updated && !is_org_updated {
return Ok(MetaHttpResponse::bad_request("No changes to update"));
}
if is_updated
&& db::user::update(
email,
&new_user.first_name,
&new_user.last_name,
&new_user.password,
new_user.password_ext,
)
.await
.is_err()
{
return Ok(MetaHttpResponse::internal_error("Failed to update user"));
}
// Update the organization membership
if is_org_updated {
if db::org_users::get(org_id, email).await.is_ok() {
if let Err(e) = db::org_users::update(
org_id,
email,
new_user.role,
&new_user.token,
new_user.rum_token,
)
.await
{
log::error!("Error updating org user relation: {e}");
return Ok(MetaHttpResponse::internal_error(
"Failed to update organization membership for user",
));
if custom_roles_need_change {
let existing_roles = get_roles_for_org_user(org_id, email).await;
let mut write_tuples = vec![];
let mut delete_tuples = vec![];
custom_roles.iter().for_each(|crole| {
if !existing_roles.contains(crole) {
write_tuples.push(get_user_crole_tuple(org_id, crole, email));
}
} else if let Err(e) = db::org_users::add(
org_id,
email,
new_user.role,
&new_user.token,
new_user.rum_token,
)
.await
{
log::error!("Error adding org user relation: {e}");
});
existing_roles.iter().for_each(|crole| {
if !custom_roles.contains(crole) {
get_user_crole_removal_tuples(
email,
&get_role_key(org_id, crole),
&mut delete_tuples,
);
}
});
if let Err(e) = update_tuples(write_tuples, delete_tuples).await {
log::error!(
"Error updating custom roles for user {email} in {org_id} org : {e}"
);
return Ok(MetaHttpResponse::internal_error(
"Failed to add organization membership for user",
"Failed to update custom roles for user",
));
}
#[cfg(feature = "enterprise")]
{
use o2_openfga::authorizer::{
authz::{get_user_crole_tuple, update_tuples, update_user_role},
roles::{
get_role_key, get_roles_for_org_user, get_user_crole_removal_tuples,
},
};
if get_openfga_config().enabled
&& let Some(old) = old_role
&& let Some(new) = new_role
{
if !old.eq(&new) {
let mut old_str = old.to_string();
let mut new_str = new.to_string();
if old.eq(&UserRole::User) || old.is_service_account() {
old_str = "allowed_user".to_string();
}
if new.eq(&UserRole::User) || new.is_service_account() {
new_str = "allowed_user".to_string();
}
if old_str != new_str {
log::debug!(
"updating openfga role for {email} from {old_str} to {new_str}"
);
update_user_role(&old_str, &new_str, email, org_id).await;
}
}
if custom_roles_need_change {
let existing_roles = get_roles_for_org_user(org_id, email).await;
let mut write_tuples = vec![];
let mut delete_tuples = vec![];
custom_roles.iter().for_each(|crole| {
if !existing_roles.contains(crole) {
write_tuples
.push(get_user_crole_tuple(org_id, crole, email));
}
});
existing_roles.iter().for_each(|crole| {
if !custom_roles.contains(crole) {
get_user_crole_removal_tuples(
email,
&get_role_key(org_id, crole),
&mut delete_tuples,
);
}
});
if let Err(e) = update_tuples(write_tuples, delete_tuples).await {
log::error!(
"Error updating custom roles for user {email} in {org_id} org : {e}"
);
return Ok(MetaHttpResponse::internal_error(
"Failed to update custom roles for user",
));
}
}
}
}
}
#[cfg(not(feature = "enterprise"))]
log::debug!("Role changed from {old_role:?} to {new_role:?}");
Ok(MetaHttpResponse::ok("User updated successfully"))
}
None => Ok(MetaHttpResponse::not_found("User not found")),
}
} else {
Ok(MetaHttpResponse::not_found("User not found"))
}
#[cfg(not(feature = "enterprise"))]
log::debug!("Role changed from {old_role:?} to {new_role:?}");
Ok(MetaHttpResponse::ok("User updated successfully"))
}
pub async fn add_admin_to_org(org_id: &str, user_email: &str) -> Result<(), anyhow::Error> {
@@ -730,6 +797,32 @@ pub async fn get_user(org_id: Option<&str>, name: &str) -> Option<User> {
}
}
/// One member of `org_id`, shaped exactly as `list_users` reports them.
///
/// `None` means the account holds no membership in this organization. An account that exists
/// elsewhere is not this organization's to describe, so it is absent rather than forbidden.
pub async fn get_user_details(org_id: &str, email: &str) -> Option<UserResponse> {
let org_user = db::org_users::get(org_id, email).await.ok()?;
let user = get_user(Some(org_id), email).await?;
let token = user
.role
.is_service_account()
.then(|| redact_token(&org_user.token));
let is_system = organization::is_system_service_account(&user.email);
Some(UserResponse {
email: user.email,
role: user.role.to_string(),
first_name: user.first_name,
last_name: user.last_name,
is_external: user.is_external,
orgs: None,
created_at: org_user.created_at,
token,
is_system,
description: is_system.then(|| "Used by the AI SRE Agent.".to_string()),
})
}
pub async fn get_user_by_token(org_id: &str, token: &str) -> Option<User> {
let rum_tokens = USERS_RUM_TOKEN.clone();
@@ -1368,6 +1461,12 @@ pub async fn create_service_account_if_not_exists(email: &str) -> Result<(), any
user_type: config::meta::user::UserType::Internal,
created_at: now,
updated_at: now,
// Service accounts are excluded from the policy sweep and have no interactive password,
// so they are never flagged; the timestamp is set anyway to keep the column non-NULL.
must_reset_password: false,
password_reset_reason: None,
flagged_at: None,
password_updated_at: Some(now),
};
infra::table::users::add(user_record).await?;
@@ -1384,6 +1483,8 @@ mod tests {
db::{self as infra_db, get_orm_client_rw},
table as infra_table,
};
#[cfg(feature = "enterprise")]
use o2_enterprise::enterprise::password_policy::meta::PasswordPolicy;
use tokio::sync::Mutex;
use super::*;
@@ -1489,6 +1590,10 @@ mod tests {
is_root: false,
created_at: 0,
updated_at: 0,
must_reset_password: false,
password_reset_reason: None,
flagged_at: None,
password_updated_at: None,
},
);
ORG_USERS.insert(
@@ -1549,7 +1654,7 @@ mod tests {
"dummy",
UserRequest {
email: "user@zo.dev".to_string(),
password: "pass#123".to_string(),
password: "Pass#1234567890".to_string(),
role: common::meta::user::UserOrgRole {
base_role: UserRole::Admin,
custom_role: None,
@@ -1585,6 +1690,7 @@ mod tests {
custom: None,
}),
change_password: false,
remove_lockout: false,
},
)
.await;
@@ -1607,6 +1713,7 @@ mod tests {
custom: None,
}),
change_password: false,
remove_lockout: false,
},
)
.await;
@@ -1839,6 +1946,7 @@ mod tests {
new_password: None,
role: None,
change_password: false,
remove_lockout: false,
},
)
.await;
@@ -1859,6 +1967,7 @@ mod tests {
new_password: None,
role: None,
change_password: false,
remove_lockout: false,
},
)
.await;
@@ -1867,6 +1976,294 @@ mod tests {
assert_eq!(response.status(), 404);
}
/// A password change must land the new hash and the columns describing it together: the flag
/// that blocks the user and the rotation clock that expires them.
#[tokio::test]
async fn test_password_change_clears_the_flag_and_restarts_the_rotation_clock() {
let _guard = set_up().await;
let email = "rotating@zo.dev";
let resp = post_user(
"dummy",
UserRequest {
email: email.to_string(),
password: "Pass#1234567890".to_string(),
role: common::meta::user::UserOrgRole {
base_role: UserRole::Admin,
custom_role: None,
},
first_name: "rot".to_owned(),
last_name: "".to_owned(),
is_external: false,
token: None,
},
"admin@zo.dev",
)
.await;
assert!(resp.is_ok());
infra_table::users::flag_all_for_password_reset("policy_tightened")
.await
.unwrap();
let before = infra_table::users::get(email).await.unwrap();
assert!(before.must_reset_password);
let resp = update_user(
"dummy",
email,
UserUpdateMode::SelfUpdate,
email,
UpdateUser {
token: None,
first_name: None,
last_name: None,
old_password: Some("Pass#1234567890".to_string()),
new_password: Some("Newpass#12345678".to_string()),
role: None,
change_password: true,
remove_lockout: false,
},
)
.await
.unwrap();
assert_eq!(resp.status(), 200);
let after = infra_table::users::get(email).await.unwrap();
assert!(!after.must_reset_password);
assert!(after.password_reset_reason.is_none());
assert!(after.flagged_at.is_none());
assert!(after.password_updated_at > before.password_updated_at);
}
/// The sweep skips root whatever the policy says. `apply_to_root` governs rotation and lockout,
/// which are recomputed per request; flagging root would instead store a block that only root
/// can clear, on the account that has to be able to repair a bad policy.
#[tokio::test]
async fn test_the_sweep_never_flags_root() {
let _guard = set_up().await;
let root_email = "sweep-root@zo.dev";
let ordinary = "sweep-user@zo.dev";
for (email, is_root) in [(root_email, true), (ordinary, false)] {
infra_table::users::add(infra_table::users::UserRecord {
email: email.to_string(),
first_name: "sweep".to_string(),
last_name: "".to_string(),
password: "hash".to_string(),
salt: "salt".to_string(),
is_root,
password_ext: None,
user_type: UserType::Internal,
created_at: 0,
updated_at: 0,
must_reset_password: false,
password_reset_reason: None,
flagged_at: None,
password_updated_at: Some(1),
})
.await
.unwrap();
}
infra_table::users::flag_all_for_password_reset("policy_tightened")
.await
.unwrap();
assert!(
!infra_table::users::get(root_email)
.await
.unwrap()
.must_reset_password,
"root is out of the sweep's scope"
);
assert!(
infra_table::users::get(ordinary)
.await
.unwrap()
.must_reset_password,
"everyone else is in it"
);
}
/// The reuse check belongs to `update_user`, not to the HTTP layer: the handlers validate
/// strength only, so a change routed through the CLI or any other caller would otherwise skip
/// it entirely.
#[cfg(feature = "enterprise")]
#[tokio::test]
async fn test_password_change_is_rejected_when_it_repeats_a_recent_password() {
let _guard = set_up().await;
let email = "reusing@zo.dev";
// `set_up` truncates the user tables but not the history a previous run left behind.
infra_table::user_password_history::delete_all_for_user(email)
.await
.unwrap();
let _ = infra_table::system_settings::create_table().await;
db::password_policy::set_policy(&PasswordPolicy {
history_count: 2,
..Default::default()
})
.await
.unwrap();
let resp = post_user(
"dummy",
UserRequest {
email: email.to_string(),
password: "Pass#1234567890".to_string(),
role: common::meta::user::UserOrgRole {
base_role: UserRole::Admin,
custom_role: None,
},
first_name: "reuse".to_owned(),
last_name: "".to_owned(),
is_external: false,
token: None,
},
"admin@zo.dev",
)
.await;
assert!(resp.is_ok());
assert_eq!(
self_change_password(email, "Pass#1234567890", "Newpass#12345678")
.await
.status(),
200
);
assert_eq!(
self_change_password(email, "Newpass#12345678", "Pass#1234567890")
.await
.status(),
400
);
assert_eq!(
self_change_password(email, "Newpass#12345678", "Third#1234567890")
.await
.status(),
200
);
db::password_policy::set_policy(&PasswordPolicy::default())
.await
.unwrap();
}
#[cfg(feature = "enterprise")]
async fn self_change_password(email: &str, old: &str, new: &str) -> Response {
update_user(
"dummy",
email,
UserUpdateMode::SelfUpdate,
email,
UpdateUser {
token: None,
first_name: None,
last_name: None,
old_password: Some(old.to_string()),
new_password: Some(new.to_string()),
role: None,
change_password: true,
remove_lockout: false,
},
)
.await
.unwrap()
}
/// Clearing a lockout is the one administrative act that leaves no trace on the user record,
/// so it has to be gated on its own: nothing else in the request tells the caller apart.
#[cfg(feature = "enterprise")]
#[tokio::test]
async fn test_only_an_administrator_can_clear_a_lockout() {
let _guard = set_up().await;
let email = "locked@zo.dev";
let resp = post_user(
"dummy",
UserRequest {
email: email.to_string(),
password: "Pass#1234567890".to_string(),
role: common::meta::user::UserOrgRole {
base_role: UserRole::Viewer,
custom_role: None,
},
first_name: "locked".to_owned(),
last_name: "".to_owned(),
is_external: false,
token: None,
},
"admin@zo.dev",
)
.await;
assert!(resp.is_ok());
infra_table::user_auth_state::insert_first_failure(email, 1)
.await
.unwrap();
// The locked-out user is exactly who must not be able to lift their own lock.
assert_eq!(
clear_lockout(email, UserUpdateMode::SelfUpdate, email)
.await
.status(),
403
);
assert_eq!(
clear_lockout(email, UserUpdateMode::OtherUpdate, email)
.await
.status(),
403,
"a viewer administers nobody, itself included"
);
assert_eq!(
infra_table::user_auth_state::get(email)
.await
.unwrap()
.unwrap()
.failed_attempts,
1,
"a refused request must not have touched the counters"
);
assert_eq!(
clear_lockout(email, UserUpdateMode::OtherUpdate, "admin@zo.dev")
.await
.status(),
200
);
assert_eq!(
infra_table::user_auth_state::get(email)
.await
.unwrap()
.unwrap()
.failed_attempts,
0
);
}
#[cfg(feature = "enterprise")]
async fn clear_lockout(email: &str, mode: UserUpdateMode, initiator: &str) -> Response {
update_user(
"dummy",
email,
mode,
initiator,
UpdateUser {
token: None,
first_name: None,
last_name: None,
old_password: None,
new_password: None,
role: None,
change_password: false,
remove_lockout: true,
},
)
.await
.unwrap()
}
#[tokio::test]
async fn test_add_user_to_org_validation() {
let _guard = set_up().await;
+1
View File
@@ -47,6 +47,7 @@ pub mod org_ingestion_tokens;
pub mod org_status;
pub mod org_users;
pub mod organization;
pub mod password_policy;
pub mod pipeline_errors;
#[cfg(feature = "vectorscan")]
pub mod re_pattern;
+175
View File
@@ -0,0 +1,175 @@
// 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/>.
//! Storage of the instance-wide authentication policy.
//!
//! The policy is a single `system_settings` row under `_meta`, written only by the admin API. There
//! is no environment-variable path and nothing runs at startup: an instance with no row enforces
//! `PasswordPolicy::default`, which is a permanent valid state rather than a bootstrap phase.
//!
//! The reads here are the caching layer over `system_settings`, which is why they stayed on this
//! side: `o2_enterprise` cannot depend on this crate. [`validate_password`] and
//! [`cookie_max_age_secs`] exist in an OSS build too, answering from the static rule and from
//! `ZO_COOKIE_MAX_AGE`, so their callers need no feature gate of their own.
#[cfg(feature = "enterprise")]
use config::{
META_ORG_ID,
meta::system_settings::{SettingCategory, SettingScope, SystemSetting, keys},
};
#[cfg(feature = "enterprise")]
use infra::table::users;
#[cfg(feature = "enterprise")]
use o2_enterprise::enterprise::password_policy::{
meta::{PasswordPolicy, PasswordResetReason},
validate::validate_password_strength_with_policy,
};
#[cfg(feature = "enterprise")]
const POLICY_LOCK_KEY: &str = "/password_policy/write";
/// Reject a password the instance would not accept, naming what it fails.
///
/// The single funnel for every human-set password. Without the enterprise feature there is no
/// configurable policy, so this is the static rule that has always applied.
pub async fn validate_password(password: &str) -> Result<(), String> {
#[cfg(feature = "enterprise")]
{
validate_password_strength_with_policy(password, &get_effective_policy().await)
}
#[cfg(not(feature = "enterprise"))]
{
config::utils::password::validate_password_strength(password).map_err(str::to_string)
}
}
/// How long a sign-in cookie should live, in seconds.
///
/// The policy wins when an administrator has set it; otherwise `ZO_COOKIE_MAX_AGE` stands, so an
/// instance nobody has configured keeps the lifetime its deployment chose. Every place that issues
/// the auth cookie reads this rather than the config value directly.
pub async fn cookie_max_age_secs() -> i64 {
let fallback = config::get_config().auth.cookie_max_age;
#[cfg(feature = "enterprise")]
{
get_effective_policy().await.cookie_max_age_secs(fallback)
}
#[cfg(not(feature = "enterprise"))]
{
fallback
}
}
/// The policy every enforcement point should read, served from the system-settings cache.
///
/// The rotation check consults it on every authenticated request, so the read has to stay off the
/// database — see [`read_stored_policy`] for how an instance with no row does that.
#[cfg(feature = "enterprise")]
pub async fn get_effective_policy() -> PasswordPolicy {
match read_stored_policy().await {
Ok(Some(policy)) => policy,
Ok(None) => PasswordPolicy::default(),
Err(e) => {
log::error!("Failed to read the password policy, falling back to the default: {e}");
PasswordPolicy::default()
}
}
}
/// Persist `policy`, flagging every eligible user for a forced password reset if the complexity
/// requirements grew. Returns the number of users flagged, which is 0 when nothing tightened.
///
/// The only writer. Callers must validate the policy first.
#[cfg(feature = "enterprise")]
pub async fn set_policy(policy: &PasswordPolicy) -> Result<u64, anyhow::Error> {
// Two admins submitting at once would otherwise both read the pre-change policy, and the second
// write could land a tightening whose sweep never ran.
let locker = infra::dist_lock::lock(POLICY_LOCK_KEY, 0).await?;
let result = set_locked(policy).await;
infra::dist_lock::unlock(&locker).await?;
result
}
#[cfg(feature = "enterprise")]
async fn set_locked(policy: &PasswordPolicy) -> Result<u64, anyhow::Error> {
// The *effective* policy, not the stored row: an instance with no row is genuinely enforcing
// the default, so a first write that raises the bar above it is a real tightening. Diffing
// against the raw row would skip the sweep on exactly the write most likely to need it.
let previous = get_effective_policy().await;
// Sweep before the write. Flagging against a policy that then fails to land is recoverable
// friction; a landed policy with nobody flagged is a silent enforcement gap.
let flagged = if policy.is_stricter_than(&previous) {
let flagged =
users::flag_all_for_password_reset(PasswordResetReason::PolicyTightened.as_str())
.await?;
// The sweep is a bulk UPDATE and emits no per-user event, so without this every node keeps
// serving must_reset_password = false from cache and the flag enforces nothing.
crate::user::broadcast_bulk_refresh().await?;
flagged
} else {
0
};
write_policy(policy).await?;
if flagged > 0 {
log::info!(
"Password policy tightened, flagged {flagged} native user(s) for a password reset"
);
}
Ok(flagged)
}
/// Read the row, caching the default in its place when there is none.
///
/// The settings cache only ever holds rows that exist, so without the seed every read on an
/// unconfigured instance falls through to a database lookup that can only return nothing — once per
/// request, forever, since no row is a permanent valid state. The cached default says exactly what
/// an absent row means, so callers need not tell the two apart, and a real row written on any node
/// replaces it through `set` or the settings watcher.
#[cfg(feature = "enterprise")]
async fn read_stored_policy() -> Result<Option<PasswordPolicy>, anyhow::Error> {
let Some(setting) = crate::system_settings::get(
&SettingScope::Org,
Some(META_ORG_ID),
None,
keys::PASSWORD_POLICY,
)
.await?
else {
crate::system_settings::set_only_cached(&policy_setting(&PasswordPolicy::default())?).await;
return Ok(None);
};
Ok(Some(serde_json::from_value(setting.setting_value)?))
}
#[cfg(feature = "enterprise")]
async fn write_policy(policy: &PasswordPolicy) -> Result<(), anyhow::Error> {
crate::system_settings::set(&policy_setting(policy)?).await?;
Ok(())
}
#[cfg(feature = "enterprise")]
fn policy_setting(policy: &PasswordPolicy) -> Result<SystemSetting, anyhow::Error> {
Ok(SystemSetting::new_org(
META_ORG_ID,
keys::PASSWORD_POLICY,
serde_json::to_value(policy)?,
)
.with_category(SettingCategory::Security)
.with_description("Instance-wide authentication policy for native users"))
}
+19
View File
@@ -166,6 +166,25 @@ pub async fn set(setting: &SystemSetting) -> Result<SystemSetting> {
Ok(result)
}
/// Put a setting into this node's cache without writing it to the database.
///
/// For a setting whose absence has a defined meaning: caching that meaning stops every subsequent
/// [`get`] falling through to a database read that can only return nothing. A real row, written
/// here or on any other node, replaces it through [`set`] or [`watch`].
#[cfg(feature = "enterprise")]
pub(crate) async fn set_only_cached(setting: &SystemSetting) {
let cache_k = cache_key(
&setting.scope,
setting.org_id.as_deref(),
setting.user_id.as_deref(),
&setting.setting_key,
);
SYSTEM_SETTINGS
.write()
.await
.insert(cache_k, setting.clone());
}
/// Delete a setting
pub async fn delete(
scope: &SettingScope,
+45
View File
@@ -39,6 +39,44 @@ pub fn is_root_user(user_id: &str) -> bool {
}
pub const USER_RECORD_KEY: &str = "/user_record/";
/// Broadcast marker for a bulk update that touched many user rows at once.
///
/// The per-row events behind [`USER_RECORD_KEY`] do not scale to a sweep that can touch every user
/// on the instance, so a sweep publishes one event here and each node rebuilds its cache instead.
pub const USER_BULK_REFRESH_KEY: &str = "/user_bulk_refresh/";
/// Tell every node its users cache is stale.
///
/// Callers that bypass the per-user write path — bulk `UPDATE`s straight to the table — must call
/// this, or the change stays invisible to every node until the next restart.
pub async fn broadcast_bulk_refresh() -> Result<(), anyhow::Error> {
put_into_db_coordinator(USER_BULK_REFRESH_KEY, Bytes::new(), true, None)
.await
.map_err(|e| anyhow::anyhow!("Error broadcasting a users cache refresh: {e}"))
}
/// Rebuild the users cache whenever a bulk update announces itself.
pub async fn watch_bulk_refresh() -> Result<(), anyhow::Error> {
let cluster_coordinator = db::get_coordinator().await;
let mut events = cluster_coordinator.watch(USER_BULK_REFRESH_KEY).await?;
let events = Arc::get_mut(&mut events).unwrap();
log::info!("Start watching user bulk refresh");
loop {
let Some(ev) = events.recv().await else {
log::error!("watch_user_bulk_refresh: event channel closed");
break;
};
// The key carries no payload; its arrival is the whole message.
if matches!(ev, db::Event::Put(_))
&& let Err(e) = cache().await
{
log::error!("Error refreshing the users cache after a bulk update: {e}");
}
}
Ok(())
}
pub async fn get_user_record(email: &str) -> Result<users::UserRecord, anyhow::Error> {
users::get(email)
.await
@@ -177,12 +215,17 @@ pub async fn add(db_user: &DBUser) -> Result<(), anyhow::Error> {
Ok(())
}
/// Update a user, then refresh the local cache and the rest of the cluster.
///
/// `password_changed` carries through to the row write — see [`users::update`] — and on to the
/// other clusters, so a password set here does not read as expired everywhere else.
pub async fn update(
user_email: &str,
first_name: &str,
last_name: &str,
password: &str,
password_ext: Option<String>,
password_changed: bool,
) -> Result<(), anyhow::Error> {
let key = format!("{USER_RECORD_KEY}{user_email}");
users::update(
@@ -191,6 +234,7 @@ pub async fn update(
last_name,
password,
password_ext.clone(),
password_changed,
)
.await
.map_err(|e| anyhow::anyhow!("Error updating user: {e}"))?;
@@ -203,6 +247,7 @@ pub async fn update(
last_name: last_name.to_string(),
password: password.to_string(),
password_ext,
password_changed,
})
.await?;
Ok(())
+2
View File
@@ -105,6 +105,8 @@ pub mod timed_annotation_panels;
pub mod timed_annotations;
#[cfg(feature = "cloud")]
pub mod trial_quota_usage;
pub mod user_auth_state;
pub mod user_password_history;
pub mod users;
pub mod workflow_associations;
pub mod workflow_drafts;
@@ -0,0 +1,37 @@
//! `SeaORM` Entity for per-user failed-login and lockout state.
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "user_auth_state")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub email: String,
pub failed_attempts: i32,
pub lockout_level: i32,
#[sea_orm(nullable)]
pub locked_until: Option<i64>,
#[sea_orm(nullable)]
pub last_failed_at: Option<i64>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::users::Entity",
from = "Column::Email",
to = "super::users::Column::Email",
on_update = "NoAction",
on_delete = "Cascade"
)]
Users,
}
impl Related<super::users::Entity> for Entity {
fn to() -> RelationDef {
Relation::Users.def()
}
}
impl ActiveModelBehavior for ActiveModel {}
@@ -0,0 +1,34 @@
//! `SeaORM` Entity for past password hashes.
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "user_password_history")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: String,
pub email: String,
pub password_hash: String,
pub created_at: i64,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::users::Entity",
from = "Column::Email",
to = "super::users::Column::Email",
on_update = "NoAction",
on_delete = "Cascade"
)]
Users,
}
impl Related<super::users::Entity> for Entity {
fn to() -> RelationDef {
Relation::Users.def()
}
}
impl ActiveModelBehavior for ActiveModel {}
+31
View File
@@ -18,12 +18,26 @@ pub struct Model {
pub user_type: i16,
pub created_at: i64,
pub updated_at: i64,
#[sea_orm(default_value = false)]
pub must_reset_password: bool,
#[sea_orm(nullable)]
pub password_reset_reason: Option<String>,
#[sea_orm(nullable)]
pub flagged_at: Option<i64>,
/// NULL only for rows written between the schema migration and the first password change;
/// the rotation check treats it as never-expired rather than as the epoch.
#[sea_orm(nullable)]
pub password_updated_at: Option<i64>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::org_users::Entity")]
OrgUsers,
#[sea_orm(has_many = "super::user_password_history::Entity")]
UserPasswordHistory,
#[sea_orm(has_one = "super::user_auth_state::Entity")]
UserAuthState,
}
impl Related<super::org_users::Entity> for Entity {
@@ -32,6 +46,18 @@ impl Related<super::org_users::Entity> for Entity {
}
}
impl Related<super::user_password_history::Entity> for Entity {
fn to() -> RelationDef {
Relation::UserPasswordHistory.def()
}
}
impl Related<super::user_auth_state::Entity> for Entity {
fn to() -> RelationDef {
Relation::UserAuthState.def()
}
}
impl ActiveModelBehavior for ActiveModel {}
#[cfg(test)]
@@ -52,9 +78,14 @@ mod tests {
user_type: 0,
created_at: 1000,
updated_at: 2000,
must_reset_password: false,
password_reset_reason: None,
flagged_at: None,
password_updated_at: Some(1000),
};
assert_eq!(m.email, "test@example.com");
assert!(!m.is_root);
assert!(m.password_ext.is_none());
assert!(!m.must_reset_password);
}
}
@@ -0,0 +1,124 @@
// 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/>.
//! Per-user state for the configurable authentication policies.
//!
//! `password_updated_at` is backfilled from `created_at` rather than left NULL: the rotation
//! check reads it as "when this password was set", so a NULL-everywhere column would mark every
//! pre-existing user as already expired the first time an admin enables `rotation_days`.
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// Two SQLite constraints shared with every other alter in this directory: ONE alter
// option per statement, and an explicit `has_column` guard because
// `add_column_if_not_exists` is not idempotent on SQLite.
add_column(manager, Users::MustResetPassword, |c| {
c.boolean().not_null().default(false)
})
.await?;
add_column(manager, Users::PasswordResetReason, |c| {
c.string_len(32).null()
})
.await?;
add_column(manager, Users::FlaggedAt, |c| c.big_integer().null()).await?;
add_column(manager, Users::PasswordUpdatedAt, |c| {
c.big_integer().null()
})
.await?;
manager.exec_stmt(backfill_password_updated_at()).await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
for col in [
Users::PasswordUpdatedAt,
Users::FlaggedAt,
Users::PasswordResetReason,
Users::MustResetPassword,
] {
manager
.alter_table(
Table::alter()
.table(Users::Table)
.drop_column(col)
.to_owned(),
)
.await?;
}
Ok(())
}
}
const USERS: &str = "users";
#[derive(DeriveIden, Clone, Copy)]
enum Users {
Table,
CreatedAt,
MustResetPassword,
PasswordResetReason,
FlaggedAt,
PasswordUpdatedAt,
}
fn backfill_password_updated_at() -> UpdateStatement {
Query::update()
.table(Users::Table)
.value(Users::PasswordUpdatedAt, Expr::col(Users::CreatedAt))
.and_where(Expr::col(Users::PasswordUpdatedAt).is_null())
.to_owned()
}
/// Add one column, skipping it if already present.
async fn add_column<F>(manager: &SchemaManager<'_>, column: Users, def: F) -> Result<(), DbErr>
where
F: FnOnce(&mut ColumnDef) -> &mut ColumnDef,
{
let name = column.into_iden().to_string();
if manager.has_column(USERS, &name).await? {
return Ok(());
}
let mut col = ColumnDef::new(column);
let col = def(&mut col).to_owned();
manager
.alter_table(
Table::alter()
.table(Users::Table)
.add_column(col)
.to_owned(),
)
.await
}
#[cfg(test)]
mod tests {
use sea_query::SqliteQueryBuilder;
use super::*;
#[test]
fn test_backfill_only_touches_null_rows() {
let sql = backfill_password_updated_at().to_string(SqliteQueryBuilder);
assert!(sql.contains("\"password_updated_at\" = \"created_at\""));
assert!(sql.contains("\"password_updated_at\" IS NULL"));
}
}
@@ -0,0 +1,145 @@
// 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/>.
//! Past password hashes, for reuse prevention.
//!
//! Keyed on `users.email` rather than `users.id` because every password-set path already carries
//! the email and none of them carry the KSUID. The cascade only fires where the backend enforces
//! foreign keys (SQLite ships with `PRAGMA foreign_keys` off), so deletion is also done
//! explicitly at the application layer.
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager.create_table(create_table_statement()).await?;
manager
.create_index(create_email_created_at_index())
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_index(
Index::drop()
.if_exists()
.name(EMAIL_CREATED_AT_IDX)
.table(UserPasswordHistory::Table)
.to_owned(),
)
.await?;
manager
.drop_table(
Table::drop()
.if_exists()
.table(UserPasswordHistory::Table)
.to_owned(),
)
.await
}
}
const EMAIL_CREATED_AT_IDX: &str = "idx_user_password_history_email_created_at";
const USERS_FK: &str = "user_password_history_users_fk";
#[derive(DeriveIden)]
enum UserPasswordHistory {
Table,
Id,
Email,
PasswordHash,
CreatedAt,
}
#[derive(DeriveIden)]
enum Users {
Table,
Email,
}
fn create_table_statement() -> TableCreateStatement {
Table::create()
.table(UserPasswordHistory::Table)
.if_not_exists()
.col(
ColumnDef::new(UserPasswordHistory::Id)
.char_len(27)
.primary_key()
.not_null(),
)
.col(
ColumnDef::new(UserPasswordHistory::Email)
.string_len(100)
.not_null(),
)
.col(
ColumnDef::new(UserPasswordHistory::PasswordHash)
.string_len(256)
.not_null(),
)
.col(
ColumnDef::new(UserPasswordHistory::CreatedAt)
.big_integer()
.not_null(),
)
.foreign_key(
ForeignKey::create()
.name(USERS_FK)
.from(UserPasswordHistory::Table, UserPasswordHistory::Email)
.to(Users::Table, Users::Email)
.on_delete(ForeignKeyAction::Cascade),
)
.to_owned()
}
/// Covers both the `WHERE email = ?` prune and the `ORDER BY created_at DESC` reuse lookup, so no
/// separate single-column index on `email` is needed.
fn create_email_created_at_index() -> IndexCreateStatement {
Index::create()
.if_not_exists()
.name(EMAIL_CREATED_AT_IDX)
.table(UserPasswordHistory::Table)
.col(UserPasswordHistory::Email)
.col(UserPasswordHistory::CreatedAt)
.to_owned()
}
#[cfg(test)]
mod tests {
use sea_query::SqliteQueryBuilder;
use super::*;
#[test]
fn test_create_table_statement() {
let sql = create_table_statement().to_string(SqliteQueryBuilder);
assert!(sql.contains("user_password_history"));
assert!(sql.contains("\"password_hash\""));
assert!(sql.to_uppercase().contains("ON DELETE CASCADE"));
}
#[test]
fn test_create_email_created_at_index() {
let sql = create_email_created_at_index().to_string(SqliteQueryBuilder);
assert!(sql.contains(EMAIL_CREATED_AT_IDX));
assert!(sql.contains("\"email\""));
assert!(sql.contains("\"created_at\""));
}
}
@@ -0,0 +1,113 @@
// 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/>.
//! Failed-login counters and lockout windows, one row per native user.
//!
//! Keyed on `users.email`: the credential-checking functions only ever have the email in scope,
//! and a row is created lazily on the first failed attempt, so most users never have one.
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager.create_table(create_table_statement()).await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(UserAuthState::Table).to_owned())
.await
}
}
const USERS_FK: &str = "user_auth_state_users_fk";
#[derive(DeriveIden)]
enum UserAuthState {
Table,
Email,
FailedAttempts,
LockoutLevel,
LockedUntil,
LastFailedAt,
}
#[derive(DeriveIden)]
enum Users {
Table,
Email,
}
fn create_table_statement() -> TableCreateStatement {
Table::create()
.table(UserAuthState::Table)
.if_not_exists()
.col(
ColumnDef::new(UserAuthState::Email)
.string_len(100)
.not_null()
.primary_key(),
)
.col(
ColumnDef::new(UserAuthState::FailedAttempts)
.integer()
.not_null()
.default(0),
)
.col(
ColumnDef::new(UserAuthState::LockoutLevel)
.integer()
.not_null()
.default(0),
)
.col(
ColumnDef::new(UserAuthState::LockedUntil)
.big_integer()
.null(),
)
.col(
ColumnDef::new(UserAuthState::LastFailedAt)
.big_integer()
.null(),
)
.foreign_key(
ForeignKey::create()
.name(USERS_FK)
.from(UserAuthState::Table, UserAuthState::Email)
.to(Users::Table, Users::Email)
.on_delete(ForeignKeyAction::Cascade),
)
.to_owned()
}
#[cfg(test)]
mod tests {
use sea_query::SqliteQueryBuilder;
use super::*;
#[test]
fn test_create_table_statement() {
let sql = create_table_statement().to_string(SqliteQueryBuilder);
assert!(sql.contains("user_auth_state"));
assert!(sql.contains("\"failed_attempts\""));
assert!(sql.contains("\"lockout_level\""));
assert!(sql.to_uppercase().contains("ON DELETE CASCADE"));
}
}
+13 -2
View File
@@ -196,6 +196,9 @@ mod m20260917_000001_add_env_to_synthetics_jobs;
mod m20260917_000001_create_llm_experiment_slot_retries;
mod m20260917_000001_create_synthetics_shared_variables;
mod m20260921_000001_add_input_preview_to_llm_annotation_queue_items;
mod m20260922_000001_add_password_policy_columns_to_users;
mod m20260922_000002_create_user_password_history_table;
mod m20260922_000003_create_user_auth_state_table;
/// Shared body of the two `folder_id` migrations above; not a migration itself.
mod workflow_folder_id;
@@ -449,12 +452,12 @@ impl MigratorTrait for Migrator {
Box::new(m20260818_000002_create_llm_remote_tasks::Migration),
Box::new(m20260820_000001_add_icon_to_folders::Migration),
Box::new(m20260820_000003_create_llm_secrets::Migration),
Box::new(m20260822_000001_create_status_pages_tables::Migration),
Box::new(m20260824_000001_create_llm_playground_snapshots::Migration),
Box::new(m20260825_000001_add_steps_configured_to_synthetics_jobs::Migration),
Box::new(m20260825_000001_add_alert_pending_period_col::Migration),
Box::new(m20260827_000001_drop_table_action_scripts::Migration),
Box::new(m20260822_000001_create_status_pages_tables::Migration),
Box::new(m20260825_000001_create_status_page_custom_domains::Migration),
Box::new(m20260827_000001_drop_table_action_scripts::Migration),
Box::new(m20260806_000001_create_oncall_tables::Migration),
Box::new(m20260807_000001_create_oncall_ownership::Migration),
Box::new(m20260811_000001_create_oncall_unrouted_signals::Migration),
@@ -477,6 +480,9 @@ impl MigratorTrait for Migrator {
Box::new(m20260917_000001_create_synthetics_shared_variables::Migration),
Box::new(m20260917_000001_add_env_to_synthetics_jobs::Migration),
Box::new(m20260921_000001_add_input_preview_to_llm_annotation_queue_items::Migration),
Box::new(m20260922_000001_add_password_policy_columns_to_users::Migration),
Box::new(m20260922_000002_create_user_password_history_table::Migration),
Box::new(m20260922_000003_create_user_auth_state_table::Migration),
]
}
}
@@ -523,6 +529,7 @@ mod tests {
87,
"m20260921_000001_add_input_preview_to_llm_annotation_queue_items",
),
(88, "m20260922_000003_create_user_auth_state_table"),
];
#[test]
@@ -594,6 +601,10 @@ mod tests {
"m20260812_000001_create_composite_alerts",
"m20260825_000001_create_status_page_custom_domains",
),
(
"m20260921_000001_add_input_preview_to_llm_annotation_queue_items",
"m20260922_000001_add_password_policy_columns_to_users",
),
] {
assert!(
position(earlier) < position(later),
+4
View File
@@ -96,6 +96,8 @@ pub mod timed_annotation_panels;
pub mod timed_annotations;
#[cfg(feature = "cloud")]
pub mod trial_quota_usage;
pub mod user_auth_state;
pub mod user_password_history;
pub mod users;
pub mod workflows;
@@ -147,6 +149,8 @@ pub async fn create_user_tables() -> Result<(), anyhow::Error> {
organizations::create_table().await?;
users::create_table().await?;
org_users::create_table().await?;
user_password_history::create_table().await?;
user_auth_state::create_table().await?;
Ok(())
}
+18 -1
View File
@@ -26,7 +26,7 @@ use config::meta::system_settings::{SettingScope, SystemSetting};
use sea_orm::{
ActiveModelTrait,
ActiveValue::{NotSet, Set},
ColumnTrait, EntityTrait, QueryFilter, QueryOrder,
ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, QueryOrder, Schema,
};
use crate::{
@@ -34,6 +34,23 @@ use crate::{
table::entity::system_settings::{ActiveModel, Column, Entity, Model},
};
/// Create the table from the entity, for callers that stand up a schema without running the
/// migrations that own it in production.
pub async fn create_table() -> Result<(), anyhow::Error> {
let client = get_orm_client_rw().await;
let builder = client.get_database_backend();
let schema = Schema::new(builder);
let create_table_stmt = schema
.create_table_from_entity(Entity)
.if_not_exists()
.take();
client.execute(builder.build(&create_table_stmt)).await?;
Ok(())
}
/// Get a single setting by scope, org_id, user_id, and key
pub async fn get(
scope: &SettingScope,
+177
View File
@@ -0,0 +1,177 @@
// 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 sea_orm::{
EntityTrait, QueryFilter, Schema, Set,
entity::prelude::*,
sea_query::{Func, OnConflict, SimpleExpr},
};
use super::entity::user_auth_state::{ActiveModel, Column, Entity, Model};
use crate::{
db::{get_orm_client_ro, get_orm_client_rw},
errors::{self, DbError, Error},
};
pub async fn create_table() -> Result<(), errors::Error> {
let client = get_orm_client_rw().await;
let builder = client.get_database_backend();
let schema = Schema::new(builder);
let create_table_stmt = schema
.create_table_from_entity(Entity)
.if_not_exists()
.take();
client
.execute(builder.build(&create_table_stmt))
.await
.map_err(|e| Error::DbError(DbError::SeaORMError(e.to_string())))?;
Ok(())
}
/// `None` means the user has never failed a login — the common case, since rows are only written
/// on failure.
pub async fn get(email: &str) -> Result<Option<Model>, errors::Error> {
let client = get_orm_client_ro().await;
Entity::find()
.filter(email_eq(email))
.one(client)
.await
.map_err(|e| Error::DbError(DbError::SeaORMError(e.to_string())))
}
/// Add one to the failure counter. `false` means there is no row yet and the caller should insert.
///
/// The arithmetic runs inside the database, so simultaneous failures against the same account
/// cannot lose an increment the way a read-modify-write from two nodes would.
pub async fn increment_failed_attempts(email: &str, now: i64) -> Result<bool, errors::Error> {
let client = get_orm_client_rw().await;
let result = Entity::update_many()
.col_expr(
Column::FailedAttempts,
Expr::col(Column::FailedAttempts).add(1),
)
.col_expr(Column::LastFailedAt, Expr::value(now))
.filter(email_eq(email))
.exec(client)
.await
.map_err(|e| Error::DbError(DbError::SeaORMError(e.to_string())))?;
Ok(result.rows_affected > 0)
}
/// Record the first failure for a user. `false` means a concurrent request inserted the row first
/// and the caller should increment it instead.
pub async fn insert_first_failure(email: &str, now: i64) -> Result<bool, errors::Error> {
let record = ActiveModel {
email: Set(email.to_lowercase()),
failed_attempts: Set(1),
lockout_level: Set(0),
locked_until: Set(None),
last_failed_at: Set(Some(now)),
};
let client = get_orm_client_rw().await;
let inserted = Entity::insert(record)
.on_conflict(OnConflict::column(Column::Email).do_nothing().to_owned())
.exec_without_returning(client)
.await
.map_err(|e| Error::DbError(DbError::SeaORMError(e.to_string())))?;
Ok(inserted > 0)
}
/// Start a lockout, and report whether this caller is the one that started it.
///
/// A compare-and-set against the state the caller read: `from_level` and `min_attempts` are part of
/// the `WHERE`, so a burst of simultaneous failures that all observe the threshold produces exactly
/// one escalation. The losers change nothing and read the winner's `locked_until`.
///
/// `failed_attempts` resets here, so the next bucket is counted from zero.
pub async fn escalate(
email: &str,
from_level: i32,
min_attempts: i32,
locked_until: i64,
) -> Result<bool, errors::Error> {
let client = get_orm_client_rw().await;
let result = Entity::update_many()
.col_expr(Column::LockoutLevel, Expr::value(from_level + 1))
.col_expr(Column::LockedUntil, Expr::value(locked_until))
.col_expr(Column::FailedAttempts, Expr::value(0))
.filter(email_eq(email))
.filter(Column::LockoutLevel.eq(from_level))
.filter(Column::FailedAttempts.gte(min_attempts))
.exec(client)
.await
.map_err(|e| Error::DbError(DbError::SeaORMError(e.to_string())))?;
Ok(result.rows_affected > 0)
}
/// Overwrite the lockout deadline, leaving the failure counters and the escalation level alone.
///
/// Unlike [`escalate`] this does not advance the level, so it expresses "this lockout is over"
/// without also making the next one longer — an account unlocked by hand, or one whose deadline a
/// test moves rather than waiting out.
pub async fn set_locked_until(
email: &str,
locked_until: Option<i64>,
) -> Result<u64, errors::Error> {
let client = get_orm_client_rw().await;
let result = Entity::update_many()
.col_expr(Column::LockedUntil, Expr::value(locked_until))
.filter(email_eq(email))
.exec(client)
.await
.map_err(|e| Error::DbError(DbError::SeaORMError(e.to_string())))?;
Ok(result.rows_affected)
}
/// Clear every counter, including the escalation level, after a successful login.
pub async fn reset(email: &str) -> Result<(), errors::Error> {
let client = get_orm_client_rw().await;
Entity::update_many()
.col_expr(Column::FailedAttempts, Expr::value(0))
.col_expr(Column::LockoutLevel, Expr::value(0))
.col_expr(Column::LockedUntil, Expr::value(Option::<i64>::None))
.col_expr(Column::LastFailedAt, Expr::value(Option::<i64>::None))
.filter(email_eq(email))
.exec(client)
.await
.map_err(|e| Error::DbError(DbError::SeaORMError(e.to_string())))?;
Ok(())
}
/// Called when a user is deleted. See `user_password_history::delete_all_for_user` for why the
/// declared cascade is not enough.
pub async fn delete(email: &str) -> Result<u64, errors::Error> {
let client = get_orm_client_rw().await;
let result = Entity::delete_many()
.filter(email_eq(email))
.exec(client)
.await
.map_err(|e| Error::DbError(DbError::SeaORMError(e.to_string())))?;
Ok(result.rows_affected)
}
fn email_eq(email: &str) -> SimpleExpr {
Expr::expr(Func::lower(Expr::col(Column::Email))).eq(email.to_lowercase())
}
@@ -0,0 +1,116 @@
// 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 config::ider;
use sea_orm::{
ColumnTrait, EntityTrait, Order, QueryFilter, QueryOrder, QuerySelect, Schema, Set,
entity::prelude::*,
sea_query::{Func, SimpleExpr},
};
use super::entity::user_password_history::{ActiveModel, Column, Entity, Model};
use crate::{
db::{get_orm_client_ro, get_orm_client_rw},
errors::{self, DbError, Error},
};
pub async fn create_table() -> Result<(), errors::Error> {
let client = get_orm_client_rw().await;
let builder = client.get_database_backend();
let schema = Schema::new(builder);
let create_table_stmt = schema
.create_table_from_entity(Entity)
.if_not_exists()
.take();
client
.execute(builder.build(&create_table_stmt))
.await
.map_err(|e| Error::DbError(DbError::SeaORMError(e.to_string())))?;
Ok(())
}
pub async fn add(email: &str, password_hash: &str) -> Result<(), errors::Error> {
let record = ActiveModel {
id: Set(ider::uuid()),
email: Set(email.to_lowercase()),
password_hash: Set(password_hash.to_string()),
created_at: Set(chrono::Utc::now().timestamp_micros()),
};
let client = get_orm_client_rw().await;
Entity::insert(record)
.exec(client)
.await
.map_err(|e| Error::DbError(DbError::SeaORMError(e.to_string())))?;
Ok(())
}
/// The `limit` most recently recorded hashes, newest first.
pub async fn list_recent(email: &str, limit: u64) -> Result<Vec<Model>, errors::Error> {
if limit == 0 {
return Ok(vec![]);
}
let client = get_orm_client_ro().await;
Entity::find()
.filter(email_eq(email))
.order_by(Column::CreatedAt, Order::Desc)
.limit(limit)
.all(client)
.await
.map_err(|e| Error::DbError(DbError::SeaORMError(e.to_string())))
}
/// Drop everything older than the `retain` most recent hashes.
pub async fn prune(email: &str, retain: u64) -> Result<u64, errors::Error> {
let kept = list_recent(email, retain).await?;
let Some(oldest_kept) = kept.last() else {
// retain == 0 means keep nothing; an empty history has nothing to prune either.
if retain > 0 {
return Ok(0);
}
return delete_all_for_user(email).await;
};
let client = get_orm_client_rw().await;
let result = Entity::delete_many()
.filter(email_eq(email))
.filter(Column::CreatedAt.lt(oldest_kept.created_at))
.exec(client)
.await
.map_err(|e| Error::DbError(DbError::SeaORMError(e.to_string())))?;
Ok(result.rows_affected)
}
/// Called when a user is deleted. The schema declares `ON DELETE CASCADE`, but SQLite ships with
/// `PRAGMA foreign_keys` off, so the cleanup cannot be left to the database.
pub async fn delete_all_for_user(email: &str) -> Result<u64, errors::Error> {
let client = get_orm_client_rw().await;
let result = Entity::delete_many()
.filter(email_eq(email))
.exec(client)
.await
.map_err(|e| Error::DbError(DbError::SeaORMError(e.to_string())))?;
Ok(result.rows_affected)
}
fn email_eq(email: &str) -> SimpleExpr {
Expr::expr(Func::lower(Expr::col(Column::Email))).eq(email.to_lowercase())
}
+134 -5
View File
@@ -19,11 +19,16 @@ use config::{
};
use sea_orm::{
ColumnTrait, EntityTrait, Order, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, Schema,
Set, entity::prelude::*, sea_query::Func,
Set,
entity::prelude::*,
sea_query::{Func, Query, SimpleExpr},
};
use serde::{Deserialize, Serialize};
use super::entity::users::{ActiveModel, Column, Entity, Model};
use super::entity::{
org_users,
users::{ActiveModel, Column, Entity, Model},
};
use crate::{
db::{get_orm_client_ro, get_orm_client_rw},
errors::{self, DbError, Error},
@@ -42,6 +47,10 @@ impl From<Model> for UserRecord {
user_type: model.user_type.into(),
created_at: model.created_at,
updated_at: model.updated_at,
must_reset_password: model.must_reset_password,
password_reset_reason: model.password_reset_reason,
flagged_at: model.flagged_at,
password_updated_at: model.password_updated_at,
}
}
}
@@ -59,6 +68,18 @@ pub struct UserRecord {
pub user_type: UserType,
pub created_at: i64,
pub updated_at: i64,
/// Set by the policy sweep; read by the access-time middleware straight from the users cache,
/// which is why it lives here rather than only on the entity model.
#[serde(default)]
pub must_reset_password: bool,
#[serde(default)]
pub password_reset_reason: Option<String>,
#[serde(default)]
pub flagged_at: Option<i64>,
/// NULL only between the schema migration and this user's first password change; the rotation
/// check reads that as never-expired rather than as the epoch.
#[serde(default)]
pub password_updated_at: Option<i64>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
@@ -68,6 +89,10 @@ pub struct UserUpdate {
pub last_name: String,
pub password: String,
pub password_ext: Option<String>,
/// Whether `password` is a new password rather than an unchanged one carried along with an
/// edit to something else. Defaulted so a message from an older build still deserializes.
#[serde(default)]
pub password_changed: bool,
}
impl From<&DBUser> for UserRecord {
@@ -91,6 +116,12 @@ impl From<&DBUser> for UserRecord {
},
created_at: 0,
updated_at: 0,
// DBUser is the API-facing shape and carries no policy state. A record built from one
// is only ever used to insert or look up, never to overwrite these columns.
must_reset_password: false,
password_reset_reason: None,
flagged_at: None,
password_updated_at: None,
}
}
}
@@ -142,6 +173,10 @@ pub async fn add(user: UserRecord) -> Result<(), errors::Error> {
created_at: Set(now),
updated_at: Set(now),
id: Set(ider::uuid()),
must_reset_password: Set(false),
password_reset_reason: Set(None),
flagged_at: Set(None),
password_updated_at: Set(Some(now)),
};
let client = get_orm_client_rw().await;
@@ -154,25 +189,70 @@ pub async fn add(user: UserRecord) -> Result<(), errors::Error> {
}
}
/// Update a user's profile fields.
///
/// `password_changed` says whether `password` is a genuinely new password. When it is, the columns
/// that describe the password — the forced-reset flag and the rotation clock — are rewritten in the
/// same statement, so a user can never hold a compliant password while still flagged for one. It
/// must stay false for edits that merely carry the existing hash along, such as the `password_ext`
/// backfill at login; restarting the rotation clock there would make expiry unreachable.
pub async fn update(
email: &str,
first_name: &str,
last_name: &str,
password: &str,
password_ext: Option<String>,
password_changed: bool,
) -> Result<u64, errors::Error> {
let client = get_orm_client_rw().await;
let result = Entity::update_many()
let now = chrono::Utc::now().timestamp_micros();
let mut stmt = Entity::update_many()
.col_expr(Column::FirstName, Expr::value(first_name))
.col_expr(Column::LastName, Expr::value(last_name))
.col_expr(Column::Password, Expr::value(password))
.col_expr(Column::PasswordExt, Expr::value(password_ext))
.col_expr(Column::UpdatedAt, Expr::value(now));
if password_changed {
stmt = stmt
.col_expr(Column::MustResetPassword, Expr::value(false))
.col_expr(
Column::PasswordResetReason,
Expr::value(Option::<String>::None),
)
.col_expr(Column::FlaggedAt, Expr::value(Option::<i64>::None))
.col_expr(Column::PasswordUpdatedAt, Expr::value(now));
}
let result = stmt
.filter(Expr::expr(Func::lower(Expr::col(Column::Email))).eq(email.to_lowercase()))
.exec(client)
.await
.map_err(|e| Error::DbError(DbError::SeaORMError(e.to_string())))?;
Ok(result.rows_affected)
}
/// Flag every interactive native user for a forced password reset.
///
/// Three groups are left alone: external users authenticate elsewhere, so the local password
/// policy never applies to them; service accounts have no interactive password to reset, so
/// flagging them would only break the automation using their tokens; and root is exempt so a
/// tightened policy can never lock the instance out of its own recovery path.
pub async fn flag_all_for_password_reset(reason: &str) -> Result<u64, errors::Error> {
let client = get_orm_client_rw().await;
let external: i16 = UserType::External.into();
let result = Entity::update_many()
.col_expr(Column::MustResetPassword, Expr::value(true))
.col_expr(Column::PasswordResetReason, Expr::value(reason))
.col_expr(
Column::UpdatedAt,
Column::FlaggedAt,
Expr::value(chrono::Utc::now().timestamp_micros()),
)
.filter(Expr::expr(Func::lower(Expr::col(Column::Email))).eq(email.to_lowercase()))
.filter(Column::UserType.ne(external))
.filter(Column::IsRoot.eq(false))
.filter(not_a_service_account())
.exec(client)
.await
.map_err(|e| Error::DbError(DbError::SeaORMError(e.to_string())))?;
@@ -188,6 +268,8 @@ pub async fn remove(email: &str) -> Result<(), errors::Error> {
.await
.map_err(|e| Error::DbError(DbError::SeaORMError(e.to_string())))?;
drop_auth_side_tables(&[email.to_string()]).await;
Ok(())
}
@@ -268,15 +350,58 @@ pub async fn batch_remove(emails: Vec<String>) -> Result<(), errors::Error> {
.await
.map_err(|e| Error::DbError(DbError::SeaORMError(e.to_string())))?;
drop_auth_side_tables(&emails).await;
Ok(())
}
async fn drop_auth_side_tables(emails: &[String]) {
for email in emails {
if let Err(e) = super::user_password_history::delete_all_for_user(email).await {
log::error!("Error deleting password history for {email}: {e}");
}
if let Err(e) = super::user_auth_state::delete(email).await {
log::error!("Error deleting lockout state for {email}: {e}");
}
}
}
/// Matches users that hold no service-account role in any organization. The role lives on
/// `org_users`, not `users`, so this has to go through a subquery.
fn not_a_service_account() -> SimpleExpr {
let service_account_roles: Vec<i16> =
vec![UserRole::ServiceAccount.into(), UserRole::SreAgent.into()];
Expr::expr(Func::lower(Expr::col(Column::Email))).not_in_subquery(
Query::select()
.expr(Func::lower(Expr::col(org_users::Column::Email)))
.from(org_users::Entity)
.and_where(org_users::Column::Role.is_in(service_account_roles))
.to_owned(),
)
}
#[cfg(test)]
mod tests {
use config::meta::user::{DBUser, UserOrg, UserRole, UserType};
use super::*;
#[test]
fn test_not_a_service_account_excludes_both_service_account_roles() {
let sql = Query::select()
.column(Column::Email)
.from(Entity)
.and_where(not_a_service_account())
.to_owned()
.to_string(sea_orm::sea_query::SqliteQueryBuilder);
assert!(sql.contains("NOT IN"), "{sql}");
assert!(sql.contains("org_users"), "{sql}");
// ServiceAccount = 5, SreAgent = 6
assert!(sql.contains("IN (5, 6)"), "{sql}");
}
fn make_db_user(is_external: bool, role: UserRole) -> DBUser {
DBUser {
email: "test@example.com".to_string(),
@@ -344,6 +469,10 @@ mod tests {
user_type: 0, // 0 = Internal
created_at: 1_000_000,
updated_at: 2_000_000,
must_reset_password: false,
password_reset_reason: None,
flagged_at: None,
password_updated_at: Some(1_000_000),
};
let rec = UserRecord::from(model);
assert_eq!(rec.email, "model@example.com");
+7
View File
@@ -363,6 +363,9 @@ pub async fn init() -> Result<(), anyhow::Error> {
"Please set root user email-id & password using ZO_ROOT_USER_EMAIL & ZO_ROOT_USER_PASSWORD environment variables. This can also indicate an invalid email ID. Email ID must comply with ([a-z0-9_+]([a-z0-9_+.-]*[a-z0-9_+])?)@([a-z0-9]+([\\-\\.]{{1}}[a-z0-9]+)*\\.[a-z]{{2,6}})"
);
}
// Deliberately the static rule, not the configured policy: this branch only runs when no
// root user exists, so no policy can exist either and the effective one would be the
// permissive default. Using it here would weaken the bootstrap credential, not align it.
if let Err(msg) =
config::utils::password::validate_password_strength(&cfg.auth.root_user_password)
{
@@ -424,6 +427,10 @@ pub async fn init() -> Result<(), anyhow::Error> {
// watch org users
tokio::task::spawn(db::user::watch());
// Only the policy-tightening sweep publishes to this key, so without the feature the watcher
// would hold a coordinator watch open forever for an event that cannot happen.
#[cfg(feature = "enterprise")]
tokio::task::spawn(db::user::watch_bulk_refresh());
tokio::task::spawn(db::org_users::watch());
tokio::task::spawn(db::org_ingestion_tokens::watch());
tokio::task::spawn(db::org_ingestion_tokens::run_splunk_token_reload());
+4
View File
@@ -79,12 +79,16 @@ async fn add(msg: Message) -> Result<()> {
async fn update(msg: Message) -> Result<()> {
let user: UserUpdate = json::from_slice(&msg.value.unwrap())?;
// The originating cluster already decided this was a password change; replaying it as one here
// keeps the rotation clock and the reset flag consistent, instead of leaving this cluster
// holding the new password with the old deadline.
if let Err(e) = users::update(
&user.email,
&user.first_name,
&user.last_name,
&user.password,
user.password_ext,
user.password_changed,
)
.await
{
+2 -2
View File
@@ -989,7 +989,7 @@ mod tests {
let body_str = r#"{
"email": "nonadmin@example.com",
"password": "Abcd12345!",
"password": "Abcd12345!Zyxwv",
"role": "admin"
}"#;
let (status, body) = make_request(
@@ -1102,7 +1102,7 @@ mod tests {
// Add the user
let body_str = r#"{
"email": "admin@example.com",
"password": "Abcd12345!",
"password": "Abcd12345!Zyxwv",
"role": "admin"
}"#;
-30
View File
@@ -84,7 +84,6 @@
"@tailwindcss/postcss": "^4.3.3",
"@tailwindcss/typography": "^0.5.20",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/vue-query-devtools": "^6.1.38",
"@types/d3-force": "^3.0.10",
"@types/d3-scale": "^4.0.9",
"@types/fs-extra": "^11.0.4",
@@ -4224,17 +4223,6 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/query-devtools": {
"version": "5.101.4",
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.101.4.tgz",
"integrity": "sha512-z5IPHnDX3aUWeTWlRKLyooBQekaCAw4xRpZqPQ390RiWTDBcTynjpPT221BArw0u2+pnQMdGvPQI9YNNubBcmA==",
"dev": true,
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/query-persist-client-core": {
"version": "5.101.4",
"resolved": "https://registry.npmjs.org/@tanstack/query-persist-client-core/-/query-persist-client-core-5.101.4.tgz",
@@ -4323,24 +4311,6 @@
}
}
},
"node_modules/@tanstack/vue-query-devtools": {
"version": "6.1.38",
"resolved": "https://registry.npmjs.org/@tanstack/vue-query-devtools/-/vue-query-devtools-6.1.38.tgz",
"integrity": "sha512-JrZR8p+0v+eRXANudvTzfj5robfr6xh2rjZEqIiHbbxFgtdoTnQAJpoB7sQBw4xfvO8VCoG/xFaSnpPX1AjihA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@tanstack/query-devtools": "5.101.4"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"@tanstack/vue-query": "^5.101.4",
"vue": "^3.3.0"
}
},
"node_modules/@tanstack/vue-query/node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
+3
View File
@@ -20,6 +20,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OToastProvider />
<ConfirmDialogProvider />
<CreateAlertDialogProvider />
<UpdatePasswordDialog />
</ConfigProvider>
</template>
@@ -33,6 +34,7 @@ import { ConfigProvider } from "reka-ui";
import OToastProvider from "@/lib/feedback/Toast/OToastProvider.vue";
import ConfirmDialogProvider from "@/components/ConfirmDialogProvider.vue";
import CreateAlertDialogProvider from "@/components/alerts/CreateAlertDialogProvider.vue";
import UpdatePasswordDialog from "@/components/common/UpdatePasswordDialog.vue";
export default {
components: {
@@ -40,6 +42,7 @@ export default {
OToastProvider,
ConfirmDialogProvider,
CreateAlertDialogProvider,
UpdatePasswordDialog,
},
setup() {
const store = useStore();
@@ -43,7 +43,7 @@ import { computed } from "vue";
const props = withDefaults(
defineProps<{
/** Reading-column width. `lg` (default) matches the section hubs. */
size?: "sm" | "md" | "lg" | "xl";
size?: "sm" | "md" | "lg" | "xl" | "full";
/** Apply the default page gutter (px-6 py-6). Off → child owns padding. */
padded?: boolean;
/** Column placement. `center` (default) for hubs; `left` for form pages. */
@@ -62,6 +62,7 @@ const SIZE_CLASS = {
md: "max-w-3xl", // settings sections, org params
lg: "max-w-5xl", // section hubs (Settings/IAM landing)
xl: "max-w-7xl", // wide reading content
full: "max-w-none", // dense settings forms that use the row width
} as const;
const maxWidthClass = computed(() => SIZE_CLASS[props.size]);
@@ -0,0 +1,61 @@
<!-- 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/>.
-->
<!-- Once per session: a session held open across the expiry date simply meets the block. -->
<template>
<OBanner
v-if="daysRemaining !== null"
bar
variant="warning"
icon="timer"
data-test="password-expiry-banner"
>
{{ t("passwordReset.expiryBanner", { count: daysRemaining }, daysRemaining) }}
<template #actions>
<div class="flex flex-wrap items-center gap-3">
<OButton
variant="banner-dismiss"
size="sm"
data-test="password-expiry-banner-update"
@click="openVoluntarily()"
>
{{ t("passwordReset.expiryBannerAction") }}
</OButton>
<OButton
variant="banner-dismiss"
size="sm"
data-test="password-expiry-banner-dismiss"
@click="dismiss"
>
{{ t("passwordReset.expiryBannerDismiss") }}
</OButton>
</div>
</template>
</OBanner>
</template>
<script setup lang="ts">
import { usePasswordExpiryWarning } from "@/composables/usePasswordExpiryWarning";
import { usePasswordReset } from "@/composables/usePasswordReset";
import OButton from "@/lib/core/Button/OButton.vue";
import OBanner from "@/lib/feedback/Banner/OBanner.vue";
import { useI18nTyped } from "@/types/i18n";
const { t } = useI18nTyped();
const { daysRemaining, dismiss } = usePasswordExpiryWarning();
const { openVoluntarily } = usePasswordReset();
</script>
@@ -0,0 +1,63 @@
// 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/>.
import { mount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import OProgressBar from "@/lib/data/ProgressBar/OProgressBar.vue";
import type { TranslateFn } from "@/types/i18n";
import { buildPasswordRequirements, DEFAULT_COMPLEXITY } from "@/utils/passwordComplexity";
import PasswordRequirementList from "./PasswordRequirementList.vue";
// Echoes the key so assertions read against stable ids rather than English copy.
const t = ((key: string) => key) as unknown as TranslateFn;
// Four rows: min length, uppercase, lowercase, digit.
const requirements = buildPasswordRequirements(
{ ...DEFAULT_COMPLEXITY, require_uppercase: true, require_lowercase: true, require_digit: true },
t,
);
const mountList = (props: { password?: string; showStrength?: boolean }) =>
mount(PasswordRequirementList, { props: { requirements, ...props } });
describe("PasswordRequirementList", () => {
it("renders the strength meter as progress over the policy's own requirements", () => {
const wrapper = mountList({ password: "ab", showStrength: true });
const bar = wrapper.findComponent(OProgressBar);
expect(bar.exists()).toBe(true);
expect(bar.attributes("data-test")).toBe("password-requirements-strength");
// One of four rows is met: only lowercase.
expect(bar.props("value")).toBeCloseTo(1 / 4);
expect(bar.props("variant")).toBe("default");
expect(bar.props("size")).toBe("xs");
});
it("turns the meter green only once every requirement is met", () => {
const wrapper = mountList({ password: "Abcdefg1", showStrength: true });
expect(wrapper.findComponent(OProgressBar).props("value")).toBe(1);
expect(wrapper.findComponent(OProgressBar).props("variant")).toBe("success");
});
it("shows no meter unless asked for", () => {
const wrapper = mountList({ password: "Abcdefg1" });
expect(wrapper.findComponent(OProgressBar).exists()).toBe(false);
expect(wrapper.find('[data-test="password-requirements-strength"]').exists()).toBe(false);
});
});
@@ -0,0 +1,68 @@
<!-- 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/>.
-->
<template>
<div v-if="requirements.length > 0" class="mt-2 flex flex-col gap-2">
<!-- Progress over the policy's own requirements, not an entropy score. -->
<OProgressBar
v-if="showStrength"
:value="metCount / requirements.length"
size="xs"
:variant="metCount === requirements.length ? 'success' : 'default'"
data-test="password-requirements-strength"
/>
<ul class="grid grid-cols-1 gap-x-4 gap-y-1.5 sm:grid-cols-2">
<li
v-for="requirement in requirements"
:key="requirement.key"
:data-test="`password-requirement-${requirement.key}`"
class="flex items-center gap-2 text-xs"
:class="isMet(requirement) ? 'text-status-positive' : 'text-text-secondary'"
>
<OIcon
:name="isMet(requirement) ? 'check-circle' : 'radio-button-unchecked'"
size="xs"
class="shrink-0"
/>
{{ requirement.label }}
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OProgressBar from "@/lib/data/ProgressBar/OProgressBar.vue";
import { countMetRequirements, type PasswordRequirement } from "@/utils/passwordComplexity";
const props = withDefaults(
defineProps<{
requirements: PasswordRequirement[];
/** The password to check the rows against. */
password?: string;
/** Adds the progress bar above the rows. */
showStrength?: boolean;
}>(),
{ password: "", showStrength: false },
);
const metCount = computed(() => countMetRequirements(props.requirements, props.password));
const isMet = (requirement: PasswordRequirement) => requirement.isMet(props.password);
</script>
@@ -0,0 +1,78 @@
// 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/>.
import { z } from "zod";
import type { PasswordComplexity } from "@/services/passwordPolicy";
import type { TranslateFn } from "@/types/i18n";
import { validateAgainstComplexity } from "@/utils/passwordComplexity";
export const updatePasswordBaseSchema = z.object({
old_password: z.string().optional().default(""),
new_password: z.string().optional().default(""),
confirm_password: z.string().optional().default(""),
});
export type UpdatePasswordForm = z.infer<typeof updatePasswordBaseSchema>;
export const updatePasswordDefaults = (): UpdatePasswordForm => ({
old_password: "",
new_password: "",
confirm_password: "",
});
/** `getComplexity` is read on every run, so one schema instance follows a policy landing after mount. */
export const makeUpdatePasswordSchema = (getComplexity: () => PasswordComplexity, t: TranslateFn) =>
updatePasswordBaseSchema.superRefine((val, zctx) => {
// Required-only: it is an existing credential, and may predate the policy being enforced now.
if (!val.old_password) {
zctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["old_password"],
message: t("passwordReset.currentPasswordRequired"),
});
}
if (!val.new_password) {
zctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["new_password"],
message: t("passwordReset.newPasswordRequired"),
});
} else {
const failure = validateAgainstComplexity(val.new_password, getComplexity(), t);
if (failure) {
zctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["new_password"],
message: failure,
});
}
}
if (!val.confirm_password) {
zctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["confirm_password"],
message: t("passwordReset.confirmPasswordRequired"),
});
} else if (val.confirm_password !== val.new_password) {
zctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["confirm_password"],
message: t("passwordReset.mismatch"),
});
}
});
@@ -0,0 +1,342 @@
// 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/>.
import { flushPromises, mount } from "@vue/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createStore } from "vuex";
import { resetPasswordComplexityCache } from "@/composables/usePasswordComplexity";
import { usePasswordReset } from "@/composables/usePasswordReset";
import i18n from "@/locales";
import UpdatePasswordDialog from "./UpdatePasswordDialog.vue";
import type { UpdatePasswordForm } from "./UpdatePasswordDialog.schema";
// Reka portals dialog content into <body>; rendered inline so the assertions can reach it.
vi.mock("reka-ui", async (importOriginal) => {
const actual = await importOriginal<typeof import("reka-ui")>();
return { ...actual, DialogPortal: actual.DialogContent };
});
vi.mock("@/services/passwordPolicy", () => ({
default: { getComplexity: vi.fn(), getPolicy: vi.fn(), updatePolicy: vi.fn() },
}));
vi.mock("@/services/users", () => ({
default: { update: vi.fn() },
}));
const toastMock = vi.fn();
vi.mock("@/lib/feedback/Toast/useToast", () => ({
toast: (...args: unknown[]) => toastMock(...args),
}));
const pushMock = vi.fn();
vi.mock("vue-router", () => ({
useRouter: () => ({ push: pushMock }),
}));
const invalidateLoginDataMock = vi.fn();
vi.mock("@/utils/zincutils", async (importOriginal) => {
const actual: any = await importOriginal();
return {
...actual,
invalidateLoginData: (...args: unknown[]) => invalidateLoginDataMock(...args),
useLocalCurrentUser: vi.fn(),
useLocalUserInfo: vi.fn(),
};
});
import passwordPolicyService from "@/services/passwordPolicy";
import userService from "@/services/users";
const STRICT_COMPLEXITY = {
min_length: 12,
max_length: 64,
require_uppercase: true,
require_lowercase: true,
require_digit: true,
require_special: true,
special_char_set: "",
};
const store = createStore({
state: {
userInfo: { email: "maya@acme.io" },
selectedOrganization: { identifier: "acme" },
zoConfig: { meta_org: "_meta" },
},
actions: { logout: vi.fn() },
});
let wrappers: any[] = [];
const mountDialog = () => {
const wrapper = mount(UpdatePasswordDialog, {
global: {
plugins: [i18n, store],
stubs: { teleport: true },
},
});
wrappers.push(wrapper);
return wrapper;
};
const { open, close, isOpen } = usePasswordReset();
describe("UpdatePasswordDialog", () => {
beforeEach(() => {
vi.clearAllMocks();
resetPasswordComplexityCache();
close();
(passwordPolicyService.getComplexity as any).mockResolvedValue({ data: STRICT_COMPLEXITY });
(userService.update as any).mockResolvedValue({ data: {} });
});
afterEach(() => {
wrappers.forEach((wrapper) => wrapper.unmount());
wrappers = [];
close();
});
it("renders nothing until the user is flagged", async () => {
const wrapper = mountDialog();
await flushPromises();
expect(wrapper.find('[data-test="password-reset-dialog"]').exists()).toBe(false);
});
it("reads the complexity when it opens", async () => {
open("policy_tightened");
mountDialog();
await flushPromises();
expect(passwordPolicyService.getComplexity).toHaveBeenCalledWith("acme");
});
it("still fetches the complexity when no organization is selected", async () => {
// A blocked user's org list is refused too, so no organization is ever selected for them.
store.state.selectedOrganization = undefined as any;
open("policy_tightened");
mountDialog();
await flushPromises();
expect(passwordPolicyService.getComplexity).toHaveBeenCalledWith("default");
store.state.selectedOrganization = { identifier: "acme" } as any;
});
it("submits the password change to a real org when none is selected", async () => {
// /api/undefined/users 401s, and the global 401 handler would make a failed change look like a success.
store.state.selectedOrganization = undefined as any;
open("policy_tightened");
const wrapper = mountDialog();
await flushPromises();
await (wrapper.vm as any).submit({
old_password: "old-secret",
new_password: "New-Secret-1!",
confirm_password: "New-Secret-1!",
});
await flushPromises();
expect(userService.update).toHaveBeenCalledWith(expect.anything(), "default", "maya@acme.io");
store.state.selectedOrganization = { identifier: "acme" } as any;
});
it("builds the checklist from the policy, not a hardcoded list", async () => {
open("policy_tightened");
const wrapper = mountDialog();
await flushPromises();
for (const key of ["minLength", "maxLength", "uppercase", "lowercase", "digit", "special"]) {
expect(wrapper.find(`[data-test="password-requirement-${key}"]`).exists()).toBe(true);
}
});
it("shows one row for a policy that enforces one thing", async () => {
(passwordPolicyService.getComplexity as any).mockResolvedValue({
data: {
...STRICT_COMPLEXITY,
max_length: 0,
require_uppercase: false,
require_lowercase: false,
require_digit: false,
require_special: false,
},
});
open("policy_tightened");
const wrapper = mountDialog();
await flushPromises();
expect(wrapper.findAll('[data-test^="password-requirement-"]')).toHaveLength(1);
expect(wrapper.find('[data-test="password-requirement-minLength"]').exists()).toBe(true);
});
it("ticks requirements as the new password is typed", async () => {
open("policy_tightened");
const wrapper = mountDialog();
await flushPromises();
const metBefore = wrapper
.find('[data-test="password-requirement-uppercase"]')
.classes()
.join(" ");
expect(metBefore).toContain("text-text-secondary");
await wrapper
.find('[data-test="password-reset-dialog-new-password"] input')
.setValue("Abcdefghijk1!");
await flushPromises();
const metAfter = wrapper
.find('[data-test="password-requirement-uppercase"]')
.classes()
.join(" ");
expect(metAfter).toContain("text-status-positive");
});
it("reveals the new password when its toggle is clicked", async () => {
open("policy_tightened");
const wrapper = mountDialog();
await flushPromises();
const input = () => wrapper.find('[data-test="password-reset-dialog-new-password"] input');
expect(input().attributes("type")).toBe("password");
await wrapper.find('[data-test="password-reset-dialog-new-password-reveal"]').trigger("click");
expect(input().attributes("type")).toBe("text");
});
it("never advertises a rule the server does not enforce", async () => {
open("policy_tightened");
const wrapper = mountDialog();
await flushPromises();
// The mockup lists it; PasswordPolicy has no such field.
expect(wrapper.text()).not.toContain("email");
});
it("still renders a usable form when the complexity fetch fails", async () => {
(passwordPolicyService.getComplexity as any).mockRejectedValue(new Error("nope"));
open("policy_tightened");
const wrapper = mountDialog();
await flushPromises();
expect(wrapper.find('[data-test="password-reset-dialog-new-password"]').exists()).toBe(true);
});
it("shows the reason the middleware gave", async () => {
open("rotation_expired");
const wrapper = mountDialog();
await flushPromises();
expect(wrapper.find('[data-test="password-reset-dialog-banner"]').text()).toContain("expired");
});
it("submits the change_password payload for the signed-in user", async () => {
open("policy_tightened");
const wrapper = mountDialog();
await flushPromises();
await (wrapper.vm as any).submit({
old_password: "old-secret",
new_password: "New-Secret-1!",
confirm_password: "New-Secret-1!",
});
await flushPromises();
expect(userService.update).toHaveBeenCalledWith(
{ change_password: true, old_password: "old-secret", new_password: "New-Secret-1!" },
"acme",
"maya@acme.io",
);
});
it("signs the user out on success, because the cookie is the old password", async () => {
open("policy_tightened");
const wrapper = mountDialog();
await flushPromises();
await (wrapper.vm as any).submit({
old_password: "old-secret",
new_password: "New-Secret-1!",
confirm_password: "New-Secret-1!",
});
await flushPromises();
expect(invalidateLoginDataMock).toHaveBeenCalled();
expect(pushMock).toHaveBeenCalledWith("/logout");
expect(isOpen.value).toBe(false);
});
it("clears the reuse error on edit", async () => {
vi.mocked(userService.update).mockRejectedValue({
response: { data: { message: "New password matches one of your last 5 passwords" } },
});
open("policy_tightened");
const wrapper = mountDialog();
await flushPromises();
await (
wrapper.vm as unknown as { submit: (values: UpdatePasswordForm) => Promise<void> }
).submit({
old_password: "old-secret",
new_password: "New-Secret-1!",
confirm_password: "New-Secret-1!",
});
await flushPromises();
const fieldError = () => wrapper.find('[data-test="password-reset-dialog-new-password-error"]');
expect(fieldError().exists()).toBe(true);
expect(fieldError().text()).toContain("last 5 passwords");
await wrapper
.find('[data-test="password-reset-dialog-new-password"] input')
.setValue("New-Secret-2!");
await flushPromises();
expect(fieldError().exists()).toBe(false);
});
it("keeps the dialog open and does not sign out when the current password is wrong", async () => {
(userService.update as any).mockRejectedValue({
response: { data: { message: "Invalid old password" } },
});
open("policy_tightened");
const wrapper = mountDialog();
await flushPromises();
await (wrapper.vm as any).submit({
old_password: "wrong",
new_password: "New-Secret-1!",
confirm_password: "New-Secret-1!",
});
await flushPromises();
expect(toastMock).toHaveBeenCalledWith(
expect.objectContaining({ variant: "error", message: "Invalid old password" }),
);
expect(pushMock).not.toHaveBeenCalled();
expect(isOpen.value).toBe(true);
});
});
@@ -0,0 +1,197 @@
<!-- 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/>.
-->
<template>
<!-- Blocked: no close button, since every other request is refused until the password changes. -->
<ODialog
v-if="isOpen"
data-test="password-reset-dialog"
:open="isOpen"
:persistent="!dismissible"
:show-close="dismissible"
size="md"
:title="t('passwordReset.title')"
:sub-title="t('passwordReset.signedInAs', { email: raw(userEmail) })"
:neutral-button-label="dismissible ? undefined : t('passwordReset.signOut')"
:secondary-button-label="dismissible ? t('common.cancel') : undefined"
:primary-button-label="t('passwordReset.submit')"
form-id="update-password-form"
@click:neutral="signOut"
@click:secondary="close"
@update:open="(open: boolean) => !open && close()"
>
<div class="flex flex-col gap-4">
<OBanner
variant="warning"
icon="warning"
:content="bannerMessage"
data-test="password-reset-dialog-banner"
/>
<OForm id="update-password-form" :form="form" class="flex flex-col gap-5">
<OFormInput
data-test="password-reset-dialog-current-password"
name="old_password"
type="password"
revealable
:label="t('passwordReset.currentPassword')"
required
autocomplete="current-password"
/>
<div>
<OFormInput
data-test="password-reset-dialog-new-password"
name="new_password"
type="password"
revealable
:label="t('passwordReset.newPassword')"
required
autocomplete="new-password"
/>
<PasswordRequirementList
:requirements="requirements"
:password="newPassword"
show-strength
data-test="password-reset-dialog-requirements"
/>
</div>
<OFormInput
data-test="password-reset-dialog-confirm-password"
name="confirm_password"
type="password"
revealable
:label="t('passwordReset.confirmPassword')"
required
autocomplete="new-password"
/>
</OForm>
</div>
</ODialog>
</template>
<script setup lang="ts">
import { computed, watch } from "vue";
import { useRouter } from "vue-router";
import { useStore } from "vuex";
import { usePasswordComplexity } from "@/composables/usePasswordComplexity";
import { remediationOrg, usePasswordReset } from "@/composables/usePasswordReset";
import OBanner from "@/lib/feedback/Banner/OBanner.vue";
import { toast } from "@/lib/feedback/Toast/useToast";
import OForm from "@/lib/forms/Form/OForm.vue";
import { setServerFieldErrors, useOForm } from "@/lib/forms/Form/useOForm";
import OFormInput from "@/lib/forms/Input/OFormInput.vue";
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
import userService from "@/services/users";
import { raw, useI18nTyped } from "@/types/i18n";
import { reuseRejection } from "@/utils/passwordComplexity";
import { invalidateLoginData, useLocalCurrentUser, useLocalUserInfo } from "@/utils/zincutils";
import PasswordRequirementList from "./PasswordRequirementList.vue";
import {
makeUpdatePasswordSchema,
updatePasswordDefaults,
type UpdatePasswordForm,
} from "./UpdatePasswordDialog.schema";
const { t } = useI18nTyped();
const store = useStore();
const router = useRouter();
const { isOpen, reason, dismissible, close } = usePasswordReset();
const { complexity, requirements, load } = usePasswordComplexity();
const userEmail = computed(() => store.state.userInfo?.email ?? "");
const bannerMessage = computed(() => {
switch (reason.value) {
case "rotation_expired":
return t("passwordReset.reasonRotationExpired");
case "rotation_warning":
return t("passwordReset.reasonRotationWarning");
default:
return t("passwordReset.reasonPolicyTightened");
}
});
// Reads the complexity on every run: the policy may arrive after the dialog has opened.
const schema = makeUpdatePasswordSchema(() => complexity.value, t);
// Headless so the owner can pin the server's reuse rejection onto the field.
const form = useOForm<UpdatePasswordForm>({
defaultValues: updatePasswordDefaults(),
schema,
onSubmit: (values) => submit(values),
});
const newPassword = form.useStore((s) => s.values.new_password);
// A server error is not re-validated on change, so it would block every later submit unless cleared.
watch(newPassword, () => setServerFieldErrors(form, {}));
watch(
isOpen,
(open) => {
if (!open) return;
form.reset(updatePasswordDefaults());
// A failed fetch is not fatal: the form still submits and the server still validates.
load();
},
{ immediate: true },
);
const signOut = () => {
invalidateLoginData();
store.dispatch("logout");
useLocalCurrentUser("", true);
useLocalUserInfo("", true);
close();
router.push("/logout");
};
const submit = async (values: UpdatePasswordForm) => {
try {
await userService.update(
{
change_password: true,
old_password: values.old_password,
new_password: values.new_password,
},
remediationOrg(store),
userEmail.value,
);
} catch (error: any) {
// A reuse rejection belongs on the field, not a toast: every checklist row is ticked.
const reused = reuseRejection(error, t);
if (reused) {
setServerFieldErrors(form, { new_password: reused });
return;
}
toast({
variant: "error",
message: raw(error?.response?.data?.message) || t("passwordReset.updateFailed"),
});
return;
}
// The session cookie IS the password, so the browser's credential went stale the moment the change landed.
toast({ variant: "success", message: t("passwordReset.updated") });
signOut();
};
</script>
+24 -26
View File
@@ -11,16 +11,9 @@
import { z } from "zod";
// Password policy (mirrors src/config/src/utils/password.rs): length 8-128 AND
// lower AND upper AND digit AND special.
export const isStrongPassword = (val: string): boolean => {
if (!val || val.length < 8 || val.length > 128) return false;
const hasLower = /[a-z]/.test(val);
const hasUpper = /[A-Z]/.test(val);
const hasDigit = /[0-9]/.test(val);
const hasSpecial = /[^A-Za-z0-9]/.test(val);
return hasLower && hasUpper && hasDigit && hasSpecial;
};
import type { PasswordComplexity } from "@/services/passwordPolicy";
import type { TranslateFn } from "@/types/i18n";
import { validateAgainstComplexity } from "@/utils/passwordComplexity";
export const userEmailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// other_organization must START WITH A LETTER, then alphanumeric / _ / - .
@@ -35,6 +28,8 @@ export interface AddUserSchemaContext {
loggedInUserEmail: string;
modelEmail: string;
organization: string;
/** The instance policy the server will hold this password to. */
complexity: PasswordComplexity;
}
// Base fields are optional; the real, mode-dependent requireds are enforced in
@@ -54,10 +49,7 @@ export const addUserBaseSchema = z.object({
export type AddUserForm = z.infer<typeof addUserBaseSchema>;
export const makeAddUserSchema = (
getCtx: () => AddUserSchemaContext,
t: (_key: string) => string,
) =>
export const makeAddUserSchema = (getCtx: () => AddUserSchemaContext, t: TranslateFn) =>
addUserBaseSchema.superRefine((val, zctx) => {
// Read the live context on every run so a single stable schema instance
// follows mode flips (e.g. the 422 add-existing → create-new switch) without
@@ -90,12 +82,15 @@ export const makeAddUserSchema = (
path: ["password"],
message: t("user.passwordRequired"),
});
} else if (!isStrongPassword(val.password)) {
zctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["password"],
message: t("user.passwordPolicyHint"),
});
} else {
const failure = validateAgainstComplexity(val.password, ctx.complexity, t);
if (failure) {
zctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["password"],
message: failure,
});
}
}
}
@@ -117,12 +112,15 @@ export const makeAddUserSchema = (
path: ["new_password"],
message: t("user.newPasswordRequired"),
});
} else if (!isStrongPassword(val.new_password)) {
zctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["new_password"],
message: t("user.passwordPolicyHint"),
});
} else {
const failure = validateAgainstComplexity(val.new_password, ctx.complexity, t);
if (failure) {
zctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["new_password"],
message: failure,
});
}
}
}
@@ -41,6 +41,7 @@ vi.mock("@/services/users", async (importOriginal) => {
create: vi.fn(),
update: vi.fn(),
updateexistinguser: vi.fn(),
get: vi.fn(() => Promise.resolve({ data: {} })),
getUserRoles: vi.fn(() => Promise.resolve({ data: [] })),
},
});
@@ -50,6 +51,11 @@ vi.mock("@/services/reodotdev_analytics", () => ({
useReo: () => ({ track: vi.fn() }),
}));
const confirmMock = vi.fn();
vi.mock("@/composables/useConfirmDialog", () => ({
useConfirmDialog: () => ({ confirm: confirmMock }),
}));
const { mockToast } = vi.hoisted(() => ({
mockToast: vi.fn(),
}));
@@ -734,5 +740,35 @@ describe("AddUser", () => {
expect(wrapper.find('[data-test="user-custom-role-field"]').exists()).toBe(true);
});
it("stages an unlock and sends it on save, after confirming", async () => {
vi.mocked(userServiece.get).mockResolvedValue({
data: { lockout: { locked: true, retry_after_secs: 240 } },
} as any);
vi.mocked(userServiece.update).mockResolvedValue({ data: {} } as any);
confirmMock.mockResolvedValue(true);
wrapper = mountComp({
modelValue: { email: "other@example.com", first_name: "Other", org_member_id: "2" },
});
await flushPromises();
expect(wrapper.find('[data-test="user-lockout-banner"]').exists()).toBe(true);
await wrapper.find('[data-test="user-lockout-unlock-btn"]').trigger("click");
setField(wrapper, "role", "admin");
await submitForm(wrapper);
expect(confirmMock).toHaveBeenCalled();
const [body] = vi.mocked(userServiece.update).mock.calls[0];
expect(body.remove_lockout).toBe(true);
});
it("never fetches lockout state for your own row", async () => {
wrapper = mountComp({
modelValue: { email: ADMIN_EMAIL, first_name: "Me", org_member_id: "1" },
});
await flushPromises();
expect(userServiece.get).not.toHaveBeenCalled();
});
});
});
+133 -9
View File
@@ -71,6 +71,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
</template>
</OFormInput>
<PasswordRequirementList
:requirements="passwordRequirements"
:password="formPassword || ''"
data-test="user-password-requirements"
/>
</div>
<OFormInput
@@ -166,6 +171,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
required
class="showLabelOnTop mt-2"
data-test="user-new-password-field"
@update:model-value="clearServerErrors"
>
<template #icon-right>
<OIcon
@@ -176,7 +182,44 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
</template>
</OFormInput>
<PasswordRequirementList
v-if="changePassword"
:requirements="passwordRequirements"
:password="formNewPassword || ''"
data-test="user-new-password-requirements"
/>
</div>
<!-- Unlock is STAGED, not sent: one PUT carries it alongside any other edit. -->
<OBanner
v-if="lockout?.locked"
variant="warning"
icon="lock"
dense
inline-actions
class="mt-4"
data-test="user-lockout-banner"
>
<div class="font-medium">{{ t("user.lockout.locked") }}</div>
<div class="text-xs">
{{
unlockStaged
? t("user.lockout.staged")
: t("user.lockout.autoUnlock", {
duration: raw(durationFormatter(lockout.retry_after_secs ?? 0)),
})
}}
</div>
<template #actions>
<OButton
:variant="unlockStaged ? 'ghost' : 'outline'"
size="sm"
data-test="user-lockout-unlock-btn"
@click="unlockStaged = !unlockStaged"
>
{{ unlockStaged ? t("user.lockout.undo") : t("user.lockout.unlock") }}
</OButton>
</template>
</OBanner>
<OFormInput
v-if="!beingUpdated && userRole != 'member' && organization == 'other'"
name="other_organization"
@@ -224,14 +267,21 @@ import {
import config from "@/aws-exports";
import { useReo } from "@/services/reodotdev_analytics";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OBanner from "@/lib/feedback/Banner/OBanner.vue";
import OForm from "@/lib/forms/Form/OForm.vue";
import { useOForm } from "@/lib/forms/Form/useOForm";
import { setServerFieldErrors, useOForm } from "@/lib/forms/Form/useOForm";
import OFormInput from "@/lib/forms/Input/OFormInput.vue";
import OFormSelect from "@/lib/forms/Select/OFormSelect.vue";
import OFormSwitch from "@/lib/forms/Switch/OFormSwitch.vue";
import { toast } from "@/lib/feedback/Toast/useToast";
import { makeAddUserSchema, type AddUserForm } from "./AddUser.schema";
import { usePasswordComplexity } from "@/composables/usePasswordComplexity";
import { useConfirmDialog } from "@/composables/useConfirmDialog";
import PasswordRequirementList from "@/components/common/PasswordRequirementList.vue";
import { durationFormatter } from "@/utils/formatters";
import { reuseRejection } from "@/utils/passwordComplexity";
const defaultValue: any = () => {
return {
org_member_id: "",
@@ -249,7 +299,18 @@ const defaultValue: any = () => {
export default defineComponent({
name: "ComponentAddUpdateUser",
components: { ODialog, ODrawer, OIcon, OForm, OFormInput, OFormSelect, OFormSwitch },
components: {
ODialog,
ODrawer,
OBanner,
OButton,
OIcon,
OForm,
OFormInput,
OFormSelect,
OFormSwitch,
PasswordRequirementList,
},
props: {
open: {
type: Boolean,
@@ -320,6 +381,10 @@ export default defineComponent({
const organization = ref(store.state.selectedOrganization.identifier);
const editRecord: any = ref(null);
const isExternalUser = ref(false);
// The edited user's lockout state, read once when the dialog opens on someone else's row.
const lockout = ref<{ locked: boolean; retry_after_secs?: number } | null>(null);
const unlockStaged = ref(false);
const { confirm } = useConfirmDialog();
const blankForm = (): AddUserForm => ({
email: "",
@@ -381,6 +446,16 @@ export default defineComponent({
delete payload.old_password;
delete payload.new_password;
}
// A security-relevant action on someone else's account confirms before the write.
if (unlockStaged.value) {
const confirmed = await confirm({
title: t("user.lockout.confirmTitle", { email: raw(userEmail) }),
message: t("user.lockout.confirmMessage"),
confirmLabel: t("user.lockout.unlock"),
});
if (!confirmed) return;
payload.remove_lockout = true;
}
try {
const res: any = await userServiece.update(payload, selectedOrg, userEmail);
if (
@@ -393,10 +468,15 @@ export default defineComponent({
emit("update:open", false);
}
} catch (err: any) {
toast({
variant: "error",
message: err.response.data.message,
});
const reused = reuseRejection(err, t);
if (reused) {
setServerFieldErrors(form, { new_password: reused });
} else {
toast({
variant: "error",
message: raw(err.response?.data?.message) || t("toastMessages.iam.userSaveFailed"),
});
}
}
track("Button Click", { button: "Update User", page: "Add User" });
} else if (existingUser.value) {
@@ -428,7 +508,7 @@ export default defineComponent({
if (err.response?.status != 403 || err?.status != 403) {
toast({
variant: "error",
message: err.response.data.message,
message: raw(err.response?.data?.message) || t("toastMessages.iam.userSaveFailed"),
});
}
}
@@ -452,7 +532,7 @@ export default defineComponent({
} catch (err: any) {
toast({
variant: "error",
message: err.response.data.message,
message: raw(err.response?.data?.message) || t("toastMessages.iam.userSaveFailed"),
});
}
track("Button Click", { button: "Create User", page: "Add User" });
@@ -464,6 +544,14 @@ export default defineComponent({
// via <OForm :form="form"> — ONE source of truth. The schema takes a context
// GETTER so a single stable instance follows mode flips (e.g. the 422
// add-existing → create-new switch) with no remount.
// The instance policy, not a hardcoded rule: a fixed mirror would drift from what the server enforces.
const {
complexity: passwordComplexity,
requirements: passwordRequirements,
load: loadPasswordComplexity,
} = usePasswordComplexity();
const addUserSchema = makeAddUserSchema(
() => ({
existingUser: existingUser.value,
@@ -472,6 +560,7 @@ export default defineComponent({
loggedInUserEmail: loggedInUserEmail.value,
modelEmail: props.modelValue?.email ?? "",
organization: organization.value,
complexity: passwordComplexity.value,
}),
t,
);
@@ -488,6 +577,29 @@ export default defineComponent({
const formEmail = form.useStore((s: any) => s.values.email);
const formRole = form.useStore((s: any) => s.values.role);
const formCustomRole = form.useStore((s: any) => s.values.custom_role);
// Read live so the requirement rows tick while typing, the same way the reset dialog does.
const formPassword = form.useStore((s: any) => s.values.password);
const formNewPassword = form.useStore((s: any) => s.values.new_password);
// A server error is not re-validated on change, so it would block every later submit unless cleared.
const clearServerErrors = () => {
setServerFieldErrors(form, {});
};
// Only when administering someone else: a locked-out user must not lift their own lock.
const loadLockoutState = (email: string) => {
lockout.value = null;
unlockStaged.value = false;
if (config.isEnterprise != "true" || email === loggedInUserEmail.value) return;
userServiece
.get(organization.value, email)
.then((response: any) => {
lockout.value = response.data?.lockout ?? null;
})
.catch(() => {
lockout.value = null;
});
};
watch(
() => props.customRoles,
@@ -500,7 +612,9 @@ export default defineComponent({
organization.value = store.state.selectedOrganization.identifier;
});
onBeforeMount(() => setOrganizationOptions());
onBeforeMount(() => {
setOrganizationOptions();
});
watch(
() => store.state.organizations,
@@ -528,6 +642,7 @@ export default defineComponent({
password: "",
};
isExternalUser.value = !!newVal.is_external;
loadLockoutState(newVal.email);
// Seed the form via reset(values) — never a per-field setFieldValue loop.
form.reset({
...blankForm(),
@@ -565,6 +680,7 @@ export default defineComponent({
(isOpen, wasOpen) => {
if (isOpen && !wasOpen) {
resetFormFromModelValue(props.modelValue);
loadPasswordComplexity();
}
},
{ immediate: true },
@@ -604,6 +720,14 @@ export default defineComponent({
formEmail,
formRole,
formCustomRole,
formPassword,
formNewPassword,
passwordRequirements,
clearServerErrors,
lockout,
unlockStaged,
durationFormatter,
raw,
isPwd,
isNewPwd,
isOldPwd,
+6 -1
View File
@@ -499,7 +499,12 @@ describe("Login", () => {
wrapper.vm.password = "password123";
await wrapper.vm.onSignIn();
expect(mockDispatch).toHaveBeenCalledWith("setUserInfo", "encoded-data");
// The store must hold the decoded object: the dialog and MainLayout read `.email` off it directly.
expect(mockDispatch).toHaveBeenCalledWith(
"setUserInfo",
expect.objectContaining({ email: "testuser", name: "testuser" }),
);
expect(mockDispatch).not.toHaveBeenCalledWith("setUserInfo", "encoded-data");
});
// Test 21: successful sign in dispatches setCurrentUser
+52 -6
View File
@@ -124,6 +124,18 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
v-if="!showSSO || (showSSO && loginAsInternalUser && showInternalLogin)"
class="login-inputs"
>
<!-- Only retry_after is shown, never the attempt counters or the thresholds behind them. -->
<OBanner
v-if="lockoutSecondsLeft > 0"
variant="error"
icon="error"
dense
class="mb-3"
:content="
t('login.lockedOut', { duration: raw(durationFormatter(lockoutSecondsLeft)) })
"
data-test="login-lockout-banner"
/>
<OForm
:schema="loginSchema"
:default-values="loginDefaults"
@@ -158,6 +170,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
block
type="submit"
:loading="submitting"
:disabled="lockoutSecondsLeft > 0"
>
{{ t("login.login") }}
</OButton>
@@ -169,11 +182,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</template>
<script lang="ts">
import { defineComponent, ref, onBeforeMount, computed } from "vue";
import { defineComponent, ref, onBeforeMount, onBeforeUnmount, computed } from "vue";
import { useStore } from "vuex";
import { useRouter } from "vue-router";
import { useI18nTyped } from "@/types/i18n";
import { raw, useI18nTyped } from "@/types/i18n";
import { durationFormatter } from "@/utils/formatters";
import { usePasswordExpiryWarning } from "@/composables/usePasswordExpiryWarning";
import authService from "@/services/auth";
import organizationsService from "@/services/organizations";
import {
@@ -188,6 +203,7 @@ import { redirectUser } from "@/utils/common";
import { useTheme } from "@/composables/useTheme";
import config from "@/aws-exports";
import OButton from "@/lib/core/Button/OButton.vue";
import OBanner from "@/lib/feedback/Banner/OBanner.vue";
import OForm from "@/lib/forms/Form/OForm.vue";
import OFormInput from "@/lib/forms/Input/OFormInput.vue";
import { openobserveRum } from "@openobserve/browser-rum";
@@ -197,13 +213,32 @@ import { makeLoginSchema, loginDefaults, type LoginForm } from "./Login.schema";
export default defineComponent({
name: "PageLogin",
components: { OButton, OForm, OFormInput },
components: { OButton, OBanner, OForm, OFormInput },
setup() {
const store = useStore();
const router = useRouter();
const { isDark } = useTheme();
const { t } = useI18nTyped();
const expiryWarning = usePasswordExpiryWarning();
// A countdown reaching zero is not "unlocked"; it only lets the server answer again.
const lockoutSecondsLeft = ref(0);
let lockoutTimer: ReturnType<typeof setInterval> | null = null;
const startLockoutCountdown = (secs: number) => {
lockoutSecondsLeft.value = Math.ceil(secs);
if (lockoutTimer) clearInterval(lockoutTimer);
lockoutTimer = setInterval(() => {
lockoutSecondsLeft.value -= 1;
if (lockoutSecondsLeft.value <= 0 && lockoutTimer) {
clearInterval(lockoutTimer);
lockoutTimer = null;
}
}, 1000);
};
onBeforeUnmount(() => {
if (lockoutTimer) clearInterval(lockoutTimer);
});
const name = ref("");
const password = ref("");
const confirmpassword = ref("");
@@ -279,6 +314,9 @@ export default defineComponent({
.then(async (res: any) => {
//if user is authorized, get user info
if (res.data.status == true) {
// Absent when there is nothing to warn about; clearing covers a previous user of this tab.
expiryWarning.dismiss();
expiryWarning.remember(res.data.password_rotation_warning);
//get user info from backend and extract auth token and set it into localstorage
getBasicAuth(name.value, password.value);
const userInfo = {
@@ -293,7 +331,7 @@ export default defineComponent({
const encodedUserInfo: any = b64EncodeStandard(JSON.stringify(userInfo));
//set user info into localstorage & store
useLocalUserInfo(encodedUserInfo);
store.dispatch("setUserInfo", encodedUserInfo);
store.dispatch("setUserInfo", userInfo);
useLocalCurrentUser(JSON.stringify(userInfo));
store.dispatch("setCurrentUser", userInfo);
@@ -404,9 +442,14 @@ export default defineComponent({
});
}
})
.catch(() => {
//if any error occurs, show error message and reset form.
.catch((error: any) => {
submitting.value = false;
const retryAfter = error?.response?.data?.lockout_retry_after_secs;
if (retryAfter > 0) {
startLockoutCountdown(retryAfter);
return;
}
//if any error occurs, show error message and reset form.
toast({
variant: "error",
message: t("toastMessages.login.invalidUsernameOrPassword"),
@@ -424,6 +467,9 @@ export default defineComponent({
return {
t,
raw,
durationFormatter,
lockoutSecondsLeft,
name,
password,
confirmpassword,
@@ -0,0 +1,150 @@
// 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/>.
// Only the server's four cross-field rules are mirrored; no floor is enforced because the API accepts a weak policy.
import { z } from "zod";
import type { LockoutPolicy, PasswordPolicy } from "@/services/passwordPolicy";
import type { TranslateFn } from "@/types/i18n";
import { DEFAULT_COMPLEXITY } from "@/utils/passwordComplexity";
const nonNegativeInt = z.coerce.number().int().min(0);
/** The server's default policy, shown until the real one loads. */
export const INITIAL_POLICY: PasswordPolicy = {
...DEFAULT_COMPLEXITY,
rotation_days: 0,
rotation_warning_days: 7,
history_count: 0,
history_max_retained: 30,
lockout: {
threshold: 0,
bucket_size: 0,
start_secs: 60,
max_secs: 3600,
backoff: "exponential",
},
cookie_max_age_secs: 0,
apply_to_root: false,
};
export const policyBaseSchema = z.object({
min_length: z.coerce.number().int().min(1),
max_length: nonNegativeInt,
require_uppercase: z.boolean(),
require_lowercase: z.boolean(),
require_digit: z.boolean(),
require_special: z.boolean(),
special_char_set: z.string(),
rotation_days: nonNegativeInt,
rotation_warning_days: nonNegativeInt,
history_count: nonNegativeInt,
history_max_retained: nonNegativeInt,
lockout: z.object({
threshold: nonNegativeInt,
bucket_size: nonNegativeInt,
start_secs: nonNegativeInt,
max_secs: nonNegativeInt,
backoff: z.enum(["linear", "exponential"]),
}),
cookie_max_age_secs: nonNegativeInt,
apply_to_root: z.boolean(),
});
export type PolicyForm = z.infer<typeof policyBaseSchema>;
export const makePolicySchema = (t: TranslateFn) =>
policyBaseSchema.superRefine((val, zctx) => {
const issue = (path: string[], message: string) =>
zctx.addIssue({ code: z.ZodIssueCode.custom, path, message });
// 0 is "unbounded", anything else must leave room for the minimum.
if (val.max_length !== 0 && val.max_length < val.min_length) {
issue(["max_length"], t("passwordPolicy.maxLengthTooSmall"));
}
// Equal is valid (warn from the first sign-in); only a LONGER window describes a deadline that never exists.
if (val.rotation_days !== 0 && val.rotation_warning_days > val.rotation_days) {
issue(["rotation_warning_days"], t("passwordPolicy.warningDaysTooLong"));
}
if (val.history_max_retained < val.history_count) {
issue(["history_max_retained"], t("passwordPolicy.historyRetainedTooSmall"));
}
// Threshold 0 switches lockout off, so the durations are inert; the rule fires again once it is re-enabled.
if (val.lockout.threshold !== 0 && val.lockout.start_secs > val.lockout.max_secs) {
issue(["lockout", "start_secs"], t("passwordPolicy.lockoutStartTooLong"));
}
});
/** The form values for a loaded policy, field for field. */
export const policyDefaults = (policy: PasswordPolicy): PolicyForm => ({
min_length: policy.min_length,
max_length: policy.max_length,
require_uppercase: policy.require_uppercase,
require_lowercase: policy.require_lowercase,
require_digit: policy.require_digit,
require_special: policy.require_special,
special_char_set: policy.special_char_set,
rotation_days: policy.rotation_days,
rotation_warning_days: policy.rotation_warning_days,
history_count: policy.history_count,
history_max_retained: policy.history_max_retained,
lockout: { ...policy.lockout },
cookie_max_age_secs: policy.cookie_max_age_secs,
apply_to_root: policy.apply_to_root,
});
/** Spread over the loaded policy: PUT is a full replacement, so an omitted field would reset to its default. */
export const buildPolicyPayload = (
loadedPolicy: PasswordPolicy,
values: PolicyForm,
): PasswordPolicy => ({
...loadedPolicy,
min_length: Number(values.min_length),
max_length: Number(values.max_length),
require_uppercase: values.require_uppercase,
require_lowercase: values.require_lowercase,
require_digit: values.require_digit,
require_special: values.require_special,
// A stale set would resurrect the next time someone flips the switch back on.
special_char_set: values.require_special ? values.special_char_set.trim() : "",
rotation_days: Number(values.rotation_days),
rotation_warning_days: Number(values.rotation_warning_days),
history_count: Number(values.history_count),
history_max_retained: Number(values.history_max_retained),
lockout: {
threshold: Number(values.lockout.threshold),
bucket_size: Number(values.lockout.bucket_size),
start_secs: Number(values.lockout.start_secs),
max_secs: Number(values.lockout.max_secs),
backoff: values.lockout.backoff,
},
cookie_max_age_secs: Number(values.cookie_max_age_secs),
apply_to_root: values.apply_to_root,
});
/** The lockout durations an account would serve, level by level, capped so a far ceiling still renders as one line. */
export const lockoutLadder = (lockout: LockoutPolicy, maxLevels = 6): number[] => {
const start = Number(lockout.start_secs);
const max = Number(lockout.max_secs);
const ladder: number[] = [];
for (let level = 1; level <= maxLevels; level++) {
const raw = lockout.backoff === "linear" ? start * level : start * 2 ** (level - 1);
const secs = Math.min(raw, max);
ladder.push(secs);
if (secs >= max) break;
}
return ladder;
};
@@ -0,0 +1,506 @@
// 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/>.
import { flushPromises, mount } from "@vue/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createStore } from "vuex";
import i18n from "@/locales";
import type { PasswordPolicy as PasswordPolicyType } from "@/services/passwordPolicy";
import PasswordPolicy from "./PasswordPolicy.vue";
import {
buildPolicyPayload,
INITIAL_POLICY,
lockoutLadder,
policyDefaults,
} from "./PasswordPolicy.schema";
vi.mock("@/services/passwordPolicy", () => ({
default: {
getPolicy: vi.fn(),
updatePolicy: vi.fn(),
getComplexity: vi.fn(),
},
}));
const confirmMock = vi.fn();
vi.mock("@/composables/useConfirmDialog", () => ({
useConfirmDialog: () => ({ confirm: confirmMock }),
}));
const toastMock = vi.fn();
vi.mock("@/lib/feedback/Toast/useToast", () => ({
toast: (...args: unknown[]) => toastMock(...args),
}));
import passwordPolicyService from "@/services/passwordPolicy";
// Every non-complexity feature configured: the values a complexity-only edit must carry back untouched.
const CONFIGURED_POLICY: PasswordPolicyType = {
min_length: 10,
max_length: 64,
require_uppercase: true,
require_lowercase: false,
require_digit: false,
require_special: false,
special_char_set: "",
rotation_days: 90,
rotation_warning_days: 7,
history_count: 5,
history_max_retained: 30,
lockout: {
threshold: 5,
bucket_size: 3,
start_secs: 120,
max_secs: 7200,
backoff: "linear",
},
cookie_max_age_secs: 0,
apply_to_root: false,
};
const store = createStore({
state: {
zoConfig: { meta_org: "_meta" },
selectedOrganization: { identifier: "_meta" },
},
});
// Tracked so every mount is torn down: a leftover component writes into the mocks the next case asserts on.
let wrappers: any[] = [];
const mountPage = () => {
const wrapper = mount(PasswordPolicy, {
global: {
plugins: [i18n, store],
stubs: { OSpinner: true },
},
});
wrappers.push(wrapper);
return wrapper;
};
// Drives the real form rather than the handler, so field coercion and the OForm submit path are covered.
const editField = async (wrapper: any, row: string, value: string) => {
const input = wrapper.find(`[data-test="settings-password-policy-${row}"] input`);
await input.setValue(value);
await flushPromises();
};
const editMinLength = (wrapper: any, value: string) => editField(wrapper, "min-length", value);
const lockoutControl = (wrapper: any, row: string, selector: string) =>
wrapper.find(`[data-test="settings-password-policy-lockout-${row}"] ${selector}`);
const pair = (wrapper: any, name: string) =>
wrapper.find(`[data-test="settings-password-policy-pair-${name}"]`);
// A DOM `trigger("submit")` returns before the awaited onSubmit settles, so the assertions would race it.
const submit = async (wrapper: any) => {
await wrapper.vm.form.handleSubmit();
await flushPromises();
};
describe("PasswordPolicy", () => {
beforeEach(() => {
vi.clearAllMocks();
confirmMock.mockResolvedValue(true);
(passwordPolicyService.getPolicy as any).mockResolvedValue({ data: CONFIGURED_POLICY });
(passwordPolicyService.updatePolicy as any).mockResolvedValue({
data: { policy: CONFIGURED_POLICY, users_flagged: 0 },
});
});
afterEach(() => {
wrappers.forEach((wrapper) => wrapper.unmount());
wrappers = [];
});
it("reads the policy from the meta org", async () => {
mountPage();
await flushPromises();
expect(passwordPolicyService.getPolicy).toHaveBeenCalledWith("_meta");
});
it("renders the complexity form once the policy loads", async () => {
const wrapper = mountPage();
await flushPromises();
expect(wrapper.find('[data-test="settings-password-policy-min-length"]').exists()).toBe(true);
expect(wrapper.find('[data-test="settings-password-policy-save-btn"]').exists()).toBe(true);
});
it("renders the not-admin empty state on a plain 403, with no form", async () => {
(passwordPolicyService.getPolicy as any).mockRejectedValue({
response: { status: 403, data: { message: "nope" } },
});
const wrapper = mountPage();
await flushPromises();
expect(wrapper.find('[data-test="password-policy-not-admin-empty-state"]').exists()).toBe(true);
expect(wrapper.find('[data-test="settings-password-policy-save-btn"]').exists()).toBe(false);
});
it("renders a retry-able error state for any other failure", async () => {
(passwordPolicyService.getPolicy as any).mockRejectedValue({ response: { status: 500 } });
const wrapper = mountPage();
await flushPromises();
expect(wrapper.find('[data-test="password-policy-load-error-empty-state"]').exists()).toBe(
true,
);
});
it("keeps Save disabled until something changes", async () => {
const wrapper = mountPage();
await flushPromises();
const save = wrapper.find('[data-test="settings-password-policy-save-btn"]');
expect(save.attributes("disabled")).toBeDefined();
});
it("confirms before writing and makes no request when cancelled", async () => {
const wrapper = mountPage();
await flushPromises();
await editMinLength(wrapper, "16");
confirmMock.mockResolvedValue(false);
await submit(wrapper);
await flushPromises();
expect(confirmMock).toHaveBeenCalled();
expect(passwordPolicyService.updatePolicy).not.toHaveBeenCalled();
});
it("posts a body that still carries rotation, reuse and lockout from the GET", async () => {
const wrapper = mountPage();
await flushPromises();
await editMinLength(wrapper, "16");
await submit(wrapper);
const [, body] = (passwordPolicyService.updatePolicy as any).mock.calls[0];
// The edited half.
expect(body.min_length).toBe(16);
// The half this page never shows — a PUT that dropped these would reset them server-side.
expect(body.rotation_days).toBe(90);
expect(body.rotation_warning_days).toBe(7);
expect(body.history_count).toBe(5);
expect(body.history_max_retained).toBe(30);
expect(body.lockout).toEqual(CONFIGURED_POLICY.lockout);
});
it("reports the server's flagged count, not a locally predicted one", async () => {
(passwordPolicyService.updatePolicy as any).mockResolvedValue({
data: { policy: CONFIGURED_POLICY, users_flagged: 12 },
});
const wrapper = mountPage();
await flushPromises();
await editMinLength(wrapper, "16");
await submit(wrapper);
expect(toastMock).toHaveBeenCalledWith(
expect.objectContaining({ variant: "success", message: expect.stringContaining("12") }),
);
});
it("writes every card's edits in one PUT, with lockout nested", async () => {
const wrapper = mountPage();
await flushPromises();
await editField(wrapper, "rotation-days", "30");
await editField(wrapper, "history-count", "8");
await editField(wrapper, "lockout-start-secs", "300");
await editField(wrapper, "cookie-max-age", "3600");
await submit(wrapper);
const [, body] = (passwordPolicyService.updatePolicy as any).mock.calls[0];
expect(body.rotation_days).toBe(30);
expect(body.history_count).toBe(8);
expect(body.lockout).toEqual({ ...CONFIGURED_POLICY.lockout, start_secs: 300 });
expect(body.cookie_max_age_secs).toBe(3600);
});
it.each([
["rotation-warning-days", "91"],
["history-max-retained", "4"],
["lockout-start-secs", "7201"],
])("refuses the cross-field rule on %s", async (row, value) => {
const wrapper = mountPage();
await flushPromises();
await editField(wrapper, row, value);
await submit(wrapper);
expect(passwordPolicyService.updatePolicy).not.toHaveBeenCalled();
expect(wrapper.vm.form.state.isValid).toBe(false);
});
it("accepts a warning window equal to the rotation period", async () => {
const wrapper = mountPage();
await flushPromises();
await editField(wrapper, "rotation-warning-days", "90");
await submit(wrapper);
expect(passwordPolicyService.updatePolicy).toHaveBeenCalled();
});
it("accepts start above max while lockout is off, since the durations are inert", async () => {
const wrapper = mountPage();
await flushPromises();
await editField(wrapper, "lockout-threshold", "0");
await editField(wrapper, "lockout-start-secs", "7201");
await submit(wrapper);
expect(passwordPolicyService.updatePolicy).toHaveBeenCalled();
});
it("disables every lockout control while the threshold is 0", async () => {
const wrapper = mountPage();
await flushPromises();
const controls = () => [
lockoutControl(wrapper, "bucket-size", "input"),
lockoutControl(wrapper, "start-secs", "input"),
lockoutControl(wrapper, "max-secs", "input"),
lockoutControl(wrapper, "backoff", "button[role='combobox']"),
];
expect(controls().map((c) => c.attributes("disabled"))).toEqual(Array(4).fill(undefined));
await editField(wrapper, "lockout-threshold", "0");
expect(controls().every((c) => c.attributes("disabled") !== undefined)).toBe(true);
expect(lockoutControl(wrapper, "threshold", "input").attributes("disabled")).toBeUndefined();
expect(wrapper.find('[data-test="settings-password-policy-lockout-preview"]').exists()).toBe(
false,
);
});
it("lays the settings out in the planned pairs", async () => {
const wrapper = mountPage();
await flushPromises();
const plan: [string, string[]][] = [
["length", ["min-length", "max-length"]],
["case", ["require-uppercase", "require-lowercase"]],
["digit-special", ["require-digit", "require-special"]],
["rotation", ["rotation-days", "rotation-warning-days"]],
["history", ["history-count", "history-max-retained"]],
["lockout-duration", ["lockout-start-secs", "lockout-max-secs"]],
["lockout-escalation", ["lockout-bucket-size", "lockout-backoff"]],
["session-enforcement", ["cookie-max-age", "apply-to-root"]],
];
for (const [name, rows] of plan) {
expect(pair(wrapper, name).exists()).toBe(true);
for (const row of rows) {
expect(
pair(wrapper, name).find(`[data-test="settings-password-policy-${row}"]`).exists(),
).toBe(true);
}
}
await wrapper
.find('[data-test="settings-password-policy-require-special"] button[role="switch"]')
.trigger("click");
await flushPromises();
// VTU has no ancestor query, so the singles are checked through the DOM.
for (const row of ["lockout-threshold", "special-char-set"]) {
const el = wrapper.find(`[data-test="settings-password-policy-${row}"]`).element;
expect(el.closest('[data-test^="settings-password-policy-pair-"]')).toBeNull();
}
});
it("renders a cross-field message under the pair, not inside the narrow field", async () => {
const wrapper = mountPage();
await flushPromises();
await editField(wrapper, "max-length", "4");
await submit(wrapper);
expect(passwordPolicyService.updatePolicy).not.toHaveBeenCalled();
const error = wrapper.find('[data-test="settings-password-policy-max-length-error"]');
expect(error.exists()).toBe(true);
expect(error.text()).toBe(
"Maximum length must be at least the minimum length, or 0 for unbounded.",
);
expect(
pair(wrapper, "length")
.find('[data-test="settings-password-policy-max-length-error"]')
.exists(),
).toBe(true);
const field = wrapper.find('[data-test="settings-password-policy-max-length"]');
expect(field.find('[data-test="settings-password-policy-max-length-error"]').exists()).toBe(
false,
);
expect(field.find(".text-input-error-text").exists()).toBe(false);
});
it("clears the footer once the values are valid again", async () => {
const wrapper = mountPage();
await flushPromises();
await editField(wrapper, "max-length", "4");
await submit(wrapper);
expect(wrapper.find('[data-test="settings-password-policy-max-length-error"]').exists()).toBe(
true,
);
await editField(wrapper, "max-length", "64");
expect(wrapper.find('[data-test="settings-password-policy-max-length-error"]').exists()).toBe(
false,
);
});
it("uses one card for session and enforcement", async () => {
const wrapper = mountPage();
await flushPromises();
expect(wrapper.text()).toContain("Session & enforcement");
expect(wrapper.findAll(".text-compact.font-semibold").map((n) => n.text())).toEqual([
"Complexity",
"Rotation",
"Reuse prevention",
"Account lockout",
"Session & enforcement",
]);
});
it("returns the root switch to off when the acknowledgement is dismissed", async () => {
const wrapper = mountPage();
await flushPromises();
await wrapper
.find('[data-test="settings-password-policy-apply-to-root"] button[role="switch"]')
.trigger("click");
await flushPromises();
expect(wrapper.vm.form.state.values.apply_to_root).toBe(true);
expect(wrapper.vm.rootDialogOpen).toBe(true);
wrapper.vm.cancelApplyToRoot();
await flushPromises();
expect(wrapper.vm.form.state.values.apply_to_root).toBe(false);
expect(wrapper.find('[data-test="settings-password-policy-root-warning"]').exists()).toBe(
false,
);
});
});
describe("INITIAL_POLICY", () => {
it("is the server's own default policy, field for field", () => {
expect(INITIAL_POLICY).toEqual({
min_length: 8,
max_length: 0,
require_uppercase: false,
require_lowercase: false,
require_digit: false,
require_special: false,
special_char_set: "",
rotation_days: 0,
rotation_warning_days: 7,
history_count: 0,
history_max_retained: 30,
lockout: {
threshold: 0,
bucket_size: 0,
start_secs: 60,
max_secs: 3600,
backoff: "exponential",
},
cookie_max_age_secs: 0,
apply_to_root: false,
});
});
});
describe("lockoutLadder", () => {
it("doubles per level and stops at the ceiling", () => {
expect(
lockoutLadder({
threshold: 5,
bucket_size: 3,
start_secs: 60,
max_secs: 600,
backoff: "exponential",
}),
).toEqual([60, 120, 240, 480, 600]);
});
it("grows linearly and caps the entry count", () => {
expect(
lockoutLadder({
threshold: 5,
bucket_size: 0,
start_secs: 60,
max_secs: 3600,
backoff: "linear",
}),
).toEqual([60, 120, 180, 240, 300, 360]);
});
});
describe("buildPolicyPayload", () => {
it("trims the special set and keeps the unedited fields", () => {
const payload = buildPolicyPayload(CONFIGURED_POLICY, {
...policyDefaults(CONFIGURED_POLICY),
min_length: 20,
max_length: 0,
require_uppercase: false,
require_lowercase: true,
require_digit: true,
require_special: true,
special_char_set: " !@# ",
});
expect(payload).toEqual({
...CONFIGURED_POLICY,
min_length: 20,
max_length: 0,
require_uppercase: false,
require_lowercase: true,
require_digit: true,
require_special: true,
special_char_set: "!@#",
});
});
it("clears the special set when the requirement is off", () => {
const payload = buildPolicyPayload(CONFIGURED_POLICY, {
...policyDefaults(CONFIGURED_POLICY),
require_special: false,
special_char_set: "!@#",
});
expect(payload.special_char_set).toBe("");
});
it("coerces numeric inputs, which arrive from the DOM as strings", () => {
const payload = buildPolicyPayload(CONFIGURED_POLICY, {
...policyDefaults(CONFIGURED_POLICY),
min_length: "12" as unknown as number,
max_length: "40" as unknown as number,
});
expect(payload.min_length).toBe(12);
expect(payload.max_length).toBe(40);
});
});
@@ -0,0 +1,568 @@
<!-- 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/>.
-->
<template>
<!-- The Settings shell owns the header, scroll and gutter; the content just flows. -->
<div class="password-policy">
<div v-if="loading" data-test="password-policy-loading" class="py-8 text-center">
<OSpinner size="sm" />
</div>
<!-- A non-admin of _meta is a designed state: the console has no per-org role to gate the nav entry on. -->
<OEmptyState
v-else-if="forbidden"
data-test="password-policy-not-admin-empty-state"
size="block"
icon="lock"
:title="t('passwordPolicy.notAdminTitle')"
:description="t('passwordPolicy.notAdmin')"
hide-action
/>
<OEmptyState
v-else-if="loadError"
data-test="password-policy-load-error-empty-state"
size="block"
icon="error"
:title="t('passwordPolicy.loadFailedTitle')"
:description="t('passwordPolicy.loadFailed')"
:action-label="t('common.retry')"
@action="loadPolicy"
/>
<template v-else>
<!-- Headless: the owner reads the form for the dependent rows, previews and dirty check. -->
<OForm id="password-policy-form" :form="form" v-slot="{ isSubmitting }">
<div class="flex flex-col gap-4">
<OFormSection :title="t('passwordPolicy.complexity')">
<OSettingRowPair data-test="settings-password-policy-pair-length">
<OSettingRow
:label="t('passwordPolicy.minLength')"
:description="t('passwordPolicy.minLengthDesc')"
data-test="settings-password-policy-min-length"
>
<OFormInput name="min_length" type="number" :min="1" width="xs" />
</OSettingRow>
<OSettingRow
:label="t('passwordPolicy.maxLength')"
:description="t('passwordPolicy.maxLengthDesc')"
data-test="settings-password-policy-max-length"
>
<OFormInput name="max_length" type="number" :min="0" width="xs">
<template #error />
</OFormInput>
</OSettingRow>
<template v-if="maxLengthError" #footer>
<p
class="text-input-error-text text-xs"
role="alert"
data-test="settings-password-policy-max-length-error"
>
{{ maxLengthError }}
</p>
</template>
</OSettingRowPair>
<OSettingRowPair data-test="settings-password-policy-pair-case">
<OSettingRow
:label="t('passwordPolicy.requireUppercase')"
:description="t('passwordPolicy.requireUppercaseDesc')"
data-test="settings-password-policy-require-uppercase"
>
<OFormSwitch name="require_uppercase" />
</OSettingRow>
<OSettingRow
:label="t('passwordPolicy.requireLowercase')"
:description="t('passwordPolicy.requireLowercaseDesc')"
data-test="settings-password-policy-require-lowercase"
>
<OFormSwitch name="require_lowercase" />
</OSettingRow>
</OSettingRowPair>
<OSettingRowPair data-test="settings-password-policy-pair-digit-special">
<OSettingRow
:label="t('passwordPolicy.requireDigit')"
:description="t('passwordPolicy.requireDigitDesc')"
data-test="settings-password-policy-require-digit"
>
<OFormSwitch name="require_digit" />
</OSettingRow>
<OSettingRow
:label="t('passwordPolicy.requireSpecial')"
:description="t('passwordPolicy.requireSpecialDesc')"
data-test="settings-password-policy-require-special"
>
<OFormSwitch name="require_special" />
</OSettingRow>
</OSettingRowPair>
<OSettingRow
v-if="values.require_special"
:label="t('passwordPolicy.specialCharSet')"
:description="t('passwordPolicy.specialCharSetDesc')"
data-test="settings-password-policy-special-char-set"
>
<OFormInput name="special_char_set" width="sm" :placeholder="raw('!@#$%^&*()')" />
</OSettingRow>
</OFormSection>
<OFormSection :title="t('passwordPolicy.rotation')">
<OSettingRowPair data-test="settings-password-policy-pair-rotation">
<OSettingRow
:label="t('passwordPolicy.rotationDays')"
:description="t('passwordPolicy.rotationDaysDesc')"
data-test="settings-password-policy-rotation-days"
>
<OFormInput name="rotation_days" type="number" :min="0" width="xs" />
</OSettingRow>
<OSettingRow
:label="t('passwordPolicy.rotationWarningDays')"
:description="t('passwordPolicy.rotationWarningDaysDesc')"
:disabled="!rotationOn"
data-test="settings-password-policy-rotation-warning-days"
>
<OFormInput
name="rotation_warning_days"
type="number"
:min="0"
width="xs"
:disabled="!rotationOn"
>
<template #error />
</OFormInput>
</OSettingRow>
<template v-if="warningDaysError" #footer>
<p
class="text-input-error-text text-xs"
role="alert"
data-test="settings-password-policy-rotation-warning-days-error"
>
{{ warningDaysError }}
</p>
</template>
</OSettingRowPair>
<p
v-if="rotationOn"
class="text-text-secondary px-3 py-3 text-xs"
data-test="settings-password-policy-rotation-preview"
>
{{ rotationPreview }}
</p>
</OFormSection>
<OFormSection :title="t('passwordPolicy.reuse')">
<OSettingRowPair data-test="settings-password-policy-pair-history">
<OSettingRow
:label="t('passwordPolicy.historyCount')"
:description="t('passwordPolicy.historyCountDesc')"
data-test="settings-password-policy-history-count"
>
<OFormInput name="history_count" type="number" :min="0" width="xs" />
</OSettingRow>
<OSettingRow
:label="t('passwordPolicy.historyMaxRetained')"
:description="t('passwordPolicy.historyMaxRetainedDesc')"
data-test="settings-password-policy-history-max-retained"
>
<OFormInput name="history_max_retained" type="number" :min="0" width="xs">
<template #error />
</OFormInput>
</OSettingRow>
<template v-if="historyRetainedError" #footer>
<p
class="text-input-error-text text-xs"
role="alert"
data-test="settings-password-policy-history-max-retained-error"
>
{{ historyRetainedError }}
</p>
</template>
</OSettingRowPair>
<p class="text-text-secondary px-3 py-3 text-xs">
{{ t("passwordPolicy.reuseExplainer") }}
</p>
</OFormSection>
<OFormSection :title="t('passwordPolicy.lockout')">
<OSettingRow
:label="t('passwordPolicy.lockoutThreshold')"
:description="t('passwordPolicy.lockoutThresholdDesc')"
data-test="settings-password-policy-lockout-threshold"
>
<OFormInput name="lockout.threshold" type="number" :min="0" width="xs" />
</OSettingRow>
<OSettingRowPair data-test="settings-password-policy-pair-lockout-duration">
<OSettingRow
:label="t('passwordPolicy.lockoutStartSecs')"
:description="t('passwordPolicy.lockoutStartSecsDesc')"
:disabled="!lockoutOn"
data-test="settings-password-policy-lockout-start-secs"
>
<OFormInput
name="lockout.start_secs"
type="number"
:min="0"
width="xs"
:disabled="!lockoutOn"
>
<template #error />
</OFormInput>
</OSettingRow>
<OSettingRow
:label="t('passwordPolicy.lockoutMaxSecs')"
:description="t('passwordPolicy.lockoutMaxSecsDesc')"
:disabled="!lockoutOn"
data-test="settings-password-policy-lockout-max-secs"
>
<OFormInput
name="lockout.max_secs"
type="number"
:min="0"
width="xs"
:disabled="!lockoutOn"
/>
</OSettingRow>
<template v-if="lockoutStartError" #footer>
<p
class="text-input-error-text text-xs"
role="alert"
data-test="settings-password-policy-lockout-start-secs-error"
>
{{ lockoutStartError }}
</p>
</template>
</OSettingRowPair>
<OSettingRowPair data-test="settings-password-policy-pair-lockout-escalation">
<OSettingRow
:label="t('passwordPolicy.lockoutBucketSize')"
:description="t('passwordPolicy.lockoutBucketSizeDesc')"
:disabled="!lockoutOn"
data-test="settings-password-policy-lockout-bucket-size"
>
<OFormInput
name="lockout.bucket_size"
type="number"
:min="0"
width="xs"
:disabled="!lockoutOn"
/>
</OSettingRow>
<OSettingRow
:label="t('passwordPolicy.lockoutBackoff')"
:description="t('passwordPolicy.lockoutBackoffDesc')"
:disabled="!lockoutOn"
data-test="settings-password-policy-lockout-backoff"
>
<OFormSelect
name="lockout.backoff"
:options="backoffOptions"
:searchable="false"
width="sm"
:disabled="!lockoutOn"
/>
</OSettingRow>
</OSettingRowPair>
<p
v-if="lockoutOn"
class="text-text-secondary px-3 py-3 text-xs"
data-test="settings-password-policy-lockout-preview"
>
{{ lockoutPreview }}
</p>
</OFormSection>
<OFormSection :title="t('passwordPolicy.sessionEnforcement')">
<OSettingRowPair data-test="settings-password-policy-pair-session-enforcement">
<OSettingRow
:label="t('passwordPolicy.cookieMaxAge')"
:description="
t('passwordPolicy.cookieMaxAgeDesc', { envVar: raw('ZO_COOKIE_MAX_AGE') })
"
data-test="settings-password-policy-cookie-max-age"
>
<OFormInput name="cookie_max_age_secs" type="number" :min="0" width="xs" />
</OSettingRow>
<OSettingRow
:label="t('passwordPolicy.applyToRoot')"
:description="t('passwordPolicy.applyToRootDesc')"
data-test="settings-password-policy-apply-to-root"
>
<OFormSwitch name="apply_to_root" @update:model-value="onApplyToRootChange" />
</OSettingRow>
</OSettingRowPair>
<!-- Standing, not one-off: a warning shown once is one the next administrator never sees. -->
<OBanner
v-if="values.apply_to_root"
variant="error-soft"
icon="warning"
dense
class="my-3"
:content="t('passwordPolicy.rootWarning')"
data-test="settings-password-policy-root-warning"
/>
</OFormSection>
</div>
<!-- Sticky offsets ignore the scrollport's py-3, so the bar bleeds and pins 0.75rem past it to sit flush. -->
<div
class="border-border-default bg-surface-base shadow-sticky-footer sticky -bottom-3 z-10 -mx-4 mt-4 -mb-3 flex justify-end gap-2 border-t px-4 py-3"
>
<OButton
data-test="settings-password-policy-cancel-btn"
variant="outline"
size="sm-action"
:disabled="!isDirty || isSubmitting"
@click="resetForm"
>
{{ t("common.cancel") }}
</OButton>
<OButton
data-test="settings-password-policy-save-btn"
variant="primary"
size="sm-action"
type="submit"
:disabled="!isDirty || !loadedPolicy"
>
{{ t("settings.saveChanges") }}
</OButton>
</div>
</OForm>
<!-- Switching apply_to_root back OFF is the safe direction and confirms nothing. -->
<ODialog
:open="rootDialogOpen"
data-test="settings-password-policy-root-dialog"
size="sm"
:title="t('passwordPolicy.rootDialogTitle')"
:secondary-button-label="t('common.cancel')"
:primary-button-label="t('passwordPolicy.rootDialogConfirm')"
primary-button-variant="destructive"
:primary-button-disabled="!rootAcknowledged"
@update:open="(open: boolean) => !open && cancelApplyToRoot()"
@click:secondary="cancelApplyToRoot"
@click:primary="rootDialogOpen = false"
>
<div class="flex flex-col gap-4">
<OBanner
variant="error"
icon="warning"
dense
:content="t('passwordPolicy.rootWarning')"
/>
<p class="text-text-body text-sm">{{ t("passwordPolicy.rootDialogMessage") }}</p>
<OCheckbox
v-model="rootAcknowledged"
:label="t('passwordPolicy.rootDialogAck')"
data-test="settings-password-policy-root-ack"
/>
</div>
</ODialog>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useStore } from "vuex";
import { useConfirmDialog } from "@/composables/useConfirmDialog";
import OButton from "@/lib/core/Button/OButton.vue";
import { OEmptyState } from "@/lib/core/EmptyState";
import OFormSection from "@/lib/core/FormSection/OFormSection.vue";
import OSettingRow from "@/lib/core/SettingRow/OSettingRow.vue";
import OSettingRowPair from "@/lib/core/SettingRow/OSettingRowPair.vue";
import OBanner from "@/lib/feedback/Banner/OBanner.vue";
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
import { toast } from "@/lib/feedback/Toast/useToast";
import OCheckbox from "@/lib/forms/Checkbox/OCheckbox.vue";
import { firstFieldError } from "@/lib/forms/Form/fieldError";
import OForm from "@/lib/forms/Form/OForm.vue";
import { useOForm } from "@/lib/forms/Form/useOForm";
import OFormInput from "@/lib/forms/Input/OFormInput.vue";
import OFormSelect from "@/lib/forms/Select/OFormSelect.vue";
import OFormSwitch from "@/lib/forms/Switch/OFormSwitch.vue";
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
import passwordPolicy, { type PasswordPolicy } from "@/services/passwordPolicy";
import { raw, useI18nTyped } from "@/types/i18n";
import { formatDate } from "@/utils/date";
import { durationFormatter } from "@/utils/formatters";
import {
buildPolicyPayload,
INITIAL_POLICY,
lockoutLadder,
makePolicySchema,
policyDefaults,
type PolicyForm,
} from "./PasswordPolicy.schema";
const DAY_MS = 86_400_000;
type RejectedResponse = { response?: { status?: number; data?: { message?: string } } };
const responseOf = (error: unknown) => (error as RejectedResponse | undefined)?.response;
const { t, locale } = useI18nTyped();
const store = useStore();
const { confirm } = useConfirmDialog();
// The WHOLE policy as the server returned it: the PUT is a full replacement (see buildPolicyPayload).
const loadedPolicy = ref<PasswordPolicy | null>(null);
const loading = ref(true);
const forbidden = ref(false);
const loadError = ref(false);
const rootDialogOpen = ref(false);
const rootAcknowledged = ref(false);
const schema = makePolicySchema(t);
const formDefaults = ref<PolicyForm>(policyDefaults(INITIAL_POLICY));
const form = useOForm<PolicyForm>({
defaultValues: formDefaults.value,
schema,
onSubmit: (values) => save(values),
});
const values = form.useStore((s: { values: PolicyForm }) => s.values);
// The four cross-field messages are shown by the pair, not the field, so both cells stay aligned.
const fieldError = (path: string) =>
form.useStore((s: { fieldMeta?: Record<string, { errors?: unknown[] }> }) =>
firstFieldError(s.fieldMeta?.[path]?.errors ?? []),
);
const maxLengthError = fieldError("max_length");
const warningDaysError = fieldError("rotation_warning_days");
const historyRetainedError = fieldError("history_max_retained");
const lockoutStartError = fieldError("lockout.start_secs");
const backoffOptions = [
{ label: t("passwordPolicy.backoffExponential"), value: "exponential" },
{ label: t("passwordPolicy.backoffLinear"), value: "linear" },
];
const rotationOn = computed(() => Number(values.value.rotation_days) > 0);
const lockoutOn = computed(() => Number(values.value.lockout.threshold) > 0);
// Two integers do not communicate a date; the two dates an operator reasons about do.
const rotationPreview = computed(() => {
const days = Number(values.value.rotation_days);
const warn = Number(values.value.rotation_warning_days);
const expires = raw(formatDate(Date.now() + days * DAY_MS, "MMM D, YYYY"));
if (warn <= 0) return t("passwordPolicy.rotationPreviewNoWarning", { expires });
const warns = raw(formatDate(Date.now() + (days - warn) * DAY_MS, "MMM D, YYYY"));
return t("passwordPolicy.rotationPreview", { expires, warns });
});
const lockoutPreview = computed(() => {
const lockout = values.value.lockout;
const [first, ...rest] = lockoutLadder(lockout).map((secs) => durationFormatter(secs));
return t("passwordPolicy.lockoutPreview", {
threshold: Number(lockout.threshold),
// 0 reuses the first threshold for every later lockout.
bucket: Number(lockout.bucket_size) || Number(lockout.threshold),
first: raw(first),
rest: raw(new Intl.ListFormat(locale.value, { type: "unit" }).format(rest)),
max: raw(durationFormatter(Number(lockout.max_secs))),
});
});
const isDirty = computed(() => {
if (!loadedPolicy.value) return false;
// Compares the payload, not the raw values: a number input hands back a string.
return (
JSON.stringify(buildPolicyPayload(loadedPolicy.value, values.value)) !==
JSON.stringify(loadedPolicy.value)
);
});
const metaOrg = computed(() => store.state.zoConfig?.meta_org);
const loadPolicy = async () => {
loading.value = true;
forbidden.value = false;
loadError.value = false;
try {
const response = await passwordPolicy.getPolicy(metaOrg.value);
loadedPolicy.value = response.data;
formDefaults.value = policyDefaults(response.data);
form.reset(formDefaults.value);
} catch (error: unknown) {
if (responseOf(error)?.status === 403) forbidden.value = true;
else loadError.value = true;
} finally {
loading.value = false;
}
};
const resetForm = () => {
form.reset(formDefaults.value);
};
const onApplyToRootChange = (on: unknown) => {
if (!on) return;
rootAcknowledged.value = false;
rootDialogOpen.value = true;
};
// The switch must not stay on behind a dialog the operator dismissed.
const cancelApplyToRoot = () => {
rootDialogOpen.value = false;
form.setFieldValue("apply_to_root", false);
};
const save = async (formValues: PolicyForm) => {
if (!loadedPolicy.value) return;
// Every save confirms, not only a tightening: the sweep a save triggers cannot be undone.
const confirmed = await confirm({
title: t("passwordPolicy.confirmTitle"),
message: t("passwordPolicy.confirmMessage"),
confirmLabel: t("settings.saveChanges"),
});
if (!confirmed) return;
try {
const response = await passwordPolicy.updatePolicy(
metaOrg.value,
buildPolicyPayload(loadedPolicy.value, formValues),
);
loadedPolicy.value = response.data.policy;
formDefaults.value = policyDefaults(response.data.policy);
form.reset(formDefaults.value);
const flagged = response.data.users_flagged ?? 0;
toast({
variant: "success",
message: t("passwordPolicy.saved", { count: flagged }, flagged),
});
} catch (error: unknown) {
toast({
variant: "error",
message: raw(responseOf(error)?.data?.message) || t("passwordPolicy.saveFailed"),
});
}
};
onMounted(loadPolicy);
</script>
+20 -3
View File
@@ -36,7 +36,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:icon="activeSectionItem?.icon as any"
class="border-border-default shrink-0 border-b"
/>
<ConstrainedPage size="lg" align="left" :padded="false" class="min-h-0 flex-1 px-4 py-3">
<ConstrainedPage
:size="activeSection === 'password_policy' ? 'full' : 'lg'"
align="left"
:padded="false"
class="min-h-0 flex-1 px-4 py-3"
>
<router-view title="" />
</ConstrainedPage>
</div>
@@ -89,6 +94,7 @@ export default defineComponent({
queryManagement: "queryManagement",
query_management: "queryManagement",
domainManagement: "domain_management",
passwordPolicy: "password_policy",
pipelineDestinations: "pipeline_destinations",
alertTemplates: "templates",
modelPricing: "model_pricing",
@@ -118,6 +124,7 @@ export default defineComponent({
"organization",
"license",
"domain_management",
"password_policy",
]);
const isConstrainedSection = computed(() => CONSTRAINED_SECTIONS.has(activeSection.value));
@@ -130,10 +137,10 @@ export default defineComponent({
query: { org_identifier: store.state.selectedOrganization?.identifier },
});
};
// Nodes and License are enterprise-only meta-org pages.
// Nodes, License and Password policy are enterprise-only meta-org pages.
const notMeta =
store.state.zoConfig.meta_org && (!isMetaOrg.value || config.isEnterprise === "false");
if ((name === "nodes" || name === "license") && notMeta) {
if ((name === "nodes" || name === "license" || name === "passwordPolicy") && notMeta) {
toGeneral();
return;
}
@@ -229,6 +236,16 @@ export default defineComponent({
dataTest: "domain-management-tab",
group: "Access & Security",
},
{
key: "password_policy",
label: t("settings.passwordPolicy"),
description: t("settings.passwordPolicyDesc"),
icon: "lock",
to: { name: "passwordPolicy", query: { org_identifier: org } },
visible: isEnt && meta,
dataTest: "password-policy-tab",
group: "Access & Security",
},
// Notification Destinations and Templates are alerting configuration and
// now live under Reliability (/alert-destinations, /alert-templates).
// Pipeline Destinations stays here — it belongs to pipelines, not
@@ -5,12 +5,15 @@ import { nextTick } from "vue";
import { createStore } from "vuex";
import { createI18n } from "vue-i18n";
import enLocale from "@/locales/languages/en-US.json";
import config from "@/aws-exports";
import SettingsIndex from "./index.vue";
// Mock composables and config with factory functions
// Settable rather than a fixed `false`: meta-org-only sections cannot be covered otherwise.
const { isMetaOrgRef } = vi.hoisted(() => ({ isMetaOrgRef: { value: false } }));
vi.mock("@/composables/useIsMetaOrg", () => ({
default: () => ({
isMetaOrg: { value: false },
isMetaOrg: isMetaOrgRef,
}),
}));
@@ -393,6 +396,61 @@ describe("SettingsIndex.vue", () => {
});
});
describe("password policy section", () => {
const findEntry = (w: any) =>
w.vm.sectionGroups
.flatMap((group: any) => group.items)
.find((item: any) => item.key === "password_policy");
afterEach(() => {
isMetaOrgRef.value = false;
config.isEnterprise = "false";
mockRouter.currentRoute.value.name = "settings";
});
it("is offered in the meta org of an enterprise build", () => {
config.isEnterprise = "true";
isMetaOrgRef.value = true;
wrapper = createWrapper();
expect(findEntry(wrapper)?.visible).toBe(true);
});
it("is hidden outside the meta org", () => {
config.isEnterprise = "true";
isMetaOrgRef.value = false;
wrapper = createWrapper();
expect(findEntry(wrapper)?.visible).toBe(false);
});
it("is hidden in an OSS build even in the meta org", () => {
// The API only exists behind the enterprise feature, so the entry would lead nowhere.
config.isEnterprise = "false";
isMetaOrgRef.value = true;
wrapper = createWrapper();
expect(findEntry(wrapper)?.visible).toBe(false);
});
it("sends an enterprise non-meta org back to General", () => {
config.isEnterprise = "true";
isMetaOrgRef.value = false;
mockRouter.currentRoute.value.name = "passwordPolicy";
mockPush.mockClear();
wrapper = createWrapper({ zoConfig: { meta_org: "_meta" } });
expect(mockPush).toHaveBeenCalledWith(expect.objectContaining({ path: "/settings/general" }));
});
it("renders in the centered reading column, like the other form sections", () => {
mockRouter.currentRoute.value.name = "passwordPolicy";
wrapper = createWrapper();
expect(wrapper.vm.isConstrainedSection).toBe(true);
});
});
describe("Template Rendering", () => {
it("should render without errors", () => {
mockRouter.currentRoute.value.name = "general";
@@ -521,7 +521,7 @@ describe("useManagementRoutes", () => {
it("should have exactly 20 children routes when enterprise is enabled", () => {
const routes = useManagementRoutes();
expect(routes[0].children).toHaveLength(20); // 5 base (incl. alert_sources redirect) + modelPricing (+ editor) + llmProviders + genAiAgentMapping + 11 enterprise
expect(routes[0].children).toHaveLength(21); // 5 base (incl. alert_sources redirect) + syntheticsLocations + modelPricing (+ editor) + llmProviders + genAiAgentMapping + 11 enterprise (incl. passwordPolicy)
});
});
@@ -604,7 +604,7 @@ describe("useManagementRoutes", () => {
it("should have exactly 21 children routes when both enterprise and cloud are enabled", () => {
const routes = useManagementRoutes();
expect(routes[0].children).toHaveLength(21); // 5 base (incl. alert_sources redirect) + modelPricing (+ editor) + llmProviders + genAiAgentMapping + 11 enterprise + 1 cloud
expect(routes[0].children).toHaveLength(22); // 5 base (incl. alert_sources redirect) + syntheticsLocations + modelPricing (+ editor) + llmProviders + genAiAgentMapping + 11 enterprise (incl. passwordPolicy) + 1 cloud
});
it("should have all enterprise routes when both are enabled", () => {
@@ -699,68 +699,101 @@ describe("useManagementRoutes", () => {
});
});
describe("passwordPolicy route", () => {
const findRoute = (routes: any) =>
routes[0].children.find((child: any) => child.name === "passwordPolicy");
it("is registered in an enterprise build", () => {
config.isEnterprise = "true";
config.isCloud = "false";
expect(findRoute(useManagementRoutes())).toBeDefined();
});
it("is absent in an OSS build, where the API does not exist", () => {
config.isEnterprise = "false";
config.isCloud = "false";
expect(findRoute(useManagementRoutes())).toBeUndefined();
});
it("is registered in enterprise and cloud builds too", () => {
config.isEnterprise = "true";
config.isCloud = "true";
expect(findRoute(useManagementRoutes())).toBeDefined();
});
it("uses the settings path the shell maps to its section key", () => {
config.isEnterprise = "true";
config.isCloud = "false";
expect(findRoute(useManagementRoutes()).path).toBe("password_policy");
});
});
describe("Edge Cases and Error Handling", () => {
it("should handle isEnterprise as string 'false'", () => {
config.isEnterprise = "false";
config.isCloud = "false";
const routes = useManagementRoutes();
expect(routes[0].children).toHaveLength(6); // syntheticsLocations ships in OSS; model_pricing (+ editor), gen_ai_agent_mapping and llm_providers are enterprise/cloud-only; alert_sources redirect is always present
expect(routes[0].children).toHaveLength(6); // syntheticsLocations ships in OSS; passwordPolicy is enterprise-only; model_pricing (+ editor), gen_ai_agent_mapping and llm_providers are enterprise/cloud-only; alert_sources redirect is always present
});
it("should handle isCloud as string 'false'", () => {
config.isEnterprise = "false";
config.isCloud = "false";
const routes = useManagementRoutes();
expect(routes[0].children).toHaveLength(6); // syntheticsLocations ships in OSS; model_pricing (+ editor), gen_ai_agent_mapping and llm_providers are enterprise/cloud-only; alert_sources redirect is always present
expect(routes[0].children).toHaveLength(6); // syntheticsLocations ships in OSS; passwordPolicy is enterprise-only; model_pricing (+ editor), gen_ai_agent_mapping and llm_providers are enterprise/cloud-only; alert_sources redirect is always present
});
it("should handle isEnterprise as undefined", () => {
(config as any).isEnterprise = undefined;
config.isCloud = "false";
const routes = useManagementRoutes();
expect(routes[0].children).toHaveLength(6); // syntheticsLocations ships in OSS; model_pricing (+ editor), gen_ai_agent_mapping and llm_providers are enterprise/cloud-only; alert_sources redirect is always present
expect(routes[0].children).toHaveLength(6); // syntheticsLocations ships in OSS; passwordPolicy is enterprise-only; model_pricing (+ editor), gen_ai_agent_mapping and llm_providers are enterprise/cloud-only; alert_sources redirect is always present
});
it("should handle isCloud as undefined", () => {
config.isEnterprise = "false";
(config as any).isCloud = undefined;
const routes = useManagementRoutes();
expect(routes[0].children).toHaveLength(6); // syntheticsLocations ships in OSS; model_pricing (+ editor), gen_ai_agent_mapping and llm_providers are enterprise/cloud-only; alert_sources redirect is always present
expect(routes[0].children).toHaveLength(6); // syntheticsLocations ships in OSS; passwordPolicy is enterprise-only; model_pricing (+ editor), gen_ai_agent_mapping and llm_providers are enterprise/cloud-only; alert_sources redirect is always present
});
it("should handle both config values as undefined", () => {
(config as any).isEnterprise = undefined;
(config as any).isCloud = undefined;
const routes = useManagementRoutes();
expect(routes[0].children).toHaveLength(6); // syntheticsLocations ships in OSS; model_pricing (+ editor), gen_ai_agent_mapping and llm_providers are enterprise/cloud-only; alert_sources redirect is always present
expect(routes[0].children).toHaveLength(6); // syntheticsLocations ships in OSS; passwordPolicy is enterprise-only; model_pricing (+ editor), gen_ai_agent_mapping and llm_providers are enterprise/cloud-only; alert_sources redirect is always present
});
it("should handle isEnterprise as non-string truthy value", () => {
(config as any).isEnterprise = true;
config.isCloud = "false";
const routes = useManagementRoutes();
expect(routes[0].children).toHaveLength(6); // syntheticsLocations ships in OSS; model_pricing (+ editor), gen_ai_agent_mapping and llm_providers are enterprise/cloud-only; alert_sources redirect is always present, since config comparison is strict "true"
expect(routes[0].children).toHaveLength(6); // syntheticsLocations ships in OSS; passwordPolicy is enterprise-only; model_pricing (+ editor), gen_ai_agent_mapping and llm_providers are enterprise/cloud-only; alert_sources redirect is always present, since config comparison is strict "true"
});
it("should handle isCloud as non-string truthy value", () => {
config.isEnterprise = "false";
(config as any).isCloud = true;
const routes = useManagementRoutes();
expect(routes[0].children).toHaveLength(6); // syntheticsLocations ships in OSS; model_pricing (+ editor), gen_ai_agent_mapping and llm_providers are enterprise/cloud-only; alert_sources redirect is always present, since config comparison is strict "true"
expect(routes[0].children).toHaveLength(6); // syntheticsLocations ships in OSS; passwordPolicy is enterprise-only; model_pricing (+ editor), gen_ai_agent_mapping and llm_providers are enterprise/cloud-only; alert_sources redirect is always present, since config comparison is strict "true"
});
it("should handle empty string for isEnterprise", () => {
config.isEnterprise = "";
config.isCloud = "false";
const routes = useManagementRoutes();
expect(routes[0].children).toHaveLength(6); // syntheticsLocations ships in OSS; model_pricing (+ editor), gen_ai_agent_mapping and llm_providers are enterprise/cloud-only; alert_sources redirect is always present
expect(routes[0].children).toHaveLength(6); // syntheticsLocations ships in OSS; passwordPolicy is enterprise-only; model_pricing (+ editor), gen_ai_agent_mapping and llm_providers are enterprise/cloud-only; alert_sources redirect is always present
});
it("should handle empty string for isCloud", () => {
config.isEnterprise = "false";
config.isCloud = "";
const routes = useManagementRoutes();
expect(routes[0].children).toHaveLength(6); // syntheticsLocations ships in OSS; model_pricing (+ editor), gen_ai_agent_mapping and llm_providers are enterprise/cloud-only; alert_sources redirect is always present
expect(routes[0].children).toHaveLength(6); // syntheticsLocations ships in OSS; passwordPolicy is enterprise-only; model_pricing (+ editor), gen_ai_agent_mapping and llm_providers are enterprise/cloud-only; alert_sources redirect is always present
});
it("should return the same structure on multiple calls", () => {
@@ -239,6 +239,18 @@ const useManagementRoutes = () => {
routeGuard(to, from, next);
},
},
{
path: "password_policy",
name: "passwordPolicy",
component: () => import("@/components/settings/PasswordPolicy.vue"),
meta: {
keepAlive: true,
titleKey: "routeTitles.passwordPolicy",
},
beforeEnter(to: any, from: any, next: any) {
routeGuard(to, from, next);
},
},
{
path: "correlation/:tab?",
name: "correlationSettings",
@@ -0,0 +1,70 @@
// 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/>.
import { computed, ref } from "vue";
import { useStore } from "vuex";
import { remediationOrg } from "@/composables/usePasswordReset";
import passwordPolicy, { type PasswordComplexity } from "@/services/passwordPolicy";
import { useI18nTyped } from "@/types/i18n";
import { buildPasswordRequirements, DEFAULT_COMPLEXITY } from "@/utils/passwordComplexity";
// Module scope so the last answer stays visible while a reopen refetches.
const complexity = ref<PasswordComplexity | null>(null);
let inFlight: Promise<void> | null = null;
/** `loaded` false must not block submit: the server validates regardless, and a missing hint is recoverable. */
export function usePasswordComplexity() {
const store = useStore();
const { t } = useI18nTyped();
const error = ref(false);
const loading = ref(false);
// Always refetch: the policy can change in Settings within the same session.
const load = async () => {
if (inFlight) return inFlight;
loading.value = true;
error.value = false;
const org = remediationOrg(store);
inFlight = passwordPolicy
.getComplexity(org)
.then((response: any) => {
complexity.value = response.data;
})
.catch(() => {
error.value = true;
})
.finally(() => {
loading.value = false;
inFlight = null;
});
return inFlight;
};
const effective = computed<PasswordComplexity>(() => complexity.value ?? DEFAULT_COMPLEXITY);
const loaded = computed(() => complexity.value !== null);
const requirements = computed(() => buildPasswordRequirements(effective.value, t));
return { complexity: effective, requirements, loaded, loading, error, load };
}
/** Test seam: drops the module-level cache so each case starts from a known state. */
export const resetPasswordComplexityCache = () => {
complexity.value = null;
inFlight = null;
};
@@ -0,0 +1,44 @@
// 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/>.
// sessionStorage rather than a module ref so the sign-in response's count survives a reload.
import { ref } from "vue";
const STORAGE_KEY = "password_rotation_warning";
const readStored = (): number | null => {
const stored = Number(sessionStorage.getItem(STORAGE_KEY));
return stored > 0 ? stored : null;
};
const daysRemaining = ref<number | null>(readStored());
export function usePasswordExpiryWarning() {
/** Record what the sign-in response said; absent means there is nothing to warn about. */
const remember = (days: unknown) => {
const count = Number(days);
if (!(count > 0)) return;
sessionStorage.setItem(STORAGE_KEY, String(count));
daysRemaining.value = count;
};
const dismiss = () => {
sessionStorage.removeItem(STORAGE_KEY);
daysRemaining.value = null;
};
return { daysRemaining, remember, dismiss };
}
@@ -0,0 +1,43 @@
// 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/>.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { usePasswordReset } from "./usePasswordReset";
const { open, close, isOpen } = usePasswordReset();
describe("usePasswordReset", () => {
beforeEach(() => {
vi.clearAllMocks();
close();
});
it("opens once however many rejections arrive", () => {
open("policy_tightened");
open("rotation_expired");
open("rotation_expired");
expect(isOpen.value).toBe(true);
// The first reason wins: six parallel requests must not rewrite the banner under the user.
expect(usePasswordReset().reason.value).toBe("policy_tightened");
});
it("falls back to policy_tightened for an unrecognised reason", () => {
open("something-else");
expect(usePasswordReset().reason.value).toBe("policy_tightened");
});
});
+74
View File
@@ -0,0 +1,74 @@
// 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/>.
// Written by the http interceptor and read by the dialog App.vue mounts, so the state lives in neither.
import { ref } from "vue";
// The two server reasons, plus the advisory one the expiry banner opens the dialog with.
export type PasswordResetReason = "policy_tightened" | "rotation_expired" | "rotation_warning";
type OrgStore = { state?: { selectedOrganization?: { identifier?: string } } };
/** Mirrors the server's DEFAULT_ORG — the one organization guaranteed to exist. */
const DEFAULT_ORG = "default";
/** A blocked user's org list is refused too, so `selectedOrganization` alone would yield `/api/undefined/...`. */
export const remediationOrg = (store: OrgStore | undefined): string =>
store?.state?.selectedOrganization?.identifier || DEFAULT_ORG;
// Module scope, not per-call: every consumer must see the same flag.
const isOpen = ref(false);
const reason = ref<PasswordResetReason | null>(null);
// Only the blocked state is a trap; a user who opened the dialog voluntarily may close it.
const dismissible = ref(false);
const serverReason = (nextReason?: string): PasswordResetReason =>
nextReason === "rotation_expired" || nextReason === "policy_tightened"
? nextReason
: "policy_tightened";
export function usePasswordReset() {
/** Enter the blocked state; the already-open guard stops parallel rejections stacking dialogs. */
const open = (nextReason?: string) => {
if (isOpen.value) return;
reason.value = serverReason(nextReason);
dismissible.value = false;
isOpen.value = true;
};
/** Open the same dialog with a way back out, ahead of expiry. */
const openVoluntarily = (nextReason: PasswordResetReason = "rotation_warning") => {
if (isOpen.value) return;
reason.value = nextReason;
dismissible.value = true;
isOpen.value = true;
};
const close = () => {
isOpen.value = false;
reason.value = null;
dismissible.value = false;
};
return {
isOpen,
reason,
dismissible,
open,
openVoluntarily,
close,
};
}
+3
View File
@@ -45,6 +45,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Operator-authored announcement bars (enterprise) -->
<AnnouncementBanner v-if="config.isEnterprise === 'true'" />
<PasswordExpiryBanner v-if="config.isEnterprise === 'true'" />
</div>
<!-- Header component containing logo, navigation, and user controls -->
@@ -275,6 +276,7 @@ import useSearchWebSocket from "@/composables/useSearchWebSocket";
import O2AIChat from "@/components/O2AIChat.vue";
import WebinarBanner from "@/components/WebinarBanner.vue";
import AnnouncementBanner from "@/components/announcements/AnnouncementBanner.vue";
import PasswordExpiryBanner from "@/components/common/PasswordExpiryBanner.vue";
import useRoutePrefetch from "@/composables/useRoutePrefetch";
import { toast, dismissAll } from "@/lib/feedback/Toast/useToast";
import { purgeOrgQueries, queryClient } from "@/composables/query/queryClient";
@@ -295,6 +297,7 @@ export default defineComponent({
AppHeader,
WebinarBanner,
AnnouncementBanner,
PasswordExpiryBanner,
"keep-alive": KeepAlive,
ONavbar,
"router-view": RouterView,
@@ -0,0 +1,65 @@
// Copyright 2026 OpenObserve Inc.
import { mount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import { raw } from "@/types/i18n";
import { SETTING_ROW_PAIR_KEY } from "./OSettingRow.types";
import OSettingRow from "./OSettingRow.vue";
describe("OSettingRow", () => {
it("renders the label and the description", () => {
const wrapper = mount(OSettingRow, {
props: { label: raw("Minimum length"), description: raw("Shortest password accepted") },
});
expect(wrapper.text()).toContain("Minimum length");
expect(wrapper.text()).toContain("Shortest password accepted");
});
it("omits the description element when there is none", () => {
const wrapper = mount(OSettingRow, { props: { label: raw("Require digit") } });
expect(wrapper.text()).toContain("Require digit");
expect(wrapper.find(".text-text-secondary").exists()).toBe(false);
});
it("renders the control in the default slot", () => {
const wrapper = mount(OSettingRow, {
props: { label: raw("Require digit") },
slots: { default: '<button data-test="control">toggle</button>' },
});
expect(wrapper.find('[data-test="control"]').exists()).toBe(true);
});
it("drops its own rule on the last row, so a card needs no per-row conditionals", () => {
const wrapper = mount(OSettingRow, { props: { label: raw("Require digit") } });
expect(wrapper.classes()).toContain("border-b");
expect(wrapper.classes()).toContain("last:border-b-0");
expect(wrapper.find(".min-w-0").classes()).toContain("w-[22.5%]");
});
it("forwards data-test for addressing the row in page tests", () => {
const wrapper = mount(OSettingRow, {
props: { label: raw("Require digit"), dataTest: "settings-password-policy-require-digit" },
});
expect(wrapper.attributes("data-test")).toBe("settings-password-policy-require-digit");
});
it("leaves rule and padding to the pair when inside one", () => {
const wrapper = mount(OSettingRow, {
props: { label: raw("Require digit") },
global: { provide: { [SETTING_ROW_PAIR_KEY as symbol]: true } },
});
expect(wrapper.classes()).not.toContain("border-b");
expect(wrapper.classes()).not.toContain("py-3");
expect(wrapper.classes()).not.toContain("last:border-b-0");
expect(wrapper.classes()).toContain("flex");
expect(wrapper.find(".min-w-0").classes()).toContain("w-1/2");
});
});
@@ -0,0 +1,23 @@
// Copyright 2026 OpenObserve Inc.
import type { InjectionKey } from "vue";
import type { I18nText } from "@/types/i18n";
export interface SettingRowProps {
/** The setting's name. */
label: I18nText;
/** One line saying what the setting does or what a special value means. */
description?: I18nText;
/** Renders the row muted and is forwarded to nothing — the control owns its own disabled state. */
disabled?: boolean;
dataTest?: string;
}
export interface SettingRowSlots {
/** The control on the right. */
default?: () => unknown;
}
/** Provided by OSettingRowPair; a row inside one leaves the rule and padding to the pair. */
export const SETTING_ROW_PAIR_KEY: InjectionKey<true> = Symbol("SettingRowPair");
@@ -0,0 +1,48 @@
<!-- 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/>.
-->
<!-- The last row drops its own rule, so a card needs no per-row conditionals. -->
<template>
<div
class="flex items-center gap-6"
:class="[
!inPair && 'border-border-default border-b px-3 py-3 last:border-b-0',
disabled && 'opacity-60',
]"
:data-test="dataTest"
>
<!-- Half of a 45% pair cell, so single and paired controls share one column. -->
<div class="min-w-0 shrink-0" :class="inPair ? 'w-1/2' : 'w-[22.5%]'">
<div class="text-text-heading text-sm font-medium">{{ label }}</div>
<div v-if="description" class="text-text-secondary mt-0.5 text-xs">
{{ description }}
</div>
</div>
<div class="flex shrink-0 items-center justify-end">
<slot />
</div>
</div>
</template>
<script setup lang="ts">
import { inject } from "vue";
import { SETTING_ROW_PAIR_KEY, type SettingRowProps } from "./OSettingRow.types";
defineProps<SettingRowProps>();
const inPair = inject(SETTING_ROW_PAIR_KEY, false);
</script>
@@ -0,0 +1,73 @@
// 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/>.
import { mount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import { h } from "vue";
import { raw } from "@/types/i18n";
import OSettingRow from "./OSettingRow.vue";
import OSettingRowPair from "./OSettingRowPair.vue";
// A render-function slot, so the required label prop can be built with raw().
const twoRows = () => [
h(OSettingRow, { label: raw("a"), dataTest: "a" }),
h(OSettingRow, { label: raw("b"), dataTest: "b" }),
];
describe("OSettingRowPair", () => {
it("renders both rows in one grid", () => {
const wrapper = mount(OSettingRowPair, { slots: { default: twoRows } });
expect(wrapper.find('[data-test="a"]').exists()).toBe(true);
expect(wrapper.find('[data-test="b"]').exists()).toBe(true);
const grid = wrapper.find(".grid");
expect(grid.classes()).toContain("grid-cols-[45%_45%]");
expect(grid.classes()).toContain("max-md:grid-cols-1");
});
it("owns the rule so the cells do not draw their own", () => {
const wrapper = mount(OSettingRowPair, { slots: { default: twoRows } });
expect(wrapper.classes()).toContain("border-b");
expect(wrapper.classes()).toContain("last:border-b-0");
for (const row of ["a", "b"]) {
const classes = wrapper.find(`[data-test="${row}"]`).classes();
expect(classes).not.toContain("border-b");
expect(classes).not.toContain("py-3");
}
});
it("renders the footer under both cells only when given", () => {
const bare = mount(OSettingRowPair, { slots: { default: twoRows } });
expect(bare.find(".mt-1").exists()).toBe(false);
const wrapper = mount(OSettingRowPair, {
slots: { default: twoRows, footer: '<p data-test="msg">too small</p>' },
});
expect(wrapper.find('[data-test="msg"]').exists()).toBe(true);
expect(wrapper.find(".grid").find('[data-test="msg"]').exists()).toBe(false);
});
it("forwards data-test", () => {
const wrapper = mount(OSettingRowPair, {
props: { dataTest: "settings-password-policy-pair-length" },
slots: { default: twoRows },
});
expect(wrapper.attributes("data-test")).toBe("settings-password-policy-pair-length");
});
});
@@ -0,0 +1,25 @@
// 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/>.
export interface SettingRowPairProps {
dataTest?: string;
}
export interface SettingRowPairSlots {
/** Exactly two OSettingRow. */
default?: () => unknown;
/** Full-width content under both cells — a cross-field validation message. */
footer?: () => unknown;
}
@@ -0,0 +1,39 @@
<!-- 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/>.
-->
<!-- Owns the rule and the padding so the two cells read as one row, not two half-width rows. -->
<template>
<div class="border-border-default border-b px-3 py-3 last:border-b-0" :data-test="dataTest">
<div class="grid grid-cols-[45%_45%] gap-x-[10%] max-md:grid-cols-1 max-md:gap-y-3">
<slot />
</div>
<div v-if="$slots.footer" class="mt-1">
<slot name="footer" />
</div>
</div>
</template>
<script setup lang="ts">
import { provide } from "vue";
import { SETTING_ROW_PAIR_KEY } from "./OSettingRow.types";
import type { SettingRowPairProps, SettingRowPairSlots } from "./OSettingRowPair.types";
defineProps<SettingRowPairProps>();
defineSlots<SettingRowPairSlots>();
provide(SETTING_ROW_PAIR_KEY, true);
</script>
+1 -1
View File
@@ -3,7 +3,7 @@ import type { I18nText } from "@/types/i18n";
import { computed, useSlots } from "vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
interface Props {
export interface Props {
variant?: "default" | "info" | "success" | "warning" | "error" | "error-soft" | "promo";
content?: I18nText;
icon?: string;
+15
View File
@@ -22,6 +22,8 @@
import { useForm, revalidateLogic } from "@tanstack/vue-form";
import type { DeepKeys } from "@tanstack/form-core";
import type { I18nText } from "@/types/i18n";
/**
* The set of valid field-path strings for a form of shape `T` (what
* setFieldValue/validateField/getFieldMeta accept as their first argument).
@@ -80,3 +82,16 @@ export function useOForm<T extends Record<string, unknown>>(options: UseOFormOpt
/** The form instance returned by useOForm — accepted by <OForm :form>. */
export type OFormInstance = ReturnType<typeof useOForm<any>>;
/** Pin server-reported errors onto fields, or clear them with `{}`; TanStack never re-validates `onServer` on change. */
export function setServerFieldErrors<T extends Record<string, unknown>>(
form: ReturnType<typeof useOForm<T>>,
fields: Partial<Record<FormFieldPath<T>, I18nText>>,
) {
// useOForm declares no server validator, which narrows the slot's type to `undefined`.
form.setErrorMap({
onServer: { form: undefined, fields } as unknown as Parameters<
typeof form.setErrorMap
>[0]["onServer"],
});
}
@@ -132,4 +132,25 @@ describe("OFormInput", () => {
.form;
expect(form.state.values.threshold).toBe(95);
});
it("forwards revealable so a password field gets its show/hide toggle", async () => {
wrapper = mount(OForm, {
props: { defaultValues: { secret: "hunter2" } },
slots: {
default: () =>
h(OFormInput, {
name: "secret",
type: "password",
revealable: true,
"data-test": "secret",
}),
},
global: { components: { OFormInput } },
});
const toggle = wrapper.find('[data-test="secret-reveal"]');
expect(toggle.exists()).toBe(true);
await toggle.trigger("click");
expect(wrapper.find("input").attributes("type")).toBe("text");
});
});
+4
View File
@@ -39,6 +39,10 @@ if (import.meta.env.DEV && !form) {
:maxlength="props.maxlength"
:debounce="props.debounce"
:mask="props.mask"
:prefix="props.prefix"
:suffix="props.suffix"
:revealable="props.revealable"
:autofocus="props.autofocus"
:autocomplete="props.autocomplete"
:name="props.name"
:id="props.id"
+114
View File
@@ -1906,6 +1906,7 @@
"startFreeTrial": "ابدأ التجربة المجانية"
},
"login": {
"lockedOut": "محاولات تسجيل دخول فاشلة كثيرة جدًا. حاول مرة أخرى خلال {duration}، أو تواصل مع المسؤول لديك.",
"redirectingToSsoMessage": "إعادة التوجيه إلى صفحة تسجيل الدخول إلى SSO. إذا لم يتم إعادة توجيهك، يرجى",
"clickHere": "اضغط هنا",
"loginWithSso": "تسجيل الدخول باستخدام SSO",
@@ -1965,6 +1966,15 @@
"fieldRequired": "الحقل مطلوب",
"passwordRequired": "كلمة المرور مطلوبة.",
"passwordPolicyHint": "يجب أن تكون كلمة المرور من 8 إلى 128 حرفًا وتحتوي على حرف صغير واحد على الأقل، وحرف كبير واحد على الأقل، ورقم واحد على الأقل، وحرف خاص واحد على الأقل.",
"lockout": {
"locked": "الحساب مقفل",
"autoUnlock": "يُفتح تلقائيًا خلال {duration}.",
"staged": "سيُفتح عند الحفظ.",
"unlock": "فتح",
"undo": "إبقاء مقفلًا",
"confirmTitle": "فتح {email}؟",
"confirmMessage": "يؤدي هذا إلى مسح عدد محاولاتهم الفاشلة والسماح لهم بتسجيل الدخول مجددًا فورًا. لا يغيّر هذا كلمة مرورهم."
},
"currentPasswordRequired": "كلمة المرور الحالية مطلوبة.",
"newPasswordRequired": "كلمة المرور الجديدة مطلوبة.",
"otherOrgInvalid": "يجب أن يبدأ اسم المنظمة بحرف وأن يكون أبجدي رقمي، أو _ أو -.",
@@ -8810,6 +8820,8 @@
"cipherKeysDesc": "مفاتيح التشفير للحقول الحساسة",
"regexPatternsDesc": "أنماط regex القابلة لإعادة الاستخدام للحذف",
"domainManagementDesc": "قيد تسجيل الدخول على نطاقات SSO المسموح بها",
"passwordPolicy": "سياسة كلمة المرور",
"passwordPolicyDesc": "سياسات المصادقة لحسابات البريد الإلكتروني/كلمة المرور الأصلية، مطبَّقة على مستوى النسخة بالكامل.",
"pipelineDestinationsDesc": "الأهداف الخارجية لمخرجات مسارات المعالجة",
"templatesDesc": "قوالب رسائل تنبيه قابلة لإعادة الاستخدام",
"storageSettingsDesc": "تكوين التخزين لكل منظمة",
@@ -16043,6 +16055,9 @@
},
"layouts": {
"ingestionNotStarted": "لم تبدأ بعد عملية استيراد البيانات. لاستكشاف صفحات أخرى، يرجى بدء استيراد البيانات."
},
"iam": {
"userSaveFailed": "تعذّر حفظ المستخدم"
}
},
"cipherKeys": {
@@ -16460,7 +16475,106 @@
"exponentName": "الموس"
}
},
"passwordPolicy": {
"complexity": "التعقيد",
"minLength": "الحد الأدنى للطول",
"minLengthDesc": "أقصر كلمة مرور مقبولة",
"maxLength": "الحد الأقصى للطول",
"maxLengthDesc": "0 = بلا حد",
"requireUppercase": "اشتراط حرف كبير",
"requireUppercaseDesc": "حرف واحد على الأقل من A–Z",
"requireLowercase": "اشتراط حرف صغير",
"requireLowercaseDesc": "حرف واحد على الأقل من a–z",
"requireDigit": "اشتراط رقم",
"requireDigitDesc": "رقم واحد على الأقل من 0–9",
"requireSpecial": "اشتراط حرف خاص",
"requireSpecialDesc": "حرف واحد على الأقل من المجموعة المسموح بها",
"specialCharSet": "الأحرف الخاصة المسموح بها",
"specialCharSetDesc": "اتركه فارغًا لقبول أي حرف غير أبجدي رقمي",
"maxLengthTooSmall": "يجب أن يكون الحد الأقصى للطول على الأقل مساويًا للحد الأدنى، أو 0 بلا حد.",
"confirmTitle": "حفظ سياسة كلمة المرور؟",
"confirmMessage": "قد يؤدي الحفظ إلى إلزام كل مستخدم أصلي بتعيين كلمة مرور جديدة قبل أن يتمكن من المتابعة. يُستثنى حساب Root والمستخدمون الخارجيون وحسابات الخدمة — وهذا يشملك ما لم تكن حساب Root. لا يمكن التراجع عن هذا الإجراء.",
"saved": "تم حفظ سياسة كلمة المرور. | تم حفظ سياسة كلمة المرور. يجب على {count} مستخدم إعادة تعيين كلمة المرور. | تم حفظ سياسة كلمة المرور. يجب على {count} مستخدم إعادة تعيين كلمة المرور.",
"saveFailed": "تعذّر حفظ سياسة كلمة المرور.",
"notAdminTitle": "مطلوب صلاحية مسؤول",
"notAdmin": "يتطلب تكوين سياسة كلمة المرور صلاحية مسؤول في المؤسسة meta.",
"loadFailedTitle": "تعذّر تحميل سياسة كلمة المرور",
"loadFailed": "حدث خطأ أثناء قراءة السياسة الحالية.",
"rotation": "التدوير",
"rotationDays": "فترة التدوير",
"rotationDaysDesc": "عدد الأيام قبل انتهاء صلاحية كلمة المرور. 0 = لا تنتهي أبدًا",
"rotationWarningDays": "التنبيه قبل الانتهاء",
"rotationWarningDaysDesc": "عدد أيام الإشعار عند تسجيل الدخول. 0 = بلا تنبيه",
"warningDaysTooLong": "لا يمكن أن تكون فترة التنبيه أطول من فترة التدوير.",
"rotationPreview": "تنتهي صلاحية كلمة مرور معيّنة اليوم في {expires}. يبدأ تنبيه تسجيل الدخول في {warns}.",
"rotationPreviewNoWarning": "تنتهي صلاحية كلمة مرور معيّنة اليوم في {expires}. لا يظهر أي تنبيه قبل ذلك.",
"reuse": "منع إعادة الاستخدام",
"historyCount": "كلمات المرور المتذكَّرة",
"historyCountDesc": "لا يجوز أن تطابق كلمة المرور الجديدة هذا العدد من كلمات المرور السابقة. 0 = بلا فحص",
"historyMaxRetained": "السجل المحفوظ",
"historyMaxRetainedDesc": "عدد كلمات المرور السابقة المحفوظة لكل مستخدم",
"historyRetainedTooSmall": "يجب أن يكون السجل المحفوظ على الأقل مساويًا لعدد كلمات المرور المتذكَّرة.",
"reuseExplainer": "تُحتسب كلمة المرور الحالية ضمن المجموعة المتذكَّرة — رفض إعادة استخدامها يتم بواسطة هذه القاعدة، وليس بفحص منفصل.",
"lockout": "قفل الحساب",
"lockoutThreshold": "المحاولات الفاشلة قبل القفل",
"lockoutThresholdDesc": "0 = القفل معطّل",
"lockoutBucketSize": "المحاولات قبل كل قفل لاحق",
"lockoutBucketSizeDesc": "0 = إعادة استخدام القيمة أعلاه",
"lockoutStartSecs": "مدة القفل الأول",
"lockoutStartSecsDesc": "ثوانٍ",
"lockoutMaxSecs": "الحد الأقصى لمدة القفل",
"lockoutMaxSecsDesc": "ثوانٍ — الحد الأقصى الذي يتوقف عنده تصعيد المدة",
"lockoutStartTooLong": "لا يمكن أن تكون مدة القفل الأول أطول من الحد الأقصى للقفل.",
"lockoutBackoff": "التصعيد",
"lockoutBackoffDesc": "كيف تتزايد مدة كل قفل عن القفل السابق",
"backoffExponential": "أُسّي",
"backoffLinear": "خطي",
"lockoutPreview": "{threshold} محاولة فاشلة → {first} · كل {bucket} محاولة إضافية → {rest} · بحد أقصى {max}. تؤدي عملية تسجيل الدخول الناجحة إلى إعادة تعيين كل شيء.",
"sessionEnforcement": "الجلسة والإنفاذ",
"cookieMaxAge": "عمر الجلسة",
"cookieMaxAgeDesc": "عدد الثواني التي تظل فيها الجلسة المسجّلة صالحة. 0 = استخدام القيمة الافتراضية للمثيل من {envVar}",
"applyToRoot": "تطبيق على حساب الجذر",
"applyToRootDesc": "حساب الجذر معفى من كل قاعدة أعلاه ما لم يتم تشغيل هذا الخيار.",
"rootWarning": "قد يؤدي قفل حساب الجذر إلى دخول النظام في حالة يتعذر استردادها.",
"rootDialogTitle": "تطبيق السياسة على حساب الجذر؟",
"rootDialogMessage": "سيخضع حساب الجذر للقفل والتدوير وإعادة التعيين الإجبارية مثل أي حساب محلي آخر. إذا كان حساب الجذر هو حسابك الإداري الوحيد، فإن قفله أو انتهاء صلاحيته دون إمكانية تلبيتها لن يترك أي طريقة للعودة إلى هذا المثيل.",
"rootDialogAck": "أفهم أنه يمكن قفل حساب الجذر من هذا المثيل بشكل دائم.",
"rootDialogConfirm": "تطبيق على الجذر"
},
"passwordReset": {
"title": "تحديث كلمة المرور",
"signedInAs": "مسجّل الدخول باسم {email}",
"reasonPolicyTightened": "تم تغيير سياسة كلمة المرور. حدّث كلمة المرور لمتابعة استخدام OpenObserve.",
"reasonRotationExpired": "انتهت صلاحية كلمة المرور. عيّن كلمة مرور جديدة لمتابعة استخدام OpenObserve.",
"reasonRotationWarning": "تنتهي صلاحية كلمة المرور قريبًا. عيّن كلمة مرور جديدة الآن لتجنب فقدان الوصول إلى عملك.",
"reused": "تم استخدام كلمة المرور هذه مؤخرًا. اختر كلمة مرور لم تستخدمها في آخر {count} كلمات مرور.",
"expiryBanner": "تنتهي صلاحية كلمة المرور خلال {count} يوم. حدّثها الآن لتجنب فقدان الوصول إلى عملك. | تنتهي صلاحية كلمة المرور خلال {count} أيام. حدّثها الآن لتجنب فقدان الوصول إلى عملك.",
"expiryBannerAction": "حدّث الآن",
"expiryBannerDismiss": "تجاهل",
"currentPassword": "كلمة المرور الحالية",
"newPassword": "كلمة المرور الجديدة",
"confirmPassword": "تأكيد كلمة المرور الجديدة",
"currentPasswordRequired": "كلمة المرور الحالية مطلوبة.",
"newPasswordRequired": "كلمة المرور الجديدة مطلوبة.",
"confirmPasswordRequired": "يرجى إعادة إدخال كلمة المرور الجديدة.",
"mismatch": "كلمتا المرور غير متطابقتين.",
"signOut": "تسجيل الخروج",
"submit": "تحديث كلمة المرور",
"updated": "تم تحديث كلمة المرور. يرجى تسجيل الدخول مرة أخرى.",
"updateFailed": "تعذّر تحديث كلمة المرور.",
"req": {
"minLength": "{count} أحرف على الأقل",
"maxLength": "{count} حرفًا كحد أقصى",
"uppercase": "حرف كبير واحد",
"lowercase": "حرف صغير واحد",
"digit": "رقم واحد",
"special": "حرف خاص واحد",
"specialFromSet": "حرف خاص واحد من {set}",
"unmet": "يجب أن تحتوي كلمة المرور على: {requirement}"
}
},
"routeTitles": {
"passwordPolicy": "سياسة كلمة المرور",
"loginCallback": "إعادة الاتصال بتسجيل الدخول",
"slackOAuthCallback": "جارٍ ربط Slack",
"emptyStateDemo": "عرض الحالة الفارغة",
+114
View File
@@ -1906,6 +1906,7 @@
"startFreeTrial": "Kostenlose Testversion starten"
},
"login": {
"lockedOut": "Zu viele fehlgeschlagene Anmeldeversuche. Versuchen Sie es in {duration} erneut, oder wenden Sie sich an Ihren Administrator.",
"redirectingToSsoMessage": "Weiterleitung zur SSO-Anmeldeseite. Wenn Sie nicht weitergeleitet werden, bitte",
"clickHere": "klicken Sie hier",
"loginWithSso": "Mit SSO anmelden",
@@ -1965,6 +1966,15 @@
"fieldRequired": "Feld ist erforderlich",
"passwordRequired": "Passwort ist erforderlich.",
"passwordPolicyHint": "Das Passwort muss aus 8 bis 128 Zeichen bestehen und mindestens einen Kleinbuchstaben, einen Großbuchstaben, eine Ziffer und ein Sonderzeichen enthalten.",
"lockout": {
"locked": "Konto gesperrt",
"autoUnlock": "Wird automatisch in {duration} entsperrt.",
"staged": "Wird beim Speichern entsperrt.",
"unlock": "Entsperren",
"undo": "Gesperrt lassen",
"confirmTitle": "{email} entsperren?",
"confirmMessage": "Dies setzt den Zähler der fehlgeschlagenen Versuche zurück und ermöglicht die sofortige erneute Anmeldung. Das Passwort wird dabei nicht geändert."
},
"currentPasswordRequired": "Das aktuelle Passwort ist erforderlich.",
"newPasswordRequired": "Ein neues Passwort ist erforderlich.",
"otherOrgInvalid": "Der Name der Organisation muss mit einem Buchstaben beginnen und alphanumerisch, _ oder -, sein.",
@@ -8810,6 +8820,8 @@
"cipherKeysDesc": "Verschlüsselungsschlüssel für sensible Felder",
"regexPatternsDesc": "Wiederverwendbare regex-Muster zur Schwärzung",
"domainManagementDesc": "Anmeldung auf zulässige SSO-Domänen beschränken",
"passwordPolicy": "Passwortrichtlinie",
"passwordPolicyDesc": "Authentifizierungsrichtlinien für native E-Mail-/Passwort-Konten, instanzweit angewendet.",
"pipelineDestinationsDesc": "Externe Ziele für die Pipeline-Ausgabe",
"templatesDesc": "Wiederverwendbare Vorlagen für Alarmmeldungen",
"storageSettingsDesc": "Speicherkonfiguration pro Organisation",
@@ -16043,6 +16055,9 @@
},
"layouts": {
"ingestionNotStarted": "Sie haben den Datenaufnahmeprozess noch nicht gestartet. Um andere Seiten zu erkunden, starten Sie bitte die Datenaufnahme."
},
"iam": {
"userSaveFailed": "Der Benutzer konnte nicht gespeichert werden"
}
},
"cipherKeys": {
@@ -16460,7 +16475,106 @@
"exponentName": "Exponent"
}
},
"passwordPolicy": {
"complexity": "Komplexität",
"minLength": "Minimale Länge",
"minLengthDesc": "Kürzestes akzeptiertes Passwort",
"maxLength": "Maximale Länge",
"maxLengthDesc": "0 = unbegrenzt",
"requireUppercase": "Großbuchstabe erforderlich",
"requireUppercaseDesc": "Mindestens ein A–Z",
"requireLowercase": "Kleinbuchstabe erforderlich",
"requireLowercaseDesc": "Mindestens ein a–z",
"requireDigit": "Ziffer erforderlich",
"requireDigitDesc": "Mindestens eine 0–9",
"requireSpecial": "Sonderzeichen erforderlich",
"requireSpecialDesc": "Mindestens eines aus der erlaubten Menge",
"specialCharSet": "Erlaubte Sonderzeichen",
"specialCharSetDesc": "Leer lassen, um beliebige nicht-alphanumerische Zeichen zu akzeptieren",
"maxLengthTooSmall": "Die maximale Länge muss mindestens der minimalen Länge entsprechen, oder 0 für unbegrenzt.",
"confirmTitle": "Passwortrichtlinie speichern?",
"confirmMessage": "Beim Speichern müssen möglicherweise alle nativen Benutzer ein neues Passwort festlegen, bevor sie fortfahren können. Root, externe Benutzer und Dienstkonten sind ausgenommen — das gilt auch für Sie, es sei denn, Sie sind das Root-Konto. Dies kann nicht rückgängig gemacht werden.",
"saved": "Passwortrichtlinie gespeichert. | Passwortrichtlinie gespeichert. {count} Benutzer muss sein Passwort zurücksetzen. | Passwortrichtlinie gespeichert. {count} Benutzer müssen ihr Passwort zurücksetzen.",
"saveFailed": "Die Passwortrichtlinie konnte nicht gespeichert werden.",
"notAdminTitle": "Administratorzugriff erforderlich",
"notAdmin": "Die Konfiguration der Passwortrichtlinie erfordert einen Administrator der Meta-Organisation.",
"loadFailedTitle": "Die Passwortrichtlinie konnte nicht geladen werden",
"loadFailed": "Beim Lesen der aktuellen Richtlinie ist ein Fehler aufgetreten.",
"rotation": "Rotation",
"rotationDays": "Rotationszeitraum",
"rotationDaysDesc": "Tage, bevor ein Passwort abläuft. 0 = läuft nie ab",
"rotationWarningDays": "Vor Ablauf warnen",
"rotationWarningDaysDesc": "Tage Vorlaufzeit bei der Anmeldung. 0 = keine Warnung",
"warningDaysTooLong": "Der Warnzeitraum darf nicht länger als der Rotationszeitraum sein.",
"rotationPreview": "Ein heute festgelegtes Passwort läuft am {expires} ab. Die Anmeldewarnung beginnt am {warns}.",
"rotationPreviewNoWarning": "Ein heute festgelegtes Passwort läuft am {expires} ab. Vorher wird keine Warnung angezeigt.",
"reuse": "Wiederverwendung verhindern",
"historyCount": "Gemerkte Passwörter",
"historyCountDesc": "Ein neues Passwort darf nicht mit so vielen vorherigen übereinstimmen. 0 = keine Prüfung",
"historyMaxRetained": "Verlauf beibehalten",
"historyMaxRetainedDesc": "Wie viele frühere Passwörter pro Benutzer gespeichert werden",
"historyRetainedTooSmall": "Der beibehaltene Verlauf muss mindestens der Anzahl der gemerkten Passwörter entsprechen.",
"reuseExplainer": "Das aktuelle Passwort zählt zur gemerkten Menge — seine Wiederverwendung wird durch diese Regel abgelehnt, nicht durch eine separate Prüfung.",
"lockout": "Kontosperrung",
"lockoutThreshold": "Fehlgeschlagene Versuche vor der Sperrung",
"lockoutThresholdDesc": "0 = Sperrung deaktiviert",
"lockoutBucketSize": "Versuche vor jeder weiteren Sperrung",
"lockoutBucketSizeDesc": "0 = Wert oben wiederverwenden",
"lockoutStartSecs": "Dauer der ersten Sperrung",
"lockoutStartSecsDesc": "Sekunden",
"lockoutMaxSecs": "Maximale Sperrdauer",
"lockoutMaxSecsDesc": "Sekunden — die Obergrenze, auf die die Eskalation begrenzt wird",
"lockoutStartTooLong": "Die erste Sperrung darf nicht länger als die maximale Sperrung sein.",
"lockoutBackoff": "Eskalation",
"lockoutBackoffDesc": "Wie jede Sperrung gegenüber der vorherigen anwächst",
"backoffExponential": "Exponentiell",
"backoffLinear": "Linear",
"lockoutPreview": "{threshold} Fehlversuche → {first} · alle {bucket} weiteren → {rest} · begrenzt auf {max}. Eine erfolgreiche Anmeldung setzt alles zurück.",
"sessionEnforcement": "Sitzung & Durchsetzung",
"cookieMaxAge": "Sitzungsdauer",
"cookieMaxAgeDesc": "Sekunden, die eine angemeldete Sitzung gültig bleibt. 0 = Instanz-Standard aus {envVar} verwenden",
"applyToRoot": "Auf das Root-Konto anwenden",
"applyToRootDesc": "Root ist von allen oben genannten Regeln ausgenommen, sofern dies nicht aktiviert ist",
"rootWarning": "Eine Sperrung von Root kann das System in einen nicht wiederherstellbaren Zustand versetzen.",
"rootDialogTitle": "Richtlinie auf Root anwenden?",
"rootDialogMessage": "Root unterliegt dann wie jedes andere native Konto Sperrungen, Rotation und erzwungenen Zurücksetzungen. Wenn Root Ihr einziges Administratorkonto ist, kann eine Sperrung oder ein Ablauf, den es nicht erfüllen kann, jeden Zugang zu dieser Instanz versperren.",
"rootDialogAck": "Mir ist bewusst, dass Root dauerhaft aus dieser Instanz ausgesperrt werden kann.",
"rootDialogConfirm": "Auf Root anwenden"
},
"passwordReset": {
"title": "Aktualisieren Sie Ihr Passwort",
"signedInAs": "Angemeldet als {email}",
"reasonPolicyTightened": "Die Passwortrichtlinie wurde geändert. Aktualisieren Sie Ihr Passwort, um OpenObserve weiter zu verwenden.",
"reasonRotationExpired": "Ihr Passwort ist abgelaufen. Legen Sie ein neues fest, um OpenObserve weiter zu verwenden.",
"reasonRotationWarning": "Ihr Passwort läuft bald ab. Legen Sie jetzt ein neues fest, um nicht von Ihrer Arbeit ausgesperrt zu werden.",
"reused": "Dieses Passwort wurde kürzlich verwendet. Wählen Sie eines, das Sie nicht unter Ihren letzten {count} Passwörtern verwendet haben.",
"expiryBanner": "Ihr Passwort läuft in {count} Tag ab. Aktualisieren Sie es jetzt, um nicht von Ihrer Arbeit ausgesperrt zu werden. | Ihr Passwort läuft in {count} Tagen ab. Aktualisieren Sie es jetzt, um nicht von Ihrer Arbeit ausgesperrt zu werden.",
"expiryBannerAction": "Jetzt aktualisieren",
"expiryBannerDismiss": "Ausblenden",
"currentPassword": "Aktuelles Passwort",
"newPassword": "Neues Passwort",
"confirmPassword": "Neues Passwort bestätigen",
"currentPasswordRequired": "Aktuelles Passwort ist erforderlich.",
"newPasswordRequired": "Neues Passwort ist erforderlich.",
"confirmPasswordRequired": "Bitte geben Sie das neue Passwort erneut ein.",
"mismatch": "Passwörter stimmen nicht überein.",
"signOut": "Abmelden",
"submit": "Passwort aktualisieren",
"updated": "Ihr Passwort wurde aktualisiert. Bitte melden Sie sich erneut an.",
"updateFailed": "Ihr Passwort konnte nicht aktualisiert werden.",
"req": {
"minLength": "Mindestens {count} Zeichen",
"maxLength": "Höchstens {count} Zeichen",
"uppercase": "Ein Großbuchstabe",
"lowercase": "Ein Kleinbuchstabe",
"digit": "Eine Ziffer",
"special": "Ein Sonderzeichen",
"specialFromSet": "Ein Sonderzeichen aus {set}",
"unmet": "Das Passwort muss enthalten: {requirement}"
}
},
"routeTitles": {
"passwordPolicy": "Passwortrichtlinie",
"loginCallback": "Anmelde-Rückruf",
"slackOAuthCallback": "Slack wird verbunden",
"emptyStateDemo": "Demo für leeren Zustand",
+114
View File
@@ -1906,6 +1906,7 @@
"startFreeTrial": "Start free trial"
},
"login": {
"lockedOut": "Too many failed sign-in attempts. Try again in {duration}, or contact your administrator.",
"redirectingToSsoMessage": "Redirecting to SSO login page. If you are not redirected, please",
"clickHere": "click here",
"loginWithSso": "Login with SSO",
@@ -1965,6 +1966,15 @@
"fieldRequired": "Field is required",
"passwordRequired": "Password is required.",
"passwordPolicyHint": "Password must be 8-128 characters and contain at least one lowercase letter, one uppercase letter, one digit, and one special character.",
"lockout": {
"locked": "Account locked",
"autoUnlock": "Unlocks automatically in {duration}.",
"staged": "Will be unlocked when you save.",
"unlock": "Unlock",
"undo": "Keep locked",
"confirmTitle": "Unlock {email}?",
"confirmMessage": "This clears their failed attempt count and lets them sign in again immediately. It does not change their password."
},
"currentPasswordRequired": "Current password is required.",
"newPasswordRequired": "New password is required.",
"otherOrgInvalid": "Organization name must start with a letter and be alphanumeric, _ or -.",
@@ -8810,6 +8820,8 @@
"cipherKeysDesc": "Encryption keys for sensitive fields",
"regexPatternsDesc": "Reusable regex patterns for redaction",
"domainManagementDesc": "Restrict sign-in to allowed SSO domains",
"passwordPolicy": "Password Policy",
"passwordPolicyDesc": "Authentication policies for native email/password accounts, applied instance-wide.",
"pipelineDestinationsDesc": "External targets for pipeline output",
"templatesDesc": "Reusable alert message templates",
"storageSettingsDesc": "Per-organization storage configuration",
@@ -16043,6 +16055,9 @@
},
"layouts": {
"ingestionNotStarted": "You haven't initiated the data ingestion process yet. To explore other pages, please start the data ingestion."
},
"iam": {
"userSaveFailed": "Could not save the user"
}
},
"cipherKeys": {
@@ -16460,7 +16475,106 @@
"exponentName": "Exponent"
}
},
"passwordPolicy": {
"complexity": "Complexity",
"minLength": "Minimum length",
"minLengthDesc": "Shortest password accepted",
"maxLength": "Maximum length",
"maxLengthDesc": "0 = unbounded",
"requireUppercase": "Require uppercase letter",
"requireUppercaseDesc": "At least one A–Z",
"requireLowercase": "Require lowercase letter",
"requireLowercaseDesc": "At least one a–z",
"requireDigit": "Require digit",
"requireDigitDesc": "At least one 0–9",
"requireSpecial": "Require special character",
"requireSpecialDesc": "At least one from the allowed set",
"specialCharSet": "Allowed special characters",
"specialCharSetDesc": "Leave empty to accept any non-alphanumeric character",
"maxLengthTooSmall": "Maximum length must be at least the minimum length, or 0 for unbounded.",
"confirmTitle": "Save password policy?",
"confirmMessage": "Saving may force every native user to set a new password before they can continue. Root, external users and service accounts are exempt — this includes you unless you are the root account. It cannot be undone.",
"saved": "Password policy saved. | Password policy saved. {count} user must reset their password. | Password policy saved. {count} users must reset their password.",
"saveFailed": "Could not save the password policy.",
"notAdminTitle": "Administrator access required",
"notAdmin": "Configuring the password policy requires an administrator of the meta organization.",
"loadFailedTitle": "Could not load the password policy",
"loadFailed": "Something went wrong while reading the current policy.",
"rotation": "Rotation",
"rotationDays": "Rotation period",
"rotationDaysDesc": "Days before a password expires. 0 = never expires",
"rotationWarningDays": "Warn before expiry",
"rotationWarningDaysDesc": "Days of notice at sign-in. 0 = no warning",
"warningDaysTooLong": "The warning window cannot be longer than the rotation period.",
"rotationPreview": "A password set today expires {expires}. The sign-in warning starts {warns}.",
"rotationPreviewNoWarning": "A password set today expires {expires}. No warning is shown before then.",
"reuse": "Reuse prevention",
"historyCount": "Passwords remembered",
"historyCountDesc": "A new password may not match this many previous ones. 0 = no check",
"historyMaxRetained": "History retained",
"historyMaxRetainedDesc": "How many past passwords are kept per user",
"historyRetainedTooSmall": "History retained must be at least the number of passwords remembered.",
"reuseExplainer": "The current password counts as one of the remembered set — reusing it is refused by this rule, not by a separate check.",
"lockout": "Account lockout",
"lockoutThreshold": "Failed attempts before lockout",
"lockoutThresholdDesc": "0 = lockout disabled",
"lockoutBucketSize": "Attempts before each further lockout",
"lockoutBucketSizeDesc": "0 = reuse the value above",
"lockoutStartSecs": "First lockout duration",
"lockoutStartSecsDesc": "Seconds",
"lockoutMaxSecs": "Maximum lockout duration",
"lockoutMaxSecsDesc": "Seconds — the ceiling escalation clamps to",
"lockoutStartTooLong": "The first lockout cannot be longer than the maximum lockout.",
"lockoutBackoff": "Escalation",
"lockoutBackoffDesc": "How each lockout grows from the one before",
"backoffExponential": "Exponential",
"backoffLinear": "Linear",
"lockoutPreview": "{threshold} failures → {first} · every {bucket} more → {rest} · capped at {max}. A successful sign-in resets everything.",
"sessionEnforcement": "Session & enforcement",
"cookieMaxAge": "Session lifetime",
"cookieMaxAgeDesc": "Seconds a signed-in session stays valid. 0 = use the instance default from {envVar}",
"applyToRoot": "Apply to the root account",
"applyToRootDesc": "Root is exempt from every rule above unless this is on",
"rootWarning": "Locking root might cause the system to go into an unrecoverable state.",
"rootDialogTitle": "Apply the policy to root?",
"rootDialogMessage": "Root will be subject to lockout, rotation and forced resets like any other native account. If root is your only administrative account, a lockout or an expiry it cannot satisfy will leave no way back into this instance.",
"rootDialogAck": "I understand root can be locked out of this instance permanently.",
"rootDialogConfirm": "Apply to root"
},
"passwordReset": {
"title": "Update your password",
"signedInAs": "Signed in as {email}",
"reasonPolicyTightened": "The password policy has changed. Update your password to continue using OpenObserve.",
"reasonRotationExpired": "Your password has expired. Set a new one to continue using OpenObserve.",
"reasonRotationWarning": "Your password expires soon. Set a new one now to avoid being locked out of your work.",
"reused": "This password was used recently. Choose one you have not used in your last {count} passwords.",
"expiryBanner": "Your password expires in {count} day. Update it now to avoid being locked out of your work. | Your password expires in {count} days. Update it now to avoid being locked out of your work.",
"expiryBannerAction": "Update now",
"expiryBannerDismiss": "Dismiss",
"currentPassword": "Current password",
"newPassword": "New password",
"confirmPassword": "Confirm new password",
"currentPasswordRequired": "Current password is required.",
"newPasswordRequired": "New password is required.",
"confirmPasswordRequired": "Please re-enter the new password.",
"mismatch": "Passwords do not match.",
"signOut": "Sign out",
"submit": "Update password",
"updated": "Your password has been updated. Please sign in again.",
"updateFailed": "Could not update your password.",
"req": {
"minLength": "At least {count} characters",
"maxLength": "No more than {count} characters",
"uppercase": "One uppercase letter",
"lowercase": "One lowercase letter",
"digit": "One digit",
"special": "One special character",
"specialFromSet": "One special character from {set}",
"unmet": "Password must have: {requirement}"
}
},
"routeTitles": {
"passwordPolicy": "Password Policy",
"loginCallback": "Login Callback",
"slackOAuthCallback": "Connecting Slack",
"emptyStateDemo": "Empty State Demo",
+114
View File
@@ -1906,6 +1906,7 @@
"startFreeTrial": "Iniciar prueba gratuita"
},
"login": {
"lockedOut": "Demasiados intentos fallidos de inicio de sesión. Vuelva a intentarlo en {duration} o póngase en contacto con su administrador.",
"redirectingToSsoMessage": "Redirigiendo a la página de inicio de sesión SSO. Si no eres redirigido, por favor",
"clickHere": "haz clic aquí",
"loginWithSso": "Iniciar sesión con SSO",
@@ -1965,6 +1966,15 @@
"fieldRequired": "El campo es obligatorio",
"passwordRequired": "La contraseña es obligatoria.",
"passwordPolicyHint": "La contraseña debe tener entre 8 y 128 caracteres y contener al menos una letra minúscula, una mayúscula, un dígito y un carácter especial.",
"lockout": {
"locked": "Cuenta bloqueada",
"autoUnlock": "Se desbloqueará automáticamente en {duration}.",
"staged": "Se desbloqueará al guardar.",
"unlock": "Desbloquear",
"undo": "Mantener bloqueada",
"confirmTitle": "¿Desbloquear {email}?",
"confirmMessage": "Esto restablece su recuento de intentos fallidos y le permite iniciar sesión de nuevo inmediatamente. No cambia su contraseña."
},
"currentPasswordRequired": "Se requiere la contraseña actual.",
"newPasswordRequired": "Se requiere una nueva contraseña.",
"otherOrgInvalid": "El nombre de la organización debe empezar por una letra y ser alfanumérico, _ o -.",
@@ -8810,6 +8820,8 @@
"cipherKeysDesc": "Claves de cifrado para campos sensibles",
"regexPatternsDesc": "Patrones regex reutilizables para redacción",
"domainManagementDesc": "Restringe el inicio de sesión a los dominios SSO permitidos",
"passwordPolicy": "Política de contraseñas",
"passwordPolicyDesc": "Políticas de autenticación para cuentas nativas de correo electrónico/contraseña, aplicadas a toda la instancia.",
"pipelineDestinationsDesc": "Destinos externos para la salida del pipeline",
"templatesDesc": "Plantillas de mensajes de alerta reutilizables",
"storageSettingsDesc": "Configuración de almacenamiento por organización",
@@ -16043,6 +16055,9 @@
},
"layouts": {
"ingestionNotStarted": "Aún no ha iniciado el proceso de ingesta de datos. Para explorar otras páginas, inicie la ingesta de datos."
},
"iam": {
"userSaveFailed": "No se pudo guardar el usuario"
}
},
"cipherKeys": {
@@ -16460,7 +16475,106 @@
"exponentName": "Exponente"
}
},
"passwordPolicy": {
"complexity": "Complejidad",
"minLength": "Longitud mínima",
"minLengthDesc": "La contraseña más corta aceptada",
"maxLength": "Longitud máxima",
"maxLengthDesc": "0 = sin límite",
"requireUppercase": "Requerir letra mayúscula",
"requireUppercaseDesc": "Al menos una A–Z",
"requireLowercase": "Requerir letra minúscula",
"requireLowercaseDesc": "Al menos una a–z",
"requireDigit": "Requerir dígito",
"requireDigitDesc": "Al menos un 0–9",
"requireSpecial": "Requerir carácter especial",
"requireSpecialDesc": "Al menos uno del conjunto permitido",
"specialCharSet": "Caracteres especiales permitidos",
"specialCharSetDesc": "Déjelo vacío para aceptar cualquier carácter no alfanumérico",
"maxLengthTooSmall": "La longitud máxima debe ser al menos la longitud mínima, o 0 para sin límite.",
"confirmTitle": "¿Guardar la política de contraseñas?",
"confirmMessage": "Guardar puede obligar a todos los usuarios nativos a establecer una nueva contraseña antes de poder continuar. El usuario root, los usuarios externos y las cuentas de servicio están exentos; esto le incluye a usted a menos que sea la cuenta root. No se puede deshacer.",
"saved": "Política de contraseñas guardada. | Política de contraseñas guardada. {count} usuario debe restablecer su contraseña. | Política de contraseñas guardada. {count} usuarios deben restablecer su contraseña.",
"saveFailed": "No se pudo guardar la política de contraseñas.",
"notAdminTitle": "Se requiere acceso de administrador",
"notAdmin": "Configurar la política de contraseñas requiere un administrador de la organización meta.",
"loadFailedTitle": "No se pudo cargar la política de contraseñas",
"loadFailed": "Algo salió mal al leer la política actual.",
"rotation": "Rotación",
"rotationDays": "Periodo de rotación",
"rotationDaysDesc": "Días antes de que caduque una contraseña. 0 = nunca caduca",
"rotationWarningDays": "Avisar antes de la caducidad",
"rotationWarningDaysDesc": "Días de aviso al iniciar sesión. 0 = sin aviso",
"warningDaysTooLong": "El periodo de aviso no puede ser más largo que el periodo de rotación.",
"rotationPreview": "Una contraseña establecida hoy caduca el {expires}. El aviso de inicio de sesión comienza el {warns}.",
"rotationPreviewNoWarning": "Una contraseña establecida hoy caduca el {expires}. No se muestra ningún aviso antes de esa fecha.",
"reuse": "Prevención de reutilización",
"historyCount": "Contraseñas recordadas",
"historyCountDesc": "Una nueva contraseña no puede coincidir con esta cantidad de contraseñas anteriores. 0 = sin comprobación",
"historyMaxRetained": "Historial conservado",
"historyMaxRetainedDesc": "Cuántas contraseñas anteriores se conservan por usuario",
"historyRetainedTooSmall": "El historial conservado debe ser al menos el número de contraseñas recordadas.",
"reuseExplainer": "La contraseña actual cuenta como una de las del conjunto recordado; esta regla rechaza reutilizarla, no una comprobación aparte.",
"lockout": "Bloqueo de cuenta",
"lockoutThreshold": "Intentos fallidos antes del bloqueo",
"lockoutThresholdDesc": "0 = bloqueo deshabilitado",
"lockoutBucketSize": "Intentos antes de cada bloqueo adicional",
"lockoutBucketSizeDesc": "0 = reutilizar el valor anterior",
"lockoutStartSecs": "Duración del primer bloqueo",
"lockoutStartSecsDesc": "Segundos",
"lockoutMaxSecs": "Duración máxima del bloqueo",
"lockoutMaxSecsDesc": "Segundos: el tope al que se limita el escalado",
"lockoutStartTooLong": "El primer bloqueo no puede ser más largo que el bloqueo máximo.",
"lockoutBackoff": "Escalado",
"lockoutBackoffDesc": "Cómo crece cada bloqueo respecto al anterior",
"backoffExponential": "Exponencial",
"backoffLinear": "Lineal",
"lockoutPreview": "{threshold} fallos → {first} · cada {bucket} más → {rest} · con tope de {max}. Un inicio de sesión exitoso restablece todo.",
"sessionEnforcement": "Sesión y aplicación",
"cookieMaxAge": "Duración de la sesión",
"cookieMaxAgeDesc": "Segundos que una sesión iniciada permanece válida. 0 = usar el valor predeterminado de la instancia especificado en {envVar}",
"applyToRoot": "Aplicar a la cuenta root",
"applyToRootDesc": "Root está exento de todas las reglas anteriores a menos que esto esté activado",
"rootWarning": "Bloquear root podría hacer que el sistema entre en un estado irrecuperable.",
"rootDialogTitle": "¿Aplicar la política a root?",
"rootDialogMessage": "Root quedará sujeto a bloqueo, rotación y restablecimientos forzados como cualquier otra cuenta nativa. Si root es tu única cuenta administrativa, un bloqueo o una expiración que no pueda cumplir no dejará forma de volver a entrar en esta instancia.",
"rootDialogAck": "Entiendo que root puede quedar bloqueado permanentemente sin acceso a esta instancia.",
"rootDialogConfirm": "Aplicar a root"
},
"passwordReset": {
"title": "Actualiza tu contraseña",
"signedInAs": "Sesión iniciada como {email}",
"reasonPolicyTightened": "La política de contraseñas ha cambiado. Actualiza tu contraseña para seguir usando OpenObserve.",
"reasonRotationExpired": "Tu contraseña ha expirado. Establece una nueva para seguir usando OpenObserve.",
"reasonRotationWarning": "Tu contraseña expira pronto. Establece una nueva ahora para evitar que te bloqueen el acceso a tu trabajo.",
"reused": "Esta contraseña se usó recientemente. Elige una que no hayas usado en tus últimas {count} contraseñas.",
"expiryBanner": "Tu contraseña expira en {count} día. Actualízala ahora para evitar que te bloqueen el acceso a tu trabajo. | Tu contraseña expira en {count} días. Actualízala ahora para evitar que te bloqueen el acceso a tu trabajo.",
"expiryBannerAction": "Actualizar ahora",
"expiryBannerDismiss": "Descartar",
"currentPassword": "Contraseña actual",
"newPassword": "Nueva contraseña",
"confirmPassword": "Confirmar nueva contraseña",
"currentPasswordRequired": "La contraseña actual es obligatoria.",
"newPasswordRequired": "La nueva contraseña es obligatoria.",
"confirmPasswordRequired": "Vuelve a introducir la nueva contraseña.",
"mismatch": "Las contraseñas no coinciden.",
"signOut": "Cerrar sesión",
"submit": "Actualizar contraseña",
"updated": "Tu contraseña se ha actualizado. Inicia sesión de nuevo.",
"updateFailed": "No se pudo actualizar tu contraseña.",
"req": {
"minLength": "Al menos {count} caracteres",
"maxLength": "Como máximo {count} caracteres",
"uppercase": "Una letra mayúscula",
"lowercase": "Una letra minúscula",
"digit": "Un dígito",
"special": "Un carácter especial",
"specialFromSet": "Un carácter especial de {set}",
"unmet": "La contraseña debe tener: {requirement}"
}
},
"routeTitles": {
"passwordPolicy": "Política de contraseñas",
"loginCallback": "Devolución de llamada de inicio de sesión",
"slackOAuthCallback": "Conectando con Slack",
"emptyStateDemo": "Demostración de estado vacío",
+114
View File
@@ -1906,6 +1906,7 @@
"startFreeTrial": "Commencer l'essai gratuit"
},
"login": {
"lockedOut": "Trop de tentatives de connexion échouées. Réessayez dans {duration} ou contactez votre administrateur.",
"redirectingToSsoMessage": "Redirection vers la page de connexion SSO. Si vous n'êtes pas redirigé, veuillez",
"clickHere": "cliquez ici",
"loginWithSso": "Se connecter avec SSO",
@@ -1965,6 +1966,15 @@
"fieldRequired": "Ce champ est obligatoire",
"passwordRequired": "Le mot de passe est obligatoire.",
"passwordPolicyHint": "Le mot de passe doit être composé de 8 à 128 caractères et contenir au moins une lettre minuscule, une lettre majuscule, un chiffre et un caractère spécial.",
"lockout": {
"locked": "Compte verrouillé",
"autoUnlock": "Déverrouillage automatique dans {duration}.",
"staged": "Sera déverrouillé lorsque vous enregistrerez.",
"unlock": "Déverrouiller",
"undo": "Garder verrouillé",
"confirmTitle": "Déverrouiller {email} ?",
"confirmMessage": "Cela réinitialise leur nombre de tentatives échouées et leur permet de se reconnecter immédiatement. Cela ne modifie pas leur mot de passe."
},
"currentPasswordRequired": "Le mot de passe actuel est requis.",
"newPasswordRequired": "Un nouveau mot de passe est requis.",
"otherOrgInvalid": "Le nom de l'organisation doit commencer par une lettre et être alphanumérique, _ ou -.",
@@ -8810,6 +8820,8 @@
"cipherKeysDesc": "Clés de chiffrement pour les champs sensibles",
"regexPatternsDesc": "Modèles regex réutilisables pour le masquage",
"domainManagementDesc": "Restreindre la connexion aux domaines SSO autorisés",
"passwordPolicy": "Politique de mots de passe",
"passwordPolicyDesc": "Politiques d'authentification pour les comptes natifs e-mail/mot de passe, appliquées à l'ensemble de l'instance.",
"pipelineDestinationsDesc": "Cibles externes pour la sortie du pipeline",
"templatesDesc": "Modèles de messages d'alerte réutilisables",
"storageSettingsDesc": "Configuration du stockage par organisation",
@@ -16043,6 +16055,9 @@
},
"layouts": {
"ingestionNotStarted": "Vous n'avez pas encore initié le processus d'ingestion des données. Pour explorer d'autres pages, veuillez démarrer l'ingestion des données."
},
"iam": {
"userSaveFailed": "Impossible d'enregistrer l'utilisateur"
}
},
"cipherKeys": {
@@ -16460,7 +16475,106 @@
"exponentName": "Exposant"
}
},
"passwordPolicy": {
"complexity": "Complexité",
"minLength": "Longueur minimale",
"minLengthDesc": "Mot de passe le plus court accepté",
"maxLength": "Longueur maximale",
"maxLengthDesc": "0 = sans limite",
"requireUppercase": "Exiger une lettre majuscule",
"requireUppercaseDesc": "Au moins un A–Z",
"requireLowercase": "Exiger une lettre minuscule",
"requireLowercaseDesc": "Au moins un a–z",
"requireDigit": "Exiger un chiffre",
"requireDigitDesc": "Au moins un 0–9",
"requireSpecial": "Exiger un caractère spécial",
"requireSpecialDesc": "Au moins un caractère de l'ensemble autorisé",
"specialCharSet": "Caractères spéciaux autorisés",
"specialCharSetDesc": "Laisser vide pour accepter tout caractère non alphanumérique",
"maxLengthTooSmall": "La longueur maximale doit être au moins égale à la longueur minimale, ou 0 pour aucune limite.",
"confirmTitle": "Enregistrer la politique de mots de passe ?",
"confirmMessage": "L'enregistrement peut forcer chaque utilisateur natif à définir un nouveau mot de passe avant de pouvoir continuer. Les comptes root, les utilisateurs externes et les comptes de service en sont exemptés — y compris vous, sauf si vous êtes le compte root. Cette action est irréversible.",
"saved": "Politique de mots de passe enregistrée. | Politique de mots de passe enregistrée. {count} utilisateur doit réinitialiser son mot de passe. | Politique de mots de passe enregistrée. {count} utilisateurs doivent réinitialiser leur mot de passe.",
"saveFailed": "Impossible d'enregistrer la politique de mots de passe.",
"notAdminTitle": "Accès administrateur requis",
"notAdmin": "La configuration de la politique de mots de passe nécessite un administrateur de l'organisation meta.",
"loadFailedTitle": "Impossible de charger la politique de mots de passe",
"loadFailed": "Une erreur s'est produite lors de la lecture de la politique actuelle.",
"rotation": "Rotation",
"rotationDays": "Période de rotation",
"rotationDaysDesc": "Nombre de jours avant l'expiration d'un mot de passe. 0 = n'expire jamais",
"rotationWarningDays": "Avertir avant l'expiration",
"rotationWarningDaysDesc": "Nombre de jours de préavis à la connexion. 0 = aucun avertissement",
"warningDaysTooLong": "La fenêtre d'avertissement ne peut pas être plus longue que la période de rotation.",
"rotationPreview": "Un mot de passe défini aujourd'hui expire {expires}. L'avertissement à la connexion commence {warns}.",
"rotationPreviewNoWarning": "Un mot de passe défini aujourd'hui expire {expires}. Aucun avertissement n'est affiché avant cette date.",
"reuse": "Prévention de la réutilisation",
"historyCount": "Mots de passe mémorisés",
"historyCountDesc": "Un nouveau mot de passe ne peut pas correspondre à ce nombre de mots de passe précédents. 0 = aucune vérification",
"historyMaxRetained": "Historique conservé",
"historyMaxRetainedDesc": "Nombre de mots de passe passés conservés par utilisateur",
"historyRetainedTooSmall": "L'historique conservé doit être au moins égal au nombre de mots de passe mémorisés.",
"reuseExplainer": "Le mot de passe actuel compte parmi l'ensemble mémorisé — sa réutilisation est refusée par cette règle, et non par une vérification distincte.",
"lockout": "Verrouillage du compte",
"lockoutThreshold": "Tentatives échouées avant verrouillage",
"lockoutThresholdDesc": "0 = verrouillage désactivé",
"lockoutBucketSize": "Tentatives avant chaque verrouillage supplémentaire",
"lockoutBucketSizeDesc": "0 = réutiliser la valeur ci-dessus",
"lockoutStartSecs": "Durée du premier verrouillage",
"lockoutStartSecsDesc": "Secondes",
"lockoutMaxSecs": "Durée maximale du verrouillage",
"lockoutMaxSecsDesc": "Secondes — le plafond auquel l'escalade est limitée",
"lockoutStartTooLong": "Le premier verrouillage ne peut pas être plus long que le verrouillage maximal.",
"lockoutBackoff": "Escalade",
"lockoutBackoffDesc": "Comment chaque verrouillage augmente par rapport au précédent",
"backoffExponential": "Exponentielle",
"backoffLinear": "Linéaire",
"lockoutPreview": "{threshold} échecs → {first} · puis tous les {bucket} → {rest} · plafonné à {max}. Une connexion réussie réinitialise tout.",
"sessionEnforcement": "Session et application",
"cookieMaxAge": "Durée de vie de la session",
"cookieMaxAgeDesc": "Durée en secondes pendant laquelle une session connectée reste valide. 0 = utiliser la valeur par défaut de l'instance définie par {envVar}",
"applyToRoot": "Appliquer au compte root",
"applyToRootDesc": "Root est exempté de toutes les règles ci-dessus, sauf si cette option est activée",
"rootWarning": "Verrouiller root pourrait placer le système dans un état irrécupérable.",
"rootDialogTitle": "Appliquer la politique à root ?",
"rootDialogMessage": "Root sera soumis au verrouillage, à la rotation et aux réinitialisations forcées comme tout autre compte natif. Si root est votre seul compte d'administration, un verrouillage ou une expiration qu'il ne peut satisfaire ne laissera aucun moyen de revenir dans cette instance.",
"rootDialogAck": "Je comprends que root peut être définitivement exclu de cette instance.",
"rootDialogConfirm": "Appliquer à root"
},
"passwordReset": {
"title": "Mettez à jour votre mot de passe",
"signedInAs": "Connecté en tant que {email}",
"reasonPolicyTightened": "La politique de mots de passe a changé. Mettez à jour votre mot de passe pour continuer à utiliser OpenObserve.",
"reasonRotationExpired": "Votre mot de passe a expiré. Définissez-en un nouveau pour continuer à utiliser OpenObserve.",
"reasonRotationWarning": "Votre mot de passe expire bientôt. Définissez-en un nouveau dès maintenant pour éviter d'être bloqué dans votre travail.",
"reused": "Ce mot de passe a été utilisé récemment. Choisissez-en un que vous n'avez pas utilisé parmi vos {count} derniers mots de passe.",
"expiryBanner": "Votre mot de passe expire dans {count} jour. Mettez-le à jour maintenant pour éviter d'être bloqué dans votre travail. | Votre mot de passe expire dans {count} jours. Mettez-le à jour maintenant pour éviter d'être bloqué dans votre travail.",
"expiryBannerAction": "Mettre à jour maintenant",
"expiryBannerDismiss": "Ignorer",
"currentPassword": "Mot de passe actuel",
"newPassword": "Nouveau mot de passe",
"confirmPassword": "Confirmer le nouveau mot de passe",
"currentPasswordRequired": "Le mot de passe actuel est obligatoire.",
"newPasswordRequired": "Le nouveau mot de passe est obligatoire.",
"confirmPasswordRequired": "Veuillez saisir à nouveau le nouveau mot de passe.",
"mismatch": "Les mots de passe ne correspondent pas.",
"signOut": "Déconnectez-vous",
"submit": "Mettre à jour le mot de passe",
"updated": "Votre mot de passe a été mis à jour. Veuillez vous reconnecter.",
"updateFailed": "Impossible de mettre à jour votre mot de passe.",
"req": {
"minLength": "Au moins {count} caractères",
"maxLength": "Pas plus de {count} caractères",
"uppercase": "Une lettre majuscule",
"lowercase": "Une lettre minuscule",
"digit": "Un chiffre",
"special": "Un caractère spécial",
"specialFromSet": "Un caractère spécial parmi {set}",
"unmet": "Le mot de passe doit contenir : {requirement}"
}
},
"routeTitles": {
"passwordPolicy": "Politique de mot de passe",
"loginCallback": "Rappel de connexion",
"slackOAuthCallback": "Connexion à Slack",
"emptyStateDemo": "Démo d'état vide",
+114
View File
@@ -1906,6 +1906,7 @@
"startFreeTrial": "Inizia la prova gratuita"
},
"login": {
"lockedOut": "Troppi tentativi di accesso non riusciti. Riprova tra {duration} o contatta l'amministratore.",
"redirectingToSsoMessage": "Reindirizzamento alla pagina di accesso SSO. Se non vieni reindirizzato, per favore",
"clickHere": "clicca qui",
"loginWithSso": "Accedi con SSO",
@@ -1965,6 +1966,15 @@
"fieldRequired": "Il campo è obbligatorio",
"passwordRequired": "La password è obbligatoria.",
"passwordPolicyHint": "La password deve contenere 8-128 caratteri e contenere almeno una lettera minuscola, una lettera maiuscola, una cifra e un carattere speciale.",
"lockout": {
"locked": "Account bloccato",
"autoUnlock": "Si sblocca automaticamente tra {duration}.",
"staged": "Verrà sbloccato quando salvi.",
"unlock": "Sblocca",
"undo": "Mantieni bloccato",
"confirmTitle": "Sbloccare {email}?",
"confirmMessage": "Questa operazione azzera il conteggio dei tentativi non riusciti e consente all'utente di accedere di nuovo immediatamente. Non modifica la password."
},
"currentPasswordRequired": "È richiesta la password attuale.",
"newPasswordRequired": "È richiesta una nuova password.",
"otherOrgInvalid": "Il nome dell'organizzazione deve iniziare con una lettera ed essere alfanumerico, _ o -.",
@@ -8810,6 +8820,8 @@
"cipherKeysDesc": "Chiavi di cifratura per i campi sensibili",
"regexPatternsDesc": "Pattern regex riutilizzabili per la redazione",
"domainManagementDesc": "Limita l'accesso ai domini SSO consentiti",
"passwordPolicy": "Criteri password",
"passwordPolicyDesc": "Criteri di autenticazione per gli account nativi con email/password, applicati all'intera istanza.",
"pipelineDestinationsDesc": "Destinazioni esterne per l'output della pipeline",
"templatesDesc": "Template di messaggio di avviso riutilizzabili",
"storageSettingsDesc": "Configurazione dello storage per organizzazione",
@@ -16043,6 +16055,9 @@
},
"layouts": {
"ingestionNotStarted": "Non hai ancora avviato il processo di acquisizione dati. Per esplorare altre pagine, avvia l'acquisizione dati."
},
"iam": {
"userSaveFailed": "Impossibile salvare l'utente"
}
},
"cipherKeys": {
@@ -16460,7 +16475,106 @@
"exponentName": "Esponente"
}
},
"passwordPolicy": {
"complexity": "Complessità",
"minLength": "Lunghezza minima",
"minLengthDesc": "La password più corta accettata",
"maxLength": "Lunghezza massima",
"maxLengthDesc": "0 = illimitata",
"requireUppercase": "Richiedi lettera maiuscola",
"requireUppercaseDesc": "Almeno una A–Z",
"requireLowercase": "Richiedi lettera minuscola",
"requireLowercaseDesc": "Almeno una a–z",
"requireDigit": "Richiedi cifra",
"requireDigitDesc": "Almeno una cifra 0–9",
"requireSpecial": "Richiedi carattere speciale",
"requireSpecialDesc": "Almeno uno dall'insieme consentito",
"specialCharSet": "Caratteri speciali consentiti",
"specialCharSetDesc": "Lascia vuoto per accettare qualsiasi carattere non alfanumerico",
"maxLengthTooSmall": "La lunghezza massima deve essere almeno pari alla lunghezza minima, oppure 0 per illimitata.",
"confirmTitle": "Salvare i criteri password?",
"confirmMessage": "Il salvataggio potrebbe obbligare tutti gli utenti nativi a impostare una nuova password prima di poter continuare. L'account root, gli utenti esterni e gli account di servizio sono esenti — questo include te, a meno che tu non sia l'account root. L'operazione non può essere annullata.",
"saved": "Criteri password salvati. | Criteri password salvati. {count} utente deve reimpostare la password. | Criteri password salvati. {count} utenti devono reimpostare la password.",
"saveFailed": "Impossibile salvare i criteri password.",
"notAdminTitle": "Accesso amministratore richiesto",
"notAdmin": "La configurazione dei criteri password richiede un amministratore dell'organizzazione meta.",
"loadFailedTitle": "Impossibile caricare i criteri password",
"loadFailed": "Si è verificato un errore durante la lettura dei criteri correnti.",
"rotation": "Rotazione",
"rotationDays": "Periodo di rotazione",
"rotationDaysDesc": "Giorni prima della scadenza di una password. 0 = non scade mai",
"rotationWarningDays": "Avvisa prima della scadenza",
"rotationWarningDaysDesc": "Giorni di preavviso all'accesso. 0 = nessun avviso",
"warningDaysTooLong": "La finestra di avviso non può essere più lunga del periodo di rotazione.",
"rotationPreview": "Una password impostata oggi scade {expires}. L'avviso all'accesso inizia {warns}.",
"rotationPreviewNoWarning": "Una password impostata oggi scade {expires}. Fino ad allora non viene mostrato alcun avviso.",
"reuse": "Prevenzione del riutilizzo",
"historyCount": "Password memorizzate",
"historyCountDesc": "Una nuova password non può coincidere con questo numero di password precedenti. 0 = nessun controllo",
"historyMaxRetained": "Cronologia conservata",
"historyMaxRetainedDesc": "Quante password precedenti vengono conservate per utente",
"historyRetainedTooSmall": "La cronologia conservata deve essere almeno pari al numero di password memorizzate.",
"reuseExplainer": "La password corrente conta come una delle password memorizzate — il suo riutilizzo viene rifiutato da questa regola, non da un controllo separato.",
"lockout": "Blocco dell'account",
"lockoutThreshold": "Tentativi non riusciti prima del blocco",
"lockoutThresholdDesc": "0 = blocco disabilitato",
"lockoutBucketSize": "Tentativi prima di ogni ulteriore blocco",
"lockoutBucketSizeDesc": "0 = riutilizza il valore precedente",
"lockoutStartSecs": "Durata del primo blocco",
"lockoutStartSecsDesc": "Secondi",
"lockoutMaxSecs": "Durata massima del blocco",
"lockoutMaxSecsDesc": "Secondi — il limite massimo a cui l'escalation viene contenuta",
"lockoutStartTooLong": "Il primo blocco non può essere più lungo del blocco massimo.",
"lockoutBackoff": "Escalation",
"lockoutBackoffDesc": "Come ogni blocco cresce rispetto a quello precedente",
"backoffExponential": "Esponenziale",
"backoffLinear": "Lineare",
"lockoutPreview": "{threshold} errori → {first} · ogni {bucket} in più → {rest} · limitato a {max}. Un accesso riuscito azzera tutto.",
"sessionEnforcement": "Sessione e applicazione",
"cookieMaxAge": "Durata della sessione",
"cookieMaxAgeDesc": "Secondi per cui una sessione autenticata resta valida. 0 = usa il valore predefinito dell'istanza da {envVar}",
"applyToRoot": "Applica all'account root",
"applyToRootDesc": "Root è esente da tutte le regole precedenti se questa opzione non è attiva",
"rootWarning": "Bloccare root potrebbe mettere il sistema in uno stato irrecuperabile.",
"rootDialogTitle": "Applicare i criteri a root?",
"rootDialogMessage": "Root sarà soggetto a blocco, rotazione e reimpostazioni forzate come qualsiasi altro account nativo. Se root è il tuo unico account amministrativo, un blocco o una scadenza che non può soddisfare non lascerà alcun modo per rientrare in questa istanza.",
"rootDialogAck": "Comprendo che root può essere bloccato permanentemente da questa istanza.",
"rootDialogConfirm": "Applica a root"
},
"passwordReset": {
"title": "Aggiorna la tua password",
"signedInAs": "Accesso effettuato come {email}",
"reasonPolicyTightened": "I criteri password sono cambiati. Aggiorna la tua password per continuare a usare OpenObserve.",
"reasonRotationExpired": "La tua password è scaduta. Impostane una nuova per continuare a usare OpenObserve.",
"reasonRotationWarning": "La tua password scadrà presto. Impostane una nuova ora per evitare di essere bloccato dal tuo lavoro.",
"reused": "Questa password è stata usata di recente. Scegline una che non hai usato nelle ultime {count} password.",
"expiryBanner": "La tua password scade tra {count} giorno. Aggiornala ora per evitare di essere bloccato dal tuo lavoro. | La tua password scade tra {count} giorni. Aggiornala ora per evitare di essere bloccato dal tuo lavoro.",
"expiryBannerAction": "Aggiorna ora",
"expiryBannerDismiss": "Ignora",
"currentPassword": "Password attuale",
"newPassword": "Nuova password",
"confirmPassword": "Conferma nuova password",
"currentPasswordRequired": "La password attuale è obbligatoria.",
"newPasswordRequired": "La nuova password è obbligatoria.",
"confirmPasswordRequired": "Reinserisci la nuova password.",
"mismatch": "Le password non corrispondono.",
"signOut": "Esci",
"submit": "Aggiorna password",
"updated": "La tua password è stata aggiornata. Accedi di nuovo.",
"updateFailed": "Impossibile aggiornare la password.",
"req": {
"minLength": "Almeno {count} caratteri",
"maxLength": "Non più di {count} caratteri",
"uppercase": "Una lettera maiuscola",
"lowercase": "Una lettera minuscola",
"digit": "Una cifra",
"special": "Un carattere speciale",
"specialFromSet": "Un carattere speciale da {set}",
"unmet": "La password deve contenere: {requirement}"
}
},
"routeTitles": {
"passwordPolicy": "Criteri password",
"loginCallback": "Callback di accesso",
"slackOAuthCallback": "Connessione a Slack",
"emptyStateDemo": "Demo stato vuoto",
+114
View File
@@ -1906,6 +1906,7 @@
"startFreeTrial": "無料トライアルを開始"
},
"login": {
"lockedOut": "サインインの失敗回数が多すぎます。{duration}後に再試行するか、管理者にお問い合わせください。",
"redirectingToSsoMessage": "SSOログインページにリダイレクトしています。リダイレクトされない場合は、",
"clickHere": "ここをクリック",
"loginWithSso": "SSOでログイン",
@@ -1965,6 +1966,15 @@
"fieldRequired": "フィールドは必須です",
"passwordRequired": "パスワードが必要です。",
"passwordPolicyHint": "パスワードは 8 ~ 128 文字で、少なくとも 1 つの小文字、1 つの大文字、1 つの数字、1 つの特殊文字を含む必要があります。",
"lockout": {
"locked": "アカウントがロックされています",
"autoUnlock": "{duration}後に自動的にロックが解除されます。",
"staged": "保存するとロックが解除されます。",
"unlock": "ロックを解除",
"undo": "ロックを維持",
"confirmTitle": "{email} のロックを解除しますか?",
"confirmMessage": "失敗回数がクリアされ、すぐに再度サインインできるようになります。パスワードは変更されません。"
},
"currentPasswordRequired": "現在のパスワードが必要です。",
"newPasswordRequired": "新しいパスワードが必要です。",
"otherOrgInvalid": "組織名は文字で始まり、英数字、_ または-でなければなりません。",
@@ -8810,6 +8820,8 @@
"cipherKeysDesc": "機密フィールドの暗号化キー",
"regexPatternsDesc": "墨消し用の再利用可能なregexパターン",
"domainManagementDesc": "サインインを許可されたSSOドメインに制限",
"passwordPolicy": "パスワードポリシー",
"passwordPolicyDesc": "ネイティブのメールアドレス/パスワードアカウント向けの認証ポリシーです。インスタンス全体に適用されます。",
"pipelineDestinationsDesc": "パイプライン出力の外部ターゲット",
"templatesDesc": "再利用可能なアラートメッセージテンプレート",
"storageSettingsDesc": "組織ごとのストレージ設定",
@@ -16043,6 +16055,9 @@
},
"layouts": {
"ingestionNotStarted": "まだデータ取り込みプロセスを開始していません。他のページを探索するには、データ取り込みを開始してください。"
},
"iam": {
"userSaveFailed": "ユーザーを保存できませんでした"
}
},
"cipherKeys": {
@@ -16460,7 +16475,106 @@
"exponentName": "指数"
}
},
"passwordPolicy": {
"complexity": "複雑さ",
"minLength": "最小文字数",
"minLengthDesc": "受け付けるパスワードの最短文字数",
"maxLength": "最大文字数",
"maxLengthDesc": "0 = 無制限",
"requireUppercase": "大文字を必須にする",
"requireUppercaseDesc": "A–Z を少なくとも1文字",
"requireLowercase": "小文字を必須にする",
"requireLowercaseDesc": "a–z を少なくとも1文字",
"requireDigit": "数字を必須にする",
"requireDigitDesc": "0–9 を少なくとも1文字",
"requireSpecial": "特殊文字を必須にする",
"requireSpecialDesc": "許可された文字セットから少なくとも1文字",
"specialCharSet": "許可する特殊文字",
"specialCharSetDesc": "空欄にすると、英数字以外のすべての文字を許可します",
"maxLengthTooSmall": "最大文字数は最小文字数以上、または無制限の場合は0にする必要があります。",
"confirmTitle": "パスワードポリシーを保存しますか?",
"confirmMessage": "保存すると、すべてのネイティブユーザーが続行前に新しいパスワードを設定する必要がある場合があります。root、外部ユーザー、サービスアカウントは対象外です。root アカウントでない限り、あなたも対象です。この操作は元に戻せません。",
"saved": "パスワードポリシーを保存しました。 | パスワードポリシーを保存しました。{count} 人のユーザーがパスワードをリセットする必要があります。 | パスワードポリシーを保存しました。{count} 人のユーザーがパスワードをリセットする必要があります。",
"saveFailed": "パスワードポリシーを保存できませんでした。",
"notAdminTitle": "管理者権限が必要です",
"notAdmin": "パスワードポリシーの設定には、メタ組織の管理者権限が必要です。",
"loadFailedTitle": "パスワードポリシーを読み込めませんでした",
"loadFailed": "現在のポリシーの読み取り中にエラーが発生しました。",
"rotation": "ローテーション",
"rotationDays": "ローテーション期間",
"rotationDaysDesc": "パスワードが有効期限切れになるまでの日数。0 = 無期限",
"rotationWarningDays": "有効期限前の警告",
"rotationWarningDaysDesc": "サインイン時の通知日数。0 = 警告なし",
"warningDaysTooLong": "警告期間はローテーション期間より長くできません。",
"rotationPreview": "今日設定したパスワードは {expires} に有効期限が切れます。サインイン時の警告は {warns} から始まります。",
"rotationPreviewNoWarning": "今日設定したパスワードは {expires} に有効期限が切れます。それまで警告は表示されません。",
"reuse": "再利用の防止",
"historyCount": "記憶するパスワード数",
"historyCountDesc": "新しいパスワードは、この数の過去のパスワードと一致してはいけません。0 = チェックなし",
"historyMaxRetained": "保持する履歴",
"historyMaxRetainedDesc": "ユーザーごとに保持される過去のパスワード数",
"historyRetainedTooSmall": "保持する履歴は、記憶するパスワード数以上である必要があります。",
"reuseExplainer": "現在のパスワードは記憶されるセットの1つとして数えられます。再利用が拒否されるのはこのルールによるもので、別のチェックによるものではありません。",
"lockout": "アカウントのロックアウト",
"lockoutThreshold": "ロックアウトまでの失敗回数",
"lockoutThresholdDesc": "0 = ロックアウト無効",
"lockoutBucketSize": "以降の各ロックアウトまでの試行回数",
"lockoutBucketSizeDesc": "0 = 上記の値を使用",
"lockoutStartSecs": "最初のロックアウト期間",
"lockoutStartSecsDesc": "秒",
"lockoutMaxSecs": "最大ロックアウト期間",
"lockoutMaxSecsDesc": "秒 — エスカレーションが上限とする値",
"lockoutStartTooLong": "最初のロックアウトは最大ロックアウトより長くできません。",
"lockoutBackoff": "エスカレーション",
"lockoutBackoffDesc": "各ロックアウトが前回からどのように延びるか",
"backoffExponential": "指数関数的",
"backoffLinear": "線形",
"lockoutPreview": "{threshold} 回失敗 → {first} · {bucket} 回ごとに → {rest} · 上限 {max}。サインインに成功するとすべてリセットされます。",
"sessionEnforcement": "セッションと適用",
"cookieMaxAge": "セッションの有効期間",
"cookieMaxAgeDesc": "サインイン済みセッションが有効な秒数。0 = {envVar} のインスタンスデフォルトを使用",
"applyToRoot": "root アカウントに適用",
"applyToRootDesc": "これがオンの場合を除き、root は上記すべてのルールから除外されます",
"rootWarning": "root をロックすると、システムが復旧不能な状態になる可能性があります。",
"rootDialogTitle": "ポリシーを root に適用しますか?",
"rootDialogMessage": "root は他のネイティブアカウントと同様に、ロックアウト、ローテーション、強制リセットの対象になります。root が唯一の管理アカウントである場合、ロックアウトや満たすことのできない有効期限により、このインスタンスに戻る手段がなくなる可能性があります。",
"rootDialogAck": "root がこのインスタンスから恒久的にロックアウトされる可能性があることを理解しました。",
"rootDialogConfirm": "root に適用"
},
"passwordReset": {
"title": "パスワードを更新",
"signedInAs": "{email} としてサインイン中",
"reasonPolicyTightened": "パスワードポリシーが変更されました。OpenObserve の使用を続けるにはパスワードを更新してください。",
"reasonRotationExpired": "パスワードの有効期限が切れました。OpenObserve の使用を続けるには新しいパスワードを設定してください。",
"reasonRotationWarning": "パスワードの有効期限がまもなく切れます。作業からロックアウトされないよう、今すぐ新しいパスワードを設定してください。",
"reused": "このパスワードは最近使用されています。直近 {count} 個のパスワードで使用していないものを選択してください。",
"expiryBanner": "{count} 日後にパスワードの有効期限が切れます。作業からロックアウトされないよう、今すぐ更新してください。 | {count} 日後にパスワードの有効期限が切れます。作業からロックアウトされないよう、今すぐ更新してください。",
"expiryBannerAction": "今すぐ更新",
"expiryBannerDismiss": "閉じる",
"currentPassword": "現在のパスワード",
"newPassword": "新しいパスワード",
"confirmPassword": "新しいパスワードの確認",
"currentPasswordRequired": "現在のパスワードは必須です。",
"newPasswordRequired": "新しいパスワードは必須です。",
"confirmPasswordRequired": "新しいパスワードを再入力してください。",
"mismatch": "パスワードが一致しません。",
"signOut": "サインアウト",
"submit": "パスワードを更新",
"updated": "パスワードを更新しました。もう一度サインインしてください。",
"updateFailed": "パスワードを更新できませんでした。",
"req": {
"minLength": "{count} 文字以上",
"maxLength": "{count} 文字以内",
"uppercase": "大文字 1 文字",
"lowercase": "小文字 1 文字",
"digit": "数字 1 文字",
"special": "特殊文字を1文字",
"specialFromSet": "{set} の特殊文字を1文字",
"unmet": "パスワードには以下が必要です: {requirement}"
}
},
"routeTitles": {
"passwordPolicy": "パスワードポリシー",
"loginCallback": "ログインコールバック",
"slackOAuthCallback": "Slackに接続中",
"emptyStateDemo": "空状態デモ",
+114
View File
@@ -1906,6 +1906,7 @@
"startFreeTrial": "무료 체험 시작"
},
"login": {
"lockedOut": "로그인 시도 실패 횟수가 너무 많습니다. {duration} 후에 다시 시도하거나 관리자에게 문의하세요.",
"redirectingToSsoMessage": "SSO 로그인 페이지로 리디렉션합니다. 리디렉션되지 않으면",
"clickHere": "여기를 클릭하세요",
"loginWithSso": "SSO로 로그인",
@@ -1965,6 +1966,15 @@
"fieldRequired": "필드는 필수입니다",
"passwordRequired": "비밀번호가 필요합니다.",
"passwordPolicyHint": "암호는 8~128자여야 하며 소문자 1개, 대문자 1개, 숫자 1개, 특수 문자 1개를 포함해야 합니다.",
"lockout": {
"locked": "계정 잠김",
"autoUnlock": "{duration} 후에 자동으로 잠금 해제됩니다.",
"staged": "저장하면 잠금이 해제됩니다.",
"unlock": "잠금 해제",
"undo": "잠금 유지",
"confirmTitle": "{email}의 잠금을 해제하시겠습니까?",
"confirmMessage": "이 작업은 해당 사용자의 실패 시도 횟수를 초기화하고 즉시 다시 로그인할 수 있게 합니다. 비밀번호는 변경되지 않습니다."
},
"currentPasswordRequired": "현재 암호가 필요합니다.",
"newPasswordRequired": "새 암호가 필요합니다.",
"otherOrgInvalid": "조직 이름은 문자로 시작하고 영숫자, _ 또는 -여야 합니다.",
@@ -8810,6 +8820,8 @@
"cipherKeysDesc": "민감한 필드를 위한 암호화 키",
"regexPatternsDesc": "수정에 재사용 가능한 regex 패턴",
"domainManagementDesc": "허용된 SSO 도메인으로 로그인 제한",
"passwordPolicy": "비밀번호 정책",
"passwordPolicyDesc": "네이티브 이메일/비밀번호 계정에 대한 인증 정책으로, 인스턴스 전체에 적용됩니다.",
"pipelineDestinationsDesc": "파이프라인 출력을 위한 외부 대상",
"templatesDesc": "재사용 가능한 알림 메시지 템플릿",
"storageSettingsDesc": "조직별 스토리지 구성",
@@ -16043,6 +16055,9 @@
},
"layouts": {
"ingestionNotStarted": "아직 데이터 수집 프로세스를 시작하지 않았습니다. 다른 페이지를 탐색하려면 데이터 수집을 시작하세요."
},
"iam": {
"userSaveFailed": "사용자를 저장할 수 없습니다"
}
},
"cipherKeys": {
@@ -16460,7 +16475,106 @@
"exponentName": "지수"
}
},
"passwordPolicy": {
"complexity": "복잡도",
"minLength": "최소 길이",
"minLengthDesc": "허용되는 가장 짧은 비밀번호",
"maxLength": "최대 길이",
"maxLengthDesc": "0 = 제한 없음",
"requireUppercase": "대문자 필수",
"requireUppercaseDesc": "A–Z 중 최소 하나",
"requireLowercase": "소문자 필수",
"requireLowercaseDesc": "a–z 중 최소 하나",
"requireDigit": "숫자 필수",
"requireDigitDesc": "0–9 중 최소 하나",
"requireSpecial": "특수 문자 필수",
"requireSpecialDesc": "허용된 집합 중 최소 하나",
"specialCharSet": "허용되는 특수 문자",
"specialCharSetDesc": "비워 두면 영숫자가 아닌 모든 문자를 허용합니다",
"maxLengthTooSmall": "최대 길이는 최소 길이 이상이어야 하며, 제한 없음은 0입니다.",
"confirmTitle": "비밀번호 정책을 저장하시겠습니까?",
"confirmMessage": "저장하면 모든 네이티브 사용자가 계속하려면 새 비밀번호를 설정해야 할 수 있습니다. Root, 외부 사용자 및 서비스 계정은 예외입니다. 여기에는 root 계정이 아닌 한 본인도 포함됩니다. 이 작업은 되돌릴 수 없습니다.",
"saved": "비밀번호 정책이 저장되었습니다. | 비밀번호 정책이 저장되었습니다. {count}명의 사용자가 비밀번호를 재설정해야 합니다. | 비밀번호 정책이 저장되었습니다. {count}명의 사용자가 비밀번호를 재설정해야 합니다.",
"saveFailed": "비밀번호 정책을 저장할 수 없습니다.",
"notAdminTitle": "관리자 권한이 필요합니다",
"notAdmin": "비밀번호 정책을 구성하려면 메타 조직의 관리자 권한이 필요합니다.",
"loadFailedTitle": "비밀번호 정책을 불러올 수 없습니다",
"loadFailed": "현재 정책을 읽는 중 문제가 발생했습니다.",
"rotation": "교체",
"rotationDays": "교체 주기",
"rotationDaysDesc": "비밀번호가 만료되기까지의 일수입니다. 0 = 만료되지 않음",
"rotationWarningDays": "만료 전 경고",
"rotationWarningDaysDesc": "로그인 시 사전 알림 일수입니다. 0 = 경고 없음",
"warningDaysTooLong": "경고 기간은 교체 주기보다 길 수 없습니다.",
"rotationPreview": "오늘 설정한 비밀번호는 {expires}에 만료됩니다. 로그인 경고는 {warns}부터 시작됩니다.",
"rotationPreviewNoWarning": "오늘 설정한 비밀번호는 {expires}에 만료됩니다. 그 전까지는 경고가 표시되지 않습니다.",
"reuse": "재사용 방지",
"historyCount": "기억되는 비밀번호 수",
"historyCountDesc": "새 비밀번호는 이 개수만큼의 이전 비밀번호와 일치할 수 없습니다. 0 = 검사 안 함",
"historyMaxRetained": "보관되는 이력",
"historyMaxRetainedDesc": "사용자당 보관되는 이전 비밀번호 수",
"historyRetainedTooSmall": "보관되는 이력은 기억되는 비밀번호 수 이상이어야 합니다.",
"reuseExplainer": "현재 비밀번호는 기억되는 집합 중 하나로 간주됩니다. 이를 재사용하는 것은 별도의 검사가 아니라 이 규칙에 의해 거부됩니다.",
"lockout": "계정 잠금",
"lockoutThreshold": "잠금 전 실패 시도 횟수",
"lockoutThresholdDesc": "0 = 잠금 비활성화",
"lockoutBucketSize": "추가 잠금마다 필요한 시도 횟수",
"lockoutBucketSizeDesc": "0 = 위 값 재사용",
"lockoutStartSecs": "첫 잠금 지속 시간",
"lockoutStartSecsDesc": "초",
"lockoutMaxSecs": "최대 잠금 지속 시간",
"lockoutMaxSecsDesc": "초 — 에스컬레이션이 제한되는 상한",
"lockoutStartTooLong": "첫 잠금은 최대 잠금보다 길 수 없습니다.",
"lockoutBackoff": "에스컬레이션",
"lockoutBackoffDesc": "각 잠금이 이전 잠금에서 얼마나 증가하는지",
"backoffExponential": "지수",
"backoffLinear": "선형",
"lockoutPreview": "{threshold}회 실패 → {first} · {bucket}회 추가마다 → {rest} · 최대 {max}. 로그인에 성공하면 모두 초기화됩니다.",
"sessionEnforcement": "세션 및 적용",
"cookieMaxAge": "세션 수명",
"cookieMaxAgeDesc": "로그인된 세션이 유효하게 유지되는 시간(초). 0 = {envVar}의 인스턴스 기본값 사용",
"applyToRoot": "루트 계정에 적용",
"applyToRootDesc": "이 옵션을 켜지 않으면 루트는 위의 모든 규칙에서 제외됩니다",
"rootWarning": "루트를 잠그면 시스템이 복구할 수 없는 상태가 될 수 있습니다.",
"rootDialogTitle": "루트에 정책을 적용할까요?",
"rootDialogMessage": "루트는 다른 네이티브 계정과 마찬가지로 잠금, 순환 및 강제 재설정의 대상이 됩니다. 루트가 유일한 관리 계정인 경우, 잠금이나 충족할 수 없는 만료가 발생하면 이 인스턴스에 다시 접근할 방법이 없어집니다.",
"rootDialogAck": "루트가 이 인스턴스에서 영구적으로 잠길 수 있음을 이해합니다.",
"rootDialogConfirm": "루트에 적용"
},
"passwordReset": {
"title": "비밀번호 업데이트",
"signedInAs": "{email}(으)로 로그인됨",
"reasonPolicyTightened": "비밀번호 정책이 변경되었습니다. OpenObserve를 계속 사용하려면 비밀번호를 업데이트하세요.",
"reasonRotationExpired": "비밀번호가 만료되었습니다. OpenObserve를 계속 사용하려면 새 비밀번호를 설정하세요.",
"reasonRotationWarning": "비밀번호가 곧 만료됩니다. 작업에서 잠기지 않도록 지금 새 비밀번호를 설정하세요.",
"reused": "최근에 사용한 비밀번호입니다. 최근 {count}개의 비밀번호에 사용하지 않은 비밀번호를 선택하세요.",
"expiryBanner": "{count}일 후에 비밀번호가 만료됩니다. 작업에서 잠기지 않도록 지금 업데이트하세요. | {count}일 후에 비밀번호가 만료됩니다. 작업에서 잠기지 않도록 지금 업데이트하세요.",
"expiryBannerAction": "지금 업데이트",
"expiryBannerDismiss": "닫기",
"currentPassword": "현재 비밀번호",
"newPassword": "새 비밀번호",
"confirmPassword": "새 비밀번호 확인",
"currentPasswordRequired": "현재 비밀번호를 입력하세요.",
"newPasswordRequired": "새 비밀번호를 입력하세요.",
"confirmPasswordRequired": "새 비밀번호를 다시 입력하세요.",
"mismatch": "비밀번호가 일치하지 않습니다.",
"signOut": "로그아웃",
"submit": "비밀번호 업데이트",
"updated": "비밀번호가 업데이트되었습니다. 다시 로그인하세요.",
"updateFailed": "비밀번호를 업데이트할 수 없습니다.",
"req": {
"minLength": "최소 {count}자",
"maxLength": "최대 {count}자",
"uppercase": "대문자 1개",
"lowercase": "소문자 1개",
"digit": "숫자 1개",
"special": "특수 문자 1개",
"specialFromSet": "{set}에서 특수 문자 1개",
"unmet": "비밀번호 요구 사항: {requirement}"
}
},
"routeTitles": {
"passwordPolicy": "비밀번호 정책",
"loginCallback": "로그인 콜백",
"slackOAuthCallback": "Slack 연결 중",
"emptyStateDemo": "빈 상태 데모",
+114
View File
@@ -1906,6 +1906,7 @@
"startFreeTrial": "Start gratis proef"
},
"login": {
"lockedOut": "Te veel mislukte aanmeldpogingen. Probeer het over {duration} opnieuw, of neem contact op met uw beheerder.",
"redirectingToSsoMessage": "Doorverwijzen naar SSO-inlogpagina. Als je niet wordt doorgestuurd, ",
"clickHere": "klik hier",
"loginWithSso": "Inloggen met SSO",
@@ -1965,6 +1966,15 @@
"fieldRequired": "Veld is verplicht",
"passwordRequired": "Wachtwoord is vereist.",
"passwordPolicyHint": "Het wachtwoord moet 8-128 tekens lang zijn en minstens één kleine letter, één hoofdletter, één cijfer en één speciaal teken bevatten.",
"lockout": {
"locked": "Account vergrendeld",
"autoUnlock": "Wordt automatisch ontgrendeld over {duration}.",
"staged": "Wordt ontgrendeld wanneer u opslaat.",
"unlock": "Ontgrendelen",
"undo": "Vergrendeld houden",
"confirmTitle": "{email} ontgrendelen?",
"confirmMessage": "Hiermee wordt het aantal mislukte pogingen gewist en kan de gebruiker zich direct weer aanmelden. Het wachtwoord wordt niet gewijzigd."
},
"currentPasswordRequired": "Het huidige wachtwoord is vereist.",
"newPasswordRequired": "Een nieuw wachtwoord is vereist.",
"otherOrgInvalid": "De naam van de organisatie moet beginnen met een letter en alfanumeriek zijn, _ of -.",
@@ -8810,6 +8820,8 @@
"cipherKeysDesc": "Versleutelingssleutels voor gevoelige velden",
"regexPatternsDesc": "Herbruikbare regex-patronen voor redactie",
"domainManagementDesc": "Aanmelden beperken tot toegestane SSO-domeinen",
"passwordPolicy": "Wachtwoordbeleid",
"passwordPolicyDesc": "Authenticatiebeleid voor native e-mail-/wachtwoordaccounts, toegepast op de hele instantie.",
"pipelineDestinationsDesc": "Externe doelen voor pipeline-uitvoer",
"templatesDesc": "Herbruikbare sjablonen voor waarschuwingsberichten",
"storageSettingsDesc": "Opslagconfiguratie per organisatie",
@@ -16043,6 +16055,9 @@
},
"layouts": {
"ingestionNotStarted": "U bent nog niet begonnen met het data-ingestieproces. Om andere pagina's te verkennen, start u de data-ingestie."
},
"iam": {
"userSaveFailed": "Kon de gebruiker niet opslaan"
}
},
"cipherKeys": {
@@ -16460,7 +16475,106 @@
"exponentName": "Exponent"
}
},
"passwordPolicy": {
"complexity": "Complexiteit",
"minLength": "Minimumlengte",
"minLengthDesc": "Kortste geaccepteerde wachtwoord",
"maxLength": "Maximumlengte",
"maxLengthDesc": "0 = onbeperkt",
"requireUppercase": "Hoofdletter vereisen",
"requireUppercaseDesc": "Ten minste één A–Z",
"requireLowercase": "Kleine letter vereisen",
"requireLowercaseDesc": "Ten minste één a–z",
"requireDigit": "Cijfer vereisen",
"requireDigitDesc": "Ten minste één 0–9",
"requireSpecial": "Speciaal teken vereisen",
"requireSpecialDesc": "Ten minste één uit de toegestane set",
"specialCharSet": "Toegestane speciale tekens",
"specialCharSetDesc": "Laat leeg om elk niet-alfanumeriek teken te accepteren",
"maxLengthTooSmall": "Maximumlengte moet ten minste de minimumlengte zijn, of 0 voor onbeperkt.",
"confirmTitle": "Wachtwoordbeleid opslaan?",
"confirmMessage": "Opslaan kan ertoe leiden dat elke native gebruiker een nieuw wachtwoord moet instellen voordat deze verder kan. Root, externe gebruikers en serviceaccounts zijn uitgezonderd — dit geldt ook voor u, tenzij u het rootaccount bent. Dit kan niet ongedaan worden gemaakt.",
"saved": "Wachtwoordbeleid opgeslagen. | Wachtwoordbeleid opgeslagen. {count} gebruiker moet zijn wachtwoord opnieuw instellen. | Wachtwoordbeleid opgeslagen. {count} gebruikers moeten hun wachtwoord opnieuw instellen.",
"saveFailed": "Kon het wachtwoordbeleid niet opslaan.",
"notAdminTitle": "Beheerdersrechten vereist",
"notAdmin": "Voor het configureren van het wachtwoordbeleid is een beheerder van de meta-organisatie vereist.",
"loadFailedTitle": "Kon het wachtwoordbeleid niet laden",
"loadFailed": "Er is iets misgegaan bij het lezen van het huidige beleid.",
"rotation": "Rotatie",
"rotationDays": "Rotatieperiode",
"rotationDaysDesc": "Dagen voordat een wachtwoord verloopt. 0 = verloopt nooit",
"rotationWarningDays": "Waarschuwen voor afloop",
"rotationWarningDaysDesc": "Dagen van kennisgeving bij aanmelding. 0 = geen waarschuwing",
"warningDaysTooLong": "Het waarschuwingsvenster kan niet langer zijn dan de rotatieperiode.",
"rotationPreview": "Een vandaag ingesteld wachtwoord verloopt op {expires}. De aanmeldwaarschuwing begint op {warns}.",
"rotationPreviewNoWarning": "Een vandaag ingesteld wachtwoord verloopt op {expires}. Er wordt voor die tijd geen waarschuwing getoond.",
"reuse": "Hergebruikpreventie",
"historyCount": "Onthouden wachtwoorden",
"historyCountDesc": "Een nieuw wachtwoord mag niet overeenkomen met dit aantal eerdere wachtwoorden. 0 = geen controle",
"historyMaxRetained": "Bewaarde geschiedenis",
"historyMaxRetainedDesc": "Hoeveel eerdere wachtwoorden per gebruiker worden bewaard",
"historyRetainedTooSmall": "Bewaarde geschiedenis moet ten minste gelijk zijn aan het aantal onthouden wachtwoorden.",
"reuseExplainer": "Het huidige wachtwoord telt als een van de onthouden wachtwoorden — hergebruik ervan wordt door deze regel geweigerd, niet door een aparte controle.",
"lockout": "Accountvergrendeling",
"lockoutThreshold": "Mislukte pogingen voor vergrendeling",
"lockoutThresholdDesc": "0 = vergrendeling uitgeschakeld",
"lockoutBucketSize": "Pogingen voor elke volgende vergrendeling",
"lockoutBucketSizeDesc": "0 = hergebruik de waarde hierboven",
"lockoutStartSecs": "Duur van eerste vergrendeling",
"lockoutStartSecsDesc": "Seconden",
"lockoutMaxSecs": "Maximale vergrendelingsduur",
"lockoutMaxSecsDesc": "Seconden — het plafond waarnaar escalatie wordt afgekapt",
"lockoutStartTooLong": "De eerste vergrendeling kan niet langer zijn dan de maximale vergrendeling.",
"lockoutBackoff": "Escalatie",
"lockoutBackoffDesc": "Hoe elke vergrendeling groeit ten opzichte van de vorige",
"backoffExponential": "Exponentieel",
"backoffLinear": "Lineair",
"lockoutPreview": "{threshold} fouten → {first} · elke {bucket} meer → {rest} · gemaximeerd op {max}. Een succesvolle aanmelding reset alles.",
"sessionEnforcement": "Sessie en handhaving",
"cookieMaxAge": "Sessielevensduur",
"cookieMaxAgeDesc": "Seconden dat een aangemelde sessie geldig blijft. 0 = gebruik de standaardwaarde van de instantie uit {envVar}",
"applyToRoot": "Toepassen op het rootaccount",
"applyToRootDesc": "Root is vrijgesteld van alle bovenstaande regels tenzij dit is ingeschakeld",
"rootWarning": "Het vergrendelen van root kan ertoe leiden dat het systeem in een onherstelbare staat terechtkomt.",
"rootDialogTitle": "Het beleid toepassen op root?",
"rootDialogMessage": "Root zal net als elk ander systeemeigen account onderhevig zijn aan vergrendeling, rotatie en gedwongen resets. Als root uw enige beheerdersaccount is, kan een vergrendeling of een vervaldatum waaraan niet kan worden voldaan, ervoor zorgen dat er geen weg terug is naar deze instantie.",
"rootDialogAck": "Ik begrijp dat root permanent buitengesloten kan worden van deze instantie.",
"rootDialogConfirm": "Toepassen op root"
},
"passwordReset": {
"title": "Uw wachtwoord bijwerken",
"signedInAs": "Aangemeld als {email}",
"reasonPolicyTightened": "Het wachtwoordbeleid is gewijzigd. Werk uw wachtwoord bij om OpenObserve te blijven gebruiken.",
"reasonRotationExpired": "Uw wachtwoord is verlopen. Stel een nieuw wachtwoord in om OpenObserve te blijven gebruiken.",
"reasonRotationWarning": "Uw wachtwoord verloopt binnenkort. Stel nu een nieuw wachtwoord in om te voorkomen dat u buitengesloten raakt van uw werk.",
"reused": "Dit wachtwoord is onlangs gebruikt. Kies een wachtwoord dat u niet hebt gebruikt in uw laatste {count} wachtwoorden.",
"expiryBanner": "Uw wachtwoord verloopt over {count} dag. Werk het nu bij om te voorkomen dat u buitengesloten raakt van uw werk. | Uw wachtwoord verloopt over {count} dagen. Werk het nu bij om te voorkomen dat u buitengesloten raakt van uw werk.",
"expiryBannerAction": "Nu bijwerken",
"expiryBannerDismiss": "Sluiten",
"currentPassword": "Huidig wachtwoord",
"newPassword": "Nieuw wachtwoord",
"confirmPassword": "Bevestig nieuw wachtwoord",
"currentPasswordRequired": "Huidig wachtwoord is vereist.",
"newPasswordRequired": "Nieuw wachtwoord is vereist.",
"confirmPasswordRequired": "Voer het nieuwe wachtwoord opnieuw in.",
"mismatch": "Wachtwoorden komen niet overeen.",
"signOut": "Uitloggen",
"submit": "Wachtwoord bijwerken",
"updated": "Uw wachtwoord is bijgewerkt. Meld u opnieuw aan.",
"updateFailed": "Uw wachtwoord kon niet worden bijgewerkt.",
"req": {
"minLength": "Minimaal {count} tekens",
"maxLength": "Maximaal {count} tekens",
"uppercase": "Eén hoofdletter",
"lowercase": "Eén kleine letter",
"digit": "Eén cijfer",
"special": "Eén speciaal teken",
"specialFromSet": "Eén speciaal teken uit {set}",
"unmet": "Wachtwoord moet bevatten: {requirement}"
}
},
"routeTitles": {
"passwordPolicy": "Wachtwoordbeleid",
"loginCallback": "Login-callback",
"slackOAuthCallback": "Slack verbinden",
"emptyStateDemo": "Lege-statusdemo",
+114
View File
@@ -1906,6 +1906,7 @@
"startFreeTrial": "Rozpocznij bezpłatny okres próbny"
},
"login": {
"lockedOut": "Zbyt wiele nieudanych prób logowania. Spróbuj ponownie za {duration} lub skontaktuj się z administratorem.",
"redirectingToSsoMessage": "Przekierowywanie do strony logowania SSO. Jeśli nie nastąpi przekierowanie, proszę",
"clickHere": "kliknij tutaj",
"loginWithSso": "Zaloguj się przez SSO",
@@ -1965,6 +1966,15 @@
"fieldRequired": "Pole jest wymagane",
"passwordRequired": "Hasło jest wymagane.",
"passwordPolicyHint": "Hasło musi mieć 8-128 znaków i zawierać co najmniej jedną małą literę, jedną wielką literę, jedną cyfrę i jeden znak specjalny.",
"lockout": {
"locked": "Konto zablokowane",
"autoUnlock": "Odblokuje się automatycznie za {duration}.",
"staged": "Zostanie odblokowane po zapisaniu.",
"unlock": "Odblokuj",
"undo": "Pozostaw zablokowane",
"confirmTitle": "Odblokować {email}?",
"confirmMessage": "Powoduje to wyczyszczenie licznika nieudanych prób i umożliwia natychmiastowe ponowne zalogowanie. Hasło nie zostanie zmienione."
},
"currentPasswordRequired": "Aktualne hasło jest wymagane.",
"newPasswordRequired": "Nowe hasło jest wymagane.",
"otherOrgInvalid": "Nazwa organizacji musi zaczynać się od litery i zawierać tylko litery, cyfry, _ lub -.",
@@ -8810,6 +8820,8 @@
"cipherKeysDesc": "Klucze szyfrowania dla poufnych pól",
"regexPatternsDesc": "Wielokrotnego użytku wzorce regex do maskowania",
"domainManagementDesc": "Ogranicz logowanie do dozwolonych domen SSO",
"passwordPolicy": "Zasady haseł",
"passwordPolicyDesc": "Zasady uwierzytelniania dla natywnych kont e-mail/hasło, stosowane w całej instancji.",
"pipelineDestinationsDesc": "Zewnętrzne cele dla wyjścia potoku",
"templatesDesc": "Wielokrotnego użytku szablony wiadomości alertów",
"storageSettingsDesc": "Konfiguracja przechowywania na organizację",
@@ -16043,6 +16055,9 @@
},
"layouts": {
"ingestionNotStarted": "Nie zainicjowano jeszcze procesu pozyskiwania danych. Aby przeglądać inne strony, rozpocznij pozyskiwanie danych."
},
"iam": {
"userSaveFailed": "Nie udało się zapisać użytkownika"
}
},
"cipherKeys": {
@@ -16460,7 +16475,106 @@
"exponentName": "Wykładnik"
}
},
"passwordPolicy": {
"complexity": "Złożoność",
"minLength": "Minimalna długość",
"minLengthDesc": "Najkrótsze akceptowane hasło",
"maxLength": "Maksymalna długość",
"maxLengthDesc": "0 = bez limitu",
"requireUppercase": "Wymagaj wielkiej litery",
"requireUppercaseDesc": "Co najmniej jedna litera A–Z",
"requireLowercase": "Wymagaj małej litery",
"requireLowercaseDesc": "Co najmniej jedna litera a–z",
"requireDigit": "Wymagaj cyfry",
"requireDigitDesc": "Co najmniej jedna cyfra 0–9",
"requireSpecial": "Wymagaj znaku specjalnego",
"requireSpecialDesc": "Co najmniej jeden z dozwolonego zestawu",
"specialCharSet": "Dozwolone znaki specjalne",
"specialCharSetDesc": "Pozostaw puste, aby akceptować dowolny znak niealfanumeryczny",
"maxLengthTooSmall": "Maksymalna długość musi wynosić co najmniej tyle, ile minimalna, albo 0 dla braku limitu.",
"confirmTitle": "Zapisać zasady haseł?",
"confirmMessage": "Zapisanie może wymusić na każdym natywnym użytkowniku ustawienie nowego hasła przed dalszym korzystaniem. Konto root, użytkownicy zewnętrzni i konta usług są zwolnione — dotyczy to również Ciebie, chyba że jesteś kontem root. Tej operacji nie można cofnąć.",
"saved": "Zasady haseł zostały zapisane. | Zasady haseł zostały zapisane. {count} użytkowników musi zresetować swoje hasła. | Zasady haseł zostały zapisane. {count} użytkowników musi zresetować swoje hasła.",
"saveFailed": "Nie udało się zapisać zasad haseł.",
"notAdminTitle": "Wymagany dostęp administratora",
"notAdmin": "Konfigurowanie zasad haseł wymaga administratora organizacji meta.",
"loadFailedTitle": "Nie udało się wczytać zasad haseł",
"loadFailed": "Wystąpił problem podczas odczytywania bieżących zasad.",
"rotation": "Rotacja",
"rotationDays": "Okres rotacji",
"rotationDaysDesc": "Liczba dni do wygaśnięcia hasła. 0 = nigdy nie wygasa",
"rotationWarningDays": "Ostrzegaj przed wygaśnięciem",
"rotationWarningDaysDesc": "Liczba dni powiadomienia przy logowaniu. 0 = brak ostrzeżenia",
"warningDaysTooLong": "Okno ostrzeżenia nie może być dłuższe niż okres rotacji.",
"rotationPreview": "Hasło ustawione dzisiaj wygasa {expires}. Ostrzeżenie przy logowaniu rozpoczyna się {warns}.",
"rotationPreviewNoWarning": "Hasło ustawione dzisiaj wygasa {expires}. Do tego czasu nie jest wyświetlane żadne ostrzeżenie.",
"reuse": "Zapobieganie ponownemu użyciu",
"historyCount": "Zapamiętane hasła",
"historyCountDesc": "Nowe hasło nie może być takie samo jak ta liczba poprzednich. 0 = brak sprawdzania",
"historyMaxRetained": "Zachowana historia",
"historyMaxRetainedDesc": "Ile poprzednich haseł jest zachowywanych na użytkownika",
"historyRetainedTooSmall": "Zachowana historia musi wynosić co najmniej tyle, ile liczba zapamiętanych haseł.",
"reuseExplainer": "Bieżące hasło liczy się jako jedno z zapamiętanego zestawu — jego ponowne użycie jest odrzucane przez tę regułę, a nie przez osobne sprawdzenie.",
"lockout": "Blokada konta",
"lockoutThreshold": "Nieudane próby przed blokadą",
"lockoutThresholdDesc": "0 = blokada wyłączona",
"lockoutBucketSize": "Próby przed każdą kolejną blokadą",
"lockoutBucketSizeDesc": "0 = użyj wartości powyżej",
"lockoutStartSecs": "Czas trwania pierwszej blokady",
"lockoutStartSecsDesc": "Sekundy",
"lockoutMaxSecs": "Maksymalny czas trwania blokady",
"lockoutMaxSecsDesc": "Sekundy — pułap, do którego ograniczana jest eskalacja",
"lockoutStartTooLong": "Pierwsza blokada nie może trwać dłużej niż maksymalna blokada.",
"lockoutBackoff": "Eskalacja",
"lockoutBackoffDesc": "Jak każda blokada rośnie względem poprzedniej",
"backoffExponential": "Wykładnicza",
"backoffLinear": "Liniowa",
"lockoutPreview": "{threshold} nieudanych prób → {first} · co {bucket} kolejnych prób → {rest} · ograniczone do {max}. Udane logowanie resetuje wszystko.",
"sessionEnforcement": "Sesja i egzekwowanie",
"cookieMaxAge": "Czas życia sesji",
"cookieMaxAgeDesc": "Liczba sekund, przez jaką zalogowana sesja pozostaje ważna. 0 = użyj domyślnej wartości instancji z {envVar}",
"applyToRoot": "Zastosuj do konta root",
"applyToRootDesc": "Konto root jest zwolnione ze wszystkich powyższych reguł, chyba że ta opcja jest włączona.",
"rootWarning": "Zablokowanie konta root może wprowadzić system w stan nie do odzyskania.",
"rootDialogTitle": "Zastosować politykę do konta root?",
"rootDialogMessage": "Konto root będzie podlegać blokadzie, rotacji i wymuszonym resetom tak jak każde inne konto natywne. Jeśli root jest Twoim jedynym kontem administracyjnym, blokada lub wygaśnięcie, którego nie da się spełnić, sprawi, że nie będzie już drogi powrotnej do tej instancji.",
"rootDialogAck": "Rozumiem, że konto root może zostać trwale zablokowane w tej instancji.",
"rootDialogConfirm": "Zastosuj do konta root"
},
"passwordReset": {
"title": "Zaktualizuj hasło",
"signedInAs": "Zalogowano jako {email}",
"reasonPolicyTightened": "Polityka haseł uległa zmianie. Zaktualizuj hasło, aby dalej korzystać z OpenObserve.",
"reasonRotationExpired": "Twoje hasło wygasło. Ustaw nowe, aby dalej korzystać z OpenObserve.",
"reasonRotationWarning": "Twoje hasło wkrótce wygaśnie. Ustaw nowe teraz, aby uniknąć zablokowania dostępu do pracy.",
"reused": "To hasło było niedawno używane. Wybierz hasło, którego nie było wśród Twoich ostatnich {count} haseł.",
"expiryBanner": "Twoje hasło wygasa za {count} dzień. Zaktualizuj je teraz, aby uniknąć zablokowania dostępu do pracy. | Twoje hasło wygasa za {count} dni. Zaktualizuj je teraz, aby uniknąć zablokowania dostępu do pracy.",
"expiryBannerAction": "Zaktualizuj teraz",
"expiryBannerDismiss": "Odrzuć",
"currentPassword": "Obecne hasło",
"newPassword": "Nowe hasło",
"confirmPassword": "Potwierdź nowe hasło",
"currentPasswordRequired": "Obecne hasło jest wymagane.",
"newPasswordRequired": "Nowe hasło jest wymagane.",
"confirmPasswordRequired": "Wprowadź ponownie nowe hasło.",
"mismatch": "Hasła nie są zgodne.",
"signOut": "Wyloguj się",
"submit": "Zaktualizuj hasło",
"updated": "Twoje hasło zostało zaktualizowane. Zaloguj się ponownie.",
"updateFailed": "Nie udało się zaktualizować hasła.",
"req": {
"minLength": "Co najmniej {count} znaków",
"maxLength": "Nie więcej niż {count} znaków",
"uppercase": "Jedna wielka litera",
"lowercase": "Jedna mała litera",
"digit": "Jedna cyfra",
"special": "Jeden znak specjalny",
"specialFromSet": "Jeden znak specjalny z {set}",
"unmet": "Hasło musi zawierać: {requirement}"
}
},
"routeTitles": {
"passwordPolicy": "Polityka haseł",
"loginCallback": "Callback logowania",
"slackOAuthCallback": "Łączenie ze Slackiem",
"emptyStateDemo": "Demo pustego stanu",
+114
View File
@@ -1906,6 +1906,7 @@
"startFreeTrial": "Iniciar avaliação gratuita"
},
"login": {
"lockedOut": "Muitas tentativas de login falharam. Tente novamente em {duration} ou contate seu administrador.",
"redirectingToSsoMessage": "Redirecionando para a página de login SSO. Se você não for redirecionado, por favor",
"clickHere": "clique aqui",
"loginWithSso": "Entrar com SSO",
@@ -1965,6 +1966,15 @@
"fieldRequired": "O campo é obrigatório",
"passwordRequired": "A senha é obrigatória.",
"passwordPolicyHint": "A senha deve ter de 8 a 128 caracteres e conter pelo menos uma letra minúscula, uma letra maiúscula, um dígito e um caractere especial.",
"lockout": {
"locked": "Conta bloqueada",
"autoUnlock": "Desbloqueia automaticamente em {duration}.",
"staged": "Será desbloqueada quando você salvar.",
"unlock": "Desbloquear",
"undo": "Manter bloqueada",
"confirmTitle": "Desbloquear {email}?",
"confirmMessage": "Isso zera a contagem de tentativas falhas e permite que ele faça login novamente imediatamente. Não altera a senha."
},
"currentPasswordRequired": "A senha atual é obrigatória.",
"newPasswordRequired": "É necessária uma nova senha.",
"otherOrgInvalid": "O nome da organização deve começar com uma letra e ser alfanumérico, _ ou -.",
@@ -8810,6 +8820,8 @@
"cipherKeysDesc": "Chaves de criptografia para campos sensíveis",
"regexPatternsDesc": "Padrões regex reutilizáveis para redação",
"domainManagementDesc": "Restringir o login a domínios SSO permitidos",
"passwordPolicy": "Política de senhas",
"passwordPolicyDesc": "Políticas de autenticação para contas nativas de e-mail/senha, aplicadas a toda a instância.",
"pipelineDestinationsDesc": "Destinos externos para a saída do pipeline",
"templatesDesc": "Modelos reutilizáveis de mensagens de alerta",
"storageSettingsDesc": "Configuração de armazenamento por organização",
@@ -16043,6 +16055,9 @@
},
"layouts": {
"ingestionNotStarted": "Você ainda não iniciou o processo de ingestão de dados. Para explorar outras páginas, inicie a ingestão de dados."
},
"iam": {
"userSaveFailed": "Não foi possível salvar o usuário"
}
},
"cipherKeys": {
@@ -16460,7 +16475,106 @@
"exponentName": "Expoente"
}
},
"passwordPolicy": {
"complexity": "Complexidade",
"minLength": "Comprimento mínimo",
"minLengthDesc": "Menor senha aceita",
"maxLength": "Comprimento máximo",
"maxLengthDesc": "0 = sem limite",
"requireUppercase": "Exigir letra maiúscula",
"requireUppercaseDesc": "Pelo menos um A–Z",
"requireLowercase": "Exigir letra minúscula",
"requireLowercaseDesc": "Pelo menos um a–z",
"requireDigit": "Exigir dígito",
"requireDigitDesc": "Pelo menos um 0–9",
"requireSpecial": "Exigir caractere especial",
"requireSpecialDesc": "Pelo menos um do conjunto permitido",
"specialCharSet": "Caracteres especiais permitidos",
"specialCharSetDesc": "Deixe vazio para aceitar qualquer caractere não alfanumérico",
"maxLengthTooSmall": "O comprimento máximo deve ser pelo menos o comprimento mínimo, ou 0 para sem limite.",
"confirmTitle": "Salvar política de senhas?",
"confirmMessage": "Salvar pode forçar todos os usuários nativos a definir uma nova senha antes de continuar. Usuários root, externos e contas de serviço estão isentos — isso inclui você, a menos que você seja a conta root. Não pode ser desfeito.",
"saved": "Política de senhas salva. | Política de senhas salva. {count} usuário deve redefinir a senha. | Política de senhas salva. {count} usuários devem redefinir a senha.",
"saveFailed": "Não foi possível salvar a política de senhas.",
"notAdminTitle": "Acesso de administrador necessário",
"notAdmin": "Configurar a política de senhas exige um administrador da meta organização.",
"loadFailedTitle": "Não foi possível carregar a política de senhas",
"loadFailed": "Algo deu errado ao ler a política atual.",
"rotation": "Rotação",
"rotationDays": "Período de rotação",
"rotationDaysDesc": "Dias antes de a senha expirar. 0 = nunca expira",
"rotationWarningDays": "Avisar antes da expiração",
"rotationWarningDaysDesc": "Dias de antecedência no login. 0 = sem aviso",
"warningDaysTooLong": "O período de aviso não pode ser maior que o período de rotação.",
"rotationPreview": "Uma senha definida hoje expira {expires}. O aviso no login começa {warns}.",
"rotationPreviewNoWarning": "Uma senha definida hoje expira {expires}. Nenhum aviso é exibido antes disso.",
"reuse": "Prevenção de reutilização",
"historyCount": "Senhas memorizadas",
"historyCountDesc": "Uma nova senha não pode ser igual a esta quantidade de senhas anteriores. 0 = sem verificação",
"historyMaxRetained": "Histórico retido",
"historyMaxRetainedDesc": "Quantas senhas anteriores são mantidas por usuário",
"historyRetainedTooSmall": "O histórico retido deve ser pelo menos igual ao número de senhas memorizadas.",
"reuseExplainer": "A senha atual conta como uma das senhas do conjunto memorizado — reutilizá-la é recusado por esta regra, não por uma verificação separada.",
"lockout": "Bloqueio de conta",
"lockoutThreshold": "Tentativas falhas antes do bloqueio",
"lockoutThresholdDesc": "0 = bloqueio desativado",
"lockoutBucketSize": "Tentativas antes de cada bloqueio adicional",
"lockoutBucketSizeDesc": "0 = reutiliza o valor acima",
"lockoutStartSecs": "Duração do primeiro bloqueio",
"lockoutStartSecsDesc": "Segundos",
"lockoutMaxSecs": "Duração máxima do bloqueio",
"lockoutMaxSecsDesc": "Segundos — o teto ao qual a escalada é limitada",
"lockoutStartTooLong": "O primeiro bloqueio não pode ser maior que o bloqueio máximo.",
"lockoutBackoff": "Escalada",
"lockoutBackoffDesc": "Como cada bloqueio cresce em relação ao anterior",
"backoffExponential": "Exponencial",
"backoffLinear": "Linear",
"lockoutPreview": "{threshold} falhas → {first} · a cada {bucket} adicionais → {rest} · limitado a {max}. Um login bem-sucedido redefine tudo.",
"sessionEnforcement": "Sessão e aplicação",
"cookieMaxAge": "Duração da sessão",
"cookieMaxAgeDesc": "Segundos durante os quais uma sessão autenticada permanece válida. 0 = usar o padrão da instância de {envVar}",
"applyToRoot": "Aplicar à conta root",
"applyToRootDesc": "O root está isento de todas as regras acima, a menos que isto esteja ativado",
"rootWarning": "Bloquear o root pode levar o sistema a um estado irrecuperável.",
"rootDialogTitle": "Aplicar a política ao root?",
"rootDialogMessage": "O root ficará sujeito a bloqueio, rotação e redefinições forçadas como qualquer outra conta nativa. Se o root for sua única conta administrativa, um bloqueio ou uma expiração que ele não consiga atender deixará você sem forma de voltar a esta instância.",
"rootDialogAck": "Entendo que o root pode ser bloqueado permanentemente desta instância.",
"rootDialogConfirm": "Aplicar ao root"
},
"passwordReset": {
"title": "Atualize sua senha",
"signedInAs": "Conectado como {email}",
"reasonPolicyTightened": "A política de senhas mudou. Atualize sua senha para continuar usando o OpenObserve.",
"reasonRotationExpired": "Sua senha expirou. Defina uma nova para continuar usando o OpenObserve.",
"reasonRotationWarning": "Sua senha expira em breve. Defina uma nova agora para não perder o acesso ao seu trabalho.",
"reused": "Esta senha foi usada recentemente. Escolha uma que você não tenha usado nas suas últimas {count} senhas.",
"expiryBanner": "Sua senha expira em {count} dia. Atualize-a agora para não perder o acesso ao seu trabalho. | Sua senha expira em {count} dias. Atualize-a agora para não perder o acesso ao seu trabalho.",
"expiryBannerAction": "Atualizar agora",
"expiryBannerDismiss": "Dispensar",
"currentPassword": "Senha atual",
"newPassword": "Nova senha",
"confirmPassword": "Confirme a nova senha",
"currentPasswordRequired": "A senha atual é obrigatória.",
"newPasswordRequired": "A nova senha é obrigatória.",
"confirmPasswordRequired": "Digite novamente a nova senha.",
"mismatch": "As senhas não coincidem.",
"signOut": "Sair",
"submit": "Atualizar senha",
"updated": "Sua senha foi atualizada. Faça login novamente.",
"updateFailed": "Não foi possível atualizar sua senha.",
"req": {
"minLength": "Pelo menos {count} caracteres",
"maxLength": "No máximo {count} caracteres",
"uppercase": "Uma letra maiúscula",
"lowercase": "Uma letra minúscula",
"digit": "Um dígito",
"special": "Um caractere especial",
"specialFromSet": "Um caractere especial de {set}",
"unmet": "A senha deve ter: {requirement}"
}
},
"routeTitles": {
"passwordPolicy": "Política de senha",
"loginCallback": "Callback de login",
"slackOAuthCallback": "Conectando ao Slack",
"emptyStateDemo": "Demonstração de estado vazio",
+114
View File
@@ -1906,6 +1906,7 @@
"startFreeTrial": "Начать бесплатный пробный период"
},
"login": {
"lockedOut": "Слишком много неудачных попыток входа. Повторите попытку через {duration} или обратитесь к администратору.",
"redirectingToSsoMessage": "Перенаправление на страницу входа SSO. Если перенаправление не произошло, пожалуйста",
"clickHere": "нажмите здесь",
"loginWithSso": "Войти через SSO",
@@ -1965,6 +1966,15 @@
"fieldRequired": "Поле обязательно",
"passwordRequired": "Требуется пароль.",
"passwordPolicyHint": "Пароль должен содержать от 8 до 128 символов, включая как минимум одну строчную букву, одну заглавную букву, одну цифру и один специальный символ.",
"lockout": {
"locked": "Учетная запись заблокирована",
"autoUnlock": "Автоматически разблокируется через {duration}.",
"staged": "Будет разблокирована при сохранении.",
"unlock": "Разблокировать",
"undo": "Оставить заблокированной",
"confirmTitle": "Разблокировать {email}?",
"confirmMessage": "Это сбрасывает счетчик неудачных попыток и позволяет сразу войти снова. Пароль при этом не меняется."
},
"currentPasswordRequired": "Требуется текущий пароль.",
"newPasswordRequired": "Требуется новый пароль.",
"otherOrgInvalid": "Имя организации должно начинаться с буквы, содержать буквы, цифры, _ или -.",
@@ -8810,6 +8820,8 @@
"cipherKeysDesc": "Ключи шифрования для конфиденциальных полей",
"regexPatternsDesc": "Многоразовые шаблоны regex для редактирования",
"domainManagementDesc": "Ограничить вход разрешенными доменами SSO",
"passwordPolicy": "Политика паролей",
"passwordPolicyDesc": "Политики аутентификации для встроенных учетных записей с email/паролем, применяются на уровне всего экземпляра.",
"pipelineDestinationsDesc": "Внешние цели для вывода конвейера",
"templatesDesc": "Многоразовые шаблоны сообщений оповещений",
"storageSettingsDesc": "Конфигурация хранилища для каждой организации",
@@ -16043,6 +16055,9 @@
},
"layouts": {
"ingestionNotStarted": "Вы еще не начали процесс приема данных. Чтобы просмотреть другие страницы, начните прием данных."
},
"iam": {
"userSaveFailed": "Не удалось сохранить пользователя"
}
},
"cipherKeys": {
@@ -16460,7 +16475,106 @@
"exponentName": "Степень"
}
},
"passwordPolicy": {
"complexity": "Сложность",
"minLength": "Минимальная длина",
"minLengthDesc": "Наименьшая принимаемая длина пароля",
"maxLength": "Максимальная длина",
"maxLengthDesc": "0 = без ограничений",
"requireUppercase": "Требовать заглавную букву",
"requireUppercaseDesc": "Минимум одна буква A–Z",
"requireLowercase": "Требовать строчную букву",
"requireLowercaseDesc": "Минимум одна буква a–z",
"requireDigit": "Требовать цифру",
"requireDigitDesc": "Минимум одна цифра 0–9",
"requireSpecial": "Требовать специальный символ",
"requireSpecialDesc": "Минимум один символ из разрешенного набора",
"specialCharSet": "Разрешенные специальные символы",
"specialCharSetDesc": "Оставьте пустым, чтобы принимать любой небуквенно-цифровой символ",
"maxLengthTooSmall": "Максимальная длина должна быть не меньше минимальной или равна 0 для отсутствия ограничений.",
"confirmTitle": "Сохранить политику паролей?",
"confirmMessage": "После сохранения всем встроенным пользователям может потребоваться задать новый пароль, прежде чем продолжить работу. Root, внешние пользователи и сервисные учетные записи не затрагиваются — включая вас, если вы не учетная запись root. Это действие нельзя отменить.",
"saved": "Политика паролей сохранена. | Политика паролей сохранена. {count} пользователь должен сбросить пароль. | Политика паролей сохранена. {count} пользователей должны сбросить пароль.",
"saveFailed": "Не удалось сохранить политику паролей.",
"notAdminTitle": "Требуется доступ администратора",
"notAdmin": "Для настройки политики паролей требуются права администратора мета-организации.",
"loadFailedTitle": "Не удалось загрузить политику паролей",
"loadFailed": "При чтении текущей политики произошла ошибка.",
"rotation": "Ротация",
"rotationDays": "Период ротации",
"rotationDaysDesc": "Дней до истечения срока действия пароля. 0 = не истекает",
"rotationWarningDays": "Предупреждать до истечения",
"rotationWarningDaysDesc": "За сколько дней предупреждать при входе. 0 = без предупреждения",
"warningDaysTooLong": "Окно предупреждения не может быть больше периода ротации.",
"rotationPreview": "Пароль, заданный сегодня, истекает {expires}. Предупреждение при входе начнется {warns}.",
"rotationPreviewNoWarning": "Пароль, заданный сегодня, истекает {expires}. До этого предупреждение не показывается.",
"reuse": "Запрет повторного использования",
"historyCount": "Запоминаемых паролей",
"historyCountDesc": "Новый пароль не должен совпадать с таким числом предыдущих. 0 = без проверки",
"historyMaxRetained": "Хранимая история",
"historyMaxRetainedDesc": "Сколько прошлых паролей хранится для каждого пользователя",
"historyRetainedTooSmall": "Хранимая история должна быть не меньше числа запоминаемых паролей.",
"reuseExplainer": "Текущий пароль входит в запоминаемый набор — его повторное использование отклоняется этим правилом, а не отдельной проверкой.",
"lockout": "Блокировка аккаунта",
"lockoutThreshold": "Неудачных попыток до блокировки",
"lockoutThresholdDesc": "0 = блокировка отключена",
"lockoutBucketSize": "Попыток до каждой следующей блокировки",
"lockoutBucketSizeDesc": "0 = использовать значение выше",
"lockoutStartSecs": "Длительность первой блокировки",
"lockoutStartSecsDesc": "Секунды",
"lockoutMaxSecs": "Максимальная длительность блокировки",
"lockoutMaxSecsDesc": "Секунды — верхний предел, до которого ограничивается эскалация",
"lockoutStartTooLong": "Первая блокировка не может быть длиннее максимальной блокировки.",
"lockoutBackoff": "Эскалация",
"lockoutBackoffDesc": "Как каждая блокировка растёт относительно предыдущей",
"backoffExponential": "Экспоненциальная",
"backoffLinear": "Линейная",
"lockoutPreview": "{threshold} неудачных попыток → {first} · каждые {bucket} ещё → {rest} · не более {max}. Успешный вход сбрасывает всё.",
"sessionEnforcement": "Сессия и применение политики",
"cookieMaxAge": "Время жизни сессии",
"cookieMaxAgeDesc": "Сколько секунд сессия остаётся действительной. 0 = использовать значение экземпляра по умолчанию из {envVar}",
"applyToRoot": "Применить к root-аккаунту",
"applyToRootDesc": "Root не подчиняется ни одному из правил выше, пока это не включено",
"rootWarning": "Блокировка root может привести систему в невосстановимое состояние.",
"rootDialogTitle": "Применить политику к root?",
"rootDialogMessage": "Root будет подчиняться блокировке, смене пароля и принудительным сбросам, как любой другой нативный аккаунт. Если root — единственный административный аккаунт, блокировка или истечение срока, которое он не сможет продлить, оставят вас без способа вернуться в этот экземпляр.",
"rootDialogAck": "Я понимаю, что root может быть навсегда заблокирован в этом экземпляре.",
"rootDialogConfirm": "Применить к root"
},
"passwordReset": {
"title": "Обновите свой пароль",
"signedInAs": "Вы вошли как {email}",
"reasonPolicyTightened": "Политика паролей изменилась. Обновите пароль, чтобы продолжить работу с OpenObserve.",
"reasonRotationExpired": "Срок действия вашего пароля истёк. Задайте новый, чтобы продолжить работу с OpenObserve.",
"reasonRotationWarning": "Срок действия вашего пароля скоро истечёт. Задайте новый сейчас, чтобы не потерять доступ к работе.",
"reused": "Этот пароль недавно использовался. Выберите пароль, которого нет среди последних {count} использованных паролей.",
"expiryBanner": "Срок действия вашего пароля истекает через {count} день. Обновите его сейчас, чтобы не потерять доступ к работе. | Срок действия вашего пароля истекает через {count} дней. Обновите его сейчас, чтобы не потерять доступ к работе.",
"expiryBannerAction": "Обновить сейчас",
"expiryBannerDismiss": "Закрыть",
"currentPassword": "Текущий пароль",
"newPassword": "Новый пароль",
"confirmPassword": "Подтвердите новый пароль",
"currentPasswordRequired": "Введите текущий пароль.",
"newPasswordRequired": "Введите новый пароль.",
"confirmPasswordRequired": "Повторно введите новый пароль.",
"mismatch": "Пароли не совпадают.",
"signOut": "Выйти",
"submit": "Обновить пароль",
"updated": "Пароль обновлён. Войдите снова.",
"updateFailed": "Не удалось обновить пароль.",
"req": {
"minLength": "Не менее {count} символов",
"maxLength": "Не более {count} символов",
"uppercase": "Одна заглавная буква",
"lowercase": "Одна строчная буква",
"digit": "Одна цифра",
"special": "Один специальный символ",
"specialFromSet": "Один специальный символ из {set}",
"unmet": "Пароль должен содержать: {requirement}"
}
},
"routeTitles": {
"passwordPolicy": "Политика паролей",
"loginCallback": "Обратный вызов входа",
"slackOAuthCallback": "Подключение Slack",
"emptyStateDemo": "Демо пустого состояния",
+114
View File
@@ -1906,6 +1906,7 @@
"startFreeTrial": "Ücretsiz denemeye başla"
},
"login": {
"lockedOut": "Çok fazla başarısız oturum açma denemesi. {duration} sonra tekrar deneyin veya yöneticinizle iletişime geçin.",
"redirectingToSsoMessage": "SSO giriş sayfasına yönlendiriliyorsunuz. Yönlendirilmezseniz, lütfen",
"clickHere": "buraya tıklayın",
"loginWithSso": "SSO ile giriş yap",
@@ -1965,6 +1966,15 @@
"fieldRequired": "Alan zorunludur",
"passwordRequired": "Şifre gereklidir.",
"passwordPolicyHint": "Şifre 8-128 karakter olmalı ve en az bir küçük harf, bir büyük harf, bir basamak ve bir özel karakter içermelidir.",
"lockout": {
"locked": "Hesap kilitlendi",
"autoUnlock": "{duration} sonra otomatik olarak kilidi açılır.",
"staged": "Kaydettiğinizde kilidi açılacak.",
"unlock": "Kilidi aç",
"undo": "Kilitli tut",
"confirmTitle": "{email} kullanıcısının kilidi açılsın mı?",
"confirmMessage": "Bu işlem, başarısız deneme sayısını sıfırlar ve hemen tekrar oturum açmalarını sağlar. Parolalarını değiştirmez."
},
"currentPasswordRequired": "Geçerli şifre gereklidir.",
"newPasswordRequired": "Yeni şifre gereklidir.",
"otherOrgInvalid": "Kuruluş adı bir harfle başlamalı ve alfasayısal, _ veya - olmalıdır.",
@@ -8810,6 +8820,8 @@
"cipherKeysDesc": "Hassas alanlar için şifreleme anahtarları",
"regexPatternsDesc": "Maskeleme için yeniden kullanılabilir regex desenleri",
"domainManagementDesc": "Oturum açmayı izin verilen SSO alan adlarıyla kısıtlayın",
"passwordPolicy": "Parola İlkesi",
"passwordPolicyDesc": "Yerel e-posta/parola hesapları için kimlik doğrulama ilkeleri, örnek genelinde uygulanır.",
"pipelineDestinationsDesc": "Pipeline çıktısı için harici hedefler",
"templatesDesc": "Yeniden kullanılabilir uyarı mesajı şablonları",
"storageSettingsDesc": "Organizasyon bazında depolama yapılandırması",
@@ -16043,6 +16055,9 @@
},
"layouts": {
"ingestionNotStarted": "Veri alım sürecini henüz başlatmadınız. Diğer sayfaları keşfetmek için lütfen veri alımını başlatın."
},
"iam": {
"userSaveFailed": "Kullanıcı kaydedilemedi"
}
},
"cipherKeys": {
@@ -16460,7 +16475,106 @@
"exponentName": "Üs"
}
},
"passwordPolicy": {
"complexity": "Karmaşıklık",
"minLength": "Minimum uzunluk",
"minLengthDesc": "Kabul edilen en kısa parola",
"maxLength": "Maksimum uzunluk",
"maxLengthDesc": "0 = sınırsız",
"requireUppercase": "Büyük harf zorunlu",
"requireUppercaseDesc": "En az bir A–Z",
"requireLowercase": "Küçük harf zorunlu",
"requireLowercaseDesc": "En az bir a–z",
"requireDigit": "Rakam zorunlu",
"requireDigitDesc": "En az bir 0–9",
"requireSpecial": "Özel karakter zorunlu",
"requireSpecialDesc": "İzin verilen kümeden en az bir karakter",
"specialCharSet": "İzin verilen özel karakterler",
"specialCharSetDesc": "Herhangi bir alfanümerik olmayan karakteri kabul etmek için boş bırakın",
"maxLengthTooSmall": "Maksimum uzunluk en az minimum uzunluk kadar olmalı veya sınırsız için 0 olmalıdır.",
"confirmTitle": "Parola ilkesi kaydedilsin mi?",
"confirmMessage": "Kaydetmek, her yerel kullanıcıyı devam edebilmesi için yeni bir parola belirlemeye zorlayabilir. Root, harici kullanıcılar ve hizmet hesapları muaftır — root hesabı değilseniz bu sizi de kapsar. Bu işlem geri alınamaz.",
"saved": "Parola ilkesi kaydedildi. | Parola ilkesi kaydedildi. {count} kullanıcının parolasını sıfırlaması gerekiyor. | Parola ilkesi kaydedildi. {count} kullanıcının parolasını sıfırlaması gerekiyor.",
"saveFailed": "Parola ilkesi kaydedilemedi.",
"notAdminTitle": "Yönetici erişimi gerekli",
"notAdmin": "Parola ilkesini yapılandırmak için meta organizasyonun bir yöneticisi gerekir.",
"loadFailedTitle": "Parola ilkesi yüklenemedi",
"loadFailed": "Geçerli ilke okunurken bir şeyler ters gitti.",
"rotation": "Rotasyon",
"rotationDays": "Rotasyon süresi",
"rotationDaysDesc": "Parolanın süresi dolmadan önceki gün sayısı. 0 = hiç süresi dolmaz",
"rotationWarningDays": "Süresi dolmadan önce uyar",
"rotationWarningDaysDesc": "Oturum açma sırasında bildirim gün sayısı. 0 = uyarı yok",
"warningDaysTooLong": "Uyarı penceresi rotasyon süresinden uzun olamaz.",
"rotationPreview": "Bugün belirlenen bir parolanın süresi {expires} tarihinde dolar. Oturum açma uyarısı {warns} tarihinde başlar.",
"rotationPreviewNoWarning": "Bugün belirlenen bir parolanın süresi {expires} tarihinde dolar. O zamana kadar uyarı gösterilmez.",
"reuse": "Yeniden kullanımı önleme",
"historyCount": "Hatırlanan parolalar",
"historyCountDesc": "Yeni bir parola bu sayıdaki önceki parolayla eşleşemez. 0 = kontrol yok",
"historyMaxRetained": "Saklanan geçmiş",
"historyMaxRetainedDesc": "Kullanıcı başına kaç geçmiş parolanın saklandığı",
"historyRetainedTooSmall": "Saklanan geçmiş, hatırlanan parola sayısından az olamaz.",
"reuseExplainer": "Geçerli parola, hatırlanan kümenin bir parçası sayılır — onu yeniden kullanmak ayrı bir kontrolle değil, bu kuralla reddedilir.",
"lockout": "Hesap kilitleme",
"lockoutThreshold": "Kilitleme öncesi başarısız deneme sayısı",
"lockoutThresholdDesc": "0 = kilitleme devre dışı",
"lockoutBucketSize": "Her ek kilitleme öncesi deneme sayısı",
"lockoutBucketSizeDesc": "0 = yukarıdaki değeri kullan",
"lockoutStartSecs": "İlk kilitleme süresi",
"lockoutStartSecsDesc": "Saniye",
"lockoutMaxSecs": "Maksimum kilitleme süresi",
"lockoutMaxSecsDesc": "Saniye — artışın sınırlandığı üst değer",
"lockoutStartTooLong": "İlk kilitleme süresi maksimum kilitleme süresinden uzun olamaz.",
"lockoutBackoff": "Artış",
"lockoutBackoffDesc": "Her kilitlemenin bir öncekine göre nasıl arttığı",
"backoffExponential": "Üstel",
"backoffLinear": "Doğrusal",
"lockoutPreview": "{threshold} başarısız deneme → {first} · her {bucket} denemede bir → {rest} · üst sınır {max}. Başarılı bir oturum açma her şeyi sıfırlar.",
"sessionEnforcement": "Oturum ve zorlama",
"cookieMaxAge": "Oturum ömrü",
"cookieMaxAgeDesc": "Oturum açmış bir oturumun geçerli kaldığı saniye sayısı. 0 = {envVar} içindeki örnek varsayılanını kullan",
"applyToRoot": "Root hesabına uygula",
"applyToRootDesc": "Bu açık olmadıkça Root, yukarıdaki tüm kurallardan muaftır",
"rootWarning": "Root'un kilitlenmesi sistemin kurtarılamaz bir duruma girmesine neden olabilir.",
"rootDialogTitle": "Politika Root'a uygulansın mı?",
"rootDialogMessage": "Root, diğer tüm yerel hesaplar gibi kilitleme, rotasyon ve zorunlu sıfırlamalara tabi olacaktır. Root tek yönetici hesabınızsa, karşılayamayacağı bir kilitleme veya süre sonu bu örneğe geri dönüş yolunu tamamen kapatabilir.",
"rootDialogAck": "Root'un bu örnekten kalıcı olarak kilitlenebileceğini anlıyorum.",
"rootDialogConfirm": "Root'a uygula"
},
"passwordReset": {
"title": "Parolanızı güncelleyin",
"signedInAs": "{email} olarak oturum açıldı",
"reasonPolicyTightened": "Parola politikası değişti. OpenObserve'u kullanmaya devam etmek için parolanızı güncelleyin.",
"reasonRotationExpired": "Parolanızın süresi doldu. OpenObserve'u kullanmaya devam etmek için yeni bir parola belirleyin.",
"reasonRotationWarning": "Parolanızın süresi yakında doluyor. Çalışmalarınıza erişimi kaybetmemek için şimdi yeni bir parola belirleyin.",
"reused": "Bu parola yakın zamanda kullanıldı. Son {count} parolanızda kullanmadığınız birini seçin.",
"expiryBanner": "Parolanızın süresi {count} gün içinde doluyor. Çalışmalarınıza erişimi kaybetmemek için şimdi güncelleyin. | Parolanızın süresi {count} gün içinde doluyor. Çalışmalarınıza erişimi kaybetmemek için şimdi güncelleyin.",
"expiryBannerAction": "Şimdi güncelle",
"expiryBannerDismiss": "Yoksay",
"currentPassword": "Mevcut parola",
"newPassword": "Yeni parola",
"confirmPassword": "Yeni parolayı doğrula",
"currentPasswordRequired": "Mevcut parola gereklidir.",
"newPasswordRequired": "Yeni parola gereklidir.",
"confirmPasswordRequired": "Lütfen yeni parolayı yeniden girin.",
"mismatch": "Parolalar eşleşmiyor.",
"signOut": "Oturumu kapat",
"submit": "Parolayı güncelle",
"updated": "Parolanız güncellendi. Lütfen tekrar oturum açın.",
"updateFailed": "Parolanız güncellenemedi.",
"req": {
"minLength": "En az {count} karakter",
"maxLength": "En fazla {count} karakter",
"uppercase": "Bir büyük harf",
"lowercase": "Bir küçük harf",
"digit": "Bir rakam",
"special": "Bir özel karakter",
"specialFromSet": "{set} kümesinden bir özel karakter",
"unmet": "Parola şunu içermelidir: {requirement}"
}
},
"routeTitles": {
"passwordPolicy": "Parola Politikası",
"loginCallback": "Giriş Geri Çağrısı",
"slackOAuthCallback": "Slack'e bağlanılıyor",
"emptyStateDemo": "Boş Durum Demosu",
+114
View File
@@ -1906,6 +1906,7 @@
"startFreeTrial": "Bắt đầu dùng thử miễn phí"
},
"login": {
"lockedOut": "Quá nhiều lần đăng nhập thất bại. Thử lại sau {duration}, hoặc liên hệ quản trị viên.",
"redirectingToSsoMessage": "Đang chuyển hướng đến trang đăng nhập SSO. Nếu bạn không được chuyển hướng, vui lòng",
"clickHere": "bấm vào đây",
"loginWithSso": "Đăng nhập bằng SSO",
@@ -1965,6 +1966,15 @@
"fieldRequired": "Trường này là bắt buộc",
"passwordRequired": "Mật khẩu là bắt buộc.",
"passwordPolicyHint": "Mật khẩu phải có 8-128 ký tự và chứa ít nhất một chữ cái thường, một chữ cái in hoa, một chữ số và một ký tự đặc biệt.",
"lockout": {
"locked": "Tài khoản đã bị khóa",
"autoUnlock": "Tự động mở khóa sau {duration}.",
"staged": "Sẽ được mở khóa khi bạn lưu.",
"unlock": "Mở khóa",
"undo": "Giữ khóa",
"confirmTitle": "Mở khóa {email}?",
"confirmMessage": "Thao tác này xóa số lần đăng nhập thất bại và cho phép họ đăng nhập lại ngay lập tức. Mật khẩu của họ không thay đổi."
},
"currentPasswordRequired": "Mật khẩu hiện tại là bắt buộc.",
"newPasswordRequired": "Mật khẩu mới là bắt buộc.",
"otherOrgInvalid": "Tên tổ chức phải bắt đầu bằng chữ cái và chỉ gồm chữ cái, số, _ hoặc -.",
@@ -8810,6 +8820,8 @@
"cipherKeysDesc": "Khóa mã hóa cho trường nhạy cảm",
"regexPatternsDesc": "Mẫu regex tái sử dụng cho việc làm rõ",
"domainManagementDesc": "Giới hạn đăng nhập vào tên miền SSO được phép",
"passwordPolicy": "Chính sách mật khẩu",
"passwordPolicyDesc": "Chính sách xác thực cho các tài khoản email/mật khẩu nội bộ, áp dụng trên toàn instance.",
"pipelineDestinationsDesc": "Mục tiêu bên ngoài cho đầu ra pipeline",
"templatesDesc": "Mẫu tin nhắn cảnh báo tái sử dụng",
"storageSettingsDesc": "Cấu hình lưu trữ theo tổ chức",
@@ -16043,6 +16055,9 @@
},
"layouts": {
"ingestionNotStarted": "Bạn chưa bắt đầu quá trình thu thập dữ liệu. Để khám phá các trang khác, vui lòng bắt đầu thu thập dữ liệu."
},
"iam": {
"userSaveFailed": "Không thể lưu người dùng"
}
},
"cipherKeys": {
@@ -16460,7 +16475,106 @@
"exponentName": "Lũy thừa"
}
},
"passwordPolicy": {
"complexity": "Độ phức tạp",
"minLength": "Độ dài tối thiểu",
"minLengthDesc": "Mật khẩu ngắn nhất được chấp nhận",
"maxLength": "Độ dài tối đa",
"maxLengthDesc": "0 = không giới hạn",
"requireUppercase": "Yêu cầu chữ hoa",
"requireUppercaseDesc": "Ít nhất một ký tự A–Z",
"requireLowercase": "Yêu cầu chữ thường",
"requireLowercaseDesc": "Ít nhất một ký tự a–z",
"requireDigit": "Yêu cầu chữ số",
"requireDigitDesc": "Ít nhất một chữ số 0–9",
"requireSpecial": "Yêu cầu ký tự đặc biệt",
"requireSpecialDesc": "Ít nhất một ký tự trong tập được phép",
"specialCharSet": "Ký tự đặc biệt được phép",
"specialCharSetDesc": "Để trống để chấp nhận mọi ký tự không phải chữ và số",
"maxLengthTooSmall": "Độ dài tối đa phải ít nhất bằng độ dài tối thiểu, hoặc 0 để không giới hạn.",
"confirmTitle": "Lưu chính sách mật khẩu?",
"confirmMessage": "Việc lưu có thể buộc mọi người dùng nội bộ đặt mật khẩu mới trước khi có thể tiếp tục. Tài khoản root, người dùng bên ngoài và tài khoản dịch vụ được miễn trừ — bao gồm cả bạn, trừ khi bạn là tài khoản root. Thao tác này không thể hoàn tác.",
"saved": "Đã lưu chính sách mật khẩu. | Đã lưu chính sách mật khẩu. {count} người dùng phải đặt lại mật khẩu. | Đã lưu chính sách mật khẩu. {count} người dùng phải đặt lại mật khẩu.",
"saveFailed": "Không thể lưu chính sách mật khẩu.",
"notAdminTitle": "Cần quyền quản trị viên",
"notAdmin": "Việc cấu hình chính sách mật khẩu yêu cầu quyền quản trị viên của tổ chức meta.",
"loadFailedTitle": "Không thể tải chính sách mật khẩu",
"loadFailed": "Đã xảy ra lỗi khi đọc chính sách hiện tại.",
"rotation": "Luân chuyển",
"rotationDays": "Chu kỳ luân chuyển",
"rotationDaysDesc": "Số ngày trước khi mật khẩu hết hạn. 0 = không bao giờ hết hạn",
"rotationWarningDays": "Cảnh báo trước khi hết hạn",
"rotationWarningDaysDesc": "Số ngày cảnh báo khi đăng nhập. 0 = không cảnh báo",
"warningDaysTooLong": "Khoảng thời gian cảnh báo không thể dài hơn chu kỳ luân chuyển.",
"rotationPreview": "Mật khẩu đặt hôm nay sẽ hết hạn vào {expires}. Cảnh báo đăng nhập bắt đầu từ {warns}.",
"rotationPreviewNoWarning": "Mật khẩu đặt hôm nay sẽ hết hạn vào {expires}. Không có cảnh báo nào trước thời điểm đó.",
"reuse": "Ngăn chặn tái sử dụng",
"historyCount": "Số mật khẩu ghi nhớ",
"historyCountDesc": "Mật khẩu mới không được trùng với số mật khẩu trước đó này. 0 = không kiểm tra",
"historyMaxRetained": "Số mật khẩu lưu giữ",
"historyMaxRetainedDesc": "Số mật khẩu cũ được lưu cho mỗi người dùng",
"historyRetainedTooSmall": "Số mật khẩu lưu giữ phải ít nhất bằng số mật khẩu ghi nhớ.",
"reuseExplainer": "Mật khẩu hiện tại được tính là một trong tập mật khẩu ghi nhớ — việc tái sử dụng nó bị từ chối bởi quy tắc này, chứ không phải bởi một kiểm tra riêng.",
"lockout": "Khóa tài khoản",
"lockoutThreshold": "Số lần thử thất bại trước khi khóa",
"lockoutThresholdDesc": "0 = tắt khóa tài khoản",
"lockoutBucketSize": "Số lần thử trước mỗi lần khóa tiếp theo",
"lockoutBucketSizeDesc": "0 = dùng lại giá trị ở trên",
"lockoutStartSecs": "Thời gian khóa lần đầu",
"lockoutStartSecsDesc": "Giây",
"lockoutMaxSecs": "Thời gian khóa tối đa",
"lockoutMaxSecsDesc": "Giây — giới hạn tối đa mà mức tăng dừng lại",
"lockoutStartTooLong": "Thời gian khóa lần đầu không được dài hơn thời gian khóa tối đa.",
"lockoutBackoff": "Mức tăng dần",
"lockoutBackoffDesc": "Cách mỗi lần khóa tăng lên so với lần trước",
"backoffExponential": "Lũy thừa",
"backoffLinear": "Tuyến tính",
"lockoutPreview": "{threshold} lần thất bại → {first} · cứ thêm {bucket} lần → {rest} · tối đa {max}. Đăng nhập thành công sẽ đặt lại mọi thứ.",
"sessionEnforcement": "Phiên làm việc & thực thi",
"cookieMaxAge": "Thời hạn phiên",
"cookieMaxAgeDesc": "Số giây một phiên đăng nhập còn hiệu lực. 0 = dùng giá trị mặc định của instance từ {envVar}",
"applyToRoot": "Áp dụng cho tài khoản root",
"applyToRootDesc": "Tài khoản root được miễn mọi quy tắc ở trên trừ khi bật tùy chọn này",
"rootWarning": "Khóa tài khoản root có thể khiến hệ thống rơi vào trạng thái không thể khôi phục.",
"rootDialogTitle": "Áp dụng chính sách cho tài khoản root?",
"rootDialogMessage": "Tài khoản root sẽ bị khóa, xoay mật khẩu và buộc đặt lại như mọi tài khoản native khác. Nếu root là tài khoản quản trị duy nhất của bạn, việc bị khóa hoặc hết hạn mà tài khoản không thể đáp ứng sẽ khiến bạn không còn cách nào truy cập lại instance này.",
"rootDialogAck": "Tôi hiểu rằng tài khoản root có thể bị khóa vĩnh viễn khỏi instance này.",
"rootDialogConfirm": "Áp dụng cho root"
},
"passwordReset": {
"title": "Cập nhật mật khẩu của bạn",
"signedInAs": "Đã đăng nhập với {email}",
"reasonPolicyTightened": "Chính sách mật khẩu đã thay đổi. Hãy cập nhật mật khẩu để tiếp tục sử dụng OpenObserve.",
"reasonRotationExpired": "Mật khẩu của bạn đã hết hạn. Hãy đặt mật khẩu mới để tiếp tục sử dụng OpenObserve.",
"reasonRotationWarning": "Mật khẩu của bạn sắp hết hạn. Hãy đặt mật khẩu mới ngay để tránh bị khóa khỏi công việc của bạn.",
"reused": "Mật khẩu này đã được dùng gần đây. Hãy chọn mật khẩu bạn chưa dùng trong {count} mật khẩu gần nhất.",
"expiryBanner": "Mật khẩu của bạn sẽ hết hạn sau {count} ngày. Hãy cập nhật ngay để tránh bị khóa khỏi công việc của bạn. | Mật khẩu của bạn sẽ hết hạn sau {count} ngày. Hãy cập nhật ngay để tránh bị khóa khỏi công việc của bạn.",
"expiryBannerAction": "Cập nhật ngay",
"expiryBannerDismiss": "Bỏ qua",
"currentPassword": "Mật khẩu hiện tại",
"newPassword": "Mật khẩu mới",
"confirmPassword": "Xác nhận mật khẩu mới",
"currentPasswordRequired": "Cần nhập mật khẩu hiện tại.",
"newPasswordRequired": "Cần nhập mật khẩu mới.",
"confirmPasswordRequired": "Vui lòng nhập lại mật khẩu mới.",
"mismatch": "Mật khẩu không khớp.",
"signOut": "Đăng xuất",
"submit": "Cập nhật mật khẩu",
"updated": "Mật khẩu của bạn đã được cập nhật. Vui lòng đăng nhập lại.",
"updateFailed": "Không thể cập nhật mật khẩu của bạn.",
"req": {
"minLength": "Ít nhất {count} ký tự",
"maxLength": "Không quá {count} ký tự",
"uppercase": "Một chữ cái in hoa",
"lowercase": "Một chữ cái in thường",
"digit": "Một chữ số",
"special": "Một ký tự đặc biệt",
"specialFromSet": "Một ký tự đặc biệt từ {set}",
"unmet": "Mật khẩu phải có: {requirement}"
}
},
"routeTitles": {
"passwordPolicy": "Chính sách mật khẩu",
"loginCallback": "Callback đăng nhập",
"slackOAuthCallback": "Đang kết nối Slack",
"emptyStateDemo": "Demo trạng thái trống",
+114
View File
@@ -1906,6 +1906,7 @@
"startFreeTrial": "开始免费试用"
},
"login": {
"lockedOut": "登录失败次数过多。请在 {duration} 后重试,或联系管理员。",
"redirectingToSsoMessage": "正在重定向到 SSO 登录页面。如果未自动重定向,请",
"clickHere": "点击此处",
"loginWithSso": "使用 SSO 登录",
@@ -1965,6 +1966,15 @@
"fieldRequired": "字段为必填字段",
"passwordRequired": "密码为必填项。",
"passwordPolicyHint": "密码必须为 8-128 个字符,并且至少包含一个小写字母、一个大写字母、一个数字和一个特殊字符。",
"lockout": {
"locked": "账户已锁定",
"autoUnlock": "将在 {duration} 后自动解锁。",
"staged": "保存后将自动解锁。",
"unlock": "解锁",
"undo": "保持锁定",
"confirmTitle": "解锁 {email}?",
"confirmMessage": "这会清除其失败尝试次数,使其可立即重新登录。不会更改其密码。"
},
"currentPasswordRequired": "当前密码为必填项。",
"newPasswordRequired": "需要新密码。",
"otherOrgInvalid": "组织名称必须以字母开头,并且必须是字母数字、_ 或-。",
@@ -8810,6 +8820,8 @@
"cipherKeysDesc": "用于敏感字段的加密密钥",
"regexPatternsDesc": "用于脱敏的可重用 regex 模式",
"domainManagementDesc": "限制仅允许的 SSO 域登录",
"passwordPolicy": "密码策略",
"passwordPolicyDesc": "适用于原生邮箱/密码账户的认证策略,在整个实例范围内生效。",
"pipelineDestinationsDesc": "流水线输出的外部目标",
"templatesDesc": "可重用的告警消息模板",
"storageSettingsDesc": "按组织划分的存储配置",
@@ -16043,6 +16055,9 @@
},
"layouts": {
"ingestionNotStarted": "您尚未开始数据接入流程。要浏览其他页面,请先开始数据接入。"
},
"iam": {
"userSaveFailed": "无法保存用户"
}
},
"cipherKeys": {
@@ -16460,7 +16475,106 @@
"exponentName": "指数"
}
},
"passwordPolicy": {
"complexity": "复杂度",
"minLength": "最小长度",
"minLengthDesc": "可接受的最短密码长度",
"maxLength": "最大长度",
"maxLengthDesc": "0 = 不限制",
"requireUppercase": "要求包含大写字母",
"requireUppercaseDesc": "至少包含一个大写字母 A–Z",
"requireLowercase": "要求包含小写字母",
"requireLowercaseDesc": "至少包含一个小写字母 a–z",
"requireDigit": "要求包含数字",
"requireDigitDesc": "至少包含一个数字 0–9",
"requireSpecial": "要求包含特殊字符",
"requireSpecialDesc": "至少包含一个允许的特殊字符",
"specialCharSet": "允许的特殊字符",
"specialCharSetDesc": "留空则接受任意非字母数字字符",
"maxLengthTooSmall": "最大长度必须不小于最小长度,或设为 0 表示不限制。",
"confirmTitle": "保存密码策略?",
"confirmMessage": "保存后可能强制所有原生用户在继续使用前设置新密码。Root、外部用户和服务账户除外——这包括你本人,除非你是 root 账户。此操作无法撤销。",
"saved": "密码策略已保存。 | 密码策略已保存。{count} 个用户必须重置密码。 | 密码策略已保存。{count} 个用户必须重置密码。",
"saveFailed": "无法保存密码策略。",
"notAdminTitle": "需要管理员权限",
"notAdmin": "配置密码策略需要 meta 组织的管理员权限。",
"loadFailedTitle": "无法加载密码策略",
"loadFailed": "读取当前策略时出错。",
"rotation": "轮换",
"rotationDays": "轮换周期",
"rotationDaysDesc": "密码过期前的天数。0 = 永不过期",
"rotationWarningDays": "过期前提醒",
"rotationWarningDaysDesc": "登录时提前提醒的天数。0 = 不提醒",
"warningDaysTooLong": "提醒窗口不能长于轮换周期。",
"rotationPreview": "今天设置的密码将于 {expires} 过期。登录提醒将于 {warns} 开始。",
"rotationPreviewNoWarning": "今天设置的密码将于 {expires} 过期。在此之前不会显示任何提醒。",
"reuse": "防止重复使用",
"historyCount": "记住的密码数",
"historyCountDesc": "新密码不得与此数量的历史密码相同。0 = 不检查",
"historyMaxRetained": "保留的历史密码数",
"historyMaxRetainedDesc": "每个用户保留的历史密码数量",
"historyRetainedTooSmall": "保留的历史密码数必须不小于记住的密码数量。",
"reuseExplainer": "当前密码属于已记住的密码之一——重复使用它会被此规则拒绝,而不是通过单独的检查。",
"lockout": "账户锁定",
"lockoutThreshold": "锁定前的失败尝试次数",
"lockoutThresholdDesc": "0 = 禁用锁定",
"lockoutBucketSize": "每次后续锁定前的尝试次数",
"lockoutBucketSizeDesc": "0 = 重用上面的值",
"lockoutStartSecs": "首次锁定时长",
"lockoutStartSecsDesc": "秒",
"lockoutMaxSecs": "最大锁定时长",
"lockoutMaxSecsDesc": "秒 — 递增锁定时长封顶于此值",
"lockoutStartTooLong": "首次锁定时长不能超过最大锁定时长。",
"lockoutBackoff": "递增",
"lockoutBackoffDesc": "每次锁定时长相对于上一次的增长方式",
"backoffExponential": "指数",
"backoffLinear": "线性",
"lockoutPreview": "{threshold} 次失败 → {first} · 每再失败 {bucket} 次 → {rest} · 上限为 {max}。成功登录会重置一切。",
"sessionEnforcement": "会话与强制执行",
"cookieMaxAge": "会话有效期",
"cookieMaxAgeDesc": "已登录会话保持有效的秒数。0 = 使用来自 {envVar} 的实例默认值",
"applyToRoot": "应用到 root 账户",
"applyToRootDesc": "除非启用此项,否则 root 不受上述所有规则的约束",
"rootWarning": "锁定 root 可能导致系统进入不可恢复状态。",
"rootDialogTitle": "将策略应用到 root?",
"rootDialogMessage": "root 将像任何其他原生账户一样受到锁定、轮换和强制重置的约束。如果 root 是您唯一的管理账户,一次锁定或无法满足的到期要求将使您无法再回到此实例。",
"rootDialogAck": "我明白 root 可能会被永久锁定在此实例之外。",
"rootDialogConfirm": "应用到 root"
},
"passwordReset": {
"title": "更新您的密码",
"signedInAs": "已登录为 {email}",
"reasonPolicyTightened": "密码策略已更改。请更新您的密码以继续使用 OpenObserve。",
"reasonRotationExpired": "您的密码已过期。请设置新密码以继续使用 OpenObserve。",
"reasonRotationWarning": "您的密码即将过期。请立即设置新密码,以免无法继续您的工作。",
"reused": "此密码最近使用过。请选择一个未在您最近 {count} 个密码中使用过的密码。",
"expiryBanner": "您的密码将在 {count} 天后过期。请立即更新,以免无法继续您的工作。 | 您的密码将在 {count} 天后过期。请立即更新,以免无法继续您的工作。",
"expiryBannerAction": "立即更新",
"expiryBannerDismiss": "忽略",
"currentPassword": "当前密码",
"newPassword": "新密码",
"confirmPassword": "确认新密码",
"currentPasswordRequired": "当前密码为必填项。",
"newPasswordRequired": "新密码为必填项。",
"confirmPasswordRequired": "请重新输入新密码。",
"mismatch": "密码不匹配。",
"signOut": "退出",
"submit": "更新密码",
"updated": "您的密码已更新。请重新登录。",
"updateFailed": "无法更新您的密码。",
"req": {
"minLength": "至少 {count} 个字符",
"maxLength": "不超过 {count} 个字符",
"uppercase": "一个大写字母",
"lowercase": "一个小写字母",
"digit": "一个数字",
"special": "一个特殊字符",
"specialFromSet": "来自 {set} 的一个特殊字符",
"unmet": "密码必须包含:{requirement}"
}
},
"routeTitles": {
"passwordPolicy": "密码策略",
"loginCallback": "登录回调",
"slackOAuthCallback": "正在连接 Slack",
"emptyStateDemo": "空状态演示",
+114
View File
@@ -1906,6 +1906,7 @@
"startFreeTrial": "開始免費試用"
},
"login": {
"lockedOut": "登入嘗試失敗次數過多。請在 {duration} 後再試,或聯絡您的管理員。",
"redirectingToSsoMessage": "正在重新導向至 SSO 登入頁面。如果您未被重新導向,請",
"clickHere": "按一下這裡",
"loginWithSso": "使用 SSO 登入",
@@ -1965,6 +1966,15 @@
"fieldRequired": "此欄位為必填",
"passwordRequired": "必須輸入密碼。",
"passwordPolicyHint": "密碼長度必須為 8 至 128 個字元,且至少包含一個小寫字母、一個大寫字母、一個數字與一個特殊字元。",
"lockout": {
"locked": "帳號已鎖定",
"autoUnlock": "將在 {duration} 後自動解鎖。",
"staged": "儲存後將解鎖。",
"unlock": "解鎖",
"undo": "保持鎖定",
"confirmTitle": "要解鎖 {email} 嗎?",
"confirmMessage": "這會清除該使用者的失敗嘗試次數,並讓其可以立即重新登入。這不會變更其密碼。"
},
"currentPasswordRequired": "必須輸入目前的密碼。",
"newPasswordRequired": "必須輸入新密碼。",
"otherOrgInvalid": "組織名稱必須以字母開頭,且僅能包含英數字元、_ 或 -。",
@@ -8805,6 +8815,8 @@
"cipherKeysDesc": "用於敏感欄位的加密金鑰",
"regexPatternsDesc": "用於遮蔽的可重複使用 regex 模式",
"domainManagementDesc": "限制僅允許的 SSO 網域登入",
"passwordPolicy": "密碼原則",
"passwordPolicyDesc": "適用於原生電子郵件/密碼帳號並套用至整個執行個體的驗證原則。",
"pipelineDestinationsDesc": "管線輸出的外部目標",
"templatesDesc": "可重複使用的警示訊息範本",
"storageSettingsDesc": "各組織的儲存設定",
@@ -16038,6 +16050,9 @@
},
"layouts": {
"ingestionNotStarted": "您尚未開始資料擷取流程。若要探索其他頁面,請先開始資料擷取。"
},
"iam": {
"userSaveFailed": "無法儲存使用者"
}
},
"cipherKeys": {
@@ -16455,7 +16470,106 @@
"exponentName": "冪運算"
}
},
"passwordPolicy": {
"complexity": "複雜度",
"minLength": "最小長度",
"minLengthDesc": "可接受的最短密碼",
"maxLength": "最大長度",
"maxLengthDesc": "0 = 無限制",
"requireUppercase": "要求大寫字母",
"requireUppercaseDesc": "至少一個 A–Z",
"requireLowercase": "要求小寫字母",
"requireLowercaseDesc": "至少一個 a–z",
"requireDigit": "要求數字",
"requireDigitDesc": "至少一個 0–9",
"requireSpecial": "要求特殊字元",
"requireSpecialDesc": "至少一個來自允許集合的字元",
"specialCharSet": "允許的特殊字元",
"specialCharSetDesc": "留空以接受任何非英數字元",
"maxLengthTooSmall": "最大長度必須至少等於最小長度,或設為 0 表示無限制。",
"confirmTitle": "儲存密碼原則?",
"confirmMessage": "儲存後可能會強制所有原生使用者在繼續之前設定新密碼。Root、外部使用者與服務帳號不受此限——除非您是 root 帳號,否則這也包含您。此操作無法復原。",
"saved": "密碼原則已儲存。 | 密碼原則已儲存。{count} 位使用者必須重設密碼。 | 密碼原則已儲存。{count} 位使用者必須重設密碼。",
"saveFailed": "無法儲存密碼原則。",
"notAdminTitle": "需要管理員存取權",
"notAdmin": "設定密碼原則需要中繼組織的管理員。",
"loadFailedTitle": "無法載入密碼原則",
"loadFailed": "讀取目前原則時發生錯誤。",
"rotation": "輪換",
"rotationDays": "輪換期間",
"rotationDaysDesc": "密碼到期前的天數。0 = 永不到期",
"rotationWarningDays": "到期前警告",
"rotationWarningDaysDesc": "登入時提前通知的天數。0 = 不顯示警告",
"warningDaysTooLong": "警告期間不能長於輪換期間。",
"rotationPreview": "今天設定的密碼將於 {expires} 到期。登入警告會從 {warns} 開始。",
"rotationPreviewNoWarning": "今天設定的密碼將於 {expires} 到期。在此之前不會顯示警告。",
"reuse": "防止重複使用",
"historyCount": "記住的密碼數",
"historyCountDesc": "新密碼不得與先前這麼多個密碼相同。0 = 不檢查",
"historyMaxRetained": "保留歷史筆數",
"historyMaxRetainedDesc": "每位使用者保留多少個過去密碼",
"historyRetainedTooSmall": "保留歷史筆數必須至少等於記住的密碼數。",
"reuseExplainer": "目前密碼會計入已記住的密碼集合之一——重複使用目前密碼會遭到此規則拒絕,而非由另一項檢查拒絕。",
"lockout": "帳戶鎖定",
"lockoutThreshold": "鎖定前的失敗嘗試次數",
"lockoutThresholdDesc": "0 = 停用鎖定",
"lockoutBucketSize": "每次進一步鎖定前的嘗試次數",
"lockoutBucketSizeDesc": "0 = 沿用上方的值",
"lockoutStartSecs": "首次鎖定持續時間",
"lockoutStartSecsDesc": "秒",
"lockoutMaxSecs": "最長鎖定持續時間",
"lockoutMaxSecsDesc": "秒 — 遞增鎖定所封頂的上限",
"lockoutStartTooLong": "首次鎖定不得長於最長鎖定時間。",
"lockoutBackoff": "遞增",
"lockoutBackoffDesc": "每次鎖定相較前一次如何遞增",
"backoffExponential": "指數",
"backoffLinear": "線性",
"lockoutPreview": "{threshold} 次失敗 → {first} · 每再 {bucket} 次 → {rest} · 上限為 {max}。成功登入會重設所有設定。",
"sessionEnforcement": "工作階段與強制執行",
"cookieMaxAge": "工作階段存續時間",
"cookieMaxAgeDesc": "已登入工作階段保持有效的秒數。0 = 使用 {envVar} 中的執行個體預設值",
"applyToRoot": "套用至 root 帳戶",
"applyToRootDesc": "除非開啟此選項,否則 root 不受上述所有規則限制",
"rootWarning": "鎖定 root 可能導致系統進入無法復原的狀態。",
"rootDialogTitle": "要將原則套用至 root 嗎?",
"rootDialogMessage": "root 將與其他原生帳戶一樣受到鎖定、輪替和強制重設的約束。如果 root 是您唯一的管理帳戶,一旦遭到鎖定或發生其無法符合的到期情形,將再也無法進入此執行個體。",
"rootDialogAck": "我了解 root 可能會永久被鎖定而無法進入此執行個體。",
"rootDialogConfirm": "套用至 root"
},
"passwordReset": {
"title": "更新您的密碼",
"signedInAs": "已登入為 {email}",
"reasonPolicyTightened": "密碼原則已變更。請更新您的密碼以繼續使用 OpenObserve。",
"reasonRotationExpired": "您的密碼已過期。請設定新密碼以繼續使用 OpenObserve。",
"reasonRotationWarning": "您的密碼即將過期。請立即設定新密碼,以免無法繼續您的工作。",
"reused": "此密碼最近曾使用過。請選擇一個未出現在您最近 {count} 個密碼中的密碼。",
"expiryBanner": "您的密碼將於 {count} 天後過期。請立即更新,以免無法繼續您的工作。 | 您的密碼將於 {count} 天後過期。請立即更新,以免無法繼續您的工作。",
"expiryBannerAction": "立即更新",
"expiryBannerDismiss": "關閉",
"currentPassword": "目前密碼",
"newPassword": "新密碼",
"confirmPassword": "確認新密碼",
"currentPasswordRequired": "必須輸入目前密碼。",
"newPasswordRequired": "必須輸入新密碼。",
"confirmPasswordRequired": "請再次輸入新密碼。",
"mismatch": "密碼不相符。",
"signOut": "登出",
"submit": "更新密碼",
"updated": "您的密碼已更新。請重新登入。",
"updateFailed": "無法更新您的密碼。",
"req": {
"minLength": "至少 {count} 個字元",
"maxLength": "不超過 {count} 個字元",
"uppercase": "一個大寫字母",
"lowercase": "一個小寫字母",
"digit": "一個數字",
"special": "一個特殊字元",
"specialFromSet": "一個來自 {set} 的特殊字元",
"unmet": "密碼必須包含:{requirement}"
}
},
"routeTitles": {
"passwordPolicy": "密碼原則",
"loginCallback": "登入回呼",
"slackOAuthCallback": "正在連線 Slack",
"emptyStateDemo": "空狀態示範",
+8
View File
@@ -19,6 +19,8 @@ import axios from "axios";
import config from "../aws-exports";
import { useLocalUserInfo, useLocalCurrentUser } from "@/utils/zincutils";
import { addUnauthorizedError } from "@/composables/useUnauthorizedErrorGrouper";
import { usePasswordReset } from "@/composables/usePasswordReset";
import { isPasswordResetError } from "@/utils/passwordResetErrors";
// Shared refresh state — ensures only one dex_refresh request is in-flight
// at a time across all axios instances and streaming fetch requests. All
@@ -107,6 +109,12 @@ const http = ({ headers } = {} as any) => {
return response;
},
function (error) {
// Ahead of the status switch: a reset-required 403 is a policy state, not an authorization failure.
if (isPasswordResetError(error)) {
usePasswordReset().open(error.response.data.reason);
// A blocked page fires many requests behind an undismissable dialog; one toast per rejection would stack.
return new Promise(() => {});
}
if (error && error.response && error.response.status) {
switch (error.response.status) {
case 400:
+72
View File
@@ -0,0 +1,72 @@
// 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/>.
import http from "./http";
/** What a password must look like. Mirrors `PasswordComplexity` in the Rust API. */
export interface PasswordComplexity {
min_length: number;
/** `0` means unbounded. */
max_length: number;
require_uppercase: boolean;
require_lowercase: boolean;
require_digit: boolean;
require_special: boolean;
/** Empty means any non-alphanumeric character counts as special. */
special_char_set: string;
}
export interface LockoutPolicy {
threshold: number;
bucket_size: number;
start_secs: number;
max_secs: number;
backoff: "linear" | "exponential";
}
/** The whole instance policy. Mirrors `PasswordPolicy` in the Rust API. */
export interface PasswordPolicy extends PasswordComplexity {
rotation_days: number;
rotation_warning_days: number;
history_count: number;
history_max_retained: number;
lockout: LockoutPolicy;
/** `0` defers to the instance's `ZO_COOKIE_MAX_AGE`. */
cookie_max_age_secs: number;
apply_to_root: boolean;
}
export interface SetPasswordPolicyResponse {
policy: PasswordPolicy;
users_flagged: number;
}
const passwordPolicy = {
/** Readable by any authenticated user — the only policy route a flagged user can reach. */
getComplexity: (orgIdentifier: string) => {
return http().get<PasswordComplexity>(`/api/${orgIdentifier}/password_complexity`);
},
getPolicy: (metaOrg: string) => {
return http().get<PasswordPolicy>(`/api/${metaOrg}/settings/password_policy`);
},
updatePolicy: (metaOrg: string, policy: PasswordPolicy) => {
return http().put<SetPasswordPolicyResponse>(
`/api/${metaOrg}/settings/password_policy`,
policy,
);
},
};
export default passwordPolicy;
+4
View File
@@ -27,6 +27,10 @@ const users = {
update: (data: any, org_identifier: string, user_email: string) => {
return http().put(`/api/${org_identifier}/users/${user_email}`, data);
},
/** One member with their failed-login lockout state. Enterprise only; Root/Admin on the org. */
get: (org_identifier: string, user_email: string) => {
return http().get(`/api/${org_identifier}/users/${user_email}`);
},
updateexistinguser: (data: any, org_identifier: string, user_email: string) => {
return http().post(`/api/${org_identifier}/users/${user_email}`, data);
},
+178
View File
@@ -0,0 +1,178 @@
// 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/>.
import { describe, expect, it } from "vitest";
import type { PasswordComplexity } from "@/services/passwordPolicy";
import type { TranslateFn } from "@/types/i18n";
import {
buildPasswordRequirements,
countMetRequirements,
DEFAULT_COMPLEXITY,
firstUnmetRequirement,
reuseRejection,
validateAgainstComplexity,
} from "./passwordComplexity";
// Echoes the key so assertions read against stable ids rather than English copy.
const t = ((key: string) => key) as unknown as TranslateFn;
const complexity = (overrides: Partial<PasswordComplexity> = {}): PasswordComplexity => ({
...DEFAULT_COMPLEXITY,
...overrides,
});
describe("buildPasswordRequirements", () => {
it("emits only the requirements the policy actually enforces", () => {
const rows = buildPasswordRequirements(complexity(), t);
expect(rows.map((r) => r.key)).toEqual(["minLength"]);
});
it("emits no rows at all when the policy enforces nothing", () => {
const rows = buildPasswordRequirements(complexity({ min_length: 0 }), t);
expect(rows).toEqual([]);
});
it("emits every row for a fully configured policy", () => {
const rows = buildPasswordRequirements(
complexity({
min_length: 12,
max_length: 64,
require_uppercase: true,
require_lowercase: true,
require_digit: true,
require_special: true,
}),
t,
);
expect(rows.map((r) => r.key)).toEqual([
"minLength",
"maxLength",
"uppercase",
"lowercase",
"digit",
"special",
]);
});
it("omits maxLength when unbounded", () => {
const rows = buildPasswordRequirements(complexity({ max_length: 0 }), t);
expect(rows.some((r) => r.key === "maxLength")).toBe(false);
});
});
describe("requirement checks", () => {
const rowFor = (key: string, policy: PasswordComplexity) =>
buildPasswordRequirements(policy, t).find((r) => r.key === key)!;
it("min and max length are inclusive bounds", () => {
const policy = complexity({ min_length: 4, max_length: 6 });
expect(rowFor("minLength", policy).isMet("abcd")).toBe(true);
expect(rowFor("minLength", policy).isMet("abc")).toBe(false);
expect(rowFor("maxLength", policy).isMet("abcdef")).toBe(true);
expect(rowFor("maxLength", policy).isMet("abcdefg")).toBe(false);
});
it("treats any non-alphanumeric as special when the set is empty", () => {
const row = rowFor("special", complexity({ require_special: true, special_char_set: "" }));
expect(row.isMet("abc-def")).toBe(true);
expect(row.isMet("abcdef1")).toBe(false);
});
it("honours a configured special-character set", () => {
const row = rowFor("special", complexity({ require_special: true, special_char_set: "!@#" }));
expect(row.isMet("abc!")).toBe(true);
// A special character outside the configured set does not count.
expect(row.isMet("abc-")).toBe(false);
});
it("checks character classes independently", () => {
const policy = complexity({
require_uppercase: true,
require_lowercase: true,
require_digit: true,
});
expect(rowFor("uppercase", policy).isMet("aB1")).toBe(true);
expect(rowFor("uppercase", policy).isMet("ab1")).toBe(false);
expect(rowFor("lowercase", policy).isMet("AB1")).toBe(false);
expect(rowFor("digit", policy).isMet("aBc")).toBe(false);
});
});
describe("countMetRequirements", () => {
it("counts only satisfied rows", () => {
const rows = buildPasswordRequirements(
complexity({ min_length: 4, require_uppercase: true, require_digit: true }),
t,
);
expect(countMetRequirements(rows, "")).toBe(0);
expect(countMetRequirements(rows, "abcd")).toBe(1);
expect(countMetRequirements(rows, "Abcd")).toBe(2);
expect(countMetRequirements(rows, "Abcd1")).toBe(3);
});
});
describe("firstUnmetRequirement / validateAgainstComplexity", () => {
it("returns null once every requirement is satisfied", () => {
const policy = complexity({ min_length: 4, require_digit: true });
expect(firstUnmetRequirement(buildPasswordRequirements(policy, t), "abc1")).toBeNull();
expect(validateAgainstComplexity("abc1", policy, t)).toBeNull();
});
it("names the first failure in policy order", () => {
const policy = complexity({ min_length: 8, require_digit: true });
expect(firstUnmetRequirement(buildPasswordRequirements(policy, t), "ab")?.key).toBe(
"minLength",
);
expect(firstUnmetRequirement(buildPasswordRequirements(policy, t), "abcdefgh")?.key).toBe(
"digit",
);
});
it("accepts anything when the policy enforces nothing", () => {
expect(validateAgainstComplexity("a", complexity({ min_length: 0 }), t)).toBeNull();
});
});
describe("reuseRejection", () => {
const tWithParams = ((key: string, params: Record<string, unknown>) =>
`${key}:${params.count}`) as unknown as TranslateFn;
const error = (message: string) => ({ response: { status: 400, data: { message } } });
it("reads the depth out of the server's message", () => {
expect(
reuseRejection(
error("New password matches one of your last 5 passwords, please choose a different one"),
tWithParams,
),
).toBe("passwordReset.reused:5");
});
it("leaves every other failure to the toast", () => {
expect(reuseRejection(error("Password too short"), tWithParams)).toBeNull();
expect(reuseRejection(new Error("network"), tWithParams)).toBeNull();
});
});
+140
View File
@@ -0,0 +1,140 @@
// 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/>.
// Pure (no Vue, no fetching) so the reset dialog and the user forms share one rule set.
import type { PasswordComplexity } from "@/services/passwordPolicy";
import { raw, type I18nText, type TranslateFn } from "@/types/i18n";
export type PasswordRequirementKey =
"minLength" | "maxLength" | "uppercase" | "lowercase" | "digit" | "special";
export interface PasswordRequirement {
key: PasswordRequirementKey;
label: I18nText;
isMet: (_password: string) => boolean;
}
/** What an instance that has never configured a policy enforces. */
export const DEFAULT_COMPLEXITY: PasswordComplexity = {
min_length: 8,
max_length: 0,
require_uppercase: false,
require_lowercase: false,
require_digit: false,
require_special: false,
special_char_set: "",
};
const isSpecialChar = (char: string, specialCharSet: string): boolean => {
if (specialCharSet) return specialCharSet.includes(char);
return !/[a-zA-Z0-9]/.test(char);
};
const hasSpecialChar = (password: string, specialCharSet: string): boolean =>
password.split("").some((char) => isSpecialChar(char, specialCharSet));
/** The requirement rows for a policy, generated so the console never states a rule the server does not check. */
export const buildPasswordRequirements = (
complexity: PasswordComplexity,
t: TranslateFn,
): PasswordRequirement[] => {
const requirements: PasswordRequirement[] = [];
if (complexity.min_length > 0) {
requirements.push({
key: "minLength",
label: t("passwordReset.req.minLength", { count: complexity.min_length }),
isMet: (password) => password.length >= complexity.min_length,
});
}
if (complexity.max_length > 0) {
requirements.push({
key: "maxLength",
label: t("passwordReset.req.maxLength", { count: complexity.max_length }),
isMet: (password) => password.length <= complexity.max_length,
});
}
if (complexity.require_uppercase) {
requirements.push({
key: "uppercase",
label: t("passwordReset.req.uppercase"),
isMet: (password) => /[A-Z]/.test(password),
});
}
if (complexity.require_lowercase) {
requirements.push({
key: "lowercase",
label: t("passwordReset.req.lowercase"),
isMet: (password) => /[a-z]/.test(password),
});
}
if (complexity.require_digit) {
requirements.push({
key: "digit",
label: t("passwordReset.req.digit"),
isMet: (password) => /[0-9]/.test(password),
});
}
if (complexity.require_special) {
requirements.push({
key: "special",
label: complexity.special_char_set
? t("passwordReset.req.specialFromSet", { set: raw(complexity.special_char_set) })
: t("passwordReset.req.special"),
isMet: (password) => hasSpecialChar(password, complexity.special_char_set),
});
}
return requirements;
};
/** How many requirements a password currently satisfies. Drives the strength indicator. */
export const countMetRequirements = (
requirements: PasswordRequirement[],
password: string,
): number => requirements.filter((requirement) => requirement.isMet(password)).length;
/** The first requirement a password fails, or `null`; the rows on screen already list the rest. */
export const firstUnmetRequirement = (
requirements: PasswordRequirement[],
password: string,
): PasswordRequirement | null =>
requirements.find((requirement) => !requirement.isMet(password)) ?? null;
/** The message for the first failure, or `null`; the server is still authoritative. */
export const validateAgainstComplexity = (
password: string,
complexity: PasswordComplexity,
t: TranslateFn,
): I18nText | null => {
const unmet = firstUnmetRequirement(buildPasswordRequirements(complexity, t), password);
return unmet ? t("passwordReset.req.unmet", { requirement: unmet.label }) : null;
};
// The 400 carries no code, so the server's wording is the only signal and the count comes from it too.
const REUSE_MESSAGE = /matches one of your last (\d+) passwords/;
/** The inline error for a reuse rejection, or `null`; every requirement row can be green while this fails. */
export const reuseRejection = (error: unknown, t: TranslateFn): I18nText | null => {
const message = (error as any)?.response?.data?.message;
const match = typeof message === "string" ? REUSE_MESSAGE.exec(message) : null;
return match ? t("passwordReset.reused", { count: Number(match[1]) }) : null;
};

Some files were not shown because too many files have changed in this diff Show More