Skip to main content

quiver_server/
auth.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! Role-based access control over scoped API keys (ADR-0011).
3//!
4//! Each API key carries a **role** — the highest [`Action`] it may perform,
5//! which implies the lesser ones (`Admin` ⊇ `Write` ⊇ `Read`) — and a
6//! [`CollectionScope`] restricting which collections it can touch. Access is
7//! **default-deny**: every operation is checked against the caller's
8//! [`Principal`] at the engine-facing op layer (`AppState`), so neither
9//! transport can forget to enforce it and a key can only ever reach the
10//! collections in its scope.
11//!
12//! Keys are provisioned through configuration. A bare secret string (the
13//! `QUIVER_API_KEYS` env form, or a plain TOML array entry) is an
14//! all-collections **admin** key, preserving the pre-RBAC behaviour; a
15//! structured entry pins a narrower role and collection scope:
16//!
17//! ```toml
18//! # quiver.toml — an admin key plus a least-privilege, namespace-scoped key
19//! api_keys = ["full-admin-secret"]
20//!
21//! [[api_keys]]
22//! secret = "readonly-acme-secret"
23//! role = "read"
24//! collections = ["acme.*"]   # exact names, or a trailing-`*` prefix; "*" = all
25//! ```
26//!
27//! A trailing-`*` pattern matches by prefix, which gives namespacing: a key
28//! scoped to `acme.*` reaches `acme.orders` but not `beta.orders`. (Avoid `/`
29//! in collection names — the REST API addresses a collection as one path
30//! segment.)
31
32use serde::de::{SeqAccess, Visitor};
33use serde::{Deserialize, Deserializer, Serialize, Serializer};
34use std::fmt;
35use std::sync::Arc;
36
37use crate::Error;
38
39/// An action a caller may be permitted to perform, ordered by privilege so that
40/// a higher role implies the lower ones (`Read < Write < Admin`).
41#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
42#[serde(rename_all = "lowercase")]
43pub enum Action {
44    /// Read access: get, search, list, and inspect collections.
45    Read,
46    /// Write access (implies read): upsert and delete points.
47    Write,
48    /// Administrative access (implies write): create and delete collections.
49    Admin,
50}
51
52/// Which collections a key may touch.
53#[derive(Debug, Clone, Default, PartialEq, Eq)]
54pub enum CollectionScope {
55    /// Every collection.
56    #[default]
57    All,
58    /// Only collections matching one of these patterns. A pattern ending in `*`
59    /// matches by prefix (e.g. `acme.*` matches `acme.orders`); otherwise it is
60    /// an exact name.
61    Patterns(Vec<String>),
62}
63
64impl CollectionScope {
65    /// Build a scope from configured patterns: any `*` widens to [`All`].
66    ///
67    /// [`All`]: CollectionScope::All
68    fn from_patterns(patterns: Vec<String>) -> Self {
69        if patterns.is_empty() || patterns.iter().any(|p| p == "*") {
70            CollectionScope::All
71        } else {
72            CollectionScope::Patterns(patterns)
73        }
74    }
75
76    /// Whether `collection` is within this scope.
77    #[must_use]
78    pub fn matches(&self, collection: &str) -> bool {
79        match self {
80            CollectionScope::All => true,
81            CollectionScope::Patterns(patterns) => {
82                patterns.iter().any(|p| pattern_matches(p, collection))
83            }
84        }
85    }
86}
87
88// `acme.*` matches any name starting with `acme.`; `*` matches everything; any
89// other pattern is an exact match.
90fn pattern_matches(pattern: &str, name: &str) -> bool {
91    match pattern.strip_suffix('*') {
92        Some(prefix) => name.starts_with(prefix),
93        None => pattern == name,
94    }
95}
96
97/// A configured API key: a bearer secret, the role it grants, and the
98/// collections it is scoped to.
99#[derive(Debug, Clone, Serialize)]
100pub struct ApiKey {
101    /// The bearer secret presented as `Authorization: Bearer <secret>`.
102    pub secret: String,
103    /// The highest action this key may perform.
104    pub role: Action,
105    /// The collections this key may touch.
106    pub collections: CollectionScope,
107    /// An optional non-secret label identifying this key in the audit log
108    /// (ADR-0011). When unset, the key is identified by a short, preimage-
109    /// resistant SHA-256 fingerprint of its secret (`key:<hex>`), so audit
110    /// records attribute an action to a key without ever revealing the secret.
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub id: Option<String>,
113}
114
115impl ApiKey {
116    /// An all-collections admin key — the meaning of a bare secret string.
117    #[must_use]
118    pub fn admin(secret: impl Into<String>) -> Self {
119        Self {
120            secret: secret.into(),
121            role: Action::Admin,
122            collections: CollectionScope::All,
123            id: None,
124        }
125    }
126
127    /// This key's actor identity for the audit log: its configured [`id`] when
128    /// set, otherwise `key:<fingerprint>` — a short, one-way SHA-256 of the
129    /// secret that never reveals it.
130    ///
131    /// [`id`]: ApiKey::id
132    pub(crate) fn actor_id(&self) -> String {
133        match &self.id {
134            Some(id) => id.clone(),
135            None => format!("key:{}", secret_fingerprint(&self.secret)),
136        }
137    }
138}
139
140// A short, preimage-resistant fingerprint of a key's secret for the audit log:
141// the first 8 bytes of its SHA-256, hex-encoded. Not reversible to the secret.
142fn secret_fingerprint(secret: &str) -> String {
143    use sha2::{Digest, Sha256};
144    let digest = Sha256::digest(secret.as_bytes());
145    digest[..8].iter().map(|b| format!("{b:02x}")).collect()
146}
147
148impl From<&str> for ApiKey {
149    fn from(secret: &str) -> Self {
150        ApiKey::admin(secret)
151    }
152}
153
154impl From<String> for ApiKey {
155    fn from(secret: String) -> Self {
156        ApiKey::admin(secret)
157    }
158}
159
160/// The authenticated caller's effective authority for one request.
161#[derive(Debug, Clone)]
162pub(crate) struct Principal {
163    role: Action,
164    collections: CollectionScope,
165    /// A non-secret identity for the audit log — the key's label or fingerprint,
166    /// or `insecure`. Never the bearer secret.
167    actor: Arc<str>,
168}
169
170impl Principal {
171    /// The implicit principal in `insecure` mode (no keys configured): full
172    /// access, matching the pre-auth behaviour.
173    pub(crate) fn insecure() -> Self {
174        Self {
175            role: Action::Admin,
176            collections: CollectionScope::All,
177            actor: Arc::from("insecure"),
178        }
179    }
180
181    fn from_key(key: &ApiKey) -> Self {
182        Self {
183            role: key.role,
184            collections: key.collections.clone(),
185            actor: Arc::from(key.actor_id()),
186        }
187    }
188
189    /// This caller's non-secret actor identity, recorded in the audit log.
190    pub(crate) fn actor(&self) -> &str {
191        &self.actor
192    }
193
194    /// Authorize `action` on an optional `collection`, returning
195    /// [`Error::Forbidden`] when the role is too low or the collection is out of
196    /// scope. With `collection = None` (collection-agnostic listing) only the
197    /// role is checked; results are then narrowed with [`Principal::can_see`].
198    pub(crate) fn require(&self, action: Action, collection: Option<&str>) -> Result<(), Error> {
199        let role_ok = self.role >= action;
200        let scope_ok = collection.is_none_or(|c| self.collections.matches(c));
201        if role_ok && scope_ok {
202            Ok(())
203        } else {
204            Err(Error::Forbidden(
205                "the API key's scope does not permit this operation".to_owned(),
206            ))
207        }
208    }
209
210    /// Whether this principal may see `collection` (used to filter list results
211    /// so a key never learns the names of collections outside its scope).
212    pub(crate) fn can_see(&self, collection: &str) -> bool {
213        self.collections.matches(collection)
214    }
215}
216
217/// Match a presented bearer secret against the configured keys in constant time,
218/// returning the caller's [`Principal`]. With no keys configured (`insecure`
219/// mode, enforced at startup) any caller is the [`Principal::insecure`] admin.
220/// `None` means authentication failed (a 401).
221pub(crate) fn authenticate(keys: &[ApiKey], presented: Option<&str>) -> Option<Principal> {
222    if keys.is_empty() {
223        return Some(Principal::insecure());
224    }
225    let token = presented?;
226    let mut matched: Option<&ApiKey> = None;
227    // Check every key (no early exit) so timing does not reveal which matched.
228    for key in keys {
229        if constant_time_eq(key.secret.as_bytes(), token.as_bytes()) {
230            matched = Some(key);
231        }
232    }
233    matched.map(Principal::from_key)
234}
235
236// Length-checked constant-time byte comparison for API keys.
237fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
238    if a.len() != b.len() {
239        return false;
240    }
241    let mut diff = 0u8;
242    for (x, y) in a.iter().zip(b) {
243        diff |= x ^ y;
244    }
245    diff == 0
246}
247
248// Deserialize `CollectionScope` from a sequence of string patterns.
249impl<'de> Deserialize<'de> for CollectionScope {
250    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
251    where
252        D: Deserializer<'de>,
253    {
254        let patterns = Vec::<String>::deserialize(deserializer)?;
255        Ok(CollectionScope::from_patterns(patterns))
256    }
257}
258
259impl Serialize for CollectionScope {
260    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
261    where
262        S: Serializer,
263    {
264        match self {
265            CollectionScope::All => ["*"].serialize(serializer),
266            CollectionScope::Patterns(patterns) => patterns.serialize(serializer),
267        }
268    }
269}
270
271// A structured key entry as written in TOML (`role` required, `collections`
272// optional and defaulting to all).
273#[derive(Deserialize)]
274struct KeySpec {
275    secret: String,
276    role: Action,
277    #[serde(default)]
278    collections: CollectionScope,
279    #[serde(default)]
280    id: Option<String>,
281}
282
283// Accept the API key list as a comma-separated string (the `QUIVER_API_KEYS`
284// env form, which figment surfaces as a scalar), or a sequence whose entries are
285// either a bare secret (an admin key) or a structured `{secret, role,
286// collections}` table.
287pub(crate) fn de_api_keys<'de, D>(deserializer: D) -> Result<Vec<ApiKey>, D::Error>
288where
289    D: Deserializer<'de>,
290{
291    struct KeysVisitor;
292
293    impl<'de> Visitor<'de> for KeysVisitor {
294        type Value = Vec<ApiKey>;
295
296        fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
297            f.write_str("a comma-separated string of secrets, or a list of secrets/key tables")
298        }
299
300        fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
301        where
302            E: serde::de::Error,
303        {
304            Ok(value
305                .split(',')
306                .map(str::trim)
307                .filter(|s| !s.is_empty())
308                .map(ApiKey::admin)
309                .collect())
310        }
311
312        fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
313        where
314            A: SeqAccess<'de>,
315        {
316            #[derive(Deserialize)]
317            #[serde(untagged)]
318            enum Entry {
319                Plain(String),
320                Structured(KeySpec),
321            }
322            let mut keys = Vec::new();
323            while let Some(entry) = seq.next_element::<Entry>()? {
324                keys.push(match entry {
325                    Entry::Plain(secret) => ApiKey::admin(secret),
326                    Entry::Structured(spec) => ApiKey {
327                        secret: spec.secret,
328                        role: spec.role,
329                        collections: spec.collections,
330                        id: spec.id,
331                    },
332                });
333            }
334            Ok(keys)
335        }
336    }
337
338    deserializer.deserialize_any(KeysVisitor)
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    #[test]
346    fn action_ordering_implies_lesser_privileges() {
347        assert!(Action::Admin > Action::Write);
348        assert!(Action::Write > Action::Read);
349    }
350
351    #[test]
352    fn collection_scope_matches_exact_prefix_and_all() {
353        let all = CollectionScope::from_patterns(vec!["*".to_owned()]);
354        assert!(matches!(all, CollectionScope::All));
355        assert!(all.matches("anything"));
356
357        let scoped = CollectionScope::from_patterns(vec!["acme.*".to_owned(), "shared".to_owned()]);
358        assert!(scoped.matches("acme.orders"));
359        assert!(scoped.matches("shared"));
360        assert!(!scoped.matches("beta.orders"));
361        assert!(!scoped.matches("acme")); // the `acme.` prefix (with the dot) is required
362        assert!(!scoped.matches("shared2"));
363    }
364
365    #[test]
366    fn require_enforces_role_and_scope() {
367        let reader = Principal {
368            role: Action::Read,
369            collections: CollectionScope::Patterns(vec!["acme.*".to_owned()]),
370            actor: Arc::from("reader"),
371        };
372        assert!(reader.require(Action::Read, Some("acme.orders")).is_ok());
373        // Over-scope on action: a reader cannot write.
374        assert!(reader.require(Action::Write, Some("acme.orders")).is_err());
375        // Over-scope on collection: a reader cannot read another namespace.
376        assert!(reader.require(Action::Read, Some("beta.orders")).is_err());
377        // Collection-agnostic listing checks the role only.
378        assert!(reader.require(Action::Read, None).is_ok());
379        assert!(reader.can_see("acme.orders"));
380        assert!(!reader.can_see("beta.orders"));
381    }
382
383    #[test]
384    fn insecure_principal_is_admin_over_all() {
385        let p = Principal::insecure();
386        assert!(p.require(Action::Admin, Some("anything")).is_ok());
387        assert!(p.can_see("anything"));
388        assert_eq!(p.actor(), "insecure");
389    }
390
391    #[test]
392    fn actor_id_uses_a_label_or_a_fingerprint_but_never_the_secret() {
393        // An explicit label identifies the key verbatim.
394        let labeled = ApiKey {
395            id: Some("ci-admin".to_owned()),
396            ..ApiKey::admin("super-secret")
397        };
398        assert_eq!(labeled.actor_id(), "ci-admin");
399        assert_eq!(Principal::from_key(&labeled).actor(), "ci-admin");
400
401        // Without one, the key is a `key:<fingerprint>` that never leaks the
402        // secret and is deterministic and key-specific.
403        let bare = ApiKey::admin("super-secret");
404        let id = bare.actor_id();
405        assert!(id.starts_with("key:"));
406        assert!(
407            !id.contains("super-secret"),
408            "the fingerprint must not contain the secret"
409        );
410        assert_eq!(id, ApiKey::admin("super-secret").actor_id());
411        assert_ne!(id, ApiKey::admin("other-secret").actor_id());
412    }
413
414    #[test]
415    fn authenticate_matches_secret_and_denies_others() {
416        let keys = vec![
417            ApiKey::admin("admin-secret"),
418            ApiKey {
419                secret: "reader-secret".to_owned(),
420                role: Action::Read,
421                collections: CollectionScope::Patterns(vec!["acme.*".to_owned()]),
422                id: None,
423            },
424        ];
425        // No keys ⇒ insecure ⇒ any caller is admin.
426        assert!(authenticate(&[], None).is_some());
427        // A valid secret resolves to its principal.
428        let reader = authenticate(&keys, Some("reader-secret")).expect("reader authenticates");
429        assert!(reader.require(Action::Write, Some("acme.x")).is_err());
430        // A wrong or missing secret is denied.
431        assert!(authenticate(&keys, Some("nope")).is_none());
432        assert!(authenticate(&keys, None).is_none());
433    }
434
435    #[test]
436    fn de_api_keys_parses_csv_strings_and_structured_tables() {
437        #[derive(Deserialize)]
438        struct Wrap {
439            #[serde(deserialize_with = "de_api_keys")]
440            api_keys: Vec<ApiKey>,
441        }
442
443        // Comma-separated string (the env form) ⇒ trimmed admin keys.
444        let csv: Wrap = serde_json::from_str(r#"{"api_keys":"a, b ,c"}"#).unwrap();
445        assert_eq!(csv.api_keys.len(), 3);
446        assert!(csv.api_keys.iter().all(|k| k.role == Action::Admin));
447        assert_eq!(csv.api_keys[1].secret, "b");
448
449        // A sequence mixing a bare secret and a structured, scoped key.
450        let mixed: Wrap = serde_json::from_str(
451            r#"{"api_keys":["root",{"secret":"ro","role":"read","collections":["acme.*"]}]}"#,
452        )
453        .unwrap();
454        assert_eq!(mixed.api_keys[0].role, Action::Admin);
455        assert!(matches!(
456            mixed.api_keys[0].collections,
457            CollectionScope::All
458        ));
459        assert_eq!(mixed.api_keys[1].role, Action::Read);
460        assert!(mixed.api_keys[1].collections.matches("acme.x"));
461        assert!(!mixed.api_keys[1].collections.matches("beta.x"));
462
463        // A structured key without `collections` defaults to all.
464        let defaulted: Wrap =
465            serde_json::from_str(r#"{"api_keys":[{"secret":"w","role":"write"}]}"#).unwrap();
466        assert!(matches!(
467            defaulted.api_keys[0].collections,
468            CollectionScope::All
469        ));
470    }
471}