1use std::error::Error;
4use std::fmt;
5use std::str::FromStr;
6
7use tonic::Request;
8use tonic::metadata::{Ascii, Binary, MetadataValue};
9use zeroize::Zeroizing;
10
11use riffdb_proto::DATABASE_METADATA_KEY;
12use riffdb_types::{DatabaseAlias, TenantId};
13
14const CAPABILITY_TOKEN_BYTES: usize = 43;
15const MAX_TRACE_PARENT_BYTES: usize = 512;
16const BOOTSTRAP_TOKEN_METADATA_KEY: &str = "riffdb-bootstrap-token-bin";
17
18#[derive(Clone, Copy, Debug, Eq, PartialEq)]
20pub enum MetadataError {
21 InvalidCapabilityToken,
23 InvalidTraceParent,
25 InvalidTenant,
27}
28
29impl fmt::Display for MetadataError {
30 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
31 formatter.write_str(match self {
32 Self::InvalidCapabilityToken => "capability credential is invalid",
33 Self::InvalidTraceParent => "trace propagation metadata is invalid",
34 Self::InvalidTenant => "tenant identifier is invalid",
35 })
36 }
37}
38
39impl Error for MetadataError {}
40
41#[derive(Clone)]
48pub struct BearerCredential {
49 authorization: MetadataValue<Ascii>,
50}
51
52impl BearerCredential {
53 pub fn new(token: &str) -> Result<Self, MetadataError> {
55 if !valid_token_presentation(token.as_bytes()) {
56 return Err(MetadataError::InvalidCapabilityToken);
57 }
58 let mut presentation = Zeroizing::new(String::with_capacity("Bearer ".len() + token.len()));
59 presentation.push_str("Bearer ");
60 presentation.push_str(token);
61 let authorization = MetadataValue::from_str(presentation.as_str())
62 .map_err(|_| MetadataError::InvalidCapabilityToken)?;
63 Ok(Self { authorization })
64 }
65
66 #[must_use]
71 pub fn has_same_presentation(&self, other: &Self) -> bool {
72 self.authorization == other.authorization
73 }
74}
75
76pub struct BootstrapCredential {
81 value: MetadataValue<Binary>,
82}
83
84impl BootstrapCredential {
85 pub fn new(token: &str) -> Result<Self, MetadataError> {
87 if !valid_token_presentation(token.as_bytes()) {
88 return Err(MetadataError::InvalidCapabilityToken);
89 }
90 Ok(Self {
91 value: MetadataValue::from_bytes(token.as_bytes()),
92 })
93 }
94}
95
96impl fmt::Debug for BootstrapCredential {
97 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
98 formatter.write_str("BootstrapCredential([REDACTED])")
99 }
100}
101
102impl fmt::Debug for BearerCredential {
103 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
104 formatter.write_str("BearerCredential([REDACTED])")
105 }
106}
107
108#[derive(Clone)]
110pub struct TraceParent {
111 value: MetadataValue<Ascii>,
112}
113
114impl TraceParent {
115 pub fn new(value: &str) -> Result<Self, MetadataError> {
117 if value.is_empty() || value.len() > MAX_TRACE_PARENT_BYTES {
118 return Err(MetadataError::InvalidTraceParent);
119 }
120 let value =
121 MetadataValue::from_str(value).map_err(|_| MetadataError::InvalidTraceParent)?;
122 Ok(Self { value })
123 }
124}
125
126impl fmt::Debug for TraceParent {
127 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
128 formatter.write_str("TraceParent([REDACTED])")
129 }
130}
131
132#[derive(Clone, Debug, Default)]
134pub struct CallMetadata {
135 credential: Option<BearerCredential>,
136 trace_parent: Option<TraceParent>,
137 database: Option<DatabaseAlias>,
138 tenant: Option<TenantId>,
139}
140
141impl CallMetadata {
142 #[must_use]
144 pub fn authenticated(credential: BearerCredential) -> Self {
145 Self {
146 credential: Some(credential),
147 trace_parent: None,
148 database: None,
149 tenant: std::env::var("RIFFDB_TENANT")
150 .ok()
151 .filter(|value| !value.is_empty())
152 .and_then(|value| TenantId::new(value).ok()),
153 }
154 }
155
156 #[must_use]
158 pub fn with_trace_parent(mut self, trace_parent: TraceParent) -> Self {
159 self.trace_parent = Some(trace_parent);
160 self
161 }
162
163 #[must_use]
165 pub fn with_database(mut self, database: DatabaseAlias) -> Self {
166 self.database = Some(database);
167 self
168 }
169
170 #[must_use]
174 pub fn with_tenant(mut self, tenant: TenantId) -> Self {
175 self.tenant = Some(tenant);
176 self
177 }
178
179 pub fn with_tenant_from_env(mut self) -> Result<Self, MetadataError> {
182 match std::env::var("RIFFDB_TENANT") {
183 Ok(raw) if !raw.is_empty() => {
184 let tenant = TenantId::new(raw).map_err(|_| MetadataError::InvalidTenant)?;
185 self.tenant = Some(tenant);
186 Ok(self)
187 }
188 Ok(_) | Err(std::env::VarError::NotPresent) => Ok(self),
189 Err(std::env::VarError::NotUnicode(_)) => Err(MetadataError::InvalidTenant),
190 }
191 }
192
193 #[must_use]
195 pub const fn tenant(&self) -> Option<&TenantId> {
196 self.tenant.as_ref()
197 }
198
199 pub(crate) fn apply<T>(&self, request: &mut Request<T>) {
200 if let Some(credential) = &self.credential {
201 request
202 .metadata_mut()
203 .insert("authorization", credential.authorization.clone());
204 }
205 if let Some(trace_parent) = &self.trace_parent {
206 request
207 .metadata_mut()
208 .insert("traceparent", trace_parent.value.clone());
209 }
210 if let Some(database) = &self.database {
211 let value = MetadataValue::from_str(database.as_str())
212 .expect("DatabaseAlias is valid ASCII metadata");
213 request.metadata_mut().insert(DATABASE_METADATA_KEY, value);
214 }
215 }
216
217 pub(crate) fn has_same_session_scope(&self, other: &Self) -> bool {
218 let credentials_match = match (&self.credential, &other.credential) {
219 (Some(left), Some(right)) => left.has_same_presentation(right),
220 (None, None) => true,
221 (Some(_), None) | (None, Some(_)) => false,
222 };
223 let traces_match = match (&self.trace_parent, &other.trace_parent) {
224 (Some(left), Some(right)) => left.value == right.value,
225 (None, None) => true,
226 (Some(_), None) | (None, Some(_)) => false,
227 };
228 credentials_match
229 && traces_match
230 && self.database == other.database
231 && self.tenant == other.tenant
232 }
233}
234
235pub struct BootstrapCallMetadata {
237 credential: BootstrapCredential,
238 trace_parent: Option<TraceParent>,
239 database: Option<DatabaseAlias>,
240}
241
242impl BootstrapCallMetadata {
243 #[must_use]
245 pub const fn new(credential: BootstrapCredential) -> Self {
246 Self {
247 credential,
248 trace_parent: None,
249 database: None,
250 }
251 }
252
253 #[must_use]
255 pub fn with_trace_parent(mut self, trace_parent: TraceParent) -> Self {
256 self.trace_parent = Some(trace_parent);
257 self
258 }
259
260 #[must_use]
262 pub fn with_database(mut self, database: DatabaseAlias) -> Self {
263 self.database = Some(database);
264 self
265 }
266
267 pub(crate) fn apply<T>(&self, request: &mut Request<T>) {
268 request
269 .metadata_mut()
270 .insert_bin(BOOTSTRAP_TOKEN_METADATA_KEY, self.credential.value.clone());
271 if let Some(trace_parent) = &self.trace_parent {
272 request
273 .metadata_mut()
274 .insert("traceparent", trace_parent.value.clone());
275 }
276 if let Some(database) = &self.database {
277 let value = MetadataValue::from_str(database.as_str())
278 .expect("DatabaseAlias is valid ASCII metadata");
279 request.metadata_mut().insert(DATABASE_METADATA_KEY, value);
280 }
281 }
282}
283
284impl fmt::Debug for BootstrapCallMetadata {
285 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
286 formatter.write_str("BootstrapCallMetadata([REDACTED])")
287 }
288}
289
290fn valid_token_presentation(token: &[u8]) -> bool {
291 token.len() == CAPABILITY_TOKEN_BYTES
292 && token
293 .iter()
294 .copied()
295 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 const TOKEN: &str = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
303
304 #[test]
305 fn credential_has_bounded_presentation_and_is_always_redacted() {
306 let credential = BearerCredential::new(TOKEN).expect("bounded token text");
307 assert_eq!(format!("{credential:?}"), "BearerCredential([REDACTED])");
308 assert!(!format!("{credential:?}").contains(TOKEN));
309 assert!(matches!(
310 BearerCredential::new("not-a-token"),
311 Err(MetadataError::InvalidCapabilityToken)
312 ));
313 }
314
315 #[test]
316 fn call_metadata_uses_only_reviewed_headers() {
317 let metadata = CallMetadata::authenticated(BearerCredential::new(TOKEN).expect("token"))
318 .with_trace_parent(TraceParent::new("00-abc-def-01").expect("ASCII metadata"))
319 .with_database(DatabaseAlias::new("ea_local").expect("database"));
320 let mut request = Request::new(());
321 metadata.apply(&mut request);
322 assert_eq!(
323 request
324 .metadata()
325 .get("authorization")
326 .expect("authorization")
327 .to_str()
328 .expect("ASCII"),
329 format!("Bearer {TOKEN}")
330 );
331 assert_eq!(
332 request.metadata().get("traceparent").expect("traceparent"),
333 "00-abc-def-01"
334 );
335 assert_eq!(
336 request
337 .metadata()
338 .get(DATABASE_METADATA_KEY)
339 .expect("database"),
340 "ea_local"
341 );
342 assert_eq!(request.metadata().len(), 3);
343 }
344
345 #[test]
347 fn call_metadata_resolves_bound_tenant_and_validates_bounds() {
348 let tenant = TenantId::new("tenant-acme").expect("bounded tenant");
349 let metadata = CallMetadata::authenticated(BearerCredential::new(TOKEN).expect("token"))
350 .with_tenant(tenant.clone());
351 assert_eq!(metadata.tenant().map(TenantId::as_str), Some("tenant-acme"));
352 let other = CallMetadata::authenticated(BearerCredential::new(TOKEN).expect("token"))
353 .with_tenant(TenantId::new("tenant-other").expect("other tenant"));
354 assert!(!metadata.has_same_session_scope(&other));
355 assert!(
356 metadata.has_same_session_scope(
357 &CallMetadata::authenticated(BearerCredential::new(TOKEN).expect("token"))
358 .with_tenant(tenant)
359 )
360 );
361 }
362
363 #[test]
364 fn bootstrap_metadata_is_binary_and_disjoint_from_authorization() {
365 let metadata = BootstrapCallMetadata::new(
366 BootstrapCredential::new(TOKEN).expect("bounded token text"),
367 )
368 .with_database(DatabaseAlias::new("bootstrap_db").expect("database"));
369 let mut request = Request::new(());
370 metadata.apply(&mut request);
371
372 assert!(request.metadata().get("authorization").is_none());
373 assert_eq!(
374 request
375 .metadata()
376 .get_bin(BOOTSTRAP_TOKEN_METADATA_KEY)
377 .expect("bootstrap credential")
378 .to_bytes()
379 .expect("valid binary metadata")
380 .as_ref(),
381 TOKEN.as_bytes()
382 );
383 assert_eq!(
384 request
385 .metadata()
386 .get(DATABASE_METADATA_KEY)
387 .expect("database"),
388 "bootstrap_db"
389 );
390 assert_eq!(request.metadata().len(), 2);
391 assert!(!format!("{metadata:?}").contains(TOKEN));
392 }
393}