Skip to main content

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}