Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
be5a55d
feat: add the proxy_key setting
gagantrivedi Aug 22, 2026
87bd6b6
feat: add the proxy config wire model
gagantrivedi Aug 22, 2026
0511458
feat: reconcile the environment index against a desired set
gagantrivedi Aug 22, 2026
871f346
feat: sync served environments from the proxy config
gagantrivedi Aug 22, 2026
48c3267
docs: present-tense health note now that config sync exists
gagantrivedi Aug 22, 2026
6cf8abe
fix: stop invalid server keys from authenticating requests
gagantrivedi Aug 22, 2026
745b4e0
fix: skip proxy config environments with no usable server key
gagantrivedi Aug 22, 2026
45b3ade
docs: drop the odd One from the wire-model comment
gagantrivedi Aug 22, 2026
004818a
refactor: rename reconcile to sync_to
gagantrivedi Aug 22, 2026
81b11ab
refactor: rename SyncOutcome to SyncResult
gagantrivedi Aug 22, 2026
046a238
docs: reattach the EnvironmentIndex doc comment
gagantrivedi Aug 22, 2026
c097fb7
refactor: the index owns the statically configured key set
gagantrivedi Aug 22, 2026
332b741
refactor: extract purge_environment_caches
gagantrivedi Aug 22, 2026
cc3d74a
fix: reject an empty proxy_key
gagantrivedi Aug 22, 2026
eeca85e
fix: sort server keys on ingest
gagantrivedi Aug 22, 2026
4576682
docs: drop the transient-removal caveat from remove_environment
gagantrivedi Aug 22, 2026
dcf221c
docs: clearer failure-semantics wording on sync_proxy_config
gagantrivedi Aug 22, 2026
4cbc32a
docs: drop the filter comment, the code says it
gagantrivedi Aug 22, 2026
134525a
refactor: rename SyncResult.displaced to replaced
gagantrivedi Aug 22, 2026
3c07643
fix: gate endpoint-cache reads on server-key validity
gagantrivedi Aug 29, 2026
90f27dd
fix: start polling only after the initial refresh completes
gagantrivedi Aug 29, 2026
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
26 changes: 26 additions & 0 deletions src/config/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,11 @@ pub struct AppSettings {
#[serde(default)]
#[validate(nested)]
pub environment_key_pairs: Vec<EnvironmentKeyPair>,
/// 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<String>,
#[serde(default = "default_api_url")]
pub api_url: String,
#[serde(default = "default_api_poll_frequency")]
Expand Down Expand Up @@ -158,6 +163,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(),
Expand Down Expand Up @@ -206,6 +212,26 @@ pub fn get_settings() -> Result<AppSettings> {
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
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
Expand Down
219 changes: 214 additions & 5 deletions src/environments.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, RwLock};

use chrono::{DateTime, Utc};
Expand Down Expand Up @@ -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 replaced: Vec<Arc<EnvironmentKeys>>,
/// Environments no longer in the config.
pub removed: Vec<Arc<EnvironmentKeys>>,
}

/// The runtime-mutable set of environments the proxy serves.
///
/// Every environment is indexed under its client key *and* each of its
Expand All @@ -53,11 +64,19 @@ impl EnvironmentKeys {
#[derive(Default)]
pub struct EnvironmentIndex {
by_key: RwLock<HashMap<String, Arc<EnvironmentKeys>>>,
/// Every key of the statically configured environments. Immutable
/// after construction; `sync_to` never overrides or removes an
/// environment whose keys appear here.
protected: HashSet<String>,
}

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(),
Expand All @@ -72,13 +91,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<Arc<EnvironmentKeys>> {
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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/// Insert or replace an environment's keys, dropping index entries
Expand Down Expand Up @@ -124,6 +158,62 @@ impl EnvironmentIndex {
Some(keys)
}

/// Bring the index in line with `desired` (what the proxy config
/// 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
/// `replaced` and `removed` (request caches are keyed by presented
/// key and consulted before the key gate).
pub fn sync_to(&self, desired: Vec<EnvironmentKeys>) -> SyncResult {
let mut result = SyncResult::default();

let desired_clients: HashSet<String> =
desired.iter().map(|keys| keys.client_key.clone()).collect();

for keys in desired {
if self.is_protected(&keys) {
continue;
}
let unchanged = self
.resolve(&keys.client_key)
.is_some_and(|current| *current == keys);
if unchanged {
continue;
}
result.changed += 1;
if let Some(previous) = self.insert(keys) {
result.replaced.push(previous);
}
}

for current in self.snapshot() {
if desired_clients.contains(&current.client_key)
|| self.protected.contains(&current.client_key)
{
continue;
}
if let Some(removed) = self.remove(&current.client_key) {
result.removed.push(removed);
}
}

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<Arc<EnvironmentKeys>> {
Expand Down Expand Up @@ -243,6 +333,125 @@ 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 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 result = index.sync_to(vec![environment("client_b", "ser.b")]);

// Then
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());
assert!(index.resolve("ser.b").is_some());
}

#[test]
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")]);

// When the config omits it entirely
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")]);

// 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]
fn sync_to_rotation_returns_the_replaced_version() {
// Given
let index = EnvironmentIndex::default();
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")]);

// Then the replaced version comes back for cache invalidation
assert_eq!(result.changed, 1);
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());
}

#[test]
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 result = index.sync_to(vec![environment("client_a", "ser.a")]);

// Then nothing changed — not even the Arc identity
assert_eq!(result.changed, 0);
assert!(result.replaced.is_empty());
assert!(result.removed.is_empty());
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
Expand Down
23 changes: 14 additions & 9 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,26 +19,31 @@ 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"
);
}

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
Expand Down
2 changes: 2 additions & 0 deletions src/models/mod.rs
Original file line number Diff line number Diff line change
@@ -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};
Loading
Loading