1use tonic::metadata::{MetadataMap, MetadataValue};
9use tonic::Request;
10
11pub 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#[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 pub bearer_token: String,
46}
47
48impl Metadata {
49 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 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 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 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(); 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}