Skip to content

Commit 714f713

Browse files
rubenfiszelclaude
andauthored
only update license key via IaC when new expiry is posterior (#7959)
When using infrastructure-as-code to update settings, if the desired license key matches the current one (same client ID and signature) but differs only in the expiration date, only apply the update if the new key has a later expiry. This prevents accidental downgrades when an older license key is present in the IaC configuration. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 68f766e commit 714f713

File tree

1 file changed

+159
-0
lines changed

1 file changed

+159
-0
lines changed

backend/windmill-common/src/instance_config.rs

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ use std::fmt;
33

44
use serde::{Deserialize, Serialize};
55

6+
use crate::global_settings::LICENSE_KEY_SETTING;
7+
68
// ---------------------------------------------------------------------------
79
// Kubernetes Secret reference support
810
// ---------------------------------------------------------------------------
@@ -746,6 +748,33 @@ pub const PROTECTED_SETTINGS: &[&str] = &[
746748
/// Internal settings that are never exposed via the API or included in config exports.
747749
pub const HIDDEN_SETTINGS: &[&str] = &["uid", "rsa_keys", "jwt_secret", "min_keep_alive_version"];
748750

751+
/// Extract the expiry timestamp from a license key JSON value.
752+
///
753+
/// License keys have the format `<client_id>.<expiry>.<signature>`.
754+
/// Returns `None` if the value is not a string or doesn't match the format.
755+
fn license_key_expiry(value: &serde_json::Value) -> Option<u64> {
756+
let s = value.as_str()?;
757+
let parts: Vec<&str> = s.split('.').collect();
758+
if parts.len() != 3 {
759+
return None;
760+
}
761+
parts[1].parse::<u64>().ok()
762+
}
763+
764+
/// Returns true if two license key values share the same client ID and signature
765+
/// (i.e. they differ only in the expiry field).
766+
fn license_keys_same_except_expiry(a: &serde_json::Value, b: &serde_json::Value) -> bool {
767+
let (Some(a_str), Some(b_str)) = (a.as_str(), b.as_str()) else {
768+
return false;
769+
};
770+
let a_parts: Vec<&str> = a_str.split('.').collect();
771+
let b_parts: Vec<&str> = b_str.split('.').collect();
772+
if a_parts.len() != 3 || b_parts.len() != 3 {
773+
return false;
774+
}
775+
a_parts[0] == b_parts[0] && a_parts[2] == b_parts[2]
776+
}
777+
749778
/// Compute the diff between current and desired global settings.
750779
pub fn diff_global_settings(
751780
current: &BTreeMap<String, serde_json::Value>,
@@ -756,6 +785,23 @@ pub fn diff_global_settings(
756785
for (key, value) in desired {
757786
match current.get(key) {
758787
Some(existing) if existing == value => {} // no change
788+
Some(existing) if key == LICENSE_KEY_SETTING => {
789+
if license_keys_same_except_expiry(existing, value) {
790+
let current_expiry = license_key_expiry(existing).unwrap_or(0);
791+
let desired_expiry = license_key_expiry(value).unwrap_or(0);
792+
if desired_expiry > current_expiry {
793+
upserts.insert(key.clone(), value.clone());
794+
} else {
795+
tracing::info!(
796+
"Skipping license_key update: desired expiry ({}) is not posterior to current expiry ({})",
797+
desired_expiry,
798+
current_expiry
799+
);
800+
}
801+
} else {
802+
upserts.insert(key.clone(), value.clone());
803+
}
804+
}
759805
_ => {
760806
upserts.insert(key.clone(), value.clone());
761807
}
@@ -1939,4 +1985,117 @@ mod tests {
19391985
Some("plain-token")
19401986
);
19411987
}
1988+
1989+
// -----------------------------------------------------------------------
1990+
// License key expiry diff tests
1991+
// -----------------------------------------------------------------------
1992+
1993+
#[test]
1994+
fn diff_license_key_skips_older_expiry() {
1995+
let mut current = BTreeMap::new();
1996+
current.insert(
1997+
"license_key".to_string(),
1998+
serde_json::json!("client1.2000000000.sig123"),
1999+
);
2000+
2001+
let mut desired = BTreeMap::new();
2002+
desired.insert(
2003+
"license_key".to_string(),
2004+
serde_json::json!("client1.1000000000.sig123"),
2005+
);
2006+
2007+
let diff = diff_global_settings(&current, &desired, ApplyMode::Merge);
2008+
assert!(
2009+
diff.upserts.is_empty(),
2010+
"Should not update license_key when desired expiry is older"
2011+
);
2012+
}
2013+
2014+
#[test]
2015+
fn diff_license_key_skips_equal_expiry() {
2016+
let mut current = BTreeMap::new();
2017+
current.insert(
2018+
"license_key".to_string(),
2019+
serde_json::json!("client1.2000000000.sig_a"),
2020+
);
2021+
2022+
let mut desired = BTreeMap::new();
2023+
desired.insert(
2024+
"license_key".to_string(),
2025+
serde_json::json!("client1.2000000000.sig_b"),
2026+
);
2027+
2028+
let diff = diff_global_settings(&current, &desired, ApplyMode::Merge);
2029+
assert_eq!(
2030+
diff.upserts.len(),
2031+
1,
2032+
"Different signature means different key, should update"
2033+
);
2034+
}
2035+
2036+
#[test]
2037+
fn diff_license_key_updates_newer_expiry() {
2038+
let mut current = BTreeMap::new();
2039+
current.insert(
2040+
"license_key".to_string(),
2041+
serde_json::json!("client1.1000000000.sig123"),
2042+
);
2043+
2044+
let mut desired = BTreeMap::new();
2045+
desired.insert(
2046+
"license_key".to_string(),
2047+
serde_json::json!("client1.2000000000.sig123"),
2048+
);
2049+
2050+
let diff = diff_global_settings(&current, &desired, ApplyMode::Merge);
2051+
assert_eq!(diff.upserts.len(), 1);
2052+
assert_eq!(
2053+
diff.upserts["license_key"],
2054+
serde_json::json!("client1.2000000000.sig123")
2055+
);
2056+
}
2057+
2058+
#[test]
2059+
fn diff_license_key_updates_different_client() {
2060+
let mut current = BTreeMap::new();
2061+
current.insert(
2062+
"license_key".to_string(),
2063+
serde_json::json!("client1.2000000000.sig123"),
2064+
);
2065+
2066+
let mut desired = BTreeMap::new();
2067+
desired.insert(
2068+
"license_key".to_string(),
2069+
serde_json::json!("client2.1000000000.sig456"),
2070+
);
2071+
2072+
let diff = diff_global_settings(&current, &desired, ApplyMode::Merge);
2073+
assert_eq!(
2074+
diff.upserts.len(),
2075+
1,
2076+
"Different client ID means different key, should always update"
2077+
);
2078+
}
2079+
2080+
#[test]
2081+
fn diff_license_key_non_string_always_updates() {
2082+
let mut current = BTreeMap::new();
2083+
current.insert(
2084+
"license_key".to_string(),
2085+
serde_json::json!({"envRef": "LIC_KEY"}),
2086+
);
2087+
2088+
let mut desired = BTreeMap::new();
2089+
desired.insert(
2090+
"license_key".to_string(),
2091+
serde_json::json!({"envRef": "NEW_LIC_KEY"}),
2092+
);
2093+
2094+
let diff = diff_global_settings(&current, &desired, ApplyMode::Merge);
2095+
assert_eq!(
2096+
diff.upserts.len(),
2097+
1,
2098+
"Non-string license keys should always be updated when different"
2099+
);
2100+
}
19422101
}

0 commit comments

Comments
 (0)