From be5a55d9a298ed731cd409b53a740b2bf6197084 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Sat, 22 Aug 2026 14:24:52 +0530 Subject: [PATCH 01/21] feat: add the proxy_key setting Credential for the proxy config endpoint. The empty-config startup warning now fires only when neither static pairs nor a proxy key are configured. --- src/config/settings.rs | 16 ++++++++++++++++ src/main.rs | 13 +++++++------ 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/config/settings.rs b/src/config/settings.rs index 64bca8f..60e078e 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -126,6 +126,10 @@ pub struct AppSettings { #[serde(default)] #[validate(nested)] pub environment_key_pairs: Vec, + /// Credential for the proxy config endpoint; when set, the served + /// environments are kept in sync with it at every poll. + #[serde(default)] + pub proxy_key: Option, #[serde(default = "default_api_url")] pub api_url: String, #[serde(default = "default_api_poll_frequency")] @@ -158,6 +162,7 @@ impl Default for AppSettings { fn default() -> Self { Self { environment_key_pairs: vec![], + proxy_key: None, api_url: default_api_url(), api_poll_frequency_seconds: default_api_poll_frequency(), api_poll_timeout_seconds: default_api_poll_timeout(), @@ -206,6 +211,17 @@ pub fn get_settings() -> Result { mod tests { use super::*; + #[test] + fn test_config_with_only_a_proxy_key_is_valid() { + // Given a config file that relies entirely on the proxy config + let settings: AppSettings = serde_json::from_str(r#"{"proxy_key": "pk.secret"}"#).unwrap(); + + // Then + assert_eq!(settings.proxy_key.as_deref(), Some("pk.secret")); + assert!(settings.environment_key_pairs.is_empty()); + assert!(settings.validate().is_ok()); + } + #[test] fn test_config_without_environment_key_pairs_parses_to_empty_valid_set() { // Given a config file omitting environment_key_pairs entirely diff --git a/src/main.rs b/src/main.rs index 0a3a060..9a3bab0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,13 +19,14 @@ async fn main() -> anyhow::Result<()> { settings.api_poll_frequency_seconds ); - // serde defaults environment_key_pairs, so a typo'd field name parses - // as an empty set and the proxy would report healthy while rejecting - // every request — make that state loud. - if settings.environment_key_pairs.is_empty() { + // serde defaults these fields, so a typo'd field name parses as an + // empty set and the proxy would report healthy while rejecting every + // request — make that state loud. + if settings.environment_key_pairs.is_empty() && settings.proxy_key.is_none() { warn!( - "No environments configured: environment_key_pairs is empty or \ - missing, so every request will be rejected with 401" + "No environments configured: environment_key_pairs and proxy_key \ + are both empty or missing, so every request will be rejected \ + with 401" ); } From 87bd6b6aba7d31ecb7f9e6e6d26b7902908e1a57 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Sat, 22 Aug 2026 14:25:18 +0530 Subject: [PATCH 02/21] feat: add the proxy config wire model Declares only the fields the proxy acts on; the endpoint's other fields are ignored by serde. --- src/models/mod.rs | 2 ++ src/models/proxy_config.rs | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 src/models/proxy_config.rs diff --git a/src/models/mod.rs b/src/models/mod.rs index 1da49b5..0c90610 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,6 +1,8 @@ pub mod engine; +pub mod proxy_config; pub mod request; pub mod response; +pub use proxy_config::ProxyConfigEnvironment; pub use request::{IdentityWithTraits, TraitModel}; pub use response::{APIFeature, APIFeatureState, IdentityResponse}; diff --git a/src/models/proxy_config.rs b/src/models/proxy_config.rs new file mode 100644 index 0000000..02ebd56 --- /dev/null +++ b/src/models/proxy_config.rs @@ -0,0 +1,38 @@ +use chrono::{DateTime, Utc}; +use serde::Deserialize; + +use crate::environments::{EnvironmentKeys, ServerKey}; + +/// One environment as the proxy config endpoint reports it. The response +/// carries more fields (id, name, project/organisation ids, updated_at); +/// only what the proxy acts on is declared, serde ignores the rest. +#[derive(Debug, Clone, Deserialize)] +pub struct ProxyConfigEnvironment { + pub client_side_key: String, + #[serde(default)] + pub server_side_keys: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ProxyConfigServerKey { + pub key: String, + pub active: bool, + pub expires_at: Option>, +} + +impl From for EnvironmentKeys { + fn from(environment: ProxyConfigEnvironment) -> Self { + Self { + client_key: environment.client_side_key, + server_keys: environment + .server_side_keys + .into_iter() + .map(|server_key| ServerKey { + key: server_key.key, + active: server_key.active, + expires_at: server_key.expires_at, + }) + .collect(), + } + } +} From 0511458e34793b4df5f0c9ac5a6b042d298a58cf Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Sat, 22 Aug 2026 14:26:10 +0530 Subject: [PATCH 03/21] feat: reconcile the environment index against a desired set The diff/apply policy lives beside the index whose invariants it enforces: protected (statically configured) client keys are never overridden or removed by the proxy config; unchanged environments are untouched; displaced and removed versions are returned so the caller can invalidate request caches. --- src/environments.rs | 144 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 143 insertions(+), 1 deletion(-) diff --git a/src/environments.rs b/src/environments.rs index cfdb34f..cc2d374 100644 --- a/src/environments.rs +++ b/src/environments.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::{Arc, RwLock}; use chrono::{DateTime, Utc}; @@ -50,6 +50,17 @@ impl EnvironmentKeys { /// Uses `std::sync::RwLock`, not tokio's: guards are held only for a map /// operation, never across an await, and lookups stay callable from /// synchronous code. +/// What a `reconcile` call did, for logging and cache invalidation. +#[derive(Debug, Default)] +pub struct ReconcileOutcome { + /// Environments inserted or updated. + pub changed: usize, + /// Previous versions replaced by an update. + pub displaced: Vec>, + /// Environments no longer in the config. + pub removed: Vec>, +} + #[derive(Default)] pub struct EnvironmentIndex { by_key: RwLock>>, @@ -124,6 +135,55 @@ impl EnvironmentIndex { Some(keys) } + /// Bring the index in line with `desired` (what the proxy config + /// reports), leaving `protected` client keys — the statically + /// configured environments, which the config never overrides or + /// removes — untouched. Safe against concurrent readers; assumes a + /// single writer, so per-operation locking suffices. + /// + /// The caller owns cache invalidation for everything returned in + /// `displaced` and `removed` (request caches are keyed by presented + /// key and consulted before the key gate). + pub fn reconcile( + &self, + desired: Vec, + protected: &HashSet, + ) -> ReconcileOutcome { + let mut outcome = ReconcileOutcome::default(); + + let desired_clients: HashSet = + desired.iter().map(|keys| keys.client_key.clone()).collect(); + + for keys in desired { + if protected.contains(&keys.client_key) { + continue; + } + let unchanged = self + .resolve(&keys.client_key) + .is_some_and(|current| *current == keys); + if unchanged { + continue; + } + outcome.changed += 1; + if let Some(previous) = self.insert(keys) { + outcome.displaced.push(previous); + } + } + + for current in self.snapshot() { + if desired_clients.contains(¤t.client_key) + || protected.contains(¤t.client_key) + { + continue; + } + if let Some(removed) = self.remove(¤t.client_key) { + outcome.removed.push(removed); + } + } + + outcome + } + /// Point-in-time snapshot of every environment's keys, ordered by /// client key so callers iterate deterministically. pub fn snapshot(&self) -> Vec> { @@ -243,6 +303,88 @@ mod tests { assert_eq!(client_keys, vec!["client_a", "client_b"]); } + fn environment(client: &str, server: &str) -> EnvironmentKeys { + EnvironmentKeys { + client_key: client.to_string(), + server_keys: vec![server_key(server)], + } + } + + #[test] + fn reconcile_inserts_new_and_removes_absent_environments() { + // Given an index serving env_a while the config now says env_b + let index = EnvironmentIndex::default(); + index.insert(environment("client_a", "ser.a")); + + // When + let outcome = index.reconcile(vec![environment("client_b", "ser.b")], &HashSet::new()); + + // Then + assert_eq!(outcome.changed, 1); + assert_eq!(outcome.removed.len(), 1); + assert_eq!(outcome.removed[0].client_key, "client_a"); + assert!(index.resolve("client_a").is_none()); + assert!(index.resolve("ser.a").is_none()); + assert!(index.resolve("client_b").is_some()); + assert!(index.resolve("ser.b").is_some()); + } + + #[test] + fn reconcile_never_touches_protected_environments() { + // Given a statically configured environment the config omits — + // and also claims with different keys + let index = EnvironmentIndex::from_settings(&[pair("client_a", "ser.a")]); + let protected = HashSet::from(["client_a".to_string()]); + + // When the config omits it entirely + let outcome = index.reconcile(vec![], &protected); + + // Then it survives + assert_eq!(outcome.removed.len(), 0); + assert!(index.resolve("ser.a").is_some()); + + // When the config claims it with a different server key + let outcome = index.reconcile(vec![environment("client_a", "ser.other")], &protected); + + // Then the static pairing wins + assert_eq!(outcome.changed, 0); + assert!(index.resolve("ser.a").is_some()); + assert!(index.resolve("ser.other").is_none()); + } + + #[test] + fn reconcile_rotation_returns_the_displaced_version() { + // Given + let index = EnvironmentIndex::default(); + index.insert(environment("client_a", "ser.old")); + + // When the config rotates the server key + let outcome = index.reconcile(vec![environment("client_a", "ser.new")], &HashSet::new()); + + // Then the displaced version comes back for cache invalidation + assert_eq!(outcome.changed, 1); + assert_eq!(outcome.displaced[0].server_keys[0].key, "ser.old"); + assert!(index.resolve("ser.old").is_none()); + assert!(index.resolve("ser.new").is_some()); + } + + #[test] + fn reconcile_leaves_unchanged_environments_alone() { + // Given + let index = EnvironmentIndex::default(); + index.insert(environment("client_a", "ser.a")); + let before = index.resolve("client_a").unwrap(); + + // When the config reports the same keys + let outcome = index.reconcile(vec![environment("client_a", "ser.a")], &HashSet::new()); + + // Then nothing changed — not even the Arc identity + assert_eq!(outcome.changed, 0); + assert!(outcome.displaced.is_empty()); + assert!(outcome.removed.is_empty()); + assert!(Arc::ptr_eq(&before, &index.resolve("client_a").unwrap())); + } + #[test] fn valid_server_key_skips_inactive_and_expired_keys() { // Given From 871f346de87f494f2fb9d1b58e7778b35fa7891f Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Sat, 22 Aug 2026 14:28:04 +0530 Subject: [PATCH 04/21] feat: sync served environments from the proxy config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When proxy_key is set, every poll first fetches the proxy config and reconciles the served set against it: new environments get their documents in the same pass, rotations and removals clear everything cached under keys that stopped resolving, and statically configured environments are never overridden or removed. A failed fetch reports through the poll result and removes nothing — an environment is only dropped by a successful response that omits it. --- src/services/environment.rs | 69 ++++++- tests/test_proxy_config_discovery.rs | 261 +++++++++++++++++++++++++++ 2 files changed, 328 insertions(+), 2 deletions(-) create mode 100644 tests/test_proxy_config_discovery.rs diff --git a/src/services/environment.rs b/src/services/environment.rs index 0ed6415..bd460bf 100644 --- a/src/services/environment.rs +++ b/src/services/environment.rs @@ -2,7 +2,9 @@ use crate::cache::{CacheKey, EndpointCache, EnvironmentsCache, LocalMemEnvironme use crate::config::settings::AppSettings; use crate::environments::{EnvironmentIndex, EnvironmentKeys}; use crate::error::{EdgeProxyError, Result}; -use crate::models::{APIFeatureState, IdentityResponse, IdentityWithTraits}; +use crate::models::{ + APIFeatureState, IdentityResponse, IdentityWithTraits, ProxyConfigEnvironment, +}; use crate::services::feature_utils::filter_out_server_key_only_flag_results; use chrono::{DateTime, Utc}; use flagsmith_flag_engine::engine::get_evaluation_result; @@ -10,6 +12,7 @@ use flagsmith_flag_engine::engine_eval::{FlagResult, add_identity_to_context}; use flagsmith_flag_engine::identities::Trait as FlagsmithTrait; use reqwest::header::HeaderMap; use reqwest::{Client, Url}; +use std::collections::HashSet; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::RwLock; @@ -60,7 +63,9 @@ impl EnvironmentService { } pub async fn refresh_environment_caches(&self) -> bool { - let mut all_success = true; + // Sync first so an environment added to the proxy config gets its + // document fetched in the same pass. + let mut all_success = self.sync_proxy_config().await; for keys in self.environments.snapshot() { match self.fetch_environment(&keys).await { @@ -108,6 +113,66 @@ impl EnvironmentService { info!("Environment removed for key: {}", keys.client_key); } + /// Bring the served environments in line with the proxy config, when + /// one is configured. Returns false on fetch failure — and removes + /// nothing then: an environment is only dropped by a successful + /// response that omits it, never by an outage or a rejected key. + async fn sync_proxy_config(&self) -> bool { + let Some(proxy_key) = &self.settings.proxy_key else { + return true; + }; + + let config = match self.fetch_proxy_config(proxy_key).await { + Ok(config) => config, + Err(e) => { + error!("Failed to fetch proxy config: {}", e); + return false; + } + }; + + let desired: Vec = config.into_iter().map(Into::into).collect(); + let protected: HashSet = self + .settings + .environment_key_pairs + .iter() + .map(|pair| pair.client_side_key.clone()) + .collect(); + + let outcome = self.environments.reconcile(desired, &protected); + + for keys in &outcome.removed { + self.cache.remove_environment(&keys.client_key).await; + self.clear_endpoint_caches(keys).await; + info!("Environment removed from proxy config: {}", keys.client_key); + } + // A displaced version's keys may no longer resolve (rotation); + // clear everything cached under them — current keys repopulate. + for keys in &outcome.displaced { + self.clear_endpoint_caches(keys).await; + } + if outcome.changed > 0 || !outcome.removed.is_empty() { + info!( + "Proxy config applied: {} changed, {} removed", + outcome.changed, + outcome.removed.len() + ); + } + + true + } + + async fn fetch_proxy_config(&self, proxy_key: &str) -> Result> { + let url = format!("{}/proxy/config/", self.settings.api_url); + let response = self + .client + .get(&url) + .header("X-Proxy-Key", proxy_key) + .send() + .await?; + response.error_for_status_ref()?; + Ok(response.json().await?) + } + /// The poll-loop counterpart: the loop iterates a snapshot, so a /// removal completing mid-fetch would have its environment's data /// re-inserted by put_environment and pinned until restart. diff --git a/tests/test_proxy_config_discovery.rs b/tests/test_proxy_config_discovery.rs new file mode 100644 index 0000000..0d27c45 --- /dev/null +++ b/tests/test_proxy_config_discovery.rs @@ -0,0 +1,261 @@ +use edge_proxy::config::settings::{ + AppSettings, EndpointCacheSettings, EndpointCachesSettings, EnvironmentKeyPair, +}; +use edge_proxy::error::EdgeProxyError; +use edge_proxy::services::EnvironmentService; +use serde_json::{Value, json}; +use wiremock::matchers::{header, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const PROXY_KEY: &str = "pk.test_proxy_key"; +const CLIENT_KEY: &str = "config_client_key"; +const SERVER_KEY: &str = "ser.config_key"; + +fn settings(api_url: &str, pairs: Vec) -> AppSettings { + AppSettings { + environment_key_pairs: pairs, + proxy_key: Some(PROXY_KEY.to_string()), + api_url: api_url.to_string(), + endpoint_caches: EndpointCachesSettings { + flags: EndpointCacheSettings { + use_cache: true, + cache_max_size: 10, + }, + environment_document: EndpointCacheSettings { + use_cache: true, + cache_max_size: 10, + }, + ..Default::default() + }, + ..AppSettings::default() + } +} + +/// The frozen contract shape, extra fields included, so the tests prove +/// serde tolerates everything the endpoint actually sends. +fn config_body(environments: &[(&str, &str)]) -> Value { + Value::Array( + environments + .iter() + .map(|(client_key, server_key)| { + json!({ + "id": 30, + "name": "Test Environment", + "client_side_key": client_key, + "server_side_keys": [ + {"key": server_key, "active": true, "expires_at": null} + ], + "updated_at": "2026-08-15T08:57:43.311081Z", + "project_id": 35, + "organisation_id": 82, + }) + }) + .collect(), + ) +} + +fn document_body(client_key: &str) -> Value { + json!({ + "id": 1, + "api_key": client_key, + "name": "Test", + "updated_at": "2026-08-22T00:00:00Z", + "allow_client_traits": true, + "hide_sensitive_data": false, + "hide_disabled_flags": null, + "use_identity_composite_key_for_hashing": true, + "use_identity_overrides_in_local_eval": true, + "project": { + "id": 1, + "name": "project-1", + "hide_disabled_flags": false, + "segments": [], + "server_key_only_feature_ids": [], + "organisation": { + "id": 1, + "name": "org-1", + "feature_analytics": false, + "persist_trait_data": true, + "stop_serving_flags": false, + }, + }, + "feature_states": [ + { + "multivariate_feature_state_values": [], + "feature_state_value": "config_value", + "feature": {"id": 1, "name": "config_flag", "type": "STANDARD"}, + "enabled": true, + "featurestate_uuid": "fs-uuid-1", + } + ], + "identity_overrides": [], + }) +} + +async fn mount_config(mock_server: &MockServer, body: Value, up_to: Option) { + let mut mock = Mock::given(method("GET")) + .and(path("/proxy/config/")) + .and(header("X-Proxy-Key", PROXY_KEY)) + .respond_with(ResponseTemplate::new(200).set_body_json(body)); + if let Some(n) = up_to { + mock = mock.up_to_n_times(n); + } + mock.mount(mock_server).await; +} + +async fn mount_document(mock_server: &MockServer, server_key: &str, client_key: &str) { + Mock::given(method("GET")) + .and(path("/environment-document/")) + .and(header("X-Environment-Key", server_key)) + .respond_with(ResponseTemplate::new(200).set_body_json(document_body(client_key))) + .mount(mock_server) + .await; +} + +#[tokio::test] +async fn test_environment_from_proxy_config_is_served_after_one_refresh() { + // Given a proxy configured with only a proxy key + let mock_server = MockServer::start().await; + mount_config(&mock_server, config_body(&[(CLIENT_KEY, SERVER_KEY)]), None).await; + mount_document(&mock_server, SERVER_KEY, CLIENT_KEY).await; + let service = EnvironmentService::new(settings(&mock_server.uri(), vec![])); + + // When + let all_success = service.refresh_environment_caches().await; + + // Then both keys serve, document and flags included + assert!(all_success); + assert!(service.get_environment(CLIENT_KEY).await.is_ok()); + assert!(service.get_environment(SERVER_KEY).await.is_ok()); + let flags = service + .get_flags_response_data(CLIENT_KEY, None) + .await + .unwrap(); + assert_eq!(flags.len(), 1); +} + +#[tokio::test] +async fn test_environment_dropped_from_proxy_config_is_removed_and_uncached() { + // Given a served environment with a primed flags endpoint cache + let mock_server = MockServer::start().await; + mount_config( + &mock_server, + config_body(&[(CLIENT_KEY, SERVER_KEY)]), + Some(1), + ) + .await; + mount_config(&mock_server, config_body(&[]), None).await; + mount_document(&mock_server, SERVER_KEY, CLIENT_KEY).await; + let service = EnvironmentService::new(settings(&mock_server.uri(), vec![])); + service.refresh_environment_caches().await; + assert!( + service + .get_flags_response_data(CLIENT_KEY, None) + .await + .is_ok() + ); + + // When the next config response omits the environment + let all_success = service.refresh_environment_caches().await; + + // Then it is gone — including the primed cache entry, which is + // consulted before the key gate + assert!(all_success); + assert!(matches!( + service.get_flags_response_data(CLIENT_KEY, None).await, + Err(EdgeProxyError::FlagsmithUnknownKey(_)) + )); + assert!(matches!( + service.get_environment(SERVER_KEY).await, + Err(EdgeProxyError::FlagsmithUnknownKey(_)) + )); +} + +#[tokio::test] +async fn test_static_environment_absent_from_proxy_config_is_never_removed() { + // Given a statically configured environment the config never mentions + let mock_server = MockServer::start().await; + mount_config(&mock_server, config_body(&[]), None).await; + mount_document(&mock_server, "ser.static_key", "static_client").await; + let service = EnvironmentService::new(settings( + &mock_server.uri(), + vec![EnvironmentKeyPair { + client_side_key: "static_client".to_string(), + server_side_key: "ser.static_key".to_string(), + }], + )); + + // When + let all_success = service.refresh_environment_caches().await; + + // Then the static environment keeps being served + assert!(all_success); + assert!(service.get_environment("static_client").await.is_ok()); + assert!(service.get_environment("ser.static_key").await.is_ok()); +} + +#[tokio::test] +async fn test_proxy_config_fetch_failure_removes_nothing() { + // Given a served environment and a proxy config endpoint that starts + // failing + let mock_server = MockServer::start().await; + mount_config( + &mock_server, + config_body(&[(CLIENT_KEY, SERVER_KEY)]), + Some(1), + ) + .await; + Mock::given(method("GET")) + .and(path("/proxy/config/")) + .respond_with(ResponseTemplate::new(500)) + .mount(&mock_server) + .await; + mount_document(&mock_server, SERVER_KEY, CLIENT_KEY).await; + let service = EnvironmentService::new(settings(&mock_server.uri(), vec![])); + assert!(service.refresh_environment_caches().await); + + // When the next sync fails + let all_success = service.refresh_environment_caches().await; + + // Then the failure is reported but nothing is removed + assert!(!all_success); + assert!(service.get_environment(CLIENT_KEY).await.is_ok()); + assert!(service.get_environment(SERVER_KEY).await.is_ok()); +} + +#[tokio::test] +async fn test_server_key_rotation_stops_serving_the_old_key() { + // Given a served environment whose document bytes are cached under + // the old server key + let mock_server = MockServer::start().await; + mount_config( + &mock_server, + config_body(&[(CLIENT_KEY, "ser.old_key")]), + Some(1), + ) + .await; + mount_config( + &mock_server, + config_body(&[(CLIENT_KEY, "ser.new_key")]), + None, + ) + .await; + mount_document(&mock_server, "ser.old_key", CLIENT_KEY).await; + mount_document(&mock_server, "ser.new_key", CLIENT_KEY).await; + let service = EnvironmentService::new(settings(&mock_server.uri(), vec![])); + service.refresh_environment_caches().await; + assert!(service.get_environment_bytes("ser.old_key").await.is_ok()); + + // When the config rotates the server key + let all_success = service.refresh_environment_caches().await; + + // Then the old key stops serving — cached bytes included — and the + // new key works + assert!(all_success); + assert!(matches!( + service.get_environment_bytes("ser.old_key").await, + Err(EdgeProxyError::FlagsmithUnknownKey(_)) + )); + assert!(service.get_environment_bytes("ser.new_key").await.is_ok()); + assert!(service.get_environment(CLIENT_KEY).await.is_ok()); +} From 48c3267113b3bec47feb2b368ffc063e4f045148 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Sat, 22 Aug 2026 15:16:01 +0530 Subject: [PATCH 05/21] docs: present-tense health note now that config sync exists --- src/services/environment.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/services/environment.rs b/src/services/environment.rs index bd460bf..5cc6a97 100644 --- a/src/services/environment.rs +++ b/src/services/environment.rs @@ -201,9 +201,11 @@ impl EnvironmentService { } async fn fetch_environment(&self, keys: &EnvironmentKeys) -> Result { - // An environment stuck here fails every poll, keeping /health red - // until a config change — or, once reconciliation exists, until it - // removes or re-keys the environment. + // An environment with no usable key fails every poll, keeping + // /health red. The next proxy config sync heals it by re-keying + // or removing the environment; a static one needs a config-file + // fix. One still in the config with only dead keys stays red + // deliberately: that is the misconfiguration signal. let server_key = keys.valid_server_key().ok_or_else(|| { EdgeProxyError::ServiceUnavailable(format!( "no active server-side key for environment {}", From 6cf8abed4872fd8fb1479beb29d458ea4d3bfc11 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Sat, 22 Aug 2026 15:36:07 +0530 Subject: [PATCH 06/21] fix: stop invalid server keys from authenticating requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit active/expires_at only gated the upstream fetch; the request path indexed and served every key regardless, so a deactivation delivered by the proxy config — the mechanism revocation is meant to propagate through — and an expiry passing between polls were both ignored. resolve now rejects a presented server key that is no longer valid; client keys are unaffected. --- src/environments.rs | 47 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/src/environments.rs b/src/environments.rs index cc2d374..1601269 100644 --- a/src/environments.rs +++ b/src/environments.rs @@ -83,13 +83,28 @@ impl EnvironmentIndex { } /// Resolve a presented key — client- or server-side — to its - /// environment's keys. + /// environment's keys. A server-side key resolves only while it is + /// valid, so a deactivation delivered by the proxy config and an + /// expiry passing between polls both take effect on the next request. pub fn resolve(&self, key: &str) -> Option> { - self.by_key + let keys = self + .by_key .read() .expect("environment index lock poisoned") .get(key) - .cloned() + .cloned()?; + + if key != keys.client_key { + let presented = keys + .server_keys + .iter() + .find(|server_key| server_key.key == key)?; + if !presented.is_valid() { + return None; + } + } + + Some(keys) } /// Insert or replace an environment's keys, dropping index entries @@ -385,6 +400,32 @@ mod tests { assert!(Arc::ptr_eq(&before, &index.resolve("client_a").unwrap())); } + #[test] + fn resolve_rejects_invalid_server_keys_but_keeps_the_client_key() { + // Given an environment whose server keys are deactivated or expired + let index = EnvironmentIndex::default(); + index.insert(EnvironmentKeys { + client_key: "client_a".to_string(), + server_keys: vec![ + ServerKey { + key: "ser.inactive".to_string(), + active: false, + expires_at: None, + }, + ServerKey { + key: "ser.expired".to_string(), + active: true, + expires_at: Some(Utc::now() - TimeDelta::days(1)), + }, + ], + }); + + // Then the invalid keys stop authenticating, the client key doesn't + assert!(index.resolve("client_a").is_some()); + assert!(index.resolve("ser.inactive").is_none()); + assert!(index.resolve("ser.expired").is_none()); + } + #[test] fn valid_server_key_skips_inactive_and_expired_keys() { // Given From 745b4e0c27e8147e04593235b92c4f93ead116a3 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Sat, 22 Aug 2026 15:37:04 +0530 Subject: [PATCH 07/21] fix: skip proxy config environments with no usable server key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A brand-new environment has no server-side keys yet, and the config reports it anyway. Indexing it made every poll fail — nothing to fetch a document with — which held /health red for the whole proxy from the moment anyone created an environment. Such environments are now filtered from the desired set (and dropped if previously served), picked up automatically by the sync after their first key is created. --- src/services/environment.rs | 30 ++++++++-- tests/test_proxy_config_discovery.rs | 83 ++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 6 deletions(-) diff --git a/src/services/environment.rs b/src/services/environment.rs index 5cc6a97..d576eca 100644 --- a/src/services/environment.rs +++ b/src/services/environment.rs @@ -130,7 +130,25 @@ impl EnvironmentService { } }; - let desired: Vec = config.into_iter().map(Into::into).collect(); + let desired: Vec = config + .into_iter() + .map(EnvironmentKeys::from) + .filter(|keys| { + // A brand-new environment has no server-side keys yet, and + // the config reports it anyway. With no usable key there is + // nothing to fetch a document with, so serving it is + // impossible — skip it (and drop it if previously served) + // rather than fail every poll and take /health red. + let usable = keys.valid_server_key().is_some(); + if !usable { + debug!( + "Skipping proxy config environment {}: no usable server-side key", + keys.client_key + ); + } + usable + }) + .collect(); let protected: HashSet = self .settings .environment_key_pairs @@ -201,11 +219,11 @@ impl EnvironmentService { } async fn fetch_environment(&self, keys: &EnvironmentKeys) -> Result { - // An environment with no usable key fails every poll, keeping - // /health red. The next proxy config sync heals it by re-keying - // or removing the environment; a static one needs a config-file - // fix. One still in the config with only dead keys stays red - // deliberately: that is the misconfiguration signal. + // The proxy config sync skips environments with no usable key, so + // only a statically configured environment can be stuck here (or, + // for at most one tick, one whose last key expired mid-pass). It + // fails every poll, keeping /health red until the config file is + // fixed — deliberately: that is the misconfiguration signal. let server_key = keys.valid_server_key().ok_or_else(|| { EdgeProxyError::ServiceUnavailable(format!( "no active server-side key for environment {}", diff --git a/tests/test_proxy_config_discovery.rs b/tests/test_proxy_config_discovery.rs index 0d27c45..b2df11c 100644 --- a/tests/test_proxy_config_discovery.rs +++ b/tests/test_proxy_config_discovery.rs @@ -259,3 +259,86 @@ async fn test_server_key_rotation_stops_serving_the_old_key() { assert!(service.get_environment_bytes("ser.new_key").await.is_ok()); assert!(service.get_environment(CLIENT_KEY).await.is_ok()); } + +#[tokio::test] +async fn test_environment_without_usable_keys_is_skipped_not_failed() { + // Given the config reports a healthy environment and a brand-new one + // with no server-side keys yet + let mock_server = MockServer::start().await; + let body = json!([ + { + "id": 30, + "name": "healthy", + "client_side_key": CLIENT_KEY, + "server_side_keys": [{"key": SERVER_KEY, "active": true, "expires_at": null}], + "updated_at": "2026-08-15T08:57:43.311081Z", + "project_id": 35, + "organisation_id": 82, + }, + { + "id": 31, + "name": "no keys yet", + "client_side_key": "keyless_client", + "server_side_keys": [], + "updated_at": "2026-08-15T08:57:43.311081Z", + "project_id": 35, + "organisation_id": 82, + }, + ]); + mount_config(&mock_server, body, None).await; + mount_document(&mock_server, SERVER_KEY, CLIENT_KEY).await; + let service = EnvironmentService::new(settings(&mock_server.uri(), vec![])); + + // When + let all_success = service.refresh_environment_caches().await; + + // Then the poll succeeds — /health stays green — the healthy + // environment serves, and the key-less one is simply not indexed + assert!(all_success); + assert!(service.get_environment(CLIENT_KEY).await.is_ok()); + assert!(matches!( + service.get_environment("keyless_client").await, + Err(EdgeProxyError::FlagsmithUnknownKey(_)) + )); +} + +#[tokio::test] +async fn test_environment_whose_only_key_is_deactivated_is_dropped() { + // Given a served environment whose only key the config then reports + // as inactive + let mock_server = MockServer::start().await; + mount_config( + &mock_server, + config_body(&[(CLIENT_KEY, SERVER_KEY)]), + Some(1), + ) + .await; + let deactivated = json!([{ + "id": 30, + "name": "Test", + "client_side_key": CLIENT_KEY, + "server_side_keys": [{"key": SERVER_KEY, "active": false, "expires_at": null}], + "updated_at": "2026-08-15T08:57:43.311081Z", + "project_id": 35, + "organisation_id": 82, + }]); + mount_config(&mock_server, deactivated, None).await; + mount_document(&mock_server, SERVER_KEY, CLIENT_KEY).await; + let service = EnvironmentService::new(settings(&mock_server.uri(), vec![])); + service.refresh_environment_caches().await; + assert!(service.get_environment(SERVER_KEY).await.is_ok()); + + // When + let all_success = service.refresh_environment_caches().await; + + // Then the environment is dropped entirely and the poll stays healthy + assert!(all_success); + assert!(matches!( + service.get_environment(SERVER_KEY).await, + Err(EdgeProxyError::FlagsmithUnknownKey(_)) + )); + assert!(matches!( + service.get_environment(CLIENT_KEY).await, + Err(EdgeProxyError::FlagsmithUnknownKey(_)) + )); +} From 45b3aded556373889268a167abb9ce4b956d32eb Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Sat, 22 Aug 2026 15:37:04 +0530 Subject: [PATCH 08/21] docs: drop the odd One from the wire-model comment --- src/models/proxy_config.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/models/proxy_config.rs b/src/models/proxy_config.rs index 02ebd56..f4cb36c 100644 --- a/src/models/proxy_config.rs +++ b/src/models/proxy_config.rs @@ -3,7 +3,7 @@ use serde::Deserialize; use crate::environments::{EnvironmentKeys, ServerKey}; -/// One environment as the proxy config endpoint reports it. The response +/// An environment as the proxy config endpoint reports it. The response /// carries more fields (id, name, project/organisation ids, updated_at); /// only what the proxy acts on is declared, serde ignores the rest. #[derive(Debug, Clone, Deserialize)] From 004818a3f831a97204d5df12311d76f24e6fd979 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Sat, 22 Aug 2026 15:43:51 +0530 Subject: [PATCH 09/21] refactor: rename reconcile to sync_to Matches the layering: sync_proxy_config fetches, the index syncs to what was fetched. SyncOutcome follows. --- src/environments.rs | 28 ++++++++++++++-------------- src/services/environment.rs | 2 +- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/environments.rs b/src/environments.rs index 1601269..e90ce27 100644 --- a/src/environments.rs +++ b/src/environments.rs @@ -50,9 +50,9 @@ impl EnvironmentKeys { /// Uses `std::sync::RwLock`, not tokio's: guards are held only for a map /// operation, never across an await, and lookups stay callable from /// synchronous code. -/// What a `reconcile` call did, for logging and cache invalidation. +/// What a `sync_to` call did, for logging and cache invalidation. #[derive(Debug, Default)] -pub struct ReconcileOutcome { +pub struct SyncOutcome { /// Environments inserted or updated. pub changed: usize, /// Previous versions replaced by an update. @@ -159,12 +159,12 @@ impl EnvironmentIndex { /// The caller owns cache invalidation for everything returned in /// `displaced` and `removed` (request caches are keyed by presented /// key and consulted before the key gate). - pub fn reconcile( + pub fn sync_to( &self, desired: Vec, protected: &HashSet, - ) -> ReconcileOutcome { - let mut outcome = ReconcileOutcome::default(); + ) -> SyncOutcome { + let mut outcome = SyncOutcome::default(); let desired_clients: HashSet = desired.iter().map(|keys| keys.client_key.clone()).collect(); @@ -326,13 +326,13 @@ mod tests { } #[test] - fn reconcile_inserts_new_and_removes_absent_environments() { + fn sync_to_inserts_new_and_removes_absent_environments() { // Given an index serving env_a while the config now says env_b let index = EnvironmentIndex::default(); index.insert(environment("client_a", "ser.a")); // When - let outcome = index.reconcile(vec![environment("client_b", "ser.b")], &HashSet::new()); + let outcome = index.sync_to(vec![environment("client_b", "ser.b")], &HashSet::new()); // Then assert_eq!(outcome.changed, 1); @@ -345,21 +345,21 @@ mod tests { } #[test] - fn reconcile_never_touches_protected_environments() { + fn sync_to_never_touches_protected_environments() { // Given a statically configured environment the config omits — // and also claims with different keys let index = EnvironmentIndex::from_settings(&[pair("client_a", "ser.a")]); let protected = HashSet::from(["client_a".to_string()]); // When the config omits it entirely - let outcome = index.reconcile(vec![], &protected); + let outcome = index.sync_to(vec![], &protected); // Then it survives assert_eq!(outcome.removed.len(), 0); assert!(index.resolve("ser.a").is_some()); // When the config claims it with a different server key - let outcome = index.reconcile(vec![environment("client_a", "ser.other")], &protected); + let outcome = index.sync_to(vec![environment("client_a", "ser.other")], &protected); // Then the static pairing wins assert_eq!(outcome.changed, 0); @@ -368,13 +368,13 @@ mod tests { } #[test] - fn reconcile_rotation_returns_the_displaced_version() { + fn sync_to_rotation_returns_the_displaced_version() { // Given let index = EnvironmentIndex::default(); index.insert(environment("client_a", "ser.old")); // When the config rotates the server key - let outcome = index.reconcile(vec![environment("client_a", "ser.new")], &HashSet::new()); + let outcome = index.sync_to(vec![environment("client_a", "ser.new")], &HashSet::new()); // Then the displaced version comes back for cache invalidation assert_eq!(outcome.changed, 1); @@ -384,14 +384,14 @@ mod tests { } #[test] - fn reconcile_leaves_unchanged_environments_alone() { + fn sync_to_leaves_unchanged_environments_alone() { // Given let index = EnvironmentIndex::default(); index.insert(environment("client_a", "ser.a")); let before = index.resolve("client_a").unwrap(); // When the config reports the same keys - let outcome = index.reconcile(vec![environment("client_a", "ser.a")], &HashSet::new()); + let outcome = index.sync_to(vec![environment("client_a", "ser.a")], &HashSet::new()); // Then nothing changed — not even the Arc identity assert_eq!(outcome.changed, 0); diff --git a/src/services/environment.rs b/src/services/environment.rs index d576eca..4af84b2 100644 --- a/src/services/environment.rs +++ b/src/services/environment.rs @@ -156,7 +156,7 @@ impl EnvironmentService { .map(|pair| pair.client_side_key.clone()) .collect(); - let outcome = self.environments.reconcile(desired, &protected); + let outcome = self.environments.sync_to(desired, &protected); for keys in &outcome.removed { self.cache.remove_environment(&keys.client_key).await; From 81b11abf70c9f1c4be6d40064fdd0d060e6ab8f6 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Sat, 22 Aug 2026 15:44:50 +0530 Subject: [PATCH 10/21] refactor: rename SyncOutcome to SyncResult Consistent with the engine's EvaluationResult vocabulary. --- src/environments.rs | 44 ++++++++++++++++++------------------- src/services/environment.rs | 12 +++++----- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/src/environments.rs b/src/environments.rs index e90ce27..9d86e5d 100644 --- a/src/environments.rs +++ b/src/environments.rs @@ -52,7 +52,7 @@ impl EnvironmentKeys { /// synchronous code. /// What a `sync_to` call did, for logging and cache invalidation. #[derive(Debug, Default)] -pub struct SyncOutcome { +pub struct SyncResult { /// Environments inserted or updated. pub changed: usize, /// Previous versions replaced by an update. @@ -163,8 +163,8 @@ impl EnvironmentIndex { &self, desired: Vec, protected: &HashSet, - ) -> SyncOutcome { - let mut outcome = SyncOutcome::default(); + ) -> SyncResult { + let mut result = SyncResult::default(); let desired_clients: HashSet = desired.iter().map(|keys| keys.client_key.clone()).collect(); @@ -179,9 +179,9 @@ impl EnvironmentIndex { if unchanged { continue; } - outcome.changed += 1; + result.changed += 1; if let Some(previous) = self.insert(keys) { - outcome.displaced.push(previous); + result.displaced.push(previous); } } @@ -192,11 +192,11 @@ impl EnvironmentIndex { continue; } if let Some(removed) = self.remove(¤t.client_key) { - outcome.removed.push(removed); + result.removed.push(removed); } } - outcome + result } /// Point-in-time snapshot of every environment's keys, ordered by @@ -332,12 +332,12 @@ mod tests { index.insert(environment("client_a", "ser.a")); // When - let outcome = index.sync_to(vec![environment("client_b", "ser.b")], &HashSet::new()); + let result = index.sync_to(vec![environment("client_b", "ser.b")], &HashSet::new()); // Then - assert_eq!(outcome.changed, 1); - assert_eq!(outcome.removed.len(), 1); - assert_eq!(outcome.removed[0].client_key, "client_a"); + assert_eq!(result.changed, 1); + assert_eq!(result.removed.len(), 1); + assert_eq!(result.removed[0].client_key, "client_a"); assert!(index.resolve("client_a").is_none()); assert!(index.resolve("ser.a").is_none()); assert!(index.resolve("client_b").is_some()); @@ -352,17 +352,17 @@ mod tests { let protected = HashSet::from(["client_a".to_string()]); // When the config omits it entirely - let outcome = index.sync_to(vec![], &protected); + let result = index.sync_to(vec![], &protected); // Then it survives - assert_eq!(outcome.removed.len(), 0); + assert_eq!(result.removed.len(), 0); assert!(index.resolve("ser.a").is_some()); // When the config claims it with a different server key - let outcome = index.sync_to(vec![environment("client_a", "ser.other")], &protected); + let result = index.sync_to(vec![environment("client_a", "ser.other")], &protected); // Then the static pairing wins - assert_eq!(outcome.changed, 0); + assert_eq!(result.changed, 0); assert!(index.resolve("ser.a").is_some()); assert!(index.resolve("ser.other").is_none()); } @@ -374,11 +374,11 @@ mod tests { index.insert(environment("client_a", "ser.old")); // When the config rotates the server key - let outcome = index.sync_to(vec![environment("client_a", "ser.new")], &HashSet::new()); + let result = index.sync_to(vec![environment("client_a", "ser.new")], &HashSet::new()); // Then the displaced version comes back for cache invalidation - assert_eq!(outcome.changed, 1); - assert_eq!(outcome.displaced[0].server_keys[0].key, "ser.old"); + assert_eq!(result.changed, 1); + assert_eq!(result.displaced[0].server_keys[0].key, "ser.old"); assert!(index.resolve("ser.old").is_none()); assert!(index.resolve("ser.new").is_some()); } @@ -391,12 +391,12 @@ mod tests { let before = index.resolve("client_a").unwrap(); // When the config reports the same keys - let outcome = index.sync_to(vec![environment("client_a", "ser.a")], &HashSet::new()); + let result = index.sync_to(vec![environment("client_a", "ser.a")], &HashSet::new()); // Then nothing changed — not even the Arc identity - assert_eq!(outcome.changed, 0); - assert!(outcome.displaced.is_empty()); - assert!(outcome.removed.is_empty()); + assert_eq!(result.changed, 0); + assert!(result.displaced.is_empty()); + assert!(result.removed.is_empty()); assert!(Arc::ptr_eq(&before, &index.resolve("client_a").unwrap())); } diff --git a/src/services/environment.rs b/src/services/environment.rs index 4af84b2..678bd89 100644 --- a/src/services/environment.rs +++ b/src/services/environment.rs @@ -156,23 +156,23 @@ impl EnvironmentService { .map(|pair| pair.client_side_key.clone()) .collect(); - let outcome = self.environments.sync_to(desired, &protected); + let result = self.environments.sync_to(desired, &protected); - for keys in &outcome.removed { + for keys in &result.removed { self.cache.remove_environment(&keys.client_key).await; self.clear_endpoint_caches(keys).await; info!("Environment removed from proxy config: {}", keys.client_key); } // A displaced version's keys may no longer resolve (rotation); // clear everything cached under them — current keys repopulate. - for keys in &outcome.displaced { + for keys in &result.displaced { self.clear_endpoint_caches(keys).await; } - if outcome.changed > 0 || !outcome.removed.is_empty() { + if result.changed > 0 || !result.removed.is_empty() { info!( "Proxy config applied: {} changed, {} removed", - outcome.changed, - outcome.removed.len() + result.changed, + result.removed.len() ); } From 046a238c7face97b419cdbdc5ba006ca893fb097 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Sat, 22 Aug 2026 15:48:47 +0530 Subject: [PATCH 11/21] docs: reattach the EnvironmentIndex doc comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SyncResult had been inserted between the index's doc block and the struct, so rustdoc attached the whole thing — lock discipline and all — to SyncResult and left EnvironmentIndex undocumented. --- src/environments.rs | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/environments.rs b/src/environments.rs index 9d86e5d..93b4380 100644 --- a/src/environments.rs +++ b/src/environments.rs @@ -37,6 +37,17 @@ impl EnvironmentKeys { } } +/// What a `sync_to` call did, for logging and cache invalidation. +#[derive(Debug, Default)] +pub struct SyncResult { + /// Environments inserted or updated. + pub changed: usize, + /// Previous versions replaced by an update. + pub displaced: Vec>, + /// Environments no longer in the config. + pub removed: Vec>, +} + /// The runtime-mutable set of environments the proxy serves. /// /// Every environment is indexed under its client key *and* each of its @@ -50,17 +61,6 @@ impl EnvironmentKeys { /// Uses `std::sync::RwLock`, not tokio's: guards are held only for a map /// operation, never across an await, and lookups stay callable from /// synchronous code. -/// What a `sync_to` call did, for logging and cache invalidation. -#[derive(Debug, Default)] -pub struct SyncResult { - /// Environments inserted or updated. - pub changed: usize, - /// Previous versions replaced by an update. - pub displaced: Vec>, - /// Environments no longer in the config. - pub removed: Vec>, -} - #[derive(Default)] pub struct EnvironmentIndex { by_key: RwLock>>, From c097fb7dfdbb6347048758ca86e10478f1ded300 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Sat, 22 Aug 2026 15:49:30 +0530 Subject: [PATCH 12/21] refactor: the index owns the statically configured key set from_settings already receives the static pairs, so remembering them makes 'static config wins' an unconditional property of the type instead of a per-call obligation, and sync_to needs no protected parameter. Protection now covers the full key namespace: a config environment whose keys collide with a static environment's keys is skipped entirely instead of silently hijacking the index entry. --- src/environments.rs | 62 ++++++++++++++++++++++++++----------- src/services/environment.rs | 10 +----- 2 files changed, 45 insertions(+), 27 deletions(-) diff --git a/src/environments.rs b/src/environments.rs index 93b4380..39cf680 100644 --- a/src/environments.rs +++ b/src/environments.rs @@ -64,11 +64,19 @@ pub struct SyncResult { #[derive(Default)] pub struct EnvironmentIndex { by_key: RwLock>>, + /// Every key of the statically configured environments. Immutable + /// after construction; `sync_to` never overrides or removes an + /// environment whose keys appear here. + protected: HashSet, } impl EnvironmentIndex { pub fn from_settings(pairs: &[EnvironmentKeyPair]) -> Self { - let index = Self::default(); + let mut index = Self::default(); + for pair in pairs { + index.protected.insert(pair.client_side_key.clone()); + index.protected.insert(pair.server_side_key.clone()); + } for pair in pairs { index.insert(EnvironmentKeys { client_key: pair.client_side_key.clone(), @@ -151,26 +159,23 @@ impl EnvironmentIndex { } /// Bring the index in line with `desired` (what the proxy config - /// reports), leaving `protected` client keys — the statically - /// configured environments, which the config never overrides or - /// removes — untouched. Safe against concurrent readers; assumes a - /// single writer, so per-operation locking suffices. + /// reports). Statically configured environments are never overridden + /// or removed, and a desired environment whose keys collide with a + /// static environment's keys is skipped entirely. Safe against + /// concurrent readers; assumes a single writer — the poll task is the + /// sole caller — so per-operation locking suffices. /// /// The caller owns cache invalidation for everything returned in /// `displaced` and `removed` (request caches are keyed by presented /// key and consulted before the key gate). - pub fn sync_to( - &self, - desired: Vec, - protected: &HashSet, - ) -> SyncResult { + pub fn sync_to(&self, desired: Vec) -> SyncResult { let mut result = SyncResult::default(); let desired_clients: HashSet = desired.iter().map(|keys| keys.client_key.clone()).collect(); for keys in desired { - if protected.contains(&keys.client_key) { + if self.is_protected(&keys) { continue; } let unchanged = self @@ -187,7 +192,7 @@ impl EnvironmentIndex { for current in self.snapshot() { if desired_clients.contains(¤t.client_key) - || protected.contains(¤t.client_key) + || self.protected.contains(¤t.client_key) { continue; } @@ -199,6 +204,16 @@ impl EnvironmentIndex { result } + /// True when the environment is statically configured, or any of its + /// keys collides with a static environment's key namespace. + fn is_protected(&self, keys: &EnvironmentKeys) -> bool { + self.protected.contains(&keys.client_key) + || keys + .server_keys + .iter() + .any(|server_key| self.protected.contains(&server_key.key)) + } + /// Point-in-time snapshot of every environment's keys, ordered by /// client key so callers iterate deterministically. pub fn snapshot(&self) -> Vec> { @@ -332,7 +347,7 @@ mod tests { index.insert(environment("client_a", "ser.a")); // When - let result = index.sync_to(vec![environment("client_b", "ser.b")], &HashSet::new()); + let result = index.sync_to(vec![environment("client_b", "ser.b")]); // Then assert_eq!(result.changed, 1); @@ -349,22 +364,33 @@ mod tests { // Given a statically configured environment the config omits — // and also claims with different keys let index = EnvironmentIndex::from_settings(&[pair("client_a", "ser.a")]); - let protected = HashSet::from(["client_a".to_string()]); // When the config omits it entirely - let result = index.sync_to(vec![], &protected); + let result = index.sync_to(vec![]); // Then it survives assert_eq!(result.removed.len(), 0); assert!(index.resolve("ser.a").is_some()); // When the config claims it with a different server key - let result = index.sync_to(vec![environment("client_a", "ser.other")], &protected); + let result = index.sync_to(vec![environment("client_a", "ser.other")]); // Then the static pairing wins assert_eq!(result.changed, 0); assert!(index.resolve("ser.a").is_some()); assert!(index.resolve("ser.other").is_none()); + + // When the config claims a different environment reusing the + // static environment's server key + let result = index.sync_to(vec![EnvironmentKeys { + client_key: "client_b".to_string(), + server_keys: vec![server_key("ser.a")], + }]); + + // Then it is skipped entirely rather than hijacking the key + assert_eq!(result.changed, 0); + assert!(index.resolve("client_b").is_none()); + assert_eq!(index.resolve("ser.a").unwrap().client_key, "client_a"); } #[test] @@ -374,7 +400,7 @@ mod tests { index.insert(environment("client_a", "ser.old")); // When the config rotates the server key - let result = index.sync_to(vec![environment("client_a", "ser.new")], &HashSet::new()); + let result = index.sync_to(vec![environment("client_a", "ser.new")]); // Then the displaced version comes back for cache invalidation assert_eq!(result.changed, 1); @@ -391,7 +417,7 @@ mod tests { let before = index.resolve("client_a").unwrap(); // When the config reports the same keys - let result = index.sync_to(vec![environment("client_a", "ser.a")], &HashSet::new()); + let result = index.sync_to(vec![environment("client_a", "ser.a")]); // Then nothing changed — not even the Arc identity assert_eq!(result.changed, 0); diff --git a/src/services/environment.rs b/src/services/environment.rs index 678bd89..c6e8876 100644 --- a/src/services/environment.rs +++ b/src/services/environment.rs @@ -12,7 +12,6 @@ use flagsmith_flag_engine::engine_eval::{FlagResult, add_identity_to_context}; use flagsmith_flag_engine::identities::Trait as FlagsmithTrait; use reqwest::header::HeaderMap; use reqwest::{Client, Url}; -use std::collections::HashSet; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::RwLock; @@ -149,14 +148,7 @@ impl EnvironmentService { usable }) .collect(); - let protected: HashSet = self - .settings - .environment_key_pairs - .iter() - .map(|pair| pair.client_side_key.clone()) - .collect(); - - let result = self.environments.sync_to(desired, &protected); + let result = self.environments.sync_to(desired); for keys in &result.removed { self.cache.remove_environment(&keys.client_key).await; From 332b741d830d2f5b6fba23bcce7d81d876eca38c Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Sat, 22 Aug 2026 15:50:48 +0530 Subject: [PATCH 13/21] refactor: extract purge_environment_caches 'Stop serving = purge both cache layers' was written in three places; a fourth cache would have been missed in one of them. Also records that removal of a config-managed environment is transient. --- src/services/environment.rs | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/services/environment.rs b/src/services/environment.rs index c6e8876..13cd64d 100644 --- a/src/services/environment.rs +++ b/src/services/environment.rs @@ -97,7 +97,9 @@ impl EnvironmentService { } /// Stop serving an environment: requests presenting any of its keys - /// are rejected, and everything cached for it is cleared. + /// are rejected, and everything cached for it is cleared. For an + /// environment the proxy config still reports, removal lasts only + /// until the next sync re-adds it. pub async fn remove_environment(&self, environment_key: &str) { let Some(keys) = self.environments.remove(environment_key) else { // Unknown key: clear caches under it anyway, so a repeated @@ -107,11 +109,18 @@ impl EnvironmentService { self.endpoint_cache.clear_environment(environment_key).await; return; }; - self.cache.remove_environment(&keys.client_key).await; - self.clear_endpoint_caches(&keys).await; + self.purge_environment_caches(&keys).await; info!("Environment removed for key: {}", keys.client_key); } + /// Stopping service of an environment means both cache layers must + /// forget it: the document/context cache under its client key, and + /// the endpoint caches under every key it can be presented by. + async fn purge_environment_caches(&self, keys: &EnvironmentKeys) { + self.cache.remove_environment(&keys.client_key).await; + self.clear_endpoint_caches(keys).await; + } + /// Bring the served environments in line with the proxy config, when /// one is configured. Returns false on fetch failure — and removes /// nothing then: an environment is only dropped by a successful @@ -151,8 +160,7 @@ impl EnvironmentService { let result = self.environments.sync_to(desired); for keys in &result.removed { - self.cache.remove_environment(&keys.client_key).await; - self.clear_endpoint_caches(keys).await; + self.purge_environment_caches(keys).await; info!("Environment removed from proxy config: {}", keys.client_key); } // A displaced version's keys may no longer resolve (rotation); @@ -188,8 +196,7 @@ impl EnvironmentService { /// re-inserted by put_environment and pinned until restart. async fn discard_environment_if_removed(&self, keys: &EnvironmentKeys) { if self.environments.resolve(&keys.client_key).is_none() { - self.cache.remove_environment(&keys.client_key).await; - self.clear_endpoint_caches(keys).await; + self.purge_environment_caches(keys).await; } } From cc3d74ad194e22d94d1307bf2c4c6f59728f2378 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Sat, 22 Aug 2026 15:50:48 +0530 Subject: [PATCH 14/21] fix: reject an empty proxy_key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some("") counted as configured — suppressing the no-environments startup warning — while sending an empty X-Proxy-Key header and failing every sync, loud only once /health went red. --- src/config/settings.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/config/settings.rs b/src/config/settings.rs index 60e078e..a812d96 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -129,6 +129,7 @@ pub struct AppSettings { /// Credential for the proxy config endpoint; when set, the served /// environments are kept in sync with it at every poll. #[serde(default)] + #[validate(length(min = 1))] pub proxy_key: Option, #[serde(default = "default_api_url")] pub api_url: String, @@ -211,6 +212,15 @@ pub fn get_settings() -> Result { mod tests { use super::*; + #[test] + fn test_config_with_empty_proxy_key_is_invalid() { + // Given an empty proxy_key, which would silently fail every sync + let settings: AppSettings = serde_json::from_str(r#"{"proxy_key": ""}"#).unwrap(); + + // Then + assert!(settings.validate().is_err()); + } + #[test] fn test_config_with_only_a_proxy_key_is_valid() { // Given a config file that relies entirely on the proxy config From eeca85e8d842ffe18ff5abfb461b3d2fba694ef6 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Sat, 22 Aug 2026 15:50:48 +0530 Subject: [PATCH 15/21] fix: sort server keys on ingest The endpoint doesn't guarantee key order, and sync_to detects change by equality; an upstream query-plan change would otherwise cause spurious displacement and cache clearing every poll. --- src/models/proxy_config.rs | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/models/proxy_config.rs b/src/models/proxy_config.rs index f4cb36c..36e6df3 100644 --- a/src/models/proxy_config.rs +++ b/src/models/proxy_config.rs @@ -22,17 +22,22 @@ pub struct ProxyConfigServerKey { impl From for EnvironmentKeys { fn from(environment: ProxyConfigEnvironment) -> Self { + let mut server_keys: Vec = environment + .server_side_keys + .into_iter() + .map(|server_key| ServerKey { + key: server_key.key, + active: server_key.active, + expires_at: server_key.expires_at, + }) + .collect(); + // The endpoint does not guarantee key order; sort so a reordered + // response is not mistaken for a changed environment. + server_keys.sort_by(|a, b| a.key.cmp(&b.key)); + Self { client_key: environment.client_side_key, - server_keys: environment - .server_side_keys - .into_iter() - .map(|server_key| ServerKey { - key: server_key.key, - active: server_key.active, - expires_at: server_key.expires_at, - }) - .collect(), + server_keys, } } } From 4576682590face780d00f1756916b806502c6714 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Sat, 22 Aug 2026 15:56:38 +0530 Subject: [PATCH 16/21] docs: drop the transient-removal caveat from remove_environment --- src/services/environment.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/services/environment.rs b/src/services/environment.rs index 13cd64d..cf4f8dc 100644 --- a/src/services/environment.rs +++ b/src/services/environment.rs @@ -97,9 +97,7 @@ impl EnvironmentService { } /// Stop serving an environment: requests presenting any of its keys - /// are rejected, and everything cached for it is cleared. For an - /// environment the proxy config still reports, removal lasts only - /// until the next sync re-adds it. + /// are rejected, and everything cached for it is cleared. pub async fn remove_environment(&self, environment_key: &str) { let Some(keys) = self.environments.remove(environment_key) else { // Unknown key: clear caches under it anyway, so a repeated From dcf221c6bef4f40f00bbdd996d69755a5e6b37d3 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Sat, 22 Aug 2026 15:57:32 +0530 Subject: [PATCH 17/21] docs: clearer failure-semantics wording on sync_proxy_config --- src/services/environment.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/services/environment.rs b/src/services/environment.rs index cf4f8dc..5d3dde9 100644 --- a/src/services/environment.rs +++ b/src/services/environment.rs @@ -120,9 +120,10 @@ impl EnvironmentService { } /// Bring the served environments in line with the proxy config, when - /// one is configured. Returns false on fetch failure — and removes - /// nothing then: an environment is only dropped by a successful - /// response that omits it, never by an outage or a rejected key. + /// one is configured. Returns false when the fetch fails, keeping the + /// current set untouched — an outage or a rejected proxy key can never + /// wipe the proxy. An environment is removed only when a successful + /// fetch no longer lists it. async fn sync_proxy_config(&self) -> bool { let Some(proxy_key) = &self.settings.proxy_key else { return true; From 4cbc32a5b71021ca06a1c2b1685aff3c29dbb4d4 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Sat, 22 Aug 2026 15:59:56 +0530 Subject: [PATCH 18/21] docs: drop the filter comment, the code says it --- src/services/environment.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/services/environment.rs b/src/services/environment.rs index 5d3dde9..b587625 100644 --- a/src/services/environment.rs +++ b/src/services/environment.rs @@ -141,11 +141,6 @@ impl EnvironmentService { .into_iter() .map(EnvironmentKeys::from) .filter(|keys| { - // A brand-new environment has no server-side keys yet, and - // the config reports it anyway. With no usable key there is - // nothing to fetch a document with, so serving it is - // impossible — skip it (and drop it if previously served) - // rather than fail every poll and take /health red. let usable = keys.valid_server_key().is_some(); if !usable { debug!( From 134525a0d5797929ccbc093461dc62d6045687f7 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Sat, 22 Aug 2026 16:02:47 +0530 Subject: [PATCH 19/21] refactor: rename SyncResult.displaced to replaced --- src/environments.rs | 14 +++++++------- src/services/environment.rs | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/environments.rs b/src/environments.rs index 39cf680..cb2d61e 100644 --- a/src/environments.rs +++ b/src/environments.rs @@ -43,7 +43,7 @@ pub struct SyncResult { /// Environments inserted or updated. pub changed: usize, /// Previous versions replaced by an update. - pub displaced: Vec>, + pub replaced: Vec>, /// Environments no longer in the config. pub removed: Vec>, } @@ -166,7 +166,7 @@ impl EnvironmentIndex { /// sole caller — so per-operation locking suffices. /// /// The caller owns cache invalidation for everything returned in - /// `displaced` and `removed` (request caches are keyed by presented + /// `replaced` and `removed` (request caches are keyed by presented /// key and consulted before the key gate). pub fn sync_to(&self, desired: Vec) -> SyncResult { let mut result = SyncResult::default(); @@ -186,7 +186,7 @@ impl EnvironmentIndex { } result.changed += 1; if let Some(previous) = self.insert(keys) { - result.displaced.push(previous); + result.replaced.push(previous); } } @@ -394,7 +394,7 @@ mod tests { } #[test] - fn sync_to_rotation_returns_the_displaced_version() { + fn sync_to_rotation_returns_the_replaced_version() { // Given let index = EnvironmentIndex::default(); index.insert(environment("client_a", "ser.old")); @@ -402,9 +402,9 @@ mod tests { // When the config rotates the server key let result = index.sync_to(vec![environment("client_a", "ser.new")]); - // Then the displaced version comes back for cache invalidation + // Then the replaced version comes back for cache invalidation assert_eq!(result.changed, 1); - assert_eq!(result.displaced[0].server_keys[0].key, "ser.old"); + assert_eq!(result.replaced[0].server_keys[0].key, "ser.old"); assert!(index.resolve("ser.old").is_none()); assert!(index.resolve("ser.new").is_some()); } @@ -421,7 +421,7 @@ mod tests { // Then nothing changed — not even the Arc identity assert_eq!(result.changed, 0); - assert!(result.displaced.is_empty()); + assert!(result.replaced.is_empty()); assert!(result.removed.is_empty()); assert!(Arc::ptr_eq(&before, &index.resolve("client_a").unwrap())); } diff --git a/src/services/environment.rs b/src/services/environment.rs index b587625..b842c60 100644 --- a/src/services/environment.rs +++ b/src/services/environment.rs @@ -157,9 +157,9 @@ impl EnvironmentService { self.purge_environment_caches(keys).await; info!("Environment removed from proxy config: {}", keys.client_key); } - // A displaced version's keys may no longer resolve (rotation); + // A replaced version's keys may no longer resolve (rotation); // clear everything cached under them — current keys repopulate. - for keys in &result.displaced { + for keys in &result.replaced { self.clear_endpoint_caches(keys).await; } if result.changed > 0 || !result.removed.is_empty() { From 3c076431dbe3d2c2d73912f77f3e8f4460048b10 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Sat, 29 Aug 2026 10:32:34 +0530 Subject: [PATCH 20/21] fix: gate endpoint-cache reads on server-key validity Cached document, flags, and identity responses returned before resolve_key ran, so a dynamic server key that expired between proxy config polls kept reading cached data until eviction. Validate the presented key before every endpoint-cache lookup. --- src/services/environment.rs | 101 +++++++++++++++++++++++++++++++++--- 1 file changed, 93 insertions(+), 8 deletions(-) diff --git a/src/services/environment.rs b/src/services/environment.rs index b842c60..b3f043a 100644 --- a/src/services/environment.rs +++ b/src/services/environment.rs @@ -331,6 +331,10 @@ impl EnvironmentService { /// Get pre-serialized environment document bytes with endpoint caching pub async fn get_environment_bytes(&self, environment_key: &str) -> Result> { + // Gate before the cache lookup: a server key that expired since the + // last poll must not keep reading cached responses. + self.resolve_key(environment_key)?; + if self.endpoint_cache.is_environment_document_cache_enabled() { let cache_key = CacheKey::new( environment_key.to_string(), @@ -383,6 +387,13 @@ impl EnvironmentService { environment_key: &str, feature_name: Option<&str>, ) -> Result> { + // Validation only — lookups below still use the raw presented key, so + // a server-side key 503s on this endpoint (contexts are stored under + // the client key). Must run before the cache lookup: a server key + // that expired since the last poll must not keep reading cached + // responses. + self.resolve_key(environment_key)?; + if self.endpoint_cache.is_flags_cache_enabled() { let cache_key = CacheKey::new( environment_key.to_string(), @@ -397,11 +408,6 @@ impl EnvironmentService { } } - // Validation only — lookups below still use the raw presented key, so - // a server-side key 503s on this endpoint (contexts are stored under - // the client key) - self.resolve_key(environment_key)?; - let context = self .cache .get_context(environment_key) @@ -459,6 +465,11 @@ impl EnvironmentService { identity: &IdentityWithTraits, environment_key: &str, ) -> Result { + // Validation only — same server-side-key caveat as + // get_flags_response_data, and before the cache lookup for the same + // expired-key reason. + self.resolve_key(environment_key)?; + if self.endpoint_cache.is_identities_cache_enabled() { // Create cache key from identity data let cache_params = @@ -476,9 +487,6 @@ impl EnvironmentService { } } - // Validation only — same server-side-key caveat as get_flags_response_data - self.resolve_key(environment_key)?; - // Get pre-computed context from cache let context = self .cache @@ -693,6 +701,83 @@ mod tests { assert!(service.cache.get_environment("client").await.is_some()); } + #[tokio::test] + async fn expired_server_key_cannot_read_primed_endpoint_caches() { + // Given every endpoint cache primed under a server key that has + // since expired without any config change (so nothing cleared them) + let settings = AppSettings { + endpoint_caches: EndpointCachesSettings { + flags: EndpointCacheSettings { + use_cache: true, + cache_max_size: 10, + }, + identities: EndpointCacheSettings { + use_cache: true, + cache_max_size: 10, + }, + environment_document: EndpointCacheSettings { + use_cache: true, + cache_max_size: 10, + }, + }, + ..AppSettings::default() + }; + let service = EnvironmentService::new(settings); + service.environments.insert(EnvironmentKeys { + client_key: "client".to_string(), + server_keys: vec![crate::environments::ServerKey { + key: "ser.expired".to_string(), + active: true, + expires_at: Some(Utc::now() - chrono::TimeDelta::seconds(1)), + }], + }); + + let identity = crate::models::IdentityWithTraits::new("id".to_string()); + let identity_params = serde_json::to_string(&identity).unwrap(); + service + .endpoint_cache + .put_flags( + CacheKey::new("ser.expired".into(), "flags".into(), "".into()), + serde_json::json!([]), + ) + .await; + service + .endpoint_cache + .put_identity( + CacheKey::new("ser.expired".into(), "identities".into(), identity_params), + serde_json::json!({"flags": [], "traits": []}), + ) + .await; + service + .endpoint_cache + .put_environment_document( + CacheKey::new( + "ser.expired".into(), + "environment_document".into(), + "".into(), + ), + b"{}".to_vec().into(), + ) + .await; + + // When / Then: every endpoint rejects the expired key instead of + // serving the cached response + assert!(matches!( + service.get_flags_response_data("ser.expired", None).await, + Err(EdgeProxyError::FlagsmithUnknownKey(_)) + )); + assert!(matches!( + service + .get_identity_response_data(&identity, "ser.expired") + .await, + Err(EdgeProxyError::FlagsmithUnknownKey(_)) + )); + assert!(matches!( + service.get_environment_bytes("ser.expired").await, + Err(EdgeProxyError::FlagsmithUnknownKey(_)) + )); + } + fn link(value: &str) -> HeaderMap { let mut h = HeaderMap::new(); h.insert(LINK, HeaderValue::from_str(value).unwrap()); From 90f27ddaf7e9c316256ceb9ee8ac65f90f8060d2 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Sat, 29 Aug 2026 10:32:46 +0530 Subject: [PATCH 21/21] fix: start polling only after the initial refresh completes The poll task was spawned before the initial refresh and tokio's interval delivers its first tick immediately, so two refresh_environment_caches runs overlapped at startup. A delayed older proxy-config response could then resurrect a removed environment or a rotated key set. Run the initial refresh first and skip the interval's immediate first tick; the poll loop itself is serial, so refreshes can no longer overlap. --- src/main.rs | 10 +++++++--- src/services/environment.rs | 3 +++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/main.rs b/src/main.rs index 9a3bab0..405c5fd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,14 +32,18 @@ async fn main() -> anyhow::Result<()> { let (app, environment_service) = create_router(settings.clone()); + // Refreshes must never overlap: a delayed older poll finishing after a + // newer one could restore removed environments or rotated keys. The + // poll loop is serial, so it just has to start after the initial + // refresh completes. + info!("Loading initial environment data..."); + environment_service.refresh_environment_caches().await; + let polling_service = environment_service.clone(); tokio::spawn(async move { polling_service.poll_environments().await; }); - info!("Loading initial environment data..."); - environment_service.refresh_environment_caches().await; - let addr = SocketAddr::from(( settings .server diff --git a/src/services/environment.rs b/src/services/environment.rs index b3f043a..8d53ebe 100644 --- a/src/services/environment.rs +++ b/src/services/environment.rs @@ -547,6 +547,9 @@ impl EnvironmentService { let mut interval = tokio::time::interval(Duration::from_secs( self.settings.api_poll_frequency_seconds, )); + // The first tick completes immediately; the caller has already done + // the initial refresh, so consume it to poll one full period later. + interval.tick().await; loop { interval.tick().await;