Skip to main content

sim_lib_openai_server/runtime/
keys.rs

1use std::sync::{Arc, Mutex, OnceLock};
2
3use serde_json::Value as JsonValue;
4use sha2::{Digest, Sha256};
5use sim_citizen_derive::non_citizen;
6use sim_kernel::{
7    CapabilityName, CapabilitySet, Cx, DefaultFactory, Error, Expr, GrantSeat, NoopEvalPolicy,
8    Object, ObjectCompat, Result, Symbol, Table, Value,
9};
10
11use crate::objects::GatewayRequest;
12
13/// Object tag identifying a serialized [`OpenAiGatewayKey`] descriptor.
14pub const OPENAI_GATEWAY_KEY_OBJECT: &str = "openai-gateway/key";
15const REDACTED_HEADER_VALUE: &str = "[redacted]";
16
17/// An API key registered with the gateway, holding its hashed secret and the
18/// capability ceiling it grants.
19///
20/// The plaintext secret is never stored; the key is identified by the SHA-256
21/// hash of the secret and carries the [`CapabilitySet`] that bounds what a
22/// request authenticated with it may do.
23#[derive(Clone, Debug, PartialEq, Eq)]
24#[non_citizen(
25    reason = "gateway key object stores redacted credential policy; serializable projection is openai/GatewayKey descriptor",
26    kind = "handle"
27)]
28pub struct OpenAiGatewayKey {
29    id: String,
30    key_hash: String,
31    capabilities: CapabilitySet,
32    default_policy: Expr,
33}
34
35impl OpenAiGatewayKey {
36    /// Returns a key from a precomputed secret hash and its capability ceiling.
37    pub fn new(key_hash: impl Into<String>, capabilities: CapabilitySet) -> Self {
38        let key_hash = key_hash.into();
39        let id = key_id(&key_hash);
40        let default_policy = key_default_policy_expr(&capabilities);
41        Self {
42            id,
43            key_hash,
44            capabilities,
45            default_policy,
46        }
47    }
48
49    /// Returns a key built by hashing `secret`, with the given capability ceiling.
50    pub fn from_secret(secret: &str, capabilities: CapabilitySet) -> Self {
51        Self::new(key_hash(secret), capabilities)
52    }
53
54    /// Returns the public key id (a `key_`-prefixed hash fragment).
55    pub fn id(&self) -> &str {
56        &self.id
57    }
58
59    /// Returns the hex SHA-256 hash of the key secret.
60    pub fn key_hash(&self) -> &str {
61        &self.key_hash
62    }
63
64    /// Returns the capability ceiling granted by this key.
65    pub fn capabilities(&self) -> &CapabilitySet {
66        &self.capabilities
67    }
68
69    /// Returns the default policy expression derived from the capability ceiling.
70    pub fn default_policy(&self) -> &Expr {
71        &self.default_policy
72    }
73
74    /// Returns the key as a serializable map descriptor.
75    pub fn to_expr(&self) -> Expr {
76        Expr::Map(vec![
77            field("object", Expr::String(OPENAI_GATEWAY_KEY_OBJECT.to_owned())),
78            field("id", Expr::String(self.id.clone())),
79            field("key-hash", Expr::String(self.key_hash.clone())),
80            field("capabilities", capabilities_expr(&self.capabilities)),
81            field("default-policy", self.default_policy.clone()),
82        ])
83    }
84}
85
86impl Object for OpenAiGatewayKey {
87    fn display(&self, _cx: &mut Cx) -> Result<String> {
88        Ok(format!("#<openai-gateway-key {}>", self.id))
89    }
90
91    fn as_any(&self) -> &dyn std::any::Any {
92        self
93    }
94}
95
96impl ObjectCompat for OpenAiGatewayKey {
97    fn as_expr(&self, _cx: &mut Cx) -> Result<Expr> {
98        Ok(self.to_expr())
99    }
100}
101
102/// Thread-safe table of gateway API keys indexed by secret hash.
103///
104/// Keys are stored in a SIM table behind a shared mutex so the same table can
105/// be cloned across route state; an anonymous capability ceiling applies to
106/// requests that present no recognized key.
107#[derive(Clone)]
108pub struct OpenAiKeyTable {
109    inner: Arc<OpenAiKeyTableInner>,
110}
111
112struct OpenAiKeyTableInner {
113    cx: Mutex<Cx>,
114    keys: Value,
115    anonymous: CapabilitySet,
116}
117
118impl OpenAiKeyTable {
119    /// Returns an empty key table with no anonymous capabilities.
120    pub fn new() -> Result<Self> {
121        Self::with_anonymous(CapabilitySet::new())
122    }
123
124    /// Returns an empty key table whose unauthenticated requests get `anonymous`.
125    pub fn with_anonymous(anonymous: CapabilitySet) -> Result<Self> {
126        let cx = Cx::new(Arc::new(NoopEvalPolicy), Arc::new(DefaultFactory));
127        let keys = cx.factory().table(Vec::new())?;
128        Ok(Self {
129            inner: Arc::new(OpenAiKeyTableInner {
130                cx: Mutex::new(cx),
131                keys,
132                anonymous,
133            }),
134        })
135    }
136
137    /// Hashes `secret`, registers it with `capabilities`, and returns the new key.
138    pub fn add_secret(
139        &self,
140        secret: &str,
141        capabilities: CapabilitySet,
142    ) -> Result<OpenAiGatewayKey> {
143        let key = OpenAiGatewayKey::from_secret(secret, capabilities);
144        self.add_key(key.clone())?;
145        Ok(key)
146    }
147
148    /// Inserts an already-constructed key, indexing it by its secret hash.
149    pub fn add_key(&self, key: OpenAiGatewayKey) -> Result<()> {
150        let mut cx = self.cx()?;
151        let value = cx.factory().opaque(Arc::new(key.clone()))?;
152        table_impl(&self.inner.keys)?.set(&mut cx, Symbol::new(key.key_hash()), value)
153    }
154
155    /// Returns the registered key matching `secret`, or `None` if unknown.
156    pub fn key_for_secret(&self, secret: &str) -> Result<Option<OpenAiGatewayKey>> {
157        self.key_for_hash(&key_hash(secret))
158    }
159
160    /// Returns the key authenticating `request`, reading its bearer/api-key header.
161    pub fn key_for_request(&self, request: &GatewayRequest) -> Result<Option<OpenAiGatewayKey>> {
162        presented_key(request)
163            .map(|secret| self.key_for_secret(secret))
164            .unwrap_or(Ok(None))
165    }
166
167    /// Returns all registered keys.
168    pub fn list_keys(&self) -> Result<Vec<OpenAiGatewayKey>> {
169        let mut cx = self.cx()?;
170        Ok(table_impl(&self.inner.keys)?
171            .entries(&mut cx)?
172            .into_iter()
173            .filter_map(|(_, value)| value.object().downcast_ref::<OpenAiGatewayKey>().cloned())
174            .collect())
175    }
176
177    /// Returns the capabilities `request` may exercise.
178    ///
179    /// The result is the key's ceiling (or the anonymous ceiling when no key is
180    /// presented), intersected with any capabilities the request body explicitly
181    /// requests, so a request can only narrow -- never widen -- its key's grant.
182    pub fn effective_capabilities(&self, request: &GatewayRequest) -> Result<CapabilitySet> {
183        let ceiling = self
184            .key_for_request(request)?
185            .map(|key| key.capabilities().clone())
186            .unwrap_or_else(|| self.inner.anonymous.clone());
187        Ok(requested_capabilities(request)
188            .map(|requested| intersect_capabilities(&requested, &ceiling))
189            .unwrap_or(ceiling))
190    }
191
192    /// Runs `f` with `cx` scoped to the request's effective capabilities.
193    pub fn with_effective_capabilities<T>(
194        &self,
195        cx: &mut Cx,
196        request: &GatewayRequest,
197        f: impl FnOnce(&mut Cx) -> Result<T>,
198    ) -> Result<T> {
199        cx.with_capabilities(self.effective_capabilities(request)?, f)
200    }
201
202    fn key_for_hash(&self, hash: &str) -> Result<Option<OpenAiGatewayKey>> {
203        let mut cx = self.cx()?;
204        let value = table_impl(&self.inner.keys)?.get(&mut cx, Symbol::new(hash))?;
205        Ok(value.object().downcast_ref::<OpenAiGatewayKey>().cloned())
206    }
207
208    fn cx(&self) -> Result<std::sync::MutexGuard<'_, Cx>> {
209        self.inner
210            .cx
211            .lock()
212            .map_err(|_| Error::PoisonedLock("openai gateway key table"))
213    }
214}
215
216impl Default for OpenAiKeyTable {
217    fn default() -> Self {
218        Self::new().expect("in-memory SIM key table creation is infallible")
219    }
220}
221
222/// Returns the process-wide shared gateway key table, initializing it on first use.
223pub fn global_openai_key_table() -> &'static OpenAiKeyTable {
224    static TABLE: OnceLock<OpenAiKeyTable> = OnceLock::new();
225    TABLE.get_or_init(OpenAiKeyTable::default)
226}
227
228/// Returns the lowercase hex SHA-256 hash of a key secret.
229pub fn key_hash(secret: &str) -> String {
230    hex_bytes(&Sha256::digest(secret.as_bytes()))
231}
232
233/// Returns a copy of `request` with sensitive auth headers replaced by `[redacted]`.
234pub fn redacted_gateway_request(request: &GatewayRequest) -> GatewayRequest {
235    GatewayRequest::new(
236        request.method().to_owned(),
237        request.path().to_owned(),
238        redact_headers(request.headers()),
239        request.body().to_vec(),
240    )
241}
242
243/// Grants every capability in `capabilities` to `cx`, through the host-held
244/// `seat` minted when `cx` was constructed.
245pub fn grant_capability_set(seat: &GrantSeat, cx: &mut Cx, capabilities: &CapabilitySet) {
246    capabilities
247        .iter()
248        .cloned()
249        .for_each(|capability| seat.grant(cx, capability));
250}
251
252fn key_id(hash: &str) -> String {
253    let prefix_len = hash.len().min(12);
254    format!("key_{}", &hash[..prefix_len])
255}
256
257fn key_default_policy_expr(capabilities: &CapabilitySet) -> Expr {
258    Expr::Map(vec![field(
259        "capability-ceiling",
260        capabilities_expr(capabilities),
261    )])
262}
263
264fn capabilities_expr(capabilities: &CapabilitySet) -> Expr {
265    Expr::Vector(
266        capabilities
267            .iter()
268            .map(|capability| Expr::String(capability.as_str().to_owned()))
269            .collect(),
270    )
271}
272
273fn requested_capabilities(request: &GatewayRequest) -> Option<CapabilitySet> {
274    let body = serde_json::from_slice::<JsonValue>(request.body()).ok()?;
275    let object = body.as_object()?;
276    object
277        .get("capabilities")
278        .or_else(|| {
279            object
280                .get("sim")
281                .and_then(JsonValue::as_object)
282                .and_then(|sim| sim.get("capabilities"))
283        })
284        .and_then(capability_set_from_json)
285}
286
287fn capability_set_from_json(value: &JsonValue) -> Option<CapabilitySet> {
288    let mut capabilities = CapabilitySet::new();
289    match value {
290        JsonValue::String(name) => capabilities.insert(CapabilityName::new(name.clone())),
291        JsonValue::Array(items) => {
292            for item in items {
293                let name = item.as_str()?;
294                capabilities.insert(CapabilityName::new(name.to_owned()));
295            }
296        }
297        _ => return None,
298    }
299    Some(capabilities)
300}
301
302fn intersect_capabilities(left: &CapabilitySet, right: &CapabilitySet) -> CapabilitySet {
303    let mut capabilities = CapabilitySet::new();
304    for capability in left.iter() {
305        if right.contains(capability) {
306            capabilities.insert(capability.clone());
307        }
308    }
309    capabilities
310}
311
312fn presented_key(request: &GatewayRequest) -> Option<&str> {
313    for (name, value) in request.headers() {
314        if name.eq_ignore_ascii_case("authorization") {
315            let value = value.trim();
316            if let Some((scheme, token)) = value.split_once(' ')
317                && scheme.eq_ignore_ascii_case("bearer")
318            {
319                let token = token.trim();
320                if !token.is_empty() {
321                    return Some(token);
322                }
323            }
324        }
325        if is_api_key_header(name) {
326            let value = value.trim();
327            if !value.is_empty() {
328                return Some(value);
329            }
330        }
331    }
332    None
333}
334
335fn redact_headers(headers: &[(String, String)]) -> Vec<(String, String)> {
336    headers
337        .iter()
338        .map(|(name, value)| {
339            if is_sensitive_header(name) {
340                (name.clone(), REDACTED_HEADER_VALUE.to_owned())
341            } else {
342                (name.clone(), value.clone())
343            }
344        })
345        .collect()
346}
347
348fn is_api_key_header(name: &str) -> bool {
349    name.eq_ignore_ascii_case("x-api-key")
350        || name.eq_ignore_ascii_case("api-key")
351        || name.eq_ignore_ascii_case("openai-api-key")
352        || name.eq_ignore_ascii_case("x-openai-api-key")
353}
354
355fn is_sensitive_header(name: &str) -> bool {
356    name.eq_ignore_ascii_case("authorization")
357        || name.eq_ignore_ascii_case("proxy-authorization")
358        || is_api_key_header(name)
359}
360
361fn table_impl(value: &Value) -> Result<&dyn Table> {
362    value.object().as_table_impl().ok_or(Error::TypeMismatch {
363        expected: "SIM table",
364        found: "non-table",
365    })
366}
367
368fn hex_bytes(bytes: &[u8]) -> String {
369    const HEX: &[u8; 16] = b"0123456789abcdef";
370    let mut output = String::with_capacity(bytes.len() * 2);
371    for byte in bytes {
372        output.push(char::from(HEX[usize::from(byte >> 4)]));
373        output.push(char::from(HEX[usize::from(byte & 0x0f)]));
374    }
375    output
376}
377
378use sim_value::build::entry as field;