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        Self::parse(&raw, path)
100    }
101
102    /// Parses a key-file body without touching disk. `origin` is only used in
103    /// error messages. Callers that assemble a file body by hand validate it
104    /// through this BEFORE the atomic write, so an invalid assembly can never
105    /// replace a good file on disk (#716).
106    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    /// Authenticate a bearer key: SHA-256 it and look the hash up. Returns the
156    /// identity tags on a match.
157    #[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/// Lowercase hex SHA-256 (the storage form of every gateway key).
164#[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        // SHA-256("abc") — the FIPS 180-2 test vector.
248        assert_eq!(
249            sha256_hex("abc"),
250            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
251        );
252    }
253}