Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
87d93c5
refactor: replace static key maps with a runtime environment registry
gagantrivedi Aug 22, 2026
de62e6b
refactor: allow environment_key_pairs to be omitted from config
gagantrivedi Aug 22, 2026
1b260fb
refactor: move server startup into lib::run
gagantrivedi Aug 22, 2026
e633269
test: build AppSettings with struct spread in LRU cache tests
gagantrivedi Aug 22, 2026
3172495
chore: trim redundant half of the environment_key_pairs comment
gagantrivedi Aug 22, 2026
ed24723
refactor: rename EnvironmentRegistry to EnvironmentIndex at top level
gagantrivedi Aug 22, 2026
9c1d3a2
refactor: rename EnvRecord to EnvironmentKeys
gagantrivedi Aug 22, 2026
3b4f5b7
refactor: drop the source field from EnvironmentKeys
gagantrivedi Aug 22, 2026
c78f64d
refactor: rename evict_environment to remove_environment
gagantrivedi Aug 22, 2026
790db10
docs: say proxy config endpoint, not inventory
gagantrivedi Aug 22, 2026
94a8bd2
refactor: drop the shared-server-key guard from index removal
gagantrivedi Aug 22, 2026
643e312
fix: clear cache writes that race environment removal
gagantrivedi Aug 22, 2026
f7fafad
refactor: return the displaced entry from EnvironmentIndex::insert
gagantrivedi Aug 22, 2026
cf82197
docs: record index assumptions and the failing-poll health obligation
gagantrivedi Aug 22, 2026
6ca6e9f
fix: warn at startup when no environments are configured
gagantrivedi Aug 22, 2026
f888c26
refactor: fold run() back into main.rs
gagantrivedi Aug 22, 2026
34a3abd
refactor: drop the endpoint-cache write guard
gagantrivedi Aug 22, 2026
24100f4
docs: plainer wording for remove_environment
gagantrivedi Aug 22, 2026
b7d582a
docs: clearer given-comment in the poll-reinsertion test
gagantrivedi Aug 22, 2026
6eeada4
refactor: say replaced, not displaced, in insert's contract
gagantrivedi Aug 22, 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
61 changes: 61 additions & 0 deletions src/cache/environment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ pub trait EnvironmentsCache: Send + Sync {
/// Store environment document and compute context. Returns true if changed.
async fn put_environment(&self, environment_key: &str, document: Value) -> bool;

/// Remove everything stored for an environment (document, context,
/// identity overrides)
async fn remove_environment(&self, environment_key: &str);

/// Get identity override data
async fn get_identity(&self, environment_api_key: &str, identifier: &str) -> Option<Value>;
}
Expand Down Expand Up @@ -98,10 +102,67 @@ impl EnvironmentsCache for LocalMemEnvironmentsCache {
changed
}

async fn remove_environment(&self, environment_key: &str) {
// Same guard order as put_environment so the two can't deadlock
let mut environments = self.environments.write().await;
let mut contexts = self.contexts.write().await;
let mut identity_overrides = self.identity_overrides.write().await;

environments.remove(environment_key);
contexts.remove(environment_key);
identity_overrides.remove(environment_key);
}

async fn get_identity(&self, environment_api_key: &str, identifier: &str) -> Option<Value> {
let identity_overrides = self.identity_overrides.read().await;
identity_overrides
.get(environment_api_key)
.and_then(|identities| identities.get(identifier).cloned())
}
}

#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;

#[tokio::test]
async fn test_remove_environment_clears_all_stored_state() {
// Given
let cache = LocalMemEnvironmentsCache::new();
let document = json!({
"api_key": "client_a",
"identity_overrides": [{"identifier": "user_1"}],
});
cache.put_environment("client_a", document).await;
assert!(cache.get_environment("client_a").await.is_some());
assert!(cache.get_identity("client_a", "user_1").await.is_some());

// When
cache.remove_environment("client_a").await;

// Then
assert!(cache.get_environment("client_a").await.is_none());
assert!(cache.get_context("client_a").await.is_none());
assert!(cache.get_identity("client_a", "user_1").await.is_none());
}

#[tokio::test]
async fn test_remove_environment_leaves_other_environments_alone() {
// Given
let cache = LocalMemEnvironmentsCache::new();
cache
.put_environment("client_a", json!({"api_key": "client_a"}))
.await;
cache
.put_environment("client_b", json!({"api_key": "client_b"}))
.await;

// When
cache.remove_environment("client_a").await;

// Then
assert!(cache.get_environment("client_a").await.is_none());
assert!(cache.get_environment("client_b").await.is_some());
}
}
12 changes: 12 additions & 0 deletions src/config/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ impl Default for HealthCheckSettings {

#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct AppSettings {
// Optional so the environment set can also come from runtime discovery
#[serde(default)]
#[validate(nested)]
pub environment_key_pairs: Vec<EnvironmentKeyPair>,
#[serde(default = "default_api_url")]
Expand Down Expand Up @@ -204,6 +206,16 @@ pub fn get_settings() -> Result<AppSettings> {
mod tests {
use super::*;

#[test]
fn test_config_without_environment_key_pairs_parses_to_empty_valid_set() {
// Given a config file omitting environment_key_pairs entirely
let settings: AppSettings = serde_json::from_str("{}").unwrap();

// Then it behaves like an explicitly empty list and validates
assert!(settings.environment_key_pairs.is_empty());
assert!(settings.validate().is_ok());
}

#[test]
fn test_client_side_key_validation_valid() {
// Given
Expand Down
286 changes: 286 additions & 0 deletions src/environments.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,286 @@
use std::collections::HashMap;
use std::sync::{Arc, RwLock};

use chrono::{DateTime, Utc};

use crate::config::settings::EnvironmentKeyPair;

/// A server-side (`ser.`) key together with the validity metadata the
/// proxy config endpoint reports. Statically configured keys carry no
/// metadata and are always valid.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServerKey {
pub key: String,
pub active: bool,
pub expires_at: Option<DateTime<Utc>>,
}

impl ServerKey {
pub fn is_valid(&self) -> bool {
self.active && self.expires_at.is_none_or(|at| at > Utc::now())
}
}

/// The key set of one environment the proxy serves: its client-side key
/// and every server-side key that can authenticate for it upstream
/// (multiple during rotation).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EnvironmentKeys {
pub client_key: String,
pub server_keys: Vec<ServerKey>,
}

impl EnvironmentKeys {
/// The first server-side key still usable for upstream fetches.
pub fn valid_server_key(&self) -> Option<&ServerKey> {
self.server_keys.iter().find(|key| key.is_valid())
}
}

/// The runtime-mutable set of environments the proxy serves.
///
/// Every environment is indexed under its client key *and* each of its
/// server keys, so a single lookup resolves whichever kind of key a request
/// presents.
///
/// Server keys are assumed unique across environments: a key duplicated in
/// the config is last-one-wins on insert, and removing either environment
/// un-indexes the shared key for both.
///
/// 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.
#[derive(Default)]
pub struct EnvironmentIndex {
by_key: RwLock<HashMap<String, Arc<EnvironmentKeys>>>,
}

impl EnvironmentIndex {
pub fn from_settings(pairs: &[EnvironmentKeyPair]) -> Self {
let index = Self::default();
for pair in pairs {
index.insert(EnvironmentKeys {
client_key: pair.client_side_key.clone(),
server_keys: vec![ServerKey {
key: pair.server_side_key.clone(),
active: true,
expires_at: None,
}],
});
}
index
}

/// Resolve a presented key — client- or server-side — to its
/// environment's keys.
pub fn resolve(&self, key: &str) -> Option<Arc<EnvironmentKeys>> {
self.by_key
.read()
.expect("environment index lock poisoned")
.get(key)
.cloned()
}

/// Insert or replace an environment's keys, dropping index entries
/// for server keys the previous version no longer has. Returns the
/// replaced version, if any: request caches are keyed by presented
/// key and consulted before the key gate, so the caller owns
/// invalidating whatever is cached under keys that stopped resolving.
pub fn insert(&self, keys: EnvironmentKeys) -> Option<Arc<EnvironmentKeys>> {
let keys = Arc::new(keys);
let mut by_key = self
.by_key
.write()
.expect("environment index lock poisoned");

let previous = by_key.get(&keys.client_key).cloned();
if let Some(previous) = &previous {
for server_key in &previous.server_keys {
by_key.remove(&server_key.key);
}
}

for server_key in &keys.server_keys {
by_key.insert(server_key.key.clone(), Arc::clone(&keys));
}
by_key.insert(keys.client_key.clone(), keys);
Comment on lines +103 to +106

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Reject key collisions before modifying by_key.

EnvironmentKeyPair permits duplicate server keys and client keys that match another indexed key. Line 104 overwrites the existing owner. A later removal of the original environment removes that shared entry, even when it now belongs to the replacement environment.

Require every client key and server key to be unique across the index. Reject an update atomically when it conflicts with another environment.

previous
}

/// Remove the environment `key` resolves to (any of its keys works),
/// returning its keys so the caller can clear per-key caches.
pub fn remove(&self, key: &str) -> Option<Arc<EnvironmentKeys>> {
let mut by_key = self
.by_key
.write()
.expect("environment index lock poisoned");
let keys = by_key.get(key).cloned()?;

by_key.remove(&keys.client_key);
for server_key in &keys.server_keys {
by_key.remove(&server_key.key);
}

Some(keys)
}

/// Point-in-time snapshot of every environment's keys, ordered by
/// client key so callers iterate deterministically.
pub fn snapshot(&self) -> Vec<Arc<EnvironmentKeys>> {
let by_key = self.by_key.read().expect("environment index lock poisoned");
let mut snapshot: Vec<Arc<EnvironmentKeys>> = by_key
.iter()
.filter(|(key, keys)| key.as_str() == keys.client_key)
.map(|(_, keys)| Arc::clone(keys))
.collect();
snapshot.sort_by(|a, b| a.client_key.cmp(&b.client_key));
snapshot
}
}

#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeDelta;

fn pair(client: &str, server: &str) -> EnvironmentKeyPair {
EnvironmentKeyPair {
client_side_key: client.to_string(),
server_side_key: server.to_string(),
}
}

fn server_key(key: &str) -> ServerKey {
ServerKey {
key: key.to_string(),
active: true,
expires_at: None,
}
}

#[test]
fn from_settings_resolves_both_keys_to_the_same_environment() {
// Given
let index = EnvironmentIndex::from_settings(&[pair("client_a", "ser.a")]);

// When
let by_client = index.resolve("client_a").unwrap();
let by_server = index.resolve("ser.a").unwrap();

// Then
assert!(Arc::ptr_eq(&by_client, &by_server));
assert_eq!(by_client.client_key, "client_a");
assert!(by_client.valid_server_key().is_some());
}

#[test]
fn resolve_unknown_key_returns_none() {
let index = EnvironmentIndex::from_settings(&[pair("client_a", "ser.a")]);
assert!(index.resolve("nope").is_none());
}

#[test]
fn insert_replaces_keys_and_drops_stale_server_key_index() {
// Given
let index = EnvironmentIndex::from_settings(&[pair("client_a", "ser.old")]);

// When the environment's server key is rotated
let replaced = index.insert(EnvironmentKeys {
client_key: "client_a".to_string(),
server_keys: vec![server_key("ser.new")],
});

// Then the caller learns which version (and keys) it replaced
assert_eq!(replaced.unwrap().server_keys[0].key, "ser.old");
assert!(index.resolve("ser.old").is_none());
assert_eq!(index.resolve("ser.new").unwrap().client_key, "client_a");
assert_eq!(index.snapshot().len(), 1);
}

#[test]
fn insert_returns_none_for_a_new_environment() {
let index = EnvironmentIndex::default();
let replaced = index.insert(EnvironmentKeys {
client_key: "client_a".to_string(),
server_keys: vec![server_key("ser.a")],
});
assert!(replaced.is_none());
}

#[test]
fn remove_by_any_key_clears_every_index_entry() {
// Given an environment with two server keys
let index = EnvironmentIndex::default();
index.insert(EnvironmentKeys {
client_key: "client_a".to_string(),
server_keys: vec![server_key("ser.one"), server_key("ser.two")],
});

// When removed via one of its server keys
let removed = index.remove("ser.two").unwrap();

// Then
assert_eq!(removed.client_key, "client_a");
assert!(index.resolve("client_a").is_none());
assert!(index.resolve("ser.one").is_none());
assert!(index.resolve("ser.two").is_none());
assert!(index.remove("client_a").is_none());
}

#[test]
fn snapshot_returns_one_entry_per_environment_sorted_by_client_key() {
// Given
let index = EnvironmentIndex::from_settings(&[
pair("client_b", "ser.b"),
pair("client_a", "ser.a"),
]);

// When
let snapshot = index.snapshot();

// Then
let client_keys: Vec<&str> = snapshot.iter().map(|r| r.client_key.as_str()).collect();
assert_eq!(client_keys, vec!["client_a", "client_b"]);
}

#[test]
fn valid_server_key_skips_inactive_and_expired_keys() {
// Given
let keys = 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)),
},
ServerKey {
key: "ser.valid".to_string(),
active: true,
expires_at: Some(Utc::now() + TimeDelta::days(1)),
},
],
};

// When / Then
assert_eq!(keys.valid_server_key().unwrap().key, "ser.valid");
}

#[test]
fn valid_server_key_returns_none_when_no_key_is_usable() {
let keys = EnvironmentKeys {
client_key: "client_a".to_string(),
server_keys: vec![ServerKey {
key: "ser.inactive".to_string(),
active: false,
expires_at: None,
}],
};
assert!(keys.valid_server_key().is_none());
}
}
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub mod cache;
pub mod config;
pub mod environments;
pub mod error;
pub mod models;
pub mod routes;
Expand Down
Loading
Loading