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