1use std::collections::{HashMap, HashSet};
39use std::fmt;
40
41use tensor_wasm_core::types::TenantId;
42
43#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum TenantScope {
46 All,
50 Set(HashSet<TenantId>),
53}
54
55impl TenantScope {
56 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 pub fn is_all(&self) -> bool {
67 matches!(self, TenantScope::All)
68 }
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct TokenScope {
77 pub tenants: TenantScope,
79}
80
81impl TokenScope {
82 pub fn all() -> Self {
84 Self {
85 tenants: TenantScope::All,
86 }
87 }
88
89 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 pub fn allows(&self, tenant: TenantId) -> bool {
101 self.tenants.allows(tenant)
102 }
103}
104
105pub type BearerString = String;
109
110#[derive(Debug, Default, Clone)]
112pub struct ParsedTokens {
113 pub token_scopes: HashMap<BearerString, TokenScope>,
115 pub deprecated_count: usize,
119}
120
121#[derive(Debug, PartialEq, Eq)]
126pub enum ScopeParseError {
127 EmptyEntry,
129 MissingBearer,
131 EmptyTenantList,
133 InvalidTenant(String),
136 WildcardMixedWithIds,
141 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
169pub 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 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 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 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
248pub 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 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 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 let looks_like_tenant_continuation = trimmed == "*" || trimmed.parse::<u64>().is_ok();
297 if in_tenant_list && !has_colon && looks_like_tenant_continuation {
298 current.push(',');
300 current.push_str(trimmed);
301 } else {
302 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 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#[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 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 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 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 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 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}