Skip to main content

graphar_flight/
auth.rs

1//! Authentication and authorization for the Cypher Flight SQL server.
2//!
3//! ## Authentication — *who are you?*
4//!
5//! Three methods, any combination, to cover the clients that matter:
6//!
7//! - **Bearer token** — a shared secret in `authorization: Bearer <token>`.
8//!   More than one token can be valid at once (a **token set**) so a secret can
9//!   be **rotated without downtime**: add the new token, let clients migrate,
10//!   then drop the old one — both are accepted during the overlap window.
11//! - **Basic (username / password)** — `authorization: Basic <base64>`. This
12//!   is what generic Flight SQL clients on Windows use: Power BI's ADBC
13//!   connector and the Arrow Flight SQL ODBC driver send username/password,
14//!   first through the Flight `Handshake` (which issues a bearer token) and
15//!   then on every call.
16//! - **mutual TLS** — client-certificate auth, configured at the transport in
17//!   [`crate::server`] (`TlsOptions::client_ca_pem`); orthogonal to the
18//!   header methods here.
19//!
20//! The same [`AuthConfig`] drives both the per-call interceptor and the
21//! handshake, so the two can never disagree about who is allowed in.
22//!
23//! ## Authorization — *what may you run?*
24//!
25//! Authentication answers *who*; the [`Authorizer`] answers *what*. After a
26//! query is resolved to its registered table name, the authorizer is consulted
27//! with the caller's [`Identity`] and may deny it (`permission_denied`). The
28//! default is [`Authorizer::AllowAll`] — no behavior change — but
29//! [`Authorizer::AllowList`] restricts each identity to a named subset of
30//! registered queries, and [`Authorizer::Custom`] takes an arbitrary closure.
31
32use std::collections::{HashMap, HashSet};
33use std::sync::Arc;
34
35use base64::Engine;
36use base64::engine::general_purpose::STANDARD as BASE64;
37use tonic::Status;
38
39/// A username / password pair accepted via Basic auth.
40#[derive(Debug, Clone)]
41pub struct BasicCredential {
42    pub username: String,
43    pub password: String,
44}
45
46/// The authenticated caller, as resolved from the `authorization` header.
47///
48/// Carried as a request extension by the interceptor (and returned by
49/// [`AuthConfig::authenticate`]) so the per-query [`Authorizer`] can key its
50/// decision on *who* is asking. The variants name the credential method:
51///
52/// - [`Identity::Token`] — a bearer token authenticated; the string is the
53///   token's mapped name (from [`AuthConfig::with_named_bearer_tokens`]) or the
54///   token value itself when unnamed.
55/// - [`Identity::Basic`] — Basic auth authenticated; the string is the username.
56/// - [`Identity::Anonymous`] — an open server with no auth configured.
57#[derive(Debug, Clone, PartialEq, Eq, Hash)]
58pub enum Identity {
59    /// A bearer token authenticated; carries the token's identity name.
60    Token(String),
61    /// Basic auth authenticated; carries the username.
62    Basic(String),
63    /// No authentication configured (open server).
64    Anonymous,
65}
66
67impl Identity {
68    /// The identity's name for authz lookups: the token-identity name, the
69    /// Basic username, or `"*"` for the anonymous (open-server) identity.
70    pub fn name(&self) -> &str {
71        match self {
72            Identity::Token(name) | Identity::Basic(name) => name,
73            Identity::Anonymous => "*",
74        }
75    }
76}
77
78/// Per-query authorization hook, consulted *after* a query resolves to its
79/// registered name. Dependency-light by design — no external policy engine.
80///
81/// The default [`Authorizer::AllowAll`] permits everything (so wiring it in is
82/// a no-op for existing servers). [`Authorizer::AllowList`] keys on
83/// [`Identity::name`] and permits only the named queries listed for that
84/// identity. [`Authorizer::Custom`] defers to a closure for arbitrary policy.
85#[derive(Clone, Default)]
86pub enum Authorizer {
87    /// Permit every identity to run every query (the default).
88    #[default]
89    AllowAll,
90    /// Permit each identity only the registered-query names listed for it.
91    /// An identity absent from the map is denied every query; the special key
92    /// `"*"` is consulted as a fallback for identities not otherwise listed.
93    AllowList(HashMap<String, HashSet<String>>),
94    /// Arbitrary policy: `(identity, resolved_query_name) -> allowed`.
95    Custom(Arc<dyn Fn(&Identity, &str) -> bool + Send + Sync>),
96}
97
98impl std::fmt::Debug for Authorizer {
99    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100        match self {
101            Authorizer::AllowAll => f.write_str("Authorizer::AllowAll"),
102            Authorizer::AllowList(m) => f.debug_tuple("Authorizer::AllowList").field(m).finish(),
103            Authorizer::Custom(_) => f.write_str("Authorizer::Custom(<fn>)"),
104        }
105    }
106}
107
108impl Authorizer {
109    /// Build an [`Authorizer::AllowList`] from `(identity_name, &[query_name])`
110    /// pairs. Each identity is permitted exactly the listed registered-query
111    /// names (table names from `register_named`, or the Cypher key otherwise).
112    pub fn allow_list<I, S1, S2>(entries: I) -> Self
113    where
114        I: IntoIterator<Item = (S1, Vec<S2>)>,
115        S1: Into<String>,
116        S2: Into<String>,
117    {
118        let map = entries
119            .into_iter()
120            .map(|(id, queries)| (id.into(), queries.into_iter().map(Into::into).collect()))
121            .collect();
122        Authorizer::AllowList(map)
123    }
124
125    /// Wrap a closure as an [`Authorizer::Custom`].
126    pub fn custom<F>(f: F) -> Self
127    where
128        F: Fn(&Identity, &str) -> bool + Send + Sync + 'static,
129    {
130        Authorizer::Custom(Arc::new(f))
131    }
132
133    /// Is `identity` allowed to run the query resolved to `query_name`?
134    pub fn is_allowed(&self, identity: &Identity, query_name: &str) -> bool {
135        match self {
136            Authorizer::AllowAll => true,
137            Authorizer::AllowList(map) => {
138                let allowed = |key: &str| map.get(key).is_some_and(|qs| qs.contains(query_name));
139                // The identity's own allowances, then the wildcard fallback.
140                allowed(identity.name()) || allowed("*")
141            }
142            Authorizer::Custom(f) => f(identity, query_name),
143        }
144    }
145
146    /// Authorize or reject, mapping a denial to `permission_denied`.
147    pub fn authorize(&self, identity: &Identity, query_name: &str) -> Result<(), Status> {
148        if self.is_allowed(identity, query_name) {
149            Ok(())
150        } else {
151            Err(Status::permission_denied(format!(
152                "identity '{}' is not permitted to run query '{query_name}'",
153                identity.name()
154            )))
155        }
156    }
157}
158
159/// Which credentials the server accepts. An empty config (the default) means
160/// the server is open — no interceptor is installed.
161#[derive(Debug, Clone, Default)]
162pub struct AuthConfig {
163    /// Accepted `Bearer` tokens, keyed by token value → identity name. Any
164    /// token in the set authenticates (rotation: old + new both valid). The
165    /// first inserted token is the one issued by the handshake.
166    bearer_tokens: Vec<(String, String)>,
167    /// Accepted Basic username/password, if any.
168    pub basic: Option<BasicCredential>,
169    /// Per-query authorization policy, consulted after a query resolves. The
170    /// default [`Authorizer::AllowAll`] is a no-op (no behavior change).
171    pub authorizer: Authorizer,
172}
173
174impl AuthConfig {
175    /// A single accepted bearer token (back-compat shape). Equivalent to
176    /// [`with_bearer_tokens`] with one element; the token names itself.
177    ///
178    /// [`with_bearer_tokens`]: AuthConfig::with_bearer_tokens
179    pub fn with_bearer_token(mut self, token: impl Into<String>) -> Self {
180        self.add_bearer_token(token);
181        self
182    }
183
184    /// A **set** of accepted bearer tokens for zero-downtime rotation. Any
185    /// listed token authenticates; the first is issued by the handshake. Each
186    /// token names itself as its [`Identity`] (use
187    /// [`with_named_bearer_tokens`] to map tokens to authz identities).
188    ///
189    /// [`with_named_bearer_tokens`]: AuthConfig::with_named_bearer_tokens
190    pub fn with_bearer_tokens<I, S>(mut self, tokens: I) -> Self
191    where
192        I: IntoIterator<Item = S>,
193        S: Into<String>,
194    {
195        for t in tokens {
196            self.add_bearer_token(t);
197        }
198        self
199    }
200
201    /// A set of accepted bearer tokens mapped to authz **identity names**, so
202    /// several tokens can share one identity (e.g. an old and a new token for
203    /// the same `analyst` role rotate transparently). Pairs are `(token,
204    /// identity_name)`; the first pair's token is issued by the handshake.
205    pub fn with_named_bearer_tokens<I, S1, S2>(mut self, tokens: I) -> Self
206    where
207        I: IntoIterator<Item = (S1, S2)>,
208        S1: Into<String>,
209        S2: Into<String>,
210    {
211        for (token, name) in tokens {
212            self.add_named_bearer_token(token, name);
213        }
214        self
215    }
216
217    /// Add one accepted bearer token (self-named). Keeps any already added —
218    /// the building block for rotation. Returns `&mut Self` for chaining.
219    pub fn add_bearer_token(&mut self, token: impl Into<String>) -> &mut Self {
220        let token = token.into();
221        let name = token.clone();
222        self.add_named_bearer_token(token, name)
223    }
224
225    /// Add one accepted bearer token mapped to an authz identity name.
226    pub fn add_named_bearer_token(
227        &mut self,
228        token: impl Into<String>,
229        name: impl Into<String>,
230    ) -> &mut Self {
231        self.bearer_tokens.push((token.into(), name.into()));
232        self
233    }
234
235    /// Accept Basic `username` / `password`.
236    pub fn with_basic(mut self, username: impl Into<String>, password: impl Into<String>) -> Self {
237        self.basic = Some(BasicCredential {
238            username: username.into(),
239            password: password.into(),
240        });
241        self
242    }
243
244    /// Attach a per-query [`Authorizer`]. Default is [`Authorizer::AllowAll`].
245    pub fn with_authorizer(mut self, authorizer: Authorizer) -> Self {
246        self.authorizer = authorizer;
247        self
248    }
249
250    /// Authorize an already-resolved query for an identity (delegates to the
251    /// configured [`Authorizer`]). Denial → `permission_denied`.
252    pub fn authorize(&self, identity: &Identity, query_name: &str) -> Result<(), Status> {
253        self.authorizer.authorize(identity, query_name)
254    }
255
256    /// All accepted bearer token values, in insertion order.
257    pub fn bearer_tokens(&self) -> impl Iterator<Item = &str> {
258        self.bearer_tokens.iter().map(|(t, _)| t.as_str())
259    }
260
261    /// Any credential method configured? When false, the server requires no
262    /// authentication and the interceptor is skipped entirely.
263    pub fn is_enabled(&self) -> bool {
264        !self.bearer_tokens.is_empty() || self.basic.is_some()
265    }
266
267    /// The token issued to a client after a successful handshake — the first
268    /// configured bearer token, which the client then replays on each call.
269    pub fn issued_token(&self) -> Option<&str> {
270        self.bearer_tokens.first().map(|(t, _)| t.as_str())
271    }
272
273    /// Validate an `authorization` header value against every configured
274    /// method. Open servers accept anything (including a missing header).
275    ///
276    /// A thin wrapper over [`authenticate`](Self::authenticate) that discards
277    /// the resolved [`Identity`]; kept for call sites that only gate access.
278    pub fn check_header(&self, header: Option<&str>) -> Result<(), Status> {
279        self.authenticate(header).map(|_| ())
280    }
281
282    /// Validate an `authorization` header and return the resolved [`Identity`].
283    ///
284    /// Open servers (no method configured) authenticate anything as
285    /// [`Identity::Anonymous`]. Otherwise a valid `Bearer` token resolves to its
286    /// mapped [`Identity::Token`] and valid Basic creds to [`Identity::Basic`];
287    /// anything else is `unauthenticated`.
288    pub fn authenticate(&self, header: Option<&str>) -> Result<Identity, Status> {
289        if !self.is_enabled() {
290            return Ok(Identity::Anonymous);
291        }
292        let value = header.ok_or_else(|| {
293            Status::unauthenticated(
294                "missing 'authorization' header (Bearer token or Basic credentials)",
295            )
296        })?;
297
298        if let Some(token) = value.strip_prefix("Bearer ") {
299            // Constant-time compare against every accepted token (rotation).
300            for (expected, name) in &self.bearer_tokens {
301                if constant_time_eq(token.as_bytes(), expected.as_bytes()) {
302                    return Ok(Identity::Token(name.clone()));
303                }
304            }
305        } else if let Some(user) = value
306            .strip_prefix("Basic ")
307            .and_then(|b64| self.check_basic(b64))
308        {
309            return Ok(Identity::Basic(user));
310        }
311        Err(Status::unauthenticated("invalid credentials"))
312    }
313
314    /// Validate a base64-encoded `user:pass` Basic payload, returning the
315    /// username on success.
316    pub fn check_basic(&self, b64: &str) -> Option<String> {
317        let expected = self.basic.as_ref()?;
318        let decoded = BASE64.decode(b64).ok()?;
319        let text = std::str::from_utf8(&decoded).ok()?;
320        let (user, pass) = text.split_once(':')?;
321        // Compare both fields in constant time; `&` (not `&&`) so timing does
322        // not reveal which field differed.
323        let ok = constant_time_eq(user.as_bytes(), expected.username.as_bytes())
324            & constant_time_eq(pass.as_bytes(), expected.password.as_bytes());
325        ok.then(|| user.to_string())
326    }
327}
328
329/// Length-aware constant-time byte comparison.
330pub(crate) fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
331    if a.len() != b.len() {
332        return false;
333    }
334    a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340
341    fn basic_header(user: &str, pass: &str) -> String {
342        format!("Basic {}", BASE64.encode(format!("{user}:{pass}")))
343    }
344
345    #[test]
346    fn open_server_accepts_anything() {
347        let auth = AuthConfig::default();
348        assert!(!auth.is_enabled());
349        assert!(auth.check_header(None).is_ok());
350        assert!(auth.check_header(Some("garbage")).is_ok());
351        assert_eq!(auth.authenticate(None).unwrap(), Identity::Anonymous);
352    }
353
354    #[test]
355    fn bearer_token_accepted_and_rejected() {
356        let auth = AuthConfig::default().with_bearer_token("s3cret");
357        assert!(auth.check_header(Some("Bearer s3cret")).is_ok());
358        assert!(auth.check_header(Some("Bearer nope")).is_err());
359        assert!(auth.check_header(None).is_err());
360        assert_eq!(
361            auth.authenticate(Some("Bearer s3cret")).unwrap(),
362            Identity::Token("s3cret".into())
363        );
364    }
365
366    #[test]
367    fn multiple_tokens_all_accepted_for_rotation() {
368        // Old + new token both valid during the overlap window.
369        let auth = AuthConfig::default().with_bearer_tokens(["old-tok", "new-tok"]);
370        assert!(auth.check_header(Some("Bearer old-tok")).is_ok());
371        assert!(auth.check_header(Some("Bearer new-tok")).is_ok());
372        // A stale/never-valid token is still rejected.
373        assert!(auth.check_header(Some("Bearer retired-tok")).is_err());
374        // The handshake issues the first (primary) token.
375        assert_eq!(auth.issued_token(), Some("old-tok"));
376    }
377
378    #[test]
379    fn add_bearer_token_grows_the_set() {
380        let mut auth = AuthConfig::default().with_bearer_token("t1");
381        auth.add_bearer_token("t2");
382        assert!(auth.check_header(Some("Bearer t1")).is_ok());
383        assert!(auth.check_header(Some("Bearer t2")).is_ok());
384        assert_eq!(auth.bearer_tokens().collect::<Vec<_>>(), vec!["t1", "t2"]);
385    }
386
387    #[test]
388    fn named_tokens_share_an_identity() {
389        // Two tokens (rotation) that both authenticate as "analyst".
390        let auth = AuthConfig::default()
391            .with_named_bearer_tokens([("tok-a", "analyst"), ("tok-b", "analyst")]);
392        assert_eq!(
393            auth.authenticate(Some("Bearer tok-a")).unwrap(),
394            Identity::Token("analyst".into())
395        );
396        assert_eq!(
397            auth.authenticate(Some("Bearer tok-b")).unwrap(),
398            Identity::Token("analyst".into())
399        );
400    }
401
402    #[test]
403    fn basic_credentials_accepted_and_rejected() {
404        let auth = AuthConfig::default().with_basic("admin", "pw");
405        assert!(
406            auth.check_header(Some(&basic_header("admin", "pw")))
407                .is_ok()
408        );
409        assert!(
410            auth.check_header(Some(&basic_header("admin", "wrong")))
411                .is_err()
412        );
413        assert!(auth.check_header(Some(&basic_header("eve", "pw"))).is_err());
414        assert!(auth.check_header(Some("Basic !!!notbase64")).is_err());
415        assert_eq!(
416            auth.authenticate(Some(&basic_header("admin", "pw")))
417                .unwrap(),
418            Identity::Basic("admin".into())
419        );
420    }
421
422    #[test]
423    fn both_methods_accepted_when_both_configured() {
424        let auth = AuthConfig::default()
425            .with_bearer_token("tok")
426            .with_basic("u", "p");
427        assert!(auth.check_header(Some("Bearer tok")).is_ok());
428        assert!(auth.check_header(Some(&basic_header("u", "p"))).is_ok());
429        assert!(auth.check_header(Some("Bearer bad")).is_err());
430    }
431
432    #[test]
433    fn issued_token_is_the_first_bearer_token() {
434        let auth = AuthConfig::default().with_bearer_tokens(["tok", "tok2"]);
435        assert_eq!(auth.issued_token(), Some("tok"));
436        assert_eq!(AuthConfig::default().issued_token(), None);
437    }
438
439    #[test]
440    fn authorizer_allow_all_permits_everything() {
441        let authz = Authorizer::AllowAll;
442        assert!(authz.is_allowed(&Identity::Token("anyone".into()), "q1"));
443        assert!(authz.is_allowed(&Identity::Anonymous, "q2"));
444        assert!(
445            authz
446                .authorize(&Identity::Basic("u".into()), "anything")
447                .is_ok()
448        );
449    }
450
451    #[test]
452    fn authorizer_allow_list_allows_and_denies_per_identity() {
453        // analyst may run q1 but not q2.
454        let authz = Authorizer::allow_list([("analyst", vec!["q1"])]);
455        let analyst = Identity::Token("analyst".into());
456        assert!(authz.is_allowed(&analyst, "q1"));
457        assert!(!authz.is_allowed(&analyst, "q2"));
458        assert!(authz.authorize(&analyst, "q1").is_ok());
459        let denied = authz.authorize(&analyst, "q2").unwrap_err();
460        assert_eq!(denied.code(), tonic::Code::PermissionDenied);
461        // An identity absent from the list is denied everything.
462        assert!(!authz.is_allowed(&Identity::Token("stranger".into()), "q1"));
463    }
464
465    #[test]
466    fn authorizer_allow_list_wildcard_fallback() {
467        // "*" grants q_public to every identity not otherwise listed.
468        let authz = Authorizer::allow_list([("analyst", vec!["q1"]), ("*", vec!["q_public"])]);
469        let analyst = Identity::Token("analyst".into());
470        assert!(authz.is_allowed(&analyst, "q1"));
471        assert!(authz.is_allowed(&analyst, "q_public")); // wildcard fallback
472        let other = Identity::Token("other".into());
473        assert!(authz.is_allowed(&other, "q_public"));
474        assert!(!authz.is_allowed(&other, "q1"));
475    }
476
477    #[test]
478    fn authorizer_custom_closure() {
479        let authz = Authorizer::custom(|id, q| id.name() == "root" || q == "q_open");
480        assert!(authz.is_allowed(&Identity::Token("root".into()), "anything"));
481        assert!(authz.is_allowed(&Identity::Token("nobody".into()), "q_open"));
482        assert!(!authz.is_allowed(&Identity::Token("nobody".into()), "q_secret"));
483    }
484}