Skip to main content

featherbit/consumers/
mod.rs

1//! Consumer identities and credentials.
2//!
3//! The featherbit analogue of APISIX's consumer concept: named API clients
4//! declared in `gateway.yaml` (or via the Admin API), each carrying
5//! per-auth-plugin credentials. Auth plugins configured with
6//! `use_consumers: true` resolve the presented credential against the
7//! [`ConsumerStore`] and, on a match, attach the consumer's identity to the
8//! request via [`attach_consumer`] so downstream nodes (consumer-restriction,
9//! loggers, rate limits keyed on `$consumer_name`) can act on it.
10//!
11//! The store is immutable once built; reloads build a fresh store and swap
12//! it atomically (`ArcSwap` in `PluginResources`), so lookups on the request
13//! path are lock-free.
14
15use std::collections::HashMap;
16use std::sync::Arc;
17
18use serde::{Deserialize, Serialize};
19
20use crate::context::Context;
21
22/// A consumer as declared in `gateway.yaml` under `consumers:`.
23///
24/// ```yaml
25/// consumers:
26///   - name: alice
27///     group: partners
28///     labels: { custom_id: "42", tier: gold }
29///     credentials:
30///       key-auth: { key: "s3cret" }
31///       basic-auth: { username: alice, password: pw }
32/// ```
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct ConsumerConfig {
35    /// Unique consumer name (the identity attached to matching requests).
36    pub name: String,
37    /// Optional consumer group, exposed as `consumer.group` / `$consumer_group_id`.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub group: Option<String>,
40    /// Free-form labels; `custom_id` is special-cased into the
41    /// `X-Consumer-Custom-ID` header (matching APISIX).
42    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
43    pub labels: HashMap<String, String>,
44    /// Per-auth-plugin credentials, keyed by plugin type
45    /// (e.g. `key-auth: {key}`, `basic-auth: {username, password}`).
46    #[serde(default)]
47    pub credentials: HashMap<String, serde_json::Value>,
48}
49
50/// A resolved consumer, shared read-only across requests.
51#[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/// Lock-free lookup structure over the declared consumers.
60///
61/// Indexes each auth type's primary credential value → consumer, so an auth
62/// plugin resolves a presented credential in one hash lookup. The primary
63/// credential field per auth type: `key-auth` → `key`, `basic-auth` →
64/// `username`, `jwt-auth` → `key` (falling back to the consumer name). Auth
65/// plugins that need more than the primary field (e.g. basic-auth's password
66/// check) read the matched consumer's `credentials`.
67#[derive(Default)]
68pub struct ConsumerStore {
69    by_name: HashMap<String, Arc<Consumer>>,
70    /// auth type → credential value → consumer
71    by_credential: HashMap<String, HashMap<String, Arc<Consumer>>>,
72}
73
74/// The credential field indexed per auth type.
75fn 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    /// Builds the store, failing fast on duplicate consumer names or on two
85    /// consumers sharing the same credential value for the same auth type.
86    pub fn from_config(consumers: &[ConsumerConfig]) -> Result<Self, String> {
87        let mut store = Self::default();
88
89        // Consumer declarations arrive with `${VAR}` placeholders intact
90        // (gateway config is loaded raw so the Admin API never serves
91        // resolved secrets); the store is where they resolve, so credential
92        // lookups index the actual values.
93        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    /// Resolves a presented credential value for an auth type.
158    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    /// Looks a consumer up by name (used for `anonymous_consumer` fallback).
163    pub fn get(&self, name: &str) -> Option<Arc<Consumer>> {
164        self.by_name.get(name).cloned()
165    }
166
167    /// Number of declared consumers.
168    #[allow(dead_code)] // store accessors; kept for completeness
169    pub fn len(&self) -> usize {
170        self.by_name.len()
171    }
172
173    /// True when no consumers are declared.
174    #[allow(dead_code)]
175    pub fn is_empty(&self) -> bool {
176        self.by_name.is_empty()
177    }
178}
179
180/// Attaches a matched consumer's identity to the request.
181///
182/// Writes the `context.message` keys downstream nodes read —
183/// `consumer.name`, `consumer.group` (when set), `consumer.labels`,
184/// `consumer.custom_id` (from `labels.custom_id`), `consumer.auth_type` —
185/// and sets the upstream request headers `X-Consumer-Username` and
186/// `X-Consumer-Custom-ID` (APISIX's upstream contract).
187pub 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
222/// Returns a copy of `c` with credential secrets replaced by `"<masked>"`.
223///
224/// Every scalar leaf under `credentials` is masked except the identifying
225/// halves of a credential pair (`username`, `access_key`), so a read-only
226/// viewer can still tell *which* credential exists without learning it.
227pub 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        // The original is untouched.
277        assert_eq!(c.credentials["key-auth"]["key"], "s3cret");
278    }
279
280    #[test]
281    fn test_consumer_credentials_resolve_env_placeholders() {
282        // gateway.yaml is loaded with `${VAR}` placeholders intact (so the
283        // Admin API never serves resolved secrets); the store must resolve
284        // them when it builds, or env-provided credentials stop matching.
285        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        // attach writes message keys + headers
338        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        // duplicate name
379        assert!(ConsumerStore::from_config(&config(serde_json::json!([
380            { "name": "a" }, { "name": "a" }
381        ])))
382        .is_err());
383
384        // shared credential value
385        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        // credential missing its primary field
392        assert!(ConsumerStore::from_config(&config(serde_json::json!([
393            { "name": "a", "credentials": { "key-auth": { "wrong": "x" } } }
394        ])))
395        .is_err());
396    }
397}