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        for config in consumers {
90            if config.name.trim().is_empty() {
91                return Err("consumer with empty name".to_string());
92            }
93            let consumer = Arc::new(Consumer {
94                name: config.name.clone(),
95                group: config.group.clone(),
96                labels: config.labels.clone(),
97                credentials: config.credentials.clone(),
98            });
99
100            if store
101                .by_name
102                .insert(consumer.name.clone(), consumer.clone())
103                .is_some()
104            {
105                return Err(format!("duplicate consumer name '{}'", consumer.name));
106            }
107
108            for (auth_type, credential) in &config.credentials {
109                let field = primary_credential_field(auth_type);
110                let value = credential
111                    .get(field)
112                    .and_then(|v| v.as_str())
113                    .ok_or_else(|| {
114                        format!(
115                            "consumer '{}': credential '{}' is missing string field '{}'",
116                            consumer.name, auth_type, field
117                        )
118                    })?;
119
120                let index = store.by_credential.entry(auth_type.clone()).or_default();
121                if let Some(existing) = index.insert(value.to_string(), consumer.clone()) {
122                    return Err(format!(
123                        "consumers '{}' and '{}' share the same {} credential",
124                        existing.name, consumer.name, auth_type
125                    ));
126                }
127            }
128        }
129
130        Ok(store)
131    }
132
133    /// Resolves a presented credential value for an auth type.
134    pub fn find_by_credential(&self, auth_type: &str, value: &str) -> Option<Arc<Consumer>> {
135        self.by_credential.get(auth_type)?.get(value).cloned()
136    }
137
138    /// Looks a consumer up by name (used for `anonymous_consumer` fallback).
139    pub fn get(&self, name: &str) -> Option<Arc<Consumer>> {
140        self.by_name.get(name).cloned()
141    }
142
143    /// Number of declared consumers.
144    #[allow(dead_code)] // store accessors; kept for completeness
145    pub fn len(&self) -> usize {
146        self.by_name.len()
147    }
148
149    /// True when no consumers are declared.
150    #[allow(dead_code)]
151    pub fn is_empty(&self) -> bool {
152        self.by_name.is_empty()
153    }
154}
155
156/// Attaches a matched consumer's identity to the request.
157///
158/// Writes the `context.message` keys downstream nodes read —
159/// `consumer.name`, `consumer.group` (when set), `consumer.labels`,
160/// `consumer.custom_id` (from `labels.custom_id`), `consumer.auth_type` —
161/// and sets the upstream request headers `X-Consumer-Username` and
162/// `X-Consumer-Custom-ID` (APISIX's upstream contract).
163pub fn attach_consumer(ctx: &mut Context, consumer: &Consumer, auth_type: &str) {
164    ctx.message.insert(
165        "consumer.name".to_string(),
166        serde_json::json!(consumer.name),
167    );
168    if let Some(ref group) = consumer.group {
169        ctx.message
170            .insert("consumer.group".to_string(), serde_json::json!(group));
171    }
172    if !consumer.labels.is_empty() {
173        ctx.message.insert(
174            "consumer.labels".to_string(),
175            serde_json::json!(consumer.labels),
176        );
177    }
178    ctx.message.insert(
179        "consumer.auth_type".to_string(),
180        serde_json::json!(auth_type),
181    );
182
183    ctx.request.headers.insert(
184        "x-consumer-username".to_string(),
185        vec![consumer.name.clone()],
186    );
187    if let Some(custom_id) = consumer.labels.get("custom_id") {
188        ctx.message.insert(
189            "consumer.custom_id".to_string(),
190            serde_json::json!(custom_id),
191        );
192        ctx.request
193            .headers
194            .insert("x-consumer-custom-id".to_string(), vec![custom_id.clone()]);
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    fn config(json: serde_json::Value) -> Vec<ConsumerConfig> {
203        serde_json::from_value(json).expect("valid consumer config")
204    }
205
206    #[test]
207    fn test_store_lookup_and_attach() {
208        let store = ConsumerStore::from_config(&config(serde_json::json!([
209            {
210                "name": "alice",
211                "group": "partners",
212                "labels": { "custom_id": "42", "tier": "gold" },
213                "credentials": {
214                    "key-auth": { "key": "alice-key" },
215                    "basic-auth": { "username": "alice", "password": "pw" }
216                }
217            },
218            { "name": "bob", "credentials": { "key-auth": { "key": "bob-key" } } }
219        ])))
220        .unwrap();
221
222        assert_eq!(store.len(), 2);
223        let alice = store.find_by_credential("key-auth", "alice-key").unwrap();
224        assert_eq!(alice.name, "alice");
225        assert!(store.find_by_credential("key-auth", "nope").is_none());
226        assert!(store.find_by_credential("jwt-auth", "alice-key").is_none());
227        assert_eq!(
228            store
229                .find_by_credential("basic-auth", "alice")
230                .unwrap()
231                .name,
232            "alice"
233        );
234        assert_eq!(store.get("bob").unwrap().name, "bob");
235
236        // attach writes message keys + headers
237        let mut ctx = crate::context::Context::new(crate::context::GatewayRequest {
238            method: "GET".into(),
239            path: "/".into(),
240            host: "h".into(),
241            scheme: "http".into(),
242            headers: HashMap::new(),
243            query_params: HashMap::new(),
244            body: bytes::Bytes::new(),
245            remote_addr: "1.2.3.4:5".into(),
246            protocol: crate::context::Protocol::Http1,
247        });
248        attach_consumer(&mut ctx, &alice, "key-auth");
249        assert_eq!(
250            ctx.message.get("consumer.name"),
251            Some(&serde_json::json!("alice"))
252        );
253        assert_eq!(
254            ctx.message.get("consumer.group"),
255            Some(&serde_json::json!("partners"))
256        );
257        assert_eq!(
258            ctx.message.get("consumer.custom_id"),
259            Some(&serde_json::json!("42"))
260        );
261        assert_eq!(
262            ctx.message.get("consumer.auth_type"),
263            Some(&serde_json::json!("key-auth"))
264        );
265        assert_eq!(
266            ctx.request.headers.get("x-consumer-username"),
267            Some(&vec!["alice".to_string()])
268        );
269        assert_eq!(
270            ctx.request.headers.get("x-consumer-custom-id"),
271            Some(&vec!["42".to_string()])
272        );
273    }
274
275    #[test]
276    fn test_store_rejects_duplicates_and_malformed() {
277        // duplicate name
278        assert!(ConsumerStore::from_config(&config(serde_json::json!([
279            { "name": "a" }, { "name": "a" }
280        ])))
281        .is_err());
282
283        // shared credential value
284        assert!(ConsumerStore::from_config(&config(serde_json::json!([
285            { "name": "a", "credentials": { "key-auth": { "key": "same" } } },
286            { "name": "b", "credentials": { "key-auth": { "key": "same" } } }
287        ])))
288        .is_err());
289
290        // credential missing its primary field
291        assert!(ConsumerStore::from_config(&config(serde_json::json!([
292            { "name": "a", "credentials": { "key-auth": { "wrong": "x" } } }
293        ])))
294        .is_err());
295    }
296}