lean_ctx/proxy/
gateway_identity.rs1use std::collections::HashMap;
24use std::path::{Path, PathBuf};
25
26use serde::Deserialize;
27
28#[derive(Debug, Clone, Default, PartialEq, Eq)]
32pub struct GatewayTags {
33 pub person: Option<String>,
34 pub team: Option<String>,
35 pub project: Option<String>,
36}
37
38#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
42pub(crate) struct TrustedGatewayRequest;
43
44impl GatewayTags {
45 #[must_use]
47 pub fn is_empty(&self) -> bool {
48 self.person.is_none() && self.team.is_none() && self.project.is_none()
49 }
50}
51
52#[derive(Debug, Deserialize)]
53struct GatewayKeysFile {
54 #[serde(default)]
55 keys: Vec<GatewayKeyEntry>,
56}
57
58#[derive(Debug, Deserialize)]
59struct GatewayKeyEntry {
60 sha256_hex: String,
62 person: String,
63 #[serde(default)]
64 team: Option<String>,
65 #[serde(default)]
66 default_project: Option<String>,
67}
68
69#[derive(Debug, Default)]
72pub struct GatewayKeys {
73 by_sha: HashMap<String, GatewayTags>,
74}
75
76impl GatewayKeys {
77 #[must_use]
80 pub fn default_path() -> PathBuf {
81 std::env::var("LEAN_CTX_GATEWAY_KEYS").ok().map_or_else(
82 || {
83 crate::core::paths::config_dir().map_or_else(
84 |_| PathBuf::from("gateway-keys.toml"),
85 |d| d.join("gateway-keys.toml"),
86 )
87 },
88 PathBuf::from,
89 )
90 }
91
92 pub fn load_default() -> anyhow::Result<Self> {
95 Self::load(&Self::default_path())
96 }
97
98 pub fn load(path: &Path) -> anyhow::Result<Self> {
99 if !path.exists() {
100 return Ok(Self::default());
101 }
102 let raw = std::fs::read_to_string(path)
103 .map_err(|e| anyhow::anyhow!("read {}: {e}", path.display()))?;
104 Self::parse(&raw, path)
105 }
106
107 pub fn parse(raw: &str, origin: &Path) -> anyhow::Result<Self> {
112 let file: GatewayKeysFile =
113 toml::from_str(raw).map_err(|e| anyhow::anyhow!("parse {}: {e}", origin.display()))?;
114 let mut by_sha = HashMap::new();
115 for entry in file.keys {
116 let sha = entry.sha256_hex.trim().to_ascii_lowercase();
117 if sha.len() != 64 || !sha.bytes().all(|b| b.is_ascii_hexdigit()) {
118 anyhow::bail!(
119 "{}: key for '{}' has invalid sha256_hex (expected 64 hex chars)",
120 origin.display(),
121 entry.person
122 );
123 }
124 let person = entry.person.trim();
125 if person.is_empty() {
126 anyhow::bail!("{}: entry with empty person", origin.display());
127 }
128 by_sha.insert(
129 sha,
130 GatewayTags {
131 person: Some(person.to_string()),
132 team: entry
133 .team
134 .as_deref()
135 .map(str::trim)
136 .filter(|t| !t.is_empty())
137 .map(str::to_string),
138 project: entry
139 .default_project
140 .as_deref()
141 .map(str::trim)
142 .filter(|p| !p.is_empty())
143 .map(str::to_string),
144 },
145 );
146 }
147 Ok(Self { by_sha })
148 }
149
150 #[must_use]
151 pub fn is_empty(&self) -> bool {
152 self.by_sha.is_empty()
153 }
154
155 #[must_use]
156 pub fn len(&self) -> usize {
157 self.by_sha.len()
158 }
159
160 #[must_use]
163 pub fn lookup(&self, bearer_key: &str) -> Option<GatewayTags> {
164 self.by_sha.get(&sha256_hex(bearer_key)).cloned()
165 }
166}
167
168pub fn reload_keys() -> anyhow::Result<GatewayKeys> {
171 GatewayKeys::load_default()
172}
173
174#[must_use]
176pub fn sha256_hex(input: &str) -> String {
177 use sha2::{Digest, Sha256};
178 let mut h = Sha256::new();
179 h.update(input.as_bytes());
180 let digest = h.finalize();
181 let mut out = String::with_capacity(digest.len() * 2);
182 for b in digest {
183 use std::fmt::Write;
184 let _ = write!(out, "{b:02x}");
185 }
186 out
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192
193 fn write_keys(dir: &Path, body: &str) -> PathBuf {
194 let path = dir.join("gateway-keys.toml");
195 std::fs::write(&path, body).unwrap();
196 path
197 }
198
199 #[test]
200 fn lookup_maps_key_hash_to_identity() {
201 let tmp = tempfile::tempdir().unwrap();
202 let sha = sha256_hex("gk-yves-secret");
203 let path = write_keys(
204 tmp.path(),
205 &format!(
206 r#"
207 [[keys]]
208 sha256_hex = "{sha}"
209 person = "yves"
210 team = "platform"
211 default_project = "ai-gateway"
212
213 [[keys]]
214 sha256_hex = "{}"
215 person = "mara"
216 "#,
217 sha256_hex("gk-mara-secret")
218 ),
219 );
220 let keys = GatewayKeys::load(&path).unwrap();
221 assert_eq!(keys.len(), 2);
222
223 let yves = keys.lookup("gk-yves-secret").expect("known key");
224 assert_eq!(yves.person.as_deref(), Some("yves"));
225 assert_eq!(yves.team.as_deref(), Some("platform"));
226 assert_eq!(yves.project.as_deref(), Some("ai-gateway"));
227
228 let mara = keys.lookup("gk-mara-secret").expect("known key");
229 assert_eq!(mara.person.as_deref(), Some("mara"));
230 assert_eq!(mara.team, None);
231 assert_eq!(mara.project, None);
232
233 assert!(keys.lookup("gk-unknown").is_none());
234 }
235
236 #[test]
237 fn missing_file_is_empty_but_malformed_is_loud() {
238 let tmp = tempfile::tempdir().unwrap();
239 let missing = GatewayKeys::load(&tmp.path().join("nope.toml")).unwrap();
240 assert!(missing.is_empty());
241
242 let bad_hash = write_keys(
243 tmp.path(),
244 r#"
245 [[keys]]
246 sha256_hex = "not-a-hash"
247 person = "yves"
248 "#,
249 );
250 assert!(
251 GatewayKeys::load(&bad_hash).is_err(),
252 "an invalid sha256_hex must fail loudly, not silently drop the key"
253 );
254 }
255
256 #[test]
257 fn sha256_hex_matches_known_vector() {
258 assert_eq!(
260 sha256_hex("abc"),
261 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
262 );
263 }
264
265 #[test]
266 fn reload_keys_rereads_the_configured_path() {
267 let _env = crate::core::data_dir::test_env_lock();
268 let tmp = tempfile::tempdir().unwrap();
269 let path = write_keys(
270 tmp.path(),
271 &format!(
272 "[[keys]]\nsha256_hex = \"{}\"\nperson = \"mara\"\n",
273 sha256_hex("gk-mara-secret")
274 ),
275 );
276 crate::test_env::set_var("LEAN_CTX_GATEWAY_KEYS", &path);
277
278 let keys = reload_keys().unwrap();
279 assert!(keys.lookup("gk-mara-secret").is_some());
280
281 crate::test_env::remove_var("LEAN_CTX_GATEWAY_KEYS");
282 }
283}