Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion crates/common/src/dynamic_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"))?;
Expand Down
4 changes: 3 additions & 1 deletion crates/server/src/handlers/admin/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")));
}
Expand Down
7 changes: 1 addition & 6 deletions crates/server/src/handlers/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
38 changes: 35 additions & 3 deletions crates/test-support/tests/admin_access.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}");
}
3 changes: 2 additions & 1 deletion db/seeds.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading