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 (same rule as `TeamTokenConfig` /
16//! `cloud_server::auth`); the plaintext key lives with the person. The file
17//! path resolves via `LEAN_CTX_GATEWAY_KEYS`, falling back to
18//! `<config_dir>/gateway-keys.toml` — deployments mount it as a secret.
19//!
20//! A caller may override the project per request with the `x-leanctx-project`
21//! header (an internal gateway header: it is deliberately not on
22//! `ALLOWED_REQUEST_HEADERS`, so it never leaks upstream).
23
24use std::collections::HashMap;
25use std::path::{Path, PathBuf};
26
27use serde::Deserialize;
28
29/// The identity tags attached to an authenticated gateway request. Inserted as
30/// a request extension by the auth guard and stamped onto the usage record by
31/// the forward path.
32#[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    /// True when there is anything worth stamping.
41    #[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    /// Lowercase hex SHA-256 of the bearer key (never the key itself).
56    sha256_hex: String,
57    person: String,
58    #[serde(default)]
59    team: Option<String>,
60    #[serde(default)]
61    default_project: Option<String>,
62}
63
64/// Loaded, lookup-ready key set. One instance per proxy process, loaded at
65/// startup (key rotation = redeploy/restart, the standard secret-mount flow).
66#[derive(Debug, Default)]
67pub struct GatewayKeys {
68    by_sha: HashMap<String, GatewayTags>,
69}
70
71impl GatewayKeys {
72    /// Resolve the keys file path: `LEAN_CTX_GATEWAY_KEYS` env wins, else
73    /// `<config_dir>/gateway-keys.toml` (next to `config.toml`).
74    #[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    /// Load from the default path; a missing file is an empty key set (the
88    /// common local case), a malformed file is a loud startup error.
89    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    /// Authenticate a bearer key: SHA-256 it and look the hash up. Returns the
148    /// identity tags on a match.
149    #[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/// Lowercase hex SHA-256 (the storage form of every gateway key).
156#[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        // SHA-256("abc") — the FIPS 180-2 test vector.
240        assert_eq!(
241            sha256_hex("abc"),
242            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
243        );
244    }
245}