Skip to main content

udb_client/
metadata.rs

1//! The request metadata every UDB call carries.
2//!
3//! The broker derives tenant isolation, project scoping, and the audit trail from
4//! these headers — they are not optional decoration. A call without
5//! `x-tenant-id` is not a call with a default tenant; it is a call the broker
6//! will refuse or scope to nothing.
7
8use tonic::metadata::{MetadataMap, MetadataValue};
9use tonic::Request;
10
11/// Header names, kept in one place so a typo cannot silently drop a tenant scope.
12pub mod headers {
13    pub const TENANT_ID: &str = "x-tenant-id";
14    pub const USER_ID: &str = "x-user-id";
15    pub const PROJECT_ID: &str = "x-udb-project-id";
16    pub const PURPOSE: &str = "x-purpose";
17    pub const CORRELATION_ID: &str = "x-correlation-id";
18    pub const SERVICE_IDENTITY: &str = "x-service-identity";
19    pub const CLIENT_CATALOG_VERSION: &str = "x-udb-client-catalog-version";
20    pub const SCOPES: &str = "x-scopes";
21    pub const AUTHORIZATION: &str = "authorization";
22}
23
24/// Identity and audit context for a connection.
25///
26/// Split deliberately into two halves, mirroring the other UDB SDKs:
27///
28/// - **Identity** — tenant, user, project, scopes, service identity — is
29///   authoritative for the connection and is never overridden per request.
30///   Letting a caller pass a different tenant per call would make the isolation
31///   boundary a client-side suggestion.
32/// - **Audit** — purpose, correlation id, catalog version — is request-scoped and
33///   may be varied per call via [`Metadata::with_audit`].
34#[derive(Clone, Debug, Default, PartialEq, Eq)]
35pub struct Metadata {
36    pub tenant_id: String,
37    pub user_id: String,
38    pub project_id: String,
39    pub purpose: String,
40    pub correlation_id: String,
41    pub service_identity: String,
42    pub client_catalog_version: String,
43    pub scopes: Vec<String>,
44    /// Bearer credential. Sent as `authorization: Bearer <token>` when non-empty.
45    pub bearer_token: String,
46}
47
48impl Metadata {
49    /// Metadata for `tenant_id`, which is the minimum the broker requires.
50    pub fn new(tenant_id: impl Into<String>) -> Self {
51        Self {
52            tenant_id: tenant_id.into(),
53            ..Default::default()
54        }
55    }
56
57    pub fn with_project(mut self, project_id: impl Into<String>) -> Self {
58        self.project_id = project_id.into();
59        self
60    }
61
62    pub fn with_user(mut self, user_id: impl Into<String>) -> Self {
63        self.user_id = user_id.into();
64        self
65    }
66
67    pub fn with_bearer_token(mut self, token: impl Into<String>) -> Self {
68        self.bearer_token = token.into();
69        self
70    }
71
72    pub fn with_scopes<I, S>(mut self, scopes: I) -> Self
73    where
74        I: IntoIterator<Item = S>,
75        S: Into<String>,
76    {
77        self.scopes = scopes.into_iter().map(Into::into).collect();
78        self
79    }
80
81    /// Request-scoped audit fields, leaving identity untouched.
82    pub fn with_audit(
83        mut self,
84        purpose: impl Into<String>,
85        correlation_id: impl Into<String>,
86    ) -> Self {
87        self.purpose = purpose.into();
88        self.correlation_id = correlation_id.into();
89        self
90    }
91
92    /// Apply every non-empty field to an outgoing request.
93    ///
94    /// Empty values are omitted rather than sent blank: the broker treats a
95    /// present-but-empty header differently from an absent one on some paths, and
96    /// sending `x-scopes: ""` is not the same as claiming no scopes.
97    pub fn apply<T>(&self, request: &mut Request<T>) -> Result<(), tonic::Status> {
98        self.apply_to_map(request.metadata_mut())
99    }
100
101    pub fn apply_to_map(&self, md: &mut MetadataMap) -> Result<(), tonic::Status> {
102        let joined_scopes = self.scopes.join(" ");
103        let pairs = [
104            (headers::TENANT_ID, self.tenant_id.as_str()),
105            (headers::USER_ID, self.user_id.as_str()),
106            (headers::PROJECT_ID, self.project_id.as_str()),
107            (headers::PURPOSE, self.purpose.as_str()),
108            (headers::CORRELATION_ID, self.correlation_id.as_str()),
109            (headers::SERVICE_IDENTITY, self.service_identity.as_str()),
110            (
111                headers::CLIENT_CATALOG_VERSION,
112                self.client_catalog_version.as_str(),
113            ),
114            (headers::SCOPES, joined_scopes.as_str()),
115        ];
116        for (name, value) in pairs {
117            if value.is_empty() {
118                continue;
119            }
120            insert(md, name, value)?;
121        }
122        if !self.bearer_token.is_empty() {
123            insert(
124                md,
125                headers::AUTHORIZATION,
126                &format!("Bearer {}", self.bearer_token),
127            )?;
128        }
129        Ok(())
130    }
131}
132
133fn insert(md: &mut MetadataMap, name: &'static str, value: &str) -> Result<(), tonic::Status> {
134    let parsed: MetadataValue<_> = value.parse().map_err(|_| {
135        // Naming the header without echoing the value: these carry tenant and
136        // credential material and this error may well be logged.
137        tonic::Status::invalid_argument(format!(
138            "metadata header `{name}` is not a valid ASCII header value"
139        ))
140    })?;
141    md.insert(name, parsed);
142    Ok(())
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    fn map_of(meta: &Metadata) -> MetadataMap {
150        let mut md = MetadataMap::new();
151        meta.apply_to_map(&mut md).expect("valid metadata");
152        md
153    }
154
155    #[test]
156    fn empty_fields_are_omitted_not_sent_blank() {
157        let md = map_of(&Metadata::new("tenant-1"));
158        assert_eq!(md.get(headers::TENANT_ID).unwrap(), "tenant-1");
159        assert!(
160            md.get(headers::USER_ID).is_none(),
161            "empty user must be absent"
162        );
163        assert!(
164            md.get(headers::SCOPES).is_none(),
165            "no scopes must be absent"
166        );
167        assert!(
168            md.get(headers::AUTHORIZATION).is_none(),
169            "absent token must not send an empty Bearer"
170        );
171    }
172
173    #[test]
174    fn bearer_token_is_prefixed() {
175        let md = map_of(&Metadata::new("t").with_bearer_token("abc.def"));
176        assert_eq!(md.get(headers::AUTHORIZATION).unwrap(), "Bearer abc.def");
177    }
178
179    #[test]
180    fn scopes_are_space_joined() {
181        let md = map_of(&Metadata::new("t").with_scopes(["read", "write"]));
182        assert_eq!(md.get(headers::SCOPES).unwrap(), "read write");
183    }
184
185    #[test]
186    fn audit_fields_do_not_disturb_identity() {
187        let base = Metadata::new("tenant-1")
188            .with_project("proj-9")
189            .with_user("user-1");
190        let scoped = base.clone().with_audit("billing", "corr-123");
191        assert_eq!(scoped.tenant_id, base.tenant_id);
192        assert_eq!(scoped.project_id, base.project_id);
193        assert_eq!(scoped.user_id, base.user_id);
194        assert_eq!(scoped.purpose, "billing");
195        assert_eq!(scoped.correlation_id, "corr-123");
196    }
197
198    #[test]
199    fn invalid_header_value_names_the_header_but_not_the_value() {
200        let mut meta = Metadata::new("tenant-1");
201        meta.bearer_token = "sekret\u{7f}".to_string(); // DEL is not a legal header byte
202        let mut md = MetadataMap::new();
203        let err = meta.apply_to_map(&mut md).expect_err("must reject");
204        assert!(err.message().contains(headers::AUTHORIZATION));
205        assert!(
206            !err.message().contains("sekret"),
207            "credential material must not reach the error text"
208        );
209    }
210}