diff --git a/crates/common/src/dynamic_config.rs b/crates/common/src/dynamic_config.rs index 22fbebfa..8ce370c1 100644 --- a/crates/common/src/dynamic_config.rs +++ b/crates/common/src/dynamic_config.rs @@ -298,6 +298,10 @@ dc_getters_bool! { is_initialized, "setup.initialized", false; rate_limit_fail_closed, "security.rate_limit_fail_closed", false; allow_registration, "auth.allow_registration", false; + // Stored as a JSON boolean. It used to be read as a string and + // compared with "true", which never matched, so the requirement + // was never reported. + totp_required, "security.totp_required", false; oidc_enabled, "oidc.enabled", false; cb_enabled, "gateway.cb_enabled", true; // Full-body capture for enterprise audit. Defaults ON: the @@ -616,7 +620,10 @@ fn validate_setting(key: &str, value: &Value) -> anyhow::Result<()> { } // Boolean settings - "setup.initialized" | "auth.allow_registration" | "security.rate_limit_fail_closed" => { + "setup.initialized" + | "auth.allow_registration" + | "security.rate_limit_fail_closed" + | "security.totp_required" => { value .as_bool() .ok_or_else(|| anyhow::anyhow!("{key}: expected a boolean value"))?; diff --git a/crates/server/src/handlers/admin/settings.rs b/crates/server/src/handlers/admin/settings.rs index e27afb11..edd9c503 100644 --- a/crates/server/src/handlers/admin/settings.rs +++ b/crates/server/src/handlers/admin/settings.rs @@ -486,7 +486,9 @@ fn validate_setting(key: &str, value: &serde_json::Value) -> Result<(), AppError } } - "auth.allow_registration" | "security.rate_limit_fail_closed" => { + "auth.allow_registration" + | "security.rate_limit_fail_closed" + | "security.totp_required" => { if !value.is_boolean() { return Err(AppError::BadRequest(format!("{key} must be a boolean"))); } diff --git a/crates/server/src/handlers/auth.rs b/crates/server/src/handlers/auth.rs index 119551a7..b1e725cb 100644 --- a/crates/server/src/handlers/auth.rs +++ b/crates/server/src/handlers/auth.rs @@ -1824,12 +1824,7 @@ pub async fn totp_status( let enabled = repo::totp_enabled(&state.db, auth_user.claims.sub).await?; // Check if platform requires TOTP - let required: bool = state - .dynamic_config - .get_string("security.totp_required") - .await - .map(|v| v == "true") - .unwrap_or(false); + let required = state.dynamic_config.totp_required().await; Ok(Json(serde_json::json!({ "enabled": enabled, diff --git a/crates/test-support/tests/admin_access.rs b/crates/test-support/tests/admin_access.rs index cdefe90b..7f172d2f 100644 --- a/crates/test-support/tests/admin_access.rs +++ b/crates/test-support/tests/admin_access.rs @@ -379,9 +379,7 @@ async fn registration_assigns_the_default_role_and_me_lists_roles_and_teams() { let app = TestApp::spawn().await; let admin = admin_session(&app).await; - // The setting has no seeded row; the admin API only changes rows - // that exist. - app.set_setting("auth.default_role", json!("")).await; + // Seeded empty: no role until an admin picks one through the API. assert_eq!(app.state.dynamic_config.default_role().await, None); // The default role must name a role that exists. admin @@ -720,3 +718,37 @@ async fn sso_provisions_a_user_then_signs_them_in_again() { let (_, status) = sso_login(&app, &idp, identity).await; assert_eq!(status, 403); } + +/// `security.totp_required` is a JSON boolean. It used to be read as a +/// string and compared with "true", which never matched, so a platform +/// that required TOTP told every user it did not. +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn requiring_totp_is_reported_to_users() { + let app = TestApp::spawn().await; + let admin = admin_session(&app).await; + + let status = get(&admin, "/api/auth/totp/status").await; + assert_eq!(status["required"], false, "{status}"); + + // A string is refused: it would read as "not required". + admin + .patch( + "/api/admin/settings", + json!({"settings": {"security.totp_required": "true"}}), + ) + .await + .unwrap() + .assert_status(400); + admin + .patch( + "/api/admin/settings", + json!({"settings": {"security.totp_required": true}}), + ) + .await + .unwrap() + .assert_ok(); + + let status = get(&admin, "/api/auth/totp/status").await; + assert_eq!(status["required"], true, "{status}"); +} diff --git a/db/seeds.sql b/db/seeds.sql index eb367bf2..f81ea791 100644 --- a/db/seeds.sql +++ b/db/seeds.sql @@ -56,7 +56,8 @@ INSERT INTO platform_pricing (id) VALUES (1) INSERT INTO system_settings (key, value, category, description) VALUES ('auth.jwt_access_ttl_secs', '900', 'auth', 'JWT access token lifetime in seconds'), ('auth.jwt_refresh_ttl_days', '7', 'auth', 'JWT refresh token lifetime in days'), -('auth.allow_registration', 'false', 'auth', 'Whether public user self-registration is allowed') +('auth.allow_registration', 'false', 'auth', 'Whether public user self-registration is allowed'), +('auth.default_role', '""', 'auth', 'Role assigned to newly registered and SSO users; empty for none') ON CONFLICT (key) DO NOTHING; -- Gateway