1use std::collections::HashMap;
16use std::sync::Arc;
17
18use serde::{Deserialize, Serialize};
19
20use crate::context::Context;
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct ConsumerConfig {
35 pub name: String,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub group: Option<String>,
40 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
43 pub labels: HashMap<String, String>,
44 #[serde(default)]
47 pub credentials: HashMap<String, serde_json::Value>,
48}
49
50#[derive(Debug)]
52pub struct Consumer {
53 pub name: String,
54 pub group: Option<String>,
55 pub labels: HashMap<String, String>,
56 pub credentials: HashMap<String, serde_json::Value>,
57}
58
59#[derive(Default)]
68pub struct ConsumerStore {
69 by_name: HashMap<String, Arc<Consumer>>,
70 by_credential: HashMap<String, HashMap<String, Arc<Consumer>>>,
72}
73
74fn primary_credential_field(auth_type: &str) -> &'static str {
76 match auth_type {
77 "basic-auth" => "username",
78 "hmac-auth" => "access_key",
79 _ => "key",
80 }
81}
82
83impl ConsumerStore {
84 pub fn from_config(consumers: &[ConsumerConfig]) -> Result<Self, String> {
87 let mut store = Self::default();
88
89 let resolve = |s: &str| -> String {
94 if s.contains("${") {
95 crate::config::interpolate_env(s)
96 } else {
97 s.to_string()
98 }
99 };
100 for config in consumers {
101 if config.name.trim().is_empty() {
102 return Err("consumer with empty name".to_string());
103 }
104 let credentials = config
105 .credentials
106 .iter()
107 .map(|(auth_type, credential)| {
108 let mut credential = credential.clone();
109 crate::config::interpolate_env_json(&mut credential);
110 (auth_type.clone(), credential)
111 })
112 .collect();
113 let consumer = Arc::new(Consumer {
114 name: resolve(&config.name),
115 group: config.group.as_deref().map(resolve),
116 labels: config
117 .labels
118 .iter()
119 .map(|(k, v)| (k.clone(), resolve(v)))
120 .collect(),
121 credentials,
122 });
123
124 if store
125 .by_name
126 .insert(consumer.name.clone(), consumer.clone())
127 .is_some()
128 {
129 return Err(format!("duplicate consumer name '{}'", consumer.name));
130 }
131
132 for (auth_type, credential) in &consumer.credentials {
133 let field = primary_credential_field(auth_type);
134 let value = credential
135 .get(field)
136 .and_then(|v| v.as_str())
137 .ok_or_else(|| {
138 format!(
139 "consumer '{}': credential '{}' is missing string field '{}'",
140 consumer.name, auth_type, field
141 )
142 })?;
143
144 let index = store.by_credential.entry(auth_type.clone()).or_default();
145 if let Some(existing) = index.insert(value.to_string(), consumer.clone()) {
146 return Err(format!(
147 "consumers '{}' and '{}' share the same {} credential",
148 existing.name, consumer.name, auth_type
149 ));
150 }
151 }
152 }
153
154 Ok(store)
155 }
156
157 pub fn find_by_credential(&self, auth_type: &str, value: &str) -> Option<Arc<Consumer>> {
159 self.by_credential.get(auth_type)?.get(value).cloned()
160 }
161
162 pub fn get(&self, name: &str) -> Option<Arc<Consumer>> {
164 self.by_name.get(name).cloned()
165 }
166
167 #[allow(dead_code)] pub fn len(&self) -> usize {
170 self.by_name.len()
171 }
172
173 #[allow(dead_code)]
175 pub fn is_empty(&self) -> bool {
176 self.by_name.is_empty()
177 }
178}
179
180pub fn attach_consumer(ctx: &mut Context, consumer: &Consumer, auth_type: &str) {
188 ctx.message.insert(
189 "consumer.name".to_string(),
190 serde_json::json!(consumer.name),
191 );
192 if let Some(ref group) = consumer.group {
193 ctx.message
194 .insert("consumer.group".to_string(), serde_json::json!(group));
195 }
196 if !consumer.labels.is_empty() {
197 ctx.message.insert(
198 "consumer.labels".to_string(),
199 serde_json::json!(consumer.labels),
200 );
201 }
202 ctx.message.insert(
203 "consumer.auth_type".to_string(),
204 serde_json::json!(auth_type),
205 );
206
207 ctx.request.headers.insert(
208 "x-consumer-username".to_string(),
209 vec![consumer.name.clone()],
210 );
211 if let Some(custom_id) = consumer.labels.get("custom_id") {
212 ctx.message.insert(
213 "consumer.custom_id".to_string(),
214 serde_json::json!(custom_id),
215 );
216 ctx.request
217 .headers
218 .insert("x-consumer-custom-id".to_string(), vec![custom_id.clone()]);
219 }
220}
221
222pub fn mask_credentials(c: &ConsumerConfig) -> ConsumerConfig {
228 fn mask(v: &serde_json::Value, key: Option<&str>) -> serde_json::Value {
229 match v {
230 serde_json::Value::Object(map) => serde_json::Value::Object(
231 map.iter()
232 .map(|(k, v)| (k.clone(), mask(v, Some(k))))
233 .collect(),
234 ),
235 serde_json::Value::Array(items) => {
236 serde_json::Value::Array(items.iter().map(|v| mask(v, key)).collect())
237 }
238 serde_json::Value::Null => serde_json::Value::Null,
239 _ if matches!(key, Some("username") | Some("access_key")) => v.clone(),
240 _ => serde_json::Value::String("<masked>".into()),
241 }
242 }
243 let mut out = c.clone();
244 out.credentials = c
245 .credentials
246 .iter()
247 .map(|(plugin, v)| (plugin.clone(), mask(v, None)))
248 .collect();
249 out
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255
256 fn config(json: serde_json::Value) -> Vec<ConsumerConfig> {
257 serde_json::from_value(json).expect("valid consumer config")
258 }
259
260 #[test]
261 fn mask_credentials_keeps_identifiers_only() {
262 let c: ConsumerConfig = serde_yaml::from_str(
263 "name: alice\ncredentials:\n key-auth:\n key: s3cret\n basic-auth:\n username: alice\n password: pw\n hmac-auth:\n access_key: ak\n secret_key: sk\n nested: {inner: x}\n tags: [tag1, tag2]\n",
264 )
265 .unwrap();
266 let m = mask_credentials(&c);
267 assert_eq!(m.name, "alice");
268 assert_eq!(m.credentials["key-auth"]["key"], "<masked>");
269 assert_eq!(m.credentials["basic-auth"]["username"], "alice");
270 assert_eq!(m.credentials["basic-auth"]["password"], "<masked>");
271 assert_eq!(m.credentials["hmac-auth"]["access_key"], "ak");
272 assert_eq!(m.credentials["hmac-auth"]["secret_key"], "<masked>");
273 assert_eq!(m.credentials["hmac-auth"]["nested"]["inner"], "<masked>");
274 assert_eq!(m.credentials["hmac-auth"]["tags"][0], "<masked>");
275 assert_eq!(m.credentials["hmac-auth"]["tags"][1], "<masked>");
276 assert_eq!(c.credentials["key-auth"]["key"], "s3cret");
278 }
279
280 #[test]
281 fn test_consumer_credentials_resolve_env_placeholders() {
282 std::env::set_var("TEST_CONSUMER_KEY", "k-resolved-123");
286 let store = ConsumerStore::from_config(&config(serde_json::json!([
287 {
288 "name": "alice",
289 "labels": { "tier": "${TEST_CONSUMER_TIER:-gold}" },
290 "credentials": { "key-auth": { "key": "${TEST_CONSUMER_KEY}" } }
291 }
292 ])))
293 .unwrap();
294
295 let alice = store
296 .find_by_credential("key-auth", "k-resolved-123")
297 .expect("credential indexed under the resolved value");
298 assert_eq!(alice.name, "alice");
299 assert_eq!(
300 alice.credentials["key-auth"]["key"],
301 serde_json::json!("k-resolved-123")
302 );
303 assert_eq!(alice.labels["tier"], "gold");
304 std::env::remove_var("TEST_CONSUMER_KEY");
305 }
306
307 #[test]
308 fn test_store_lookup_and_attach() {
309 let store = ConsumerStore::from_config(&config(serde_json::json!([
310 {
311 "name": "alice",
312 "group": "partners",
313 "labels": { "custom_id": "42", "tier": "gold" },
314 "credentials": {
315 "key-auth": { "key": "alice-key" },
316 "basic-auth": { "username": "alice", "password": "pw" }
317 }
318 },
319 { "name": "bob", "credentials": { "key-auth": { "key": "bob-key" } } }
320 ])))
321 .unwrap();
322
323 assert_eq!(store.len(), 2);
324 let alice = store.find_by_credential("key-auth", "alice-key").unwrap();
325 assert_eq!(alice.name, "alice");
326 assert!(store.find_by_credential("key-auth", "nope").is_none());
327 assert!(store.find_by_credential("jwt-auth", "alice-key").is_none());
328 assert_eq!(
329 store
330 .find_by_credential("basic-auth", "alice")
331 .unwrap()
332 .name,
333 "alice"
334 );
335 assert_eq!(store.get("bob").unwrap().name, "bob");
336
337 let mut ctx = crate::context::Context::new(crate::context::GatewayRequest {
339 method: "GET".into(),
340 path: "/".into(),
341 host: "h".into(),
342 scheme: "http".into(),
343 headers: HashMap::new(),
344 query_params: HashMap::new(),
345 body: bytes::Bytes::new(),
346 remote_addr: "1.2.3.4:5".into(),
347 protocol: crate::context::Protocol::Http1,
348 });
349 attach_consumer(&mut ctx, &alice, "key-auth");
350 assert_eq!(
351 ctx.message.get("consumer.name"),
352 Some(&serde_json::json!("alice"))
353 );
354 assert_eq!(
355 ctx.message.get("consumer.group"),
356 Some(&serde_json::json!("partners"))
357 );
358 assert_eq!(
359 ctx.message.get("consumer.custom_id"),
360 Some(&serde_json::json!("42"))
361 );
362 assert_eq!(
363 ctx.message.get("consumer.auth_type"),
364 Some(&serde_json::json!("key-auth"))
365 );
366 assert_eq!(
367 ctx.request.headers.get("x-consumer-username"),
368 Some(&vec!["alice".to_string()])
369 );
370 assert_eq!(
371 ctx.request.headers.get("x-consumer-custom-id"),
372 Some(&vec!["42".to_string()])
373 );
374 }
375
376 #[test]
377 fn test_store_rejects_duplicates_and_malformed() {
378 assert!(ConsumerStore::from_config(&config(serde_json::json!([
380 { "name": "a" }, { "name": "a" }
381 ])))
382 .is_err());
383
384 assert!(ConsumerStore::from_config(&config(serde_json::json!([
386 { "name": "a", "credentials": { "key-auth": { "key": "same" } } },
387 { "name": "b", "credentials": { "key-auth": { "key": "same" } } }
388 ])))
389 .is_err());
390
391 assert!(ConsumerStore::from_config(&config(serde_json::json!([
393 { "name": "a", "credentials": { "key-auth": { "wrong": "x" } } }
394 ])))
395 .is_err());
396 }
397}