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
39/// Internal trust marker set only after the proxy auth guard accepts a
40/// gateway-owned credential (or explicit loopback-open mode). Provider API-key
41/// fallback must not be able to manufacture managed OCLA lineage headers.
42#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
43pub(crate) struct TrustedGatewayRequest;
44
45impl GatewayTags {
46    /// True when there is anything worth stamping.
47    #[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    /// Lowercase hex SHA-256 of the bearer key (never the key itself).
62    sha256_hex: String,
63    person: String,
64    #[serde(default)]
65    team: Option<String>,
66    #[serde(default)]
67    default_project: Option<String>,
68}
69
70/// Loaded, lookup-ready key set. One instance per proxy process, loaded at
71/// startup (key rotation = redeploy/restart, the standard secret-mount flow).
72#[derive(Debug, Default)]
73pub struct GatewayKeys {
74    by_sha: HashMap<String, GatewayTags>,
75}
76
77impl GatewayKeys {
78    /// Resolve the keys file path: `LEAN_CTX_GATEWAY_KEYS` env wins, else
79    /// `<config_dir>/gateway-keys.toml` (next to `config.toml`).
80    #[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    /// Load from the default path; a missing file is an empty key set (the
94    /// common local case), a malformed file is a loud startup error.
95    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    /// Parses a key-file body without touching disk. `origin` is only used in
109    /// error messages. Callers that assemble a file body by hand validate it
110    /// through this BEFORE the atomic write, so an invalid assembly can never
111    /// replace a good file on disk (#716).
112    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    /// Authenticate a bearer key: SHA-256 it and look the hash up. Returns the
162    /// identity tags on a match.
163    #[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/// Lowercase hex SHA-256 (the storage form of every gateway key).
170#[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        // SHA-256("abc") — the FIPS 180-2 test vector.
254        assert_eq!(
255            sha256_hex("abc"),
256            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
257        );
258    }
259}