Skip to main content

postrust_auth/
hasura.rs

1//! Hasura's authentication contract.
2//!
3//! Hasura settles three things about a request before any query runs: whether
4//! the caller is authenticated at all, which role it is speaking as, and what
5//! session variables that role carries. The first is what makes the other two
6//! safe, and reading them apart is the fault this module exists to fix.
7//!
8//! A caller that holds the admin secret is an administrator, and an
9//! administrator may ask to be treated as someone else: `X-Hasura-Role: user`
10//! alongside the secret says "answer as `user` would be answered", and the
11//! other `x-hasura-*` headers are that role's session variables. Naming a role
12//! is therefore something an *authenticated* caller does, not an alternative
13//! to authenticating -- a role header arriving on its own is an
14//! unauthenticated request, and Hasura refuses it.
15//!
16//! # Where this deliberately differs from Hasura
17//!
18//! Hasura configured with no admin secret treats every caller as an
19//! administrator, which means an unsecured deployment also lets any caller
20//! name its own role and its own identity. This server does not: with no
21//! secret configured, `x-hasura-*` headers are ignored entirely and session
22//! variables come from a verified token, as they did before this module
23//! existed. A policy reading a value the caller chose is not a policy, and the
24//! failure is silent -- the query succeeds, against the wrong rows.
25//!
26//! Nothing is lost by it. Every case in Hasura's corpus that names a role
27//! sends the secret beside it, because that is what Hasura's own suite does.
28
29use std::collections::HashMap;
30
31/// The name a caller with the secret speaks as unless it asks for another.
32pub const ADMIN_ROLE: &str = "admin";
33
34/// Hasura's own wording, which a migrating client may be matching on.
35pub const SECRET_MISSING: &str = "\"x-hasura-admin-secret\" required, but not found";
36/// Likewise.
37pub const SECRET_INVALID: &str = "invalid x-hasura-admin-secret/x-hasura-access-key";
38
39/// What this server was told about who may claim what.
40#[derive(Clone, Debug, Default)]
41pub struct HasuraAuthConfig {
42    /// The shared secret that authenticates an administrator. Everything in
43    /// this module is dark until this is set; see the divergence above.
44    pub admin_secret: Option<String>,
45    /// The role a request falls to when nothing authenticated it. Hasura's
46    /// `HASURA_GRAPHQL_UNAUTHORIZED_ROLE`. Unset means such a request is
47    /// refused rather than answered as a stranger.
48    pub unauthorized_role: Option<String>,
49}
50
51impl HasuraAuthConfig {
52    /// Whether this server gates on a secret at all.
53    pub fn is_configured(&self) -> bool {
54        self.admin_secret.is_some()
55    }
56}
57
58/// Who the caller is, in Hasura's sense of the word.
59///
60/// This is not a database role and must not be used as one. The corpus names
61/// roles like `Artist` and `anonymous` that exist in no catalogue, and Hasura
62/// connects as one database user whatever role a request claims. What the role
63/// decides is which permissions apply and what `x-hasura-role` reads as inside
64/// a function; which database user the transaction runs as is settled
65/// elsewhere and separately.
66#[derive(Clone, Debug)]
67pub struct HasuraIdentity {
68    /// The role the caller speaks as.
69    pub role: String,
70    /// Session variables, stripped of the `x-hasura-` prefix, lowercased, and
71    /// with dashes turned into underscores: `user_id`, `allowed_ids`. The same
72    /// shape a verified token's claims are reduced to, because a policy should
73    /// not be able to tell which of the two it is reading.
74    pub session: HashMap<String, String>,
75    /// Whether the admin secret authenticated this request.
76    ///
77    /// Distinct from `role == "admin"`, which an administrator gives up the
78    /// moment it asks to be treated as someone else. Hasura keeps the
79    /// distinction for one purpose -- a mutation marked `backend_only` is
80    /// reachable only by a caller that proved it holds the secret, whatever
81    /// role it then claims -- and that is what this records.
82    pub elevated: bool,
83}
84
85/// What the admin secret alone can settle about a request.
86///
87/// Three of the four outcomes are not answers. A secret that is configured and
88/// not offered does not refuse the request, because a token or a webhook may
89/// still authenticate it; only a secret that is offered and wrong is a
90/// refusal, since a caller that tried to be an administrator and failed is not
91/// then asked for a token.
92#[derive(Clone, Debug)]
93pub enum SecretOutcome {
94    /// No secret is configured. This server does not gate on one, and no
95    /// header is trusted.
96    NotConfigured,
97    /// The caller holds the secret.
98    Accepted(HasuraIdentity),
99    /// A secret is configured and none was offered. Something else may
100    /// authenticate the request.
101    Absent,
102    /// A secret is configured and the caller offered the wrong one.
103    Rejected,
104}
105
106/// Header names that carry the prefix and are not session variables.
107///
108/// The secret is a credential and would otherwise be readable by any policy
109/// and any function that takes the session document. The role is the identity
110/// itself rather than one of its attributes, and is reported separately. The
111/// backend-only flag is a request about which fields exist, not a fact about
112/// the caller.
113fn reserved(name: &str) -> bool {
114    matches!(
115        name,
116        "admin-secret" | "access-key" | "role" | "use-backend-only-permissions"
117    )
118}
119
120/// Reduce `x-hasura-*` headers to session variables.
121///
122/// Case is not significant in a header name and Hasura treats session variable
123/// names the same way, so everything is lowercased on the way in. A repeated
124/// header keeps its first value, which is what a single-valued setting can
125/// mean.
126pub fn session_from_headers(headers: &[(&str, &str)]) -> HashMap<String, String> {
127    let mut session = HashMap::new();
128    for (name, value) in headers {
129        let lowered = name.to_ascii_lowercase();
130        let Some(bare) = lowered.strip_prefix("x-hasura-") else {
131            continue;
132        };
133        if bare.is_empty() || reserved(bare) {
134            continue;
135        }
136        session
137            .entry(bare.replace('-', "_"))
138            .or_insert_with(|| value.to_string());
139    }
140    session
141}
142
143/// The value of one header, by a name compared without regard to case.
144fn header<'a>(headers: &[(&'a str, &'a str)], wanted: &str) -> Option<&'a str> {
145    headers
146        .iter()
147        .find(|(name, _)| name.eq_ignore_ascii_case(wanted))
148        .map(|(_, value)| *value)
149}
150
151/// Settle what the admin secret says about a request.
152///
153/// `headers` is a slice of name/value pairs rather than any particular header
154/// map, which is what lets this crate settle the question without depending on
155/// an HTTP library. It is walked more than once.
156pub fn from_admin_secret(config: &HasuraAuthConfig, headers: &[(&str, &str)]) -> SecretOutcome {
157    let Some(expected) = config.admin_secret.as_deref() else {
158        return SecretOutcome::NotConfigured;
159    };
160
161    // Both spellings: `x-hasura-access-key` is what the secret was called
162    // before v1.0.0-beta.6, and a client old enough to send it is exactly the
163    // one that cannot be changed.
164    let offered =
165        header(headers, "x-hasura-admin-secret").or_else(|| header(headers, "x-hasura-access-key"));
166
167    let Some(offered) = offered else {
168        return SecretOutcome::Absent;
169    };
170    if !constant_time_eq(offered.as_bytes(), expected.as_bytes()) {
171        return SecretOutcome::Rejected;
172    }
173
174    // Authenticated. From here the caller decides who to be.
175    let role = header(headers, "x-hasura-role")
176        .filter(|role| !role.is_empty())
177        .unwrap_or(ADMIN_ROLE)
178        .to_string();
179
180    SecretOutcome::Accepted(HasuraIdentity {
181        role,
182        session: session_from_headers(headers),
183        elevated: true,
184    })
185}
186
187/// The identity a request falls to when nothing authenticated it.
188///
189/// `None` means the request is refused, which is what an unset
190/// `unauthorized_role` asks for.
191pub fn unauthenticated(config: &HasuraAuthConfig) -> Option<HasuraIdentity> {
192    config
193        .unauthorized_role
194        .as_ref()
195        .map(|role| HasuraIdentity {
196            role: role.clone(),
197            session: HashMap::new(),
198            elevated: false,
199        })
200}
201
202/// Whether a caller asked for the fields a `backend_only` permission hides.
203///
204/// Hasura reads this only from a request the admin secret authenticated, which
205/// is the whole point of the flag: a mutation marked backend-only is one a
206/// client must not be able to reach by naming a role.
207pub fn backend_only_requested(headers: &[(&str, &str)], elevated: bool) -> bool {
208    elevated
209        && header(headers, "x-hasura-use-backend-only-permissions")
210            .and_then(boolean_text)
211            .unwrap_or(false)
212}
213
214/// What Hasura reads as true and false in a header, and the words it uses when
215/// a header is neither.
216///
217/// Not Rust's `bool` parser and not a truthiness test: the accepted texts are
218/// a closed set Hasura documents in the refusal itself, and `1` is not among
219/// them. A value outside it is a client mistake, and answering it as `false`
220/// would send a backend-only write down the path meant for everyone else --
221/// silently, on a header the client thought it had set.
222pub fn boolean_text(value: &str) -> Option<bool> {
223    match value.trim().to_ascii_lowercase().as_str() {
224        "true" | "t" | "yes" | "y" => Some(true),
225        "false" | "f" | "no" | "n" => Some(false),
226        _ => None,
227    }
228}
229
230/// The header whose value is read as a boolean, and Hasura's refusal for one
231/// that is not.
232///
233/// The two spaces after the colon and before `False` are Hasura's own, and a
234/// client showing this to a person is showing text it already ships.
235pub const BACKEND_ONLY_HEADER: &str = "x-hasura-use-backend-only-permissions";
236
237/// Whether a request carries that header with something unreadable in it.
238pub fn unreadable_boolean_header<'a>(headers: &[(&'a str, &'a str)]) -> Option<String> {
239    let given = header(headers, BACKEND_ONLY_HEADER)?;
240    if boolean_text(given).is_some() {
241        return None;
242    }
243    Some(format!(
244        "\"{}\":  Not a valid boolean text. True values are \
245         [\"true\",\"t\",\"yes\",\"y\"] and  False values are \
246         [\"false\",\"f\",\"no\",\"n\"]. All values are case insensitive",
247        BACKEND_ONLY_HEADER
248    ))
249}
250
251/// Hasura's wording for a caller that asked to be a role its token does not
252/// list.
253pub const ROLE_NOT_ALLOWED: &str = "Your requested role is not in allowed roles";
254
255/// Where Hasura puts its claims in a token unless it is told otherwise.
256const NAMESPACE: &str = "https://hasura.io/jwt/claims";
257
258/// One claim, from under the namespace if it is there and from the top level
259/// otherwise. Hasura mints the namespaced spelling and accepts both, so both
260/// are read here; a name is compared without regard to case, as a header is.
261fn claim<'a>(
262    claims: &'a HashMap<String, serde_json::Value>,
263    wanted: &str,
264) -> Option<&'a serde_json::Value> {
265    if let Some(serde_json::Value::Object(namespaced)) = claims.get(NAMESPACE) {
266        let found = namespaced
267            .iter()
268            .find(|(key, _)| key.eq_ignore_ascii_case(wanted))
269            .map(|(_, value)| value);
270        if found.is_some() {
271            return found;
272        }
273    }
274    claims
275        .iter()
276        .find(|(key, _)| key.eq_ignore_ascii_case(wanted))
277        .map(|(_, value)| value)
278}
279
280/// The role a verified token names, if it names one.
281///
282/// `x-hasura-role` where the token fixes the role outright, and
283/// `x-hasura-default-role` where it offers a default beside the
284/// `x-hasura-allowed-roles` it may choose among -- which is the shape Hasura's
285/// own documentation mints.
286///
287/// `None` leaves the database role standing in, which is what happened before
288/// the two were told apart.
289pub fn role_from_claims(claims: &HashMap<String, serde_json::Value>) -> Option<String> {
290    ["x-hasura-role", "x-hasura-default-role"]
291        .into_iter()
292        .find_map(|wanted| {
293            claim(claims, wanted)
294                .and_then(serde_json::Value::as_str)
295                .filter(|role| !role.is_empty())
296                .map(str::to_string)
297        })
298}
299
300/// The roles a token allows its bearer to ask to be.
301///
302/// Empty where the claim is absent or is not a list of names, which is the
303/// reading that grants nothing: the list is what widens an identity, so a
304/// value that cannot be read as one must not widen it.
305pub fn allowed_roles(claims: &HashMap<String, serde_json::Value>) -> Vec<String> {
306    match claim(claims, "x-hasura-allowed-roles") {
307        Some(serde_json::Value::Array(items)) => items
308            .iter()
309            .filter_map(serde_json::Value::as_str)
310            .map(str::to_string)
311            .collect(),
312        _ => Vec::new(),
313    }
314}
315
316/// Who a verified token's bearer speaks as.
317#[derive(Clone, Debug, PartialEq, Eq)]
318pub enum TokenRole {
319    /// Who the caller is. `None` leaves the database role standing in.
320    Is(Option<String>),
321    /// The caller asked to be a role its token does not list.
322    NotAllowed,
323}
324
325/// Settle which role a verified token speaks as, given what the caller asked
326/// for.
327///
328/// A token that allows more than one identity carries both
329/// `x-hasura-default-role` -- who the caller is when it asks for nothing --
330/// and `x-hasura-allowed-roles`, the list it may ask for instead. The asking
331/// is done with an `X-Hasura-Role` header, and the list is what makes reading
332/// that header safe: it sits inside the signature, so a caller can choose
333/// among the identities it was issued and cannot add one.
334///
335/// This is the one place a header names a role without the admin secret beside
336/// it, and it is safe for that reason alone. A token with no list allows only
337/// the role it already names -- an absent list is not permission to be anyone.
338pub fn role_for_token(
339    claims: &HashMap<String, serde_json::Value>,
340    headers: &[(&str, &str)],
341) -> TokenRole {
342    let named = role_from_claims(claims);
343
344    let Some(asked) = header(headers, "x-hasura-role").filter(|role| !role.is_empty()) else {
345        return TokenRole::Is(named);
346    };
347
348    // Asking to be who you already are needs no list: a token that fixes one
349    // role and a client that names that role agree, and refusing them would
350    // break a client that sets the header on every request.
351    if named.as_deref() == Some(asked) || allowed_roles(claims).iter().any(|role| role == asked) {
352        return TokenRole::Is(Some(asked.to_string()));
353    }
354
355    TokenRole::NotAllowed
356}
357
358/// Collect `x-hasura-*` claims from a verified token as session variables.
359///
360/// Hasura puts them under the namespace by default and allows them at the top
361/// level; both spellings are read, and the prefix is dropped so a policy names
362/// `hasura.user_id` rather than `hasura.x-hasura-user-id`. `x-hasura-role` is
363/// left out: the role is what the token was authenticated as, and re-reading
364/// it here would let a claim override that decision.
365pub fn session_from_claims(claims: &HashMap<String, serde_json::Value>) -> HashMap<String, String> {
366    let mut session = HashMap::new();
367
368    let mut take = |key: &str, value: &serde_json::Value| {
369        let lowered = key.to_ascii_lowercase();
370        let Some(name) = lowered.strip_prefix("x-hasura-") else {
371            return;
372        };
373        if name == "role" || name.is_empty() {
374            return;
375        }
376        // A claim may be a string, a number or a list of ids; the setting is
377        // text either way, and a string keeps its own spelling rather than
378        // gaining quotes.
379        let rendered = match value {
380            serde_json::Value::String(text) => text.clone(),
381            other => other.to_string(),
382        };
383        session.insert(name.replace('-', "_"), rendered);
384    };
385
386    if let Some(serde_json::Value::Object(namespaced)) = claims.get(NAMESPACE) {
387        for (key, value) in namespaced {
388            take(key, value);
389        }
390    }
391    for (key, value) in claims {
392        take(key, value);
393    }
394
395    session
396}
397
398/// Compare two secrets without letting the time taken say how much of the
399/// first one was right.
400fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
401    if left.len() != right.len() {
402        return false;
403    }
404    left.iter()
405        .zip(right)
406        .fold(0u8, |acc, (a, b)| acc | (a ^ b))
407        == 0
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413
414    fn configured() -> HasuraAuthConfig {
415        HasuraAuthConfig {
416            admin_secret: Some("shh".into()),
417            unauthorized_role: None,
418        }
419    }
420
421    #[test]
422    fn nothing_is_trusted_without_a_secret() {
423        let headers = [("x-hasura-role", "user"), ("x-hasura-user-id", "1")];
424        let outcome = from_admin_secret(&HasuraAuthConfig::default(), &headers[..]);
425        assert!(matches!(outcome, SecretOutcome::NotConfigured));
426    }
427
428    #[test]
429    fn the_secret_alone_is_an_administrator() {
430        let headers = [("X-Hasura-Admin-Secret", "shh")];
431        let SecretOutcome::Accepted(identity) = from_admin_secret(&configured(), &headers[..])
432        else {
433            panic!("the secret should have been accepted");
434        };
435        assert_eq!(identity.role, "admin");
436        assert!(identity.elevated);
437        assert!(identity.session.is_empty());
438    }
439
440    #[test]
441    fn an_administrator_may_ask_to_be_someone_else() {
442        let headers = [
443            ("X-Hasura-Admin-Secret", "shh"),
444            ("X-Hasura-Role", "user"),
445            ("X-Hasura-User-Id", "1"),
446            ("X-Hasura-Allowed-Ids", "{1,2,3}"),
447        ];
448        let SecretOutcome::Accepted(identity) = from_admin_secret(&configured(), &headers[..])
449        else {
450            panic!("the secret should have been accepted");
451        };
452        assert_eq!(identity.role, "user");
453        // Still elevated: the caller proved it holds the secret, and giving up
454        // the admin role does not undo that.
455        assert!(identity.elevated);
456        assert_eq!(
457            identity.session.get("user_id").map(String::as_str),
458            Some("1")
459        );
460        assert_eq!(
461            identity.session.get("allowed_ids").map(String::as_str),
462            Some("{1,2,3}")
463        );
464        // The credential is not one of the caller's attributes.
465        assert!(!identity.session.contains_key("admin_secret"));
466        // Nor is the identity itself.
467        assert!(!identity.session.contains_key("role"));
468    }
469
470    #[test]
471    fn a_role_header_on_its_own_is_not_authentication() {
472        let headers = [("X-Hasura-Role", "user"), ("X-Hasura-User-Id", "1")];
473        assert!(matches!(
474            from_admin_secret(&configured(), &headers[..]),
475            SecretOutcome::Absent
476        ));
477    }
478
479    #[test]
480    fn a_wrong_secret_is_refused_rather_than_fallen_through() {
481        let headers = [("X-Hasura-Admin-Secret", "guess")];
482        assert!(matches!(
483            from_admin_secret(&configured(), &headers[..]),
484            SecretOutcome::Rejected
485        ));
486    }
487
488    #[test]
489    fn the_older_spelling_of_the_secret_is_accepted() {
490        let headers = [("X-Hasura-Access-Key", "shh")];
491        assert!(matches!(
492            from_admin_secret(&configured(), &headers[..]),
493            SecretOutcome::Accepted(_)
494        ));
495    }
496
497    #[test]
498    fn an_empty_role_header_is_no_role_at_all() {
499        let headers = [("X-Hasura-Admin-Secret", "shh"), ("X-Hasura-Role", "")];
500        let SecretOutcome::Accepted(identity) = from_admin_secret(&configured(), &headers[..])
501        else {
502            panic!("the secret should have been accepted");
503        };
504        assert_eq!(identity.role, "admin");
505    }
506
507    #[test]
508    fn session_names_are_lowercased_and_underscored() {
509        let session = session_from_headers(&[("X-Hasura-Infant-Name", "Bittu")][..]);
510        assert_eq!(
511            session.get("infant_name").map(String::as_str),
512            Some("Bittu")
513        );
514    }
515
516    #[test]
517    fn headers_without_the_prefix_are_not_session_variables() {
518        let session = session_from_headers(&[("Cookie", "refresh_token=x"), ("Accept", "*/*")][..]);
519        assert!(session.is_empty());
520    }
521
522    #[test]
523    fn an_unauthenticated_request_falls_to_the_role_it_was_given() {
524        let config = HasuraAuthConfig {
525            admin_secret: Some("shh".into()),
526            unauthorized_role: Some("anonymous".into()),
527        };
528        let identity = unauthenticated(&config).expect("a role was configured");
529        assert_eq!(identity.role, "anonymous");
530        assert!(!identity.elevated);
531        assert!(identity.session.is_empty());
532    }
533
534    #[test]
535    fn without_that_role_an_unauthenticated_request_is_refused() {
536        assert!(unauthenticated(&configured()).is_none());
537    }
538
539    #[test]
540    fn backend_only_is_only_for_a_caller_that_proved_it() {
541        let headers = [("X-Hasura-Use-Backend-Only-Permissions", "true")];
542        assert!(backend_only_requested(&headers[..], true));
543        // The same header from a caller that never held the secret says
544        // nothing, which is what makes the flag worth having.
545        assert!(!backend_only_requested(&headers[..], false));
546    }
547
548    /// The accepted texts are a closed set, and `1` is not in it.
549    #[test]
550    fn a_header_that_is_not_a_boolean_text_is_neither_true_nor_false() {
551        for yes in ["true", "T", "yes", " Y "] {
552            assert_eq!(boolean_text(yes), Some(true), "{}", yes);
553        }
554        for no in ["false", "F", "no", "N"] {
555            assert_eq!(boolean_text(no), Some(false), "{}", no);
556        }
557        for neither in ["1", "0", "random", ""] {
558            assert_eq!(boolean_text(neither), None, "{}", neither);
559        }
560
561        let headers = [(BACKEND_ONLY_HEADER, "random")];
562        let refusal = unreadable_boolean_header(&headers[..]).expect("it is refused");
563        assert!(refusal.starts_with("\"x-hasura-use-backend-only-permissions\":  Not a valid"));
564        // And a request that does not carry it at all is not a bad request.
565        assert_eq!(unreadable_boolean_header(&[][..]), None);
566        assert_eq!(
567            unreadable_boolean_header(&[(BACKEND_ONLY_HEADER, "yes")][..]),
568            None
569        );
570    }
571
572    fn claims(value: serde_json::Value) -> HashMap<String, serde_json::Value> {
573        match value {
574            serde_json::Value::Object(map) => map.into_iter().collect(),
575            other => panic!("claims must be an object, got {other}"),
576        }
577    }
578
579    /// The default is who the caller is when it asks for nothing.
580    #[test]
581    fn a_token_that_asks_for_nothing_speaks_as_its_default() {
582        let token = claims(serde_json::json!({
583            "https://hasura.io/jwt/claims": {
584                "x-hasura-default-role": "user",
585                "x-hasura-allowed-roles": ["user", "editor"],
586            }
587        }));
588        assert_eq!(
589            role_for_token(&token, &[][..]),
590            TokenRole::Is(Some("user".into()))
591        );
592    }
593
594    /// The list inside the signature is what makes the header safe to read.
595    #[test]
596    fn a_role_the_token_lists_may_be_asked_for() {
597        let token = claims(serde_json::json!({
598            "https://hasura.io/jwt/claims": {
599                "x-hasura-default-role": "user",
600                "x-hasura-allowed-roles": ["user", "editor"],
601            }
602        }));
603        let headers = [("X-Hasura-Role", "editor")];
604        assert_eq!(
605            role_for_token(&token, &headers[..]),
606            TokenRole::Is(Some("editor".into()))
607        );
608    }
609
610    /// A caller cannot widen what it was issued.
611    #[test]
612    fn a_role_outside_the_list_is_refused() {
613        let token = claims(serde_json::json!({
614            "https://hasura.io/jwt/claims": {
615                "x-hasura-default-role": "user",
616                "x-hasura-allowed-roles": ["user", "editor"],
617            }
618        }));
619        for wanted in ["admin", "Editor", "anonymous"] {
620            let headers = [("x-hasura-role", wanted)];
621            assert_eq!(
622                role_for_token(&token, &headers[..]),
623                TokenRole::NotAllowed,
624                "{wanted}"
625            );
626        }
627    }
628
629    /// An absent list is not permission to be anyone.
630    #[test]
631    fn a_token_without_a_list_allows_only_the_role_it_names() {
632        let token = claims(serde_json::json!({ "x-hasura-role": "user" }));
633
634        let same = [("x-hasura-role", "user")];
635        assert_eq!(
636            role_for_token(&token, &same[..]),
637            TokenRole::Is(Some("user".into()))
638        );
639
640        let other = [("x-hasura-role", "admin")];
641        assert_eq!(role_for_token(&token, &other[..]), TokenRole::NotAllowed);
642    }
643
644    /// A list that is not a list of names grants nothing.
645    #[test]
646    fn a_list_that_cannot_be_read_widens_no_identity() {
647        for unreadable in [
648            serde_json::json!("editor"),
649            serde_json::json!({"0": "editor"}),
650            serde_json::json!(null),
651        ] {
652            let token = claims(serde_json::json!({
653                "x-hasura-default-role": "user",
654                "x-hasura-allowed-roles": unreadable,
655            }));
656            let headers = [("x-hasura-role", "editor")];
657            assert_eq!(role_for_token(&token, &headers[..]), TokenRole::NotAllowed);
658        }
659    }
660
661    /// Both spellings are read, and the namespace wins where both are present.
662    #[test]
663    fn a_claim_is_read_from_the_namespace_or_the_top_level() {
664        let top = claims(serde_json::json!({
665            "x-hasura-default-role": "user",
666            "x-hasura-allowed-roles": ["user", "editor"],
667        }));
668        let headers = [("x-hasura-role", "editor")];
669        assert_eq!(
670            role_for_token(&top, &headers[..]),
671            TokenRole::Is(Some("editor".into()))
672        );
673
674        let both = claims(serde_json::json!({
675            "https://hasura.io/jwt/claims": { "x-hasura-default-role": "namespaced" },
676            "x-hasura-default-role": "top",
677        }));
678        assert_eq!(role_from_claims(&both), Some("namespaced".into()));
679    }
680
681    /// A token naming no role at all leaves the database role standing in.
682    #[test]
683    fn a_token_that_names_no_role_names_none() {
684        let token = claims(serde_json::json!({ "sub": "1" }));
685        assert_eq!(role_for_token(&token, &[][..]), TokenRole::Is(None));
686    }
687
688    /// The role is reported separately; a claim repeating it must not become a
689    /// session variable a policy could read instead.
690    #[test]
691    fn the_role_claim_is_not_a_session_variable() {
692        let token = claims(serde_json::json!({
693            "https://hasura.io/jwt/claims": {
694                "x-hasura-role": "user",
695                "x-hasura-user-id": 7,
696                "x-hasura-org-ids": ["1", "2"],
697            }
698        }));
699        let session = session_from_claims(&token);
700        assert!(!session.contains_key("role"));
701        assert_eq!(session.get("user_id").map(String::as_str), Some("7"));
702        assert_eq!(
703            session.get("org_ids").map(String::as_str),
704            Some("[\"1\",\"2\"]")
705        );
706    }
707
708    #[test]
709    fn secrets_of_different_lengths_do_not_match() {
710        assert!(!constant_time_eq(b"shh", b"shhh"));
711        assert!(constant_time_eq(b"shh", b"shh"));
712    }
713}