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