Skip to main content

isb_server/auth/
scope.rs

1//! API token scopes: what a token may do on top of its role.
2
3use super::{AuthError, AuthResult};
4
5/// What an API token may do on top of its role. A token with no scopes has
6/// the role's whole reach (agents administer their org by default); scopes
7/// only ever narrow it.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum Scope {
10    /// Read-only tools, never secret values.
11    Read,
12    /// `read`, plus deploys, redeploys, rollbacks, scaling and builds.
13    Deploy,
14    /// Everything the role allows, including tokens and members.
15    Admin,
16    /// Tools whose name matches a glob: `tool:app_*`.
17    Tools(String),
18}
19
20impl Scope {
21    pub fn parse(s: &str) -> AuthResult<Scope> {
22        let s = s.trim();
23        match s {
24            "read" => Ok(Scope::Read),
25            "deploy" => Ok(Scope::Deploy),
26            "admin" => Ok(Scope::Admin),
27            _ => match s.strip_prefix("tool:") {
28                Some(g)
29                    if !g.is_empty()
30                        && g.len() <= 128
31                        && g.bytes()
32                            .all(|b| b.is_ascii_alphanumeric() || b"_.-*?[]!^".contains(&b)) =>
33                {
34                    Ok(Scope::Tools(g.to_string()))
35                }
36                _ => Err(AuthError::Invalid(format!(
37                    "scope {s:?}: read, deploy, admin or tool:GLOB"
38                ))),
39            },
40        }
41    }
42
43    pub fn as_string(&self) -> String {
44        match self {
45            Scope::Read => "read".into(),
46            Scope::Deploy => "deploy".into(),
47            Scope::Admin => "admin".into(),
48            Scope::Tools(g) => format!("tool:{g}"),
49        }
50    }
51
52    /// Check a list, normalized and without duplicates.
53    pub fn normalize(v: &[String]) -> AuthResult<Vec<String>> {
54        if v.len() > 32 {
55            return Err(AuthError::Invalid("at most 32 scopes".into()));
56        }
57        let mut out: Vec<String> = Vec::new();
58        for s in v {
59            let s = Scope::parse(s)?.as_string();
60            if !out.contains(&s) {
61                out.push(s);
62            }
63        }
64        Ok(out)
65    }
66}