Skip to main content

tensor_wasm_api/
token_scope.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Craton Software Company
3
4//! Per-tenant scoped bearer tokens.
5//!
6//! Implements PATH-TO-V1 v0.4 exit-criterion *Scoped tokens*: each accepted
7//! bearer token carries a [`TokenScope`] describing which tenants it may
8//! address. Routes that bind to a tenant call
9//! [`crate::rate_limit::AuthContext::authorize_tenant`] before doing any
10//! per-tenant work and return `403 Forbidden { kind: "tenant_scope_denied" }`
11//! when the caller's token does not cover the requested tenant.
12//!
13//! ## Wire format for `TENSOR_WASM_API_TOKENS`
14//!
15//! Two entry shapes are accepted in the comma-separated allowlist:
16//!
17//! 1. `token1:tenant=1,2,3` — scoped: the token grants access to tenants
18//!    `1`, `2`, and `3` only.
19//! 2. `token1:tenant=*` — wildcard: the token grants access to every tenant.
20//! 3. `token1` — bare (legacy): treated as the wildcard form with a one-time
21//!    deprecation warning emitted at startup. Scheduled for removal in v1.0.
22//!
23//! Forms can be mixed in the same env var; the parser splits on commas at
24//! the *top level only*. Because tenant lists are themselves comma-separated
25//! and live inside an entry, the parser groups all comma-separated values
26//! that follow a `:tenant=` clause back into a single entry.
27//!
28//! ## Parser strategy
29//!
30//! The grammar is small enough that hand-rolling a one-pass scanner is the
31//! lowest-overhead choice. We split the input on commas to obtain *raw
32//! pieces*, then walk them left-to-right, joining pieces back together when
33//! we observe a piece that is itself a continuation of a `:tenant=` clause
34//! (i.e. it starts with neither a token name nor a colon — a numeric or `*`
35//! continuation list). The walker accepts the same trailing whitespace the
36//! pre-existing parser does (`split(',').map(str::trim)`).
37
38use std::collections::{HashMap, HashSet};
39use std::fmt;
40
41use tensor_wasm_core::types::TenantId;
42
43/// Tenant scope attached to a single bearer token.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum TenantScope {
46    /// Wildcard — the token grants access to every tenant. Produced by
47    /// `token:tenant=*` and by the legacy bare-token shape (with deprecation
48    /// warning).
49    All,
50    /// Explicit allowlist — the token grants access only to the listed
51    /// tenant ids.
52    Set(HashSet<TenantId>),
53}
54
55impl TenantScope {
56    /// `true` if `tenant` is covered by this scope.
57    pub fn allows(&self, tenant: TenantId) -> bool {
58        match self {
59            TenantScope::All => true,
60            TenantScope::Set(s) => s.contains(&tenant),
61        }
62    }
63
64    /// `true` if this scope is the wildcard form. Used by callers (and
65    /// integration tests) that want to assert legacy semantics held.
66    pub fn is_all(&self) -> bool {
67        matches!(self, TenantScope::All)
68    }
69}
70
71/// Authorization metadata derived from a single bearer-token entry.
72///
73/// Stored as a value in [`ParsedTokens::token_scopes`]; the bearer string
74/// itself is the map key.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct TokenScope {
77    /// Tenants this token may address.
78    pub tenants: TenantScope,
79}
80
81impl TokenScope {
82    /// Wildcard scope — covers every tenant.
83    pub fn all() -> Self {
84        Self {
85            tenants: TenantScope::All,
86        }
87    }
88
89    /// Construct a scope from an explicit set of tenants.
90    pub fn from_tenants<I>(iter: I) -> Self
91    where
92        I: IntoIterator<Item = TenantId>,
93    {
94        Self {
95            tenants: TenantScope::Set(iter.into_iter().collect()),
96        }
97    }
98
99    /// Convenience accessor: `true` if the scope admits `tenant`.
100    pub fn allows(&self, tenant: TenantId) -> bool {
101        self.tenants.allows(tenant)
102    }
103}
104
105/// Alias for the raw bearer-token string used as the map key in
106/// [`ParsedTokens`]. Kept as a type alias rather than a newtype because the
107/// existing [`crate::middleware::AuthConfig`] also stores the raw string.
108pub type BearerString = String;
109
110/// Output of [`parse_tokens_env`].
111#[derive(Debug, Default, Clone)]
112pub struct ParsedTokens {
113    /// All accepted bearer tokens with their scopes.
114    pub token_scopes: HashMap<BearerString, TokenScope>,
115    /// Count of entries that used the legacy bare-token shape and were
116    /// silently coerced to the wildcard scope. The server emits a single
117    /// `tracing::warn!` at startup if this is nonzero.
118    pub deprecated_count: usize,
119}
120
121/// Errors produced when parsing a single token entry. The wrapper around
122/// [`parse_tokens_env`] turns each into a startup-time `tracing::warn!`
123/// rather than aborting the process — the gateway prefers to keep the
124/// remaining valid entries.
125#[derive(Debug, PartialEq, Eq)]
126pub enum ScopeParseError {
127    /// The entry was empty after trimming.
128    EmptyEntry,
129    /// The entry was `:tenant=...` with no bearer token before the colon.
130    MissingBearer,
131    /// The entry contained a `:tenant=` clause with no value after `=`.
132    EmptyTenantList,
133    /// The tenant list contained a token that was neither `*` nor a valid
134    /// `u64`. Carries the offending fragment for diagnostics.
135    InvalidTenant(String),
136    /// The entry mixed `*` with explicit tenant ids in the same clause.
137    /// Either use `tenant=*` exactly or list ids; we refuse to silently
138    /// promote `tenant=1,*` to wildcard because the user's intent is
139    /// ambiguous.
140    WildcardMixedWithIds,
141    /// The entry used an unrecognised key after the colon (only `tenant=` is
142    /// supported in v0.4). Carries the offending key.
143    UnknownKey(String),
144}
145
146impl fmt::Display for ScopeParseError {
147    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
148        match self {
149            ScopeParseError::EmptyEntry => f.write_str("empty token entry"),
150            ScopeParseError::MissingBearer => {
151                f.write_str("token entry has scope clause but no bearer token")
152            }
153            ScopeParseError::EmptyTenantList => f.write_str("tenant= clause has no value"),
154            ScopeParseError::InvalidTenant(s) => {
155                write!(f, "tenant id {s:?} is neither '*' nor a u64")
156            }
157            ScopeParseError::WildcardMixedWithIds => {
158                f.write_str("tenant=* cannot be mixed with explicit tenant ids")
159            }
160            ScopeParseError::UnknownKey(k) => {
161                write!(f, "unknown scope key {k:?} (only 'tenant=' is supported)")
162            }
163        }
164    }
165}
166
167impl std::error::Error for ScopeParseError {}
168
169/// Parse a single entry of the `TENSOR_WASM_API_TOKENS` allowlist.
170///
171/// Accepted shapes (after trimming surrounding whitespace):
172///
173/// * `token` — legacy bare bearer. Returns [`TokenScope::all`].
174/// * `token:tenant=*` — explicit wildcard.
175/// * `token:tenant=1,2,3` — explicit allowlist.
176///
177/// The parser is intentionally strict on the scoped form: an empty tenant
178/// list, an unknown key, or a tenant id that does not parse as a `u64`
179/// returns an [`Err`]. The caller decides whether to ignore (skip the
180/// entry) or surface the error — see [`parse_tokens_env`] for the policy
181/// applied at startup.
182pub fn parse_token_entry(s: &str) -> Result<(BearerString, TokenScope), ScopeParseError> {
183    let trimmed = s.trim();
184    if trimmed.is_empty() {
185        return Err(ScopeParseError::EmptyEntry);
186    }
187    match trimmed.split_once(':') {
188        None => {
189            // Bare-token form. Coerce to wildcard; the caller stamps the
190            // deprecation warning.
191            Ok((trimmed.to_owned(), TokenScope::all()))
192        }
193        Some((bearer, rest)) => {
194            let bearer = bearer.trim();
195            if bearer.is_empty() {
196                return Err(ScopeParseError::MissingBearer);
197            }
198            let rest = rest.trim();
199            // The v0.4 grammar only knows the `tenant=` key. Future keys
200            // (e.g. `qps=`) can extend this match without breaking the
201            // existing wire format.
202            let value = match rest.split_once('=') {
203                Some(("tenant", v)) => v.trim(),
204                Some((other, _)) => {
205                    return Err(ScopeParseError::UnknownKey(other.to_owned()));
206                }
207                None => {
208                    return Err(ScopeParseError::UnknownKey(rest.to_owned()));
209                }
210            };
211            if value.is_empty() {
212                return Err(ScopeParseError::EmptyTenantList);
213            }
214            // Split the comma-separated tenant list, sniffing for wildcard.
215            let mut saw_wildcard = false;
216            let mut ids: HashSet<TenantId> = HashSet::new();
217            for raw in value.split(',') {
218                let frag = raw.trim();
219                if frag.is_empty() {
220                    return Err(ScopeParseError::InvalidTenant(frag.to_owned()));
221                }
222                if frag == "*" {
223                    saw_wildcard = true;
224                } else {
225                    match frag.parse::<u64>() {
226                        Ok(v) => {
227                            ids.insert(TenantId(v));
228                        }
229                        Err(_) => return Err(ScopeParseError::InvalidTenant(frag.to_owned())),
230                    }
231                }
232            }
233            if saw_wildcard && !ids.is_empty() {
234                return Err(ScopeParseError::WildcardMixedWithIds);
235            }
236            let scope = if saw_wildcard {
237                TokenScope::all()
238            } else {
239                TokenScope {
240                    tenants: TenantScope::Set(ids),
241                }
242            };
243            Ok((bearer.to_owned(), scope))
244        }
245    }
246}
247
248/// Parse the `TENSOR_WASM_API_TOKENS` env-var value into a map of bearer →
249/// scope, plus a count of legacy (bare) entries that were coerced to the
250/// wildcard scope.
251///
252/// The input may mix the two entry shapes freely. Splitting is two-pass:
253///
254/// 1. Split on commas to obtain *raw fragments*.
255/// 2. Walk fragments left to right, re-joining any fragment that does not
256///    contain a `:` and follows an entry whose scope clause was `tenant=`
257///    (a continuation of the tenant list).
258///
259/// Entries that fail [`parse_token_entry`] are dropped with a
260/// `tracing::warn!` so a malformed entry never causes the gateway to refuse
261/// to start. The remaining valid entries are returned in [`ParsedTokens`].
262pub fn parse_tokens_env(env_value: &str) -> ParsedTokens {
263    let mut out = ParsedTokens::default();
264    if env_value.trim().is_empty() {
265        return out;
266    }
267
268    // Re-glue fragments that belong to a single `tenant=...` list which was
269    // itself comma-separated. We accumulate fragments into `current`; a
270    // fragment that starts a new entry is one that either looks like
271    // `token` or `token:tenant=...`. A continuation fragment is one that
272    // does not contain `:` AND we are currently mid-tenant-list.
273    let raw_fragments: Vec<&str> = env_value.split(',').collect();
274    let mut entries: Vec<String> = Vec::with_capacity(raw_fragments.len());
275    let mut current = String::new();
276    let mut in_tenant_list = false;
277
278    for frag in raw_fragments {
279        let trimmed = frag.trim();
280        if trimmed.is_empty() {
281            // Empty fragments (`",,"`) flush whatever we have so the
282            // subsequent fragment starts a fresh entry. Without this,
283            // `foo:tenant=,1` would be silently glued into `foo:tenant=1`.
284            if !current.is_empty() {
285                entries.push(std::mem::take(&mut current));
286            }
287            in_tenant_list = false;
288            continue;
289        }
290        let has_colon = trimmed.contains(':');
291        // A continuation fragment must be a *tenant-list-shaped* token: the
292        // wildcard `*` or a `u64`. Anything else (e.g. a bare bearer named
293        // `foo`) is treated as a new entry — otherwise `a:tenant=*,foo` would
294        // silently glue `foo` onto the wildcard list and fail with
295        // `WildcardMixedWithIds`, rather than parsing as two entries.
296        let looks_like_tenant_continuation = trimmed == "*" || trimmed.parse::<u64>().is_ok();
297        if in_tenant_list && !has_colon && looks_like_tenant_continuation {
298            // Continuation of the previous entry's tenant list.
299            current.push(',');
300            current.push_str(trimmed);
301        } else {
302            // Start a new entry.
303            if !current.is_empty() {
304                entries.push(std::mem::take(&mut current));
305            }
306            current.push_str(trimmed);
307            in_tenant_list = has_colon
308                && trimmed
309                    .split_once(':')
310                    .is_some_and(|(_, rest)| rest.trim().starts_with("tenant="));
311        }
312    }
313    if !current.is_empty() {
314        entries.push(current);
315    }
316
317    for entry in entries {
318        let is_bare = !entry.contains(':');
319        match parse_token_entry(&entry) {
320            Ok((bearer, scope)) => {
321                if is_bare {
322                    out.deprecated_count += 1;
323                }
324                out.token_scopes.insert(bearer, scope);
325            }
326            Err(e) => {
327                // We deliberately do not fail startup on a malformed entry —
328                // the operator can fix it without losing all the other
329                // tokens in the allowlist. The warning carries enough
330                // context to find it.
331                tracing::warn!(
332                    target: "tensor_wasm_api::token_scope",
333                    error = %e,
334                    entry = %entry,
335                    "ignoring malformed TENSOR_WASM_API_TOKENS entry",
336                );
337            }
338        }
339    }
340    out
341}
342
343// ---------------------------------------------------------------------------
344// Tests
345// ---------------------------------------------------------------------------
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    fn ids(it: impl IntoIterator<Item = u64>) -> HashSet<TenantId> {
352        it.into_iter().map(TenantId).collect()
353    }
354
355    #[test]
356    fn parse_bare_entry_yields_wildcard() {
357        let (bearer, scope) = parse_token_entry("alpha").expect("parses");
358        assert_eq!(bearer, "alpha");
359        assert!(scope.tenants.is_all());
360    }
361
362    #[test]
363    fn parse_bare_entry_trims_whitespace() {
364        let (bearer, _) = parse_token_entry("  alpha  ").expect("parses");
365        assert_eq!(bearer, "alpha");
366    }
367
368    #[test]
369    fn parse_wildcard_entry_yields_wildcard() {
370        let (bearer, scope) = parse_token_entry("alpha:tenant=*").expect("parses");
371        assert_eq!(bearer, "alpha");
372        assert!(scope.tenants.is_all());
373    }
374
375    #[test]
376    fn parse_single_tenant_entry() {
377        let (bearer, scope) = parse_token_entry("alpha:tenant=7").expect("parses");
378        assert_eq!(bearer, "alpha");
379        assert_eq!(scope.tenants, TenantScope::Set(ids([7])));
380    }
381
382    #[test]
383    fn parse_multi_tenant_entry() {
384        let (bearer, scope) = parse_token_entry("alpha:tenant=1,2,3").expect("parses");
385        assert_eq!(bearer, "alpha");
386        assert_eq!(scope.tenants, TenantScope::Set(ids([1, 2, 3])));
387        assert!(scope.allows(TenantId(2)));
388        assert!(!scope.allows(TenantId(4)));
389    }
390
391    #[test]
392    fn parse_multi_tenant_entry_with_whitespace() {
393        let (bearer, scope) = parse_token_entry("alpha:tenant= 1 , 2 , 3 ").expect("parses");
394        assert_eq!(bearer, "alpha");
395        assert_eq!(scope.tenants, TenantScope::Set(ids([1, 2, 3])));
396    }
397
398    #[test]
399    fn parse_empty_entry_errors() {
400        assert_eq!(
401            parse_token_entry("").unwrap_err(),
402            ScopeParseError::EmptyEntry
403        );
404        assert_eq!(
405            parse_token_entry("   ").unwrap_err(),
406            ScopeParseError::EmptyEntry
407        );
408    }
409
410    #[test]
411    fn parse_missing_bearer_errors() {
412        assert_eq!(
413            parse_token_entry(":tenant=1").unwrap_err(),
414            ScopeParseError::MissingBearer
415        );
416    }
417
418    #[test]
419    fn parse_empty_tenant_list_errors() {
420        assert_eq!(
421            parse_token_entry("alpha:tenant=").unwrap_err(),
422            ScopeParseError::EmptyTenantList
423        );
424    }
425
426    #[test]
427    fn parse_invalid_tenant_errors() {
428        let err = parse_token_entry("alpha:tenant=not-a-number").unwrap_err();
429        assert_eq!(err, ScopeParseError::InvalidTenant("not-a-number".into()));
430    }
431
432    #[test]
433    fn parse_unknown_key_errors() {
434        // We accept only `tenant=` in v0.4.
435        let err = parse_token_entry("alpha:qps=100").unwrap_err();
436        assert_eq!(err, ScopeParseError::UnknownKey("qps".into()));
437    }
438
439    #[test]
440    fn parse_missing_eq_errors() {
441        let err = parse_token_entry("alpha:tenant").unwrap_err();
442        assert_eq!(err, ScopeParseError::UnknownKey("tenant".into()));
443    }
444
445    #[test]
446    fn parse_wildcard_mixed_with_ids_errors() {
447        let err = parse_token_entry("alpha:tenant=1,*,2").unwrap_err();
448        assert_eq!(err, ScopeParseError::WildcardMixedWithIds);
449    }
450
451    #[test]
452    fn parse_tenant_list_with_trailing_comma_is_invalid() {
453        // `alpha:tenant=1,` → the second fragment is empty, which we treat
454        // as an invalid tenant rather than silently accepting it.
455        let err = parse_token_entry("alpha:tenant=1,").unwrap_err();
456        assert_eq!(err, ScopeParseError::InvalidTenant(String::new()));
457    }
458
459    #[test]
460    fn parse_env_empty_returns_default() {
461        let parsed = parse_tokens_env("");
462        assert!(parsed.token_scopes.is_empty());
463        assert_eq!(parsed.deprecated_count, 0);
464
465        let parsed = parse_tokens_env("   ");
466        assert!(parsed.token_scopes.is_empty());
467        assert_eq!(parsed.deprecated_count, 0);
468    }
469
470    #[test]
471    fn parse_env_single_bare_token() {
472        let parsed = parse_tokens_env("alpha");
473        assert_eq!(parsed.deprecated_count, 1);
474        assert_eq!(parsed.token_scopes.len(), 1);
475        assert!(parsed.token_scopes.get("alpha").unwrap().tenants.is_all());
476    }
477
478    #[test]
479    fn parse_env_three_bare_tokens() {
480        let parsed = parse_tokens_env("alpha,beta,gamma");
481        assert_eq!(parsed.deprecated_count, 3);
482        assert_eq!(parsed.token_scopes.len(), 3);
483        for name in ["alpha", "beta", "gamma"] {
484            assert!(
485                parsed.token_scopes.get(name).unwrap().tenants.is_all(),
486                "{name} not wildcard",
487            );
488        }
489    }
490
491    #[test]
492    fn parse_env_mixed_bare_and_scoped() {
493        let parsed = parse_tokens_env("alpha,beta:tenant=1,2,gamma:tenant=*");
494        assert_eq!(parsed.deprecated_count, 1, "only `alpha` is bare");
495        assert_eq!(parsed.token_scopes.len(), 3);
496        assert!(parsed.token_scopes["alpha"].tenants.is_all());
497        assert_eq!(
498            parsed.token_scopes["beta"].tenants,
499            TenantScope::Set(ids([1, 2]))
500        );
501        assert!(parsed.token_scopes["gamma"].tenants.is_all());
502    }
503
504    #[test]
505    fn parse_env_scoped_token_with_long_tenant_list() {
506        let parsed = parse_tokens_env("alpha:tenant=1,2,3,4,5,6,7");
507        assert_eq!(parsed.deprecated_count, 0);
508        assert_eq!(parsed.token_scopes.len(), 1);
509        let scope = &parsed.token_scopes["alpha"];
510        for t in 1..=7u64 {
511            assert!(scope.allows(TenantId(t)));
512        }
513        assert!(!scope.allows(TenantId(8)));
514    }
515
516    #[test]
517    fn parse_env_handles_whitespace_around_entries() {
518        let parsed = parse_tokens_env("  alpha , beta:tenant=1 , gamma:tenant=*  ");
519        assert_eq!(parsed.token_scopes.len(), 3);
520        assert!(parsed.token_scopes.contains_key("alpha"));
521        assert!(parsed.token_scopes.contains_key("beta"));
522        assert!(parsed.token_scopes.contains_key("gamma"));
523    }
524
525    #[test]
526    fn parse_env_drops_malformed_entry_but_keeps_others() {
527        // The middle entry has no bearer prefix → dropped. The bookend
528        // entries survive.
529        let parsed = parse_tokens_env("alpha,:tenant=1,gamma:tenant=*");
530        assert_eq!(parsed.token_scopes.len(), 2);
531        assert!(parsed.token_scopes.contains_key("alpha"));
532        assert!(parsed.token_scopes.contains_key("gamma"));
533        assert_eq!(parsed.deprecated_count, 1, "only `alpha` was bare");
534    }
535
536    #[test]
537    fn parse_env_two_scoped_tokens_with_continuation_lists() {
538        // The tricky case: two scoped tokens, each with a comma-separated
539        // tenant list. The continuation logic must group `5,6` back with
540        // `beta:tenant=4` and `1,2,3` with `alpha:tenant=`.
541        let parsed = parse_tokens_env("alpha:tenant=1,2,3,beta:tenant=4,5,6");
542        assert_eq!(parsed.deprecated_count, 0);
543        assert_eq!(parsed.token_scopes.len(), 2);
544        assert_eq!(
545            parsed.token_scopes["alpha"].tenants,
546            TenantScope::Set(ids([1, 2, 3]))
547        );
548        assert_eq!(
549            parsed.token_scopes["beta"].tenants,
550            TenantScope::Set(ids([4, 5, 6]))
551        );
552    }
553
554    #[test]
555    fn parse_env_bare_token_after_scoped_token() {
556        // Tricky-case the continuation logic: `a:tenant=*,bare` must parse
557        // as two entries, not one. The naive "no colon → continuation"
558        // rule would glue `bare` onto the wildcard list and produce
559        // WildcardMixedWithIds; the continuation-must-be-numeric rule
560        // correctly splits.
561        let parsed = parse_tokens_env("a:tenant=*,bare,b:tenant=4,5");
562        assert_eq!(parsed.token_scopes.len(), 3);
563        assert!(parsed.token_scopes["a"].tenants.is_all());
564        assert!(parsed.token_scopes["bare"].tenants.is_all());
565        assert_eq!(parsed.deprecated_count, 1, "`bare` is bare");
566        assert_eq!(
567            parsed.token_scopes["b"].tenants,
568            TenantScope::Set(ids([4, 5]))
569        );
570    }
571
572    #[test]
573    fn tenant_scope_allows_in_set() {
574        let s = TokenScope::from_tenants([TenantId(1), TenantId(2)]);
575        assert!(s.allows(TenantId(1)));
576        assert!(s.allows(TenantId(2)));
577        assert!(!s.allows(TenantId(3)));
578    }
579
580    #[test]
581    fn tenant_scope_wildcard_admits_arbitrary() {
582        let s = TokenScope::all();
583        assert!(s.allows(TenantId(0)));
584        assert!(s.allows(TenantId(u64::MAX)));
585    }
586}