Skip to main content

lean_ctx/proxy/
gateway_identity.rs

1//! Per-person gateway keys + request identity tags (enterprise#11).
2//!
3//! `gateway-keys.toml` maps SHA-256 hashes of bearer keys to an identity
4//! (person, optional team, optional default project), so an org gateway can
5//! meter usage per person/project without the clients sharing one token:
6//!
7//! ```toml
8//! [[keys]]
9//! sha256_hex = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
10//! person = "yves"
11//! team = "platform"
12//! default_project = "ai-gateway"
13//! ```
14//!
15//! Only the hash is ever stored; the plaintext key lives with the person. The file
16//! path resolves via `LEAN_CTX_GATEWAY_KEYS`, falling back to
17//! `<config_dir>/gateway-keys.toml` — deployments mount it as a secret.
18//!
19//! A caller may override the project per request with the `x-leanctx-project`
20//! header (an internal gateway header: it is deliberately not on
21//! `ALLOWED_REQUEST_HEADERS`, so it never leaks upstream).
22
23use std::collections::HashMap;
24use std::path::{Path, PathBuf};
25
26use serde::Deserialize;
27
28/// The identity tags attached to an authenticated gateway request. Inserted as
29/// a request extension by the auth guard and stamped onto the usage record by
30/// the forward path.
31#[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/// Internal trust marker set only after the proxy auth guard accepts a
39/// gateway-owned credential (or explicit loopback-open mode). Provider API-key
40/// fallback must not be able to manufacture managed OCLA lineage headers.
41#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
42pub(crate) struct TrustedGatewayRequest;
43
44impl GatewayTags {
45    /// True when there is anything worth stamping.
46    #[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    /// Lowercase hex SHA-256 of the bearer key (never the key itself).
61    sha256_hex: String,
62    person: String,
63    #[serde(default)]
64    team: Option<String>,
65    #[serde(default)]
66    default_project: Option<String>,
67}
68
69/// Loaded, lookup-ready key set. One instance per proxy process, loaded at
70/// startup (key rotation = redeploy/restart, the standard secret-mount flow).
71#[derive(Debug, Default)]
72pub struct GatewayKeys {
73    by_sha: HashMap<String, GatewayTags>,
74}
75
76impl GatewayKeys {
77    /// Resolve the keys file path: `LEAN_CTX_GATEWAY_KEYS` env wins, else
78    /// `<config_dir>/gateway-keys.toml` (next to `config.toml`).
79    #[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    /// Load from the default path; a missing file is an empty key set (the
93    /// common local case), a malformed file is a loud startup error.
94    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    /// Parses a key-file body without touching disk. `origin` is only used in
108    /// error messages. Callers that assemble a file body by hand validate it
109    /// through this BEFORE the atomic write, so an invalid assembly can never
110    /// replace a good file on disk (#716).
111    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    /// Authenticate a bearer key: SHA-256 it and look the hash up. Returns the
161    /// identity tags on a match.
162    #[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
168/// Re-read gateway keys from their configured file path. Callers can replace
169/// their current snapshot with the result to apply a rotated secret.
170pub fn reload_keys() -> anyhow::Result<GatewayKeys> {
171    GatewayKeys::load_default()
172}
173
174/// Lowercase hex SHA-256 (the storage form of every gateway key).
175#[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        // SHA-256("abc") — the FIPS 180-2 test vector.
259        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}