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 let file: GatewayKeysFile =
100 toml::from_str(&raw).map_err(|e| anyhow::anyhow!("parse {}: {e}", path.display()))?;
101 let mut by_sha = HashMap::new();
102 for entry in file.keys {
103 let sha = entry.sha256_hex.trim().to_ascii_lowercase();
104 if sha.len() != 64 || !sha.bytes().all(|b| b.is_ascii_hexdigit()) {
105 anyhow::bail!(
106 "{}: key for '{}' has invalid sha256_hex (expected 64 hex chars)",
107 path.display(),
108 entry.person
109 );
110 }
111 let person = entry.person.trim();
112 if person.is_empty() {
113 anyhow::bail!("{}: entry with empty person", path.display());
114 }
115 by_sha.insert(
116 sha,
117 GatewayTags {
118 person: Some(person.to_string()),
119 team: entry
120 .team
121 .as_deref()
122 .map(str::trim)
123 .filter(|t| !t.is_empty())
124 .map(str::to_string),
125 project: entry
126 .default_project
127 .as_deref()
128 .map(str::trim)
129 .filter(|p| !p.is_empty())
130 .map(str::to_string),
131 },
132 );
133 }
134 Ok(Self { by_sha })
135 }
136
137 #[must_use]
138 pub fn is_empty(&self) -> bool {
139 self.by_sha.is_empty()
140 }
141
142 #[must_use]
143 pub fn len(&self) -> usize {
144 self.by_sha.len()
145 }
146
147 #[must_use]
150 pub fn lookup(&self, bearer_key: &str) -> Option<GatewayTags> {
151 self.by_sha.get(&sha256_hex(bearer_key)).cloned()
152 }
153}
154
155#[must_use]
157pub fn sha256_hex(input: &str) -> String {
158 use sha2::{Digest, Sha256};
159 let mut h = Sha256::new();
160 h.update(input.as_bytes());
161 let digest = h.finalize();
162 let mut out = String::with_capacity(digest.len() * 2);
163 for b in digest {
164 use std::fmt::Write;
165 let _ = write!(out, "{b:02x}");
166 }
167 out
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 fn write_keys(dir: &Path, body: &str) -> PathBuf {
175 let path = dir.join("gateway-keys.toml");
176 std::fs::write(&path, body).unwrap();
177 path
178 }
179
180 #[test]
181 fn lookup_maps_key_hash_to_identity() {
182 let tmp = tempfile::tempdir().unwrap();
183 let sha = sha256_hex("gk-yves-secret");
184 let path = write_keys(
185 tmp.path(),
186 &format!(
187 r#"
188 [[keys]]
189 sha256_hex = "{sha}"
190 person = "yves"
191 team = "platform"
192 default_project = "ai-gateway"
193
194 [[keys]]
195 sha256_hex = "{}"
196 person = "mara"
197 "#,
198 sha256_hex("gk-mara-secret")
199 ),
200 );
201 let keys = GatewayKeys::load(&path).unwrap();
202 assert_eq!(keys.len(), 2);
203
204 let yves = keys.lookup("gk-yves-secret").expect("known key");
205 assert_eq!(yves.person.as_deref(), Some("yves"));
206 assert_eq!(yves.team.as_deref(), Some("platform"));
207 assert_eq!(yves.project.as_deref(), Some("ai-gateway"));
208
209 let mara = keys.lookup("gk-mara-secret").expect("known key");
210 assert_eq!(mara.person.as_deref(), Some("mara"));
211 assert_eq!(mara.team, None);
212 assert_eq!(mara.project, None);
213
214 assert!(keys.lookup("gk-unknown").is_none());
215 }
216
217 #[test]
218 fn missing_file_is_empty_but_malformed_is_loud() {
219 let tmp = tempfile::tempdir().unwrap();
220 let missing = GatewayKeys::load(&tmp.path().join("nope.toml")).unwrap();
221 assert!(missing.is_empty());
222
223 let bad_hash = write_keys(
224 tmp.path(),
225 r#"
226 [[keys]]
227 sha256_hex = "not-a-hash"
228 person = "yves"
229 "#,
230 );
231 assert!(
232 GatewayKeys::load(&bad_hash).is_err(),
233 "an invalid sha256_hex must fail loudly, not silently drop the key"
234 );
235 }
236
237 #[test]
238 fn sha256_hex_matches_known_vector() {
239 assert_eq!(
241 sha256_hex("abc"),
242 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
243 );
244 }
245}