brain_edge/port/mod.rs
1//! The two seams that let one edge serve two deployments.
2//!
3//! `brain-edge` ships a self-host default: forward the caller's bearer token to
4//! Brain (which resolves the identity itself) and meter nothing. The hosted Arc
5//! gateway injects its own [`CredentialResolver`] (API key → tenant identity)
6//! and [`MeteringSink`] (usage analytics) without forking the translation layer.
7//!
8//! Both ports are trait objects held in [`crate::EdgeState`], so the router and
9//! handlers stay non-generic and the host app plugs in behavior at construction.
10
11pub mod file_store;
12
13pub use file_store::FileLlmCredentialStore;
14
15use axum::http::{HeaderMap, header};
16
17use crate::error::ApiError;
18
19/// The Brain wire credential the edge authenticates with, plus optional resolved
20/// metadata for metering and logging.
21#[derive(Clone, Debug)]
22pub struct ResolvedCredential {
23 /// Opaque token: the connection-pool cache key and — in the self-host
24 /// default — the Brain wire credential forwarded downstream.
25 pub credential: String,
26 /// A resolved tenant label, when the resolver knows it. The gateway fills
27 /// this from the API key (e.g. `namespace/agent`); the bearer default leaves
28 /// it `None` because Brain, not the edge, resolves the identity there.
29 pub tenant: Option<String>,
30}
31
32/// Resolve an incoming request to a Brain credential.
33///
34/// The default is [`BearerResolver`]. The hosted gateway supplies an
35/// implementation that looks an API key up in its key store and returns the
36/// tenant's Brain credential (and, later, a per-request identity).
37#[async_trait::async_trait]
38pub trait CredentialResolver: Send + Sync + 'static {
39 /// Map request headers to a [`ResolvedCredential`], or a `401`/`403` error.
40 async fn resolve(&self, headers: &HeaderMap) -> Result<ResolvedCredential, ApiError>;
41}
42
43/// The outcome of one data-plane operation, for metering.
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45pub enum Outcome {
46 /// The operation completed successfully.
47 Ok,
48 /// The operation failed (transport, engine, or validation).
49 Err,
50}
51
52impl Outcome {
53 /// Classify a verb `Result` without inspecting its payload.
54 #[must_use]
55 pub fn of<T, E>(result: &Result<T, E>) -> Self {
56 if result.is_ok() { Self::Ok } else { Self::Err }
57 }
58}
59
60/// One metered event: which tenant ran which op, and whether it succeeded.
61#[derive(Debug)]
62pub struct MeterEvent<'a> {
63 /// The resolved tenant label, if the resolver knew it.
64 pub tenant: Option<&'a str>,
65 /// The verb name (`"encode"`, `"recall"`, …).
66 pub op: &'a str,
67 /// Success or failure.
68 pub outcome: Outcome,
69}
70
71/// Record usage. Called once per data-plane op; implementations must not block
72/// the response path (fire-and-forget).
73pub trait MeteringSink: Send + Sync + 'static {
74 /// Record one operation's outcome.
75 fn record(&self, event: &MeterEvent<'_>);
76}
77
78/// A tenant's stored LLM provider credential, as it is persisted.
79///
80/// Carries **no plaintext**: the provider key lives only in `sealed`, and only
81/// [`crate::crypto::KeyRing`] — which holds the key-encryption key in this
82/// process — can open it. Everything else here is metadata safe to store and
83/// to show an operator.
84#[derive(Clone, Debug)]
85pub struct StoredLlmCredential {
86 /// Provider this key is for (`"openai"`, `"anthropic"`).
87 pub provider: String,
88 /// Model the tenant registered the key for. The resolver hands this to
89 /// Brain so extraction and HyPE run on a model the key can actually pay
90 /// for.
91 pub model: String,
92 /// The sealed provider key. Ciphertext only.
93 pub sealed: crate::crypto::SealedSecret,
94 /// Display-safe tail of the key (e.g. `sk-…4f2a`), so a dashboard can show
95 /// *which* key is installed without the key itself ever being readable
96 /// again. Never reconstructable into the original.
97 pub fingerprint: String,
98 /// When this credential was stored, unix seconds.
99 pub created_at_unix_secs: u64,
100}
101
102/// Persist tenants' sealed LLM provider credentials.
103///
104/// **The implementation never sees a plaintext key.** Sealing and opening both
105/// happen in the edge, against a key-encryption key that lives in this
106/// process's configuration and never in its data — so whatever backs this port
107/// (Postgres in the hosted gateway, a file in self-host) holds ciphertext and
108/// nothing else. A database compromise therefore yields no usable provider
109/// keys.
110///
111/// Keyed by namespace: one registered credential per tenant.
112#[async_trait::async_trait]
113pub trait LlmCredentialStore: Send + Sync + 'static {
114 /// Store (or replace) `namespace`'s credential.
115 async fn put(&self, namespace: &str, record: StoredLlmCredential) -> Result<(), ApiError>;
116 /// Fetch `namespace`'s credential, or `None` when the tenant has not
117 /// registered one. `None` is the signal that drives
118 /// `llm_credential.missing` back to the caller.
119 async fn get(&self, namespace: &str) -> Result<Option<StoredLlmCredential>, ApiError>;
120 /// Remove `namespace`'s credential. Returns whether a record existed, so a
121 /// delete can be reported honestly rather than always 204.
122 async fn delete(&self, namespace: &str) -> Result<bool, ApiError>;
123}
124
125/// The self-host default store: process-local, lost on restart.
126///
127/// Chosen over a "refuse everything" default so a self-host operator can try
128/// BYOK without standing up a database — but it is **not** durable, and the
129/// hosted gateway injects a persistent implementation. A restart drops every
130/// registered key, and writes fail the whole ENCODE path until they are
131/// re-registered, which is why this says so loudly rather than quietly losing
132/// data.
133#[derive(Debug, Default)]
134pub struct InMemoryLlmCredentialStore {
135 records: std::sync::RwLock<std::collections::HashMap<String, StoredLlmCredential>>,
136}
137
138#[async_trait::async_trait]
139impl LlmCredentialStore for InMemoryLlmCredentialStore {
140 async fn put(&self, namespace: &str, record: StoredLlmCredential) -> Result<(), ApiError> {
141 let mut guard = self
142 .records
143 .write()
144 .map_err(|_| ApiError::internal("credential store lock poisoned"))?;
145 guard.insert(namespace.to_string(), record);
146 Ok(())
147 }
148
149 async fn get(&self, namespace: &str) -> Result<Option<StoredLlmCredential>, ApiError> {
150 let guard = self
151 .records
152 .read()
153 .map_err(|_| ApiError::internal("credential store lock poisoned"))?;
154 Ok(guard.get(namespace).cloned())
155 }
156
157 async fn delete(&self, namespace: &str) -> Result<bool, ApiError> {
158 let mut guard = self
159 .records
160 .write()
161 .map_err(|_| ApiError::internal("credential store lock poisoned"))?;
162 Ok(guard.remove(namespace).is_some())
163 }
164}
165
166/// Display-safe tail of a provider key.
167///
168/// Shows enough to tell two keys apart and nothing more: the last four
169/// characters, which do not narrow a brute force meaningfully, behind an
170/// ellipsis. Short keys render fully masked rather than mostly revealed — a
171/// 6-character key would otherwise expose most of itself.
172#[must_use]
173pub fn key_fingerprint(key: &str) -> String {
174 const TAIL: usize = 4;
175 // Count characters, not bytes: a multi-byte tail must not slice mid-char.
176 let chars: Vec<char> = key.chars().collect();
177 if chars.len() <= TAIL * 2 {
178 return "…".to_string();
179 }
180 let tail: String = chars[chars.len() - TAIL..].iter().collect();
181 format!("…{tail}")
182}
183
184/// The self-host default resolver: forward `Authorization: Bearer <key>` (or
185/// `X-API-Key`) to Brain, which resolves the identity itself.
186#[derive(Debug)]
187pub struct BearerResolver;
188
189#[async_trait::async_trait]
190impl CredentialResolver for BearerResolver {
191 async fn resolve(&self, headers: &HeaderMap) -> Result<ResolvedCredential, ApiError> {
192 Ok(ResolvedCredential {
193 credential: bearer_token(headers)?,
194 tenant: None,
195 })
196 }
197}
198
199/// The self-host default sink: record nothing.
200#[derive(Debug)]
201pub struct NoopMeter;
202
203impl MeteringSink for NoopMeter {
204 fn record(&self, _event: &MeterEvent<'_>) {}
205}
206
207/// Read the API key from `Authorization: Bearer …` or `X-API-Key`.
208///
209/// # Errors
210/// Returns a `401` if neither header carries a non-empty token.
211pub fn bearer_token(headers: &HeaderMap) -> Result<String, ApiError> {
212 if let Some(raw) = headers
213 .get(header::AUTHORIZATION)
214 .and_then(|v| v.to_str().ok())
215 {
216 let stripped = raw
217 .strip_prefix("Bearer ")
218 .or_else(|| raw.strip_prefix("bearer "));
219 if let Some(tok) = stripped {
220 let tok = tok.trim();
221 if !tok.is_empty() {
222 return Ok(tok.to_string());
223 }
224 }
225 }
226 if let Some(raw) = headers.get("x-api-key").and_then(|v| v.to_str().ok()) {
227 let tok = raw.trim();
228 if !tok.is_empty() {
229 return Ok(tok.to_string());
230 }
231 }
232 Err(ApiError::unauthorized(
233 "missing API key — send `Authorization: Bearer <key>` or `X-API-Key`",
234 ))
235}
236
237#[cfg(test)]
238mod byok_tests {
239 use super::*;
240
241 #[test]
242 fn a_fingerprint_reveals_only_the_tail() {
243 let fp = key_fingerprint("sk-proj-abcdefghijklmnop4f2a");
244 assert_eq!(fp, "…4f2a");
245 assert!(!fp.contains("abcdefgh"), "body must not appear: {fp}");
246 }
247
248 #[test]
249 fn a_short_key_is_fully_masked() {
250 // A 6-character key with a 4-character tail would expose most of
251 // itself, so short inputs render with no tail at all.
252 assert_eq!(key_fingerprint("sk-abc"), "…");
253 assert_eq!(key_fingerprint(""), "…");
254 assert_eq!(key_fingerprint("12345678"), "…", "boundary: exactly 2*TAIL");
255 assert_eq!(
256 key_fingerprint("123456789"),
257 "…6789",
258 "one over the boundary"
259 );
260 }
261
262 #[test]
263 fn a_multibyte_key_does_not_panic_on_the_tail() {
264 // Slicing by byte would split a multi-byte char and panic; the
265 // fingerprint counts characters.
266 let fp = key_fingerprint("sk-ключ-日本語テスト");
267 assert!(fp.starts_with('…'));
268 }
269
270 #[tokio::test]
271 async fn the_in_memory_store_round_trips_and_deletes() {
272 use crate::crypto::{KeyRing, SecretBinding};
273 use zeroize::Zeroizing;
274
275 let ring = KeyRing::new(
276 [(
277 "k1".to_string(),
278 Zeroizing::new(vec![9u8; crate::crypto::KEK_LEN]),
279 )],
280 "k1",
281 )
282 .unwrap();
283 let binding = SecretBinding {
284 namespace: "acme",
285 provider: "openai",
286 };
287 let sealed = ring.seal(b"sk-live-secret", binding).unwrap();
288
289 let store = InMemoryLlmCredentialStore::default();
290 assert!(store.get("acme").await.unwrap().is_none(), "empty to start");
291
292 store
293 .put(
294 "acme",
295 StoredLlmCredential {
296 provider: "openai".into(),
297 model: "gpt-4o-mini".into(),
298 sealed: sealed.clone(),
299 fingerprint: key_fingerprint("sk-live-secret"),
300 created_at_unix_secs: 1,
301 },
302 )
303 .await
304 .unwrap();
305
306 let got = store.get("acme").await.unwrap().expect("stored");
307 assert_eq!(got.model, "gpt-4o-mini");
308 // What comes back out of the store is still ciphertext — it only
309 // becomes a key by going through the keyring.
310 assert_eq!(
311 &*ring.open(&got.sealed, binding).unwrap(),
312 b"sk-live-secret"
313 );
314
315 assert!(store.delete("acme").await.unwrap(), "existed");
316 assert!(
317 !store.delete("acme").await.unwrap(),
318 "second delete is false"
319 );
320 assert!(store.get("acme").await.unwrap().is_none());
321 }
322
323 #[tokio::test]
324 async fn one_tenants_credential_is_not_visible_to_another() {
325 let store = InMemoryLlmCredentialStore::default();
326 let ring = crate::crypto::KeyRing::new(
327 [(
328 "k1".to_string(),
329 zeroize::Zeroizing::new(vec![3u8; crate::crypto::KEK_LEN]),
330 )],
331 "k1",
332 )
333 .unwrap();
334 let sealed = ring
335 .seal(
336 b"acme-key",
337 crate::crypto::SecretBinding {
338 namespace: "acme",
339 provider: "openai",
340 },
341 )
342 .unwrap();
343 store
344 .put(
345 "acme",
346 StoredLlmCredential {
347 provider: "openai".into(),
348 model: "gpt-4o-mini".into(),
349 sealed,
350 fingerprint: "…key".into(),
351 created_at_unix_secs: 1,
352 },
353 )
354 .await
355 .unwrap();
356 assert!(store.get("globex").await.unwrap().is_none());
357 }
358}