sim_lib_openai_server/runtime/
keys.rs1use 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
13pub const OPENAI_GATEWAY_KEY_OBJECT: &str = "openai-gateway/key";
15const REDACTED_HEADER_VALUE: &str = "[redacted]";
16
17#[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 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 pub fn from_secret(secret: &str, capabilities: CapabilitySet) -> Self {
51 Self::new(key_hash(secret), capabilities)
52 }
53
54 pub fn id(&self) -> &str {
56 &self.id
57 }
58
59 pub fn key_hash(&self) -> &str {
61 &self.key_hash
62 }
63
64 pub fn capabilities(&self) -> &CapabilitySet {
66 &self.capabilities
67 }
68
69 pub fn default_policy(&self) -> &Expr {
71 &self.default_policy
72 }
73
74 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#[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 pub fn new() -> Result<Self> {
121 Self::with_anonymous(CapabilitySet::new())
122 }
123
124 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 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 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 pub fn key_for_secret(&self, secret: &str) -> Result<Option<OpenAiGatewayKey>> {
157 self.key_for_hash(&key_hash(secret))
158 }
159
160 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 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 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 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
222pub fn global_openai_key_table() -> &'static OpenAiKeyTable {
224 static TABLE: OnceLock<OpenAiKeyTable> = OnceLock::new();
225 TABLE.get_or_init(OpenAiKeyTable::default)
226}
227
228pub fn key_hash(secret: &str) -> String {
230 hex_bytes(&Sha256::digest(secret.as_bytes()))
231}
232
233pub 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
243pub 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;