Skip to main content

riffdb_client_rust/
metadata.rs

1//! Narrow outbound metadata owned by the public client.
2
3use 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/// A safe local metadata-construction failure.
19#[derive(Clone, Copy, Debug, Eq, PartialEq)]
20pub enum MetadataError {
21    /// The capability token is not valid bounded v1 presentation text.
22    InvalidCapabilityToken,
23    /// Trace propagation metadata is empty, oversized, or not valid ASCII metadata.
24    InvalidTraceParent,
25    /// The tenant identifier is empty or exceeds the bounded tenant-scope limit.
26    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/// One bounded POC capability bearer credential.
42///
43/// Formatting is always redacted. This transport wrapper validates only the
44/// exact length and alphabet needed for safe metadata presentation. The
45/// auth-owned server decoder remains the sole authority for canonical token
46/// decoding and authentication.
47#[derive(Clone)]
48pub struct BearerCredential {
49    authorization: MetadataValue<Ascii>,
50}
51
52impl BearerCredential {
53    /// Constructs a bearer credential from bounded v1 presentation text.
54    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    /// Reports whether two checked credentials have the same presentation.
67    ///
68    /// This is a non-exposing equality check for delivery verification, not a
69    /// constant-time authentication primitive.
70    #[must_use]
71    pub fn has_same_presentation(&self, other: &Self) -> bool {
72        self.authorization == other.authorization
73    }
74}
75
76/// One bounded credential for the loopback-only bootstrap call.
77///
78/// This type cannot be placed in ordinary [`CallMetadata`]. Canonical token
79/// decoding remains owned by the server's auth boundary.
80pub struct BootstrapCredential {
81    value: MetadataValue<Binary>,
82}
83
84impl BootstrapCredential {
85    /// Constructs the dedicated binary metadata value from bounded token text.
86    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/// One bounded outbound `traceparent` metadata value.
109#[derive(Clone)]
110pub struct TraceParent {
111    value: MetadataValue<Ascii>,
112}
113
114impl TraceParent {
115    /// Validates one trace propagation value as bounded ASCII gRPC metadata.
116    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/// Checked metadata applied consistently to one SDK call.
133#[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    /// Constructs metadata for an authenticated call.
143    #[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    /// Adds bounded trace propagation to this call.
157    #[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    /// Binds every request using this metadata to one canonical database.
164    #[must_use]
165    pub fn with_database(mut self, database: DatabaseAlias) -> Self {
166        self.database = Some(database);
167        self
168    }
169
170    /// Binds the local client metadata to one explicit tenant identifier so
171    /// application code can pass it into tenant-keyed command and query inputs
172    /// without duplicating the string literal.
173    #[must_use]
174    pub fn with_tenant(mut self, tenant: TenantId) -> Self {
175        self.tenant = Some(tenant);
176        self
177    }
178
179    /// Resolves `RIFFDB_TENANT` from the process environment when present and
180    /// non-empty, failing closed if the value violates [`TenantId`] bounds.
181    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    /// Returns the bound tenant identifier when this session is tenant-scoped.
194    #[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
235/// Checked metadata available only to loopback bootstrap capability creation.
236pub struct BootstrapCallMetadata {
237    credential: BootstrapCredential,
238    trace_parent: Option<TraceParent>,
239    database: Option<DatabaseAlias>,
240}
241
242impl BootstrapCallMetadata {
243    /// Constructs metadata carrying exactly one bootstrap credential.
244    #[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    /// Adds bounded trace propagation to the bootstrap call.
254    #[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    /// Binds the loopback bootstrap request to one canonical database.
261    #[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    // req: DRV-005, DRV-007, DRV-008, DX-004, DX-046
346    #[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}