Skip to main content

teamcraft/domain/
tokens.rs

1//! Scopes of personal access tokens (threat model F2).
2//!
3//! A scope narrows what a token may do; it never widens the rights of the
4//! token's user. The REST API and the MCP endpoint enforce it centrally in
5//! the `ApiUser` extractor (`auth::ApiCaller`) and in the MCP dispatcher.
6
7use serde::{Deserialize, Serialize};
8
9/// The path of the MCP endpoint (the only one an `mcp_only` token reaches).
10pub const MCP_PATH: &str = "/api/v1/mcp";
11
12/// Whether a request approves or releases something: the review of a change
13/// order (its approval releases the revisions), the decision on a change
14/// request and making a revision obsolete. Needs the `approve` right of a
15/// token, i.e. scope `full`.
16pub fn is_approval(method: &str, path: &str) -> bool {
17    if method != "POST" {
18        return false;
19    }
20    let Some(rest) = path.strip_prefix("/api/v1/") else {
21        return false;
22    };
23    let parts: Vec<&str> = rest.trim_end_matches('/').split('/').collect();
24    matches!(
25        parts.as_slice(),
26        ["changes", _, "review"]
27            | ["change-requests", _, "decide"]
28            | ["items", _, "revisions", _, "obsolete"]
29    )
30}
31
32/// What a personal access token may do with the rights of its user.
33#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum TokenScope {
36    /// Everything the user may do: read, write and approve (REST and MCP).
37    /// Every token created before scopes existed, and the desktop app's
38    /// tokens, have it, so nothing changes for them.
39    #[default]
40    Full,
41    /// Read and write, but not approve: a change order cannot be reviewed
42    /// (approved or rejected, which releases it), a change request cannot be
43    /// decided and a revision cannot be made obsolete. For integrations that
44    /// create and edit data but must not release it.
45    Write,
46    /// Only reads: `GET`/`HEAD` requests and the read-only MCP tools.
47    ReadOnly,
48    /// Only the MCP endpoint (`POST /api/v1/mcp`), with all its tools.
49    McpOnly,
50}
51
52impl TokenScope {
53    pub const ALL: [TokenScope; 4] = [
54        TokenScope::Full,
55        TokenScope::Write,
56        TokenScope::ReadOnly,
57        TokenScope::McpOnly,
58    ];
59
60    pub fn as_str(self) -> &'static str {
61        match self {
62            TokenScope::Full => "full",
63            TokenScope::Write => "write",
64            TokenScope::ReadOnly => "read_only",
65            TokenScope::McpOnly => "mcp_only",
66        }
67    }
68
69    /// Accepts the stored names and their spellings with a dash.
70    pub fn parse(s: &str) -> Option<TokenScope> {
71        match s.trim().to_ascii_lowercase().replace('-', "_").as_str() {
72            "full" => Some(TokenScope::Full),
73            "write" => Some(TokenScope::Write),
74            "read_only" => Some(TokenScope::ReadOnly),
75            "mcp_only" => Some(TokenScope::McpOnly),
76            _ => None,
77        }
78    }
79
80    pub fn label(self) -> &'static str {
81        match self {
82            TokenScope::Full => "full access",
83            TokenScope::Write => "read and write, no approval",
84            TokenScope::ReadOnly => "read-only",
85            TokenScope::McpOnly => "MCP only",
86        }
87    }
88
89    /// Whether a request with this HTTP method to this path (the full path,
90    /// e.g. `/api/v1/items`) is within the scope. A `read_only` token may
91    /// post to the MCP endpoint; which tools it may call is checked there
92    /// ([`TokenScope::allows_tool`]).
93    pub fn allows_request(self, method: &str, path: &str) -> bool {
94        let read = matches!(method, "GET" | "HEAD");
95        let mcp = method == "POST" && path == MCP_PATH;
96        match self {
97            TokenScope::Full => true,
98            TokenScope::Write => !is_approval(method, path),
99            TokenScope::ReadOnly => read || mcp,
100            TokenScope::McpOnly => mcp,
101        }
102    }
103
104    /// Whether an MCP tool may be called (`read_only` = the tool only reads).
105    pub fn allows_tool(self, read_only: bool) -> bool {
106        self != TokenScope::ReadOnly || read_only
107    }
108
109    /// Why a request outside the scope was refused (403).
110    pub fn refusal(self) -> &'static str {
111        match self {
112            TokenScope::Full => "",
113            TokenScope::Write => {
114                "This token may not approve: it cannot review a change order, decide a change request or make a revision obsolete."
115            }
116            TokenScope::ReadOnly => {
117                "This token is read-only: it may send GET requests and call the read-only MCP tools."
118            }
119            TokenScope::McpOnly => "This token may only call the MCP endpoint (POST /api/v1/mcp).",
120        }
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn scopes_round_trip() {
130        for s in TokenScope::ALL {
131            assert_eq!(TokenScope::parse(s.as_str()), Some(s));
132        }
133        assert_eq!(TokenScope::parse(" Read-Only "), Some(TokenScope::ReadOnly));
134        assert_eq!(TokenScope::parse("mcp-only"), Some(TokenScope::McpOnly));
135        assert_eq!(TokenScope::parse("write"), Some(TokenScope::Write));
136        for bad in ["", "read", "admin", "approve"] {
137            assert_eq!(TokenScope::parse(bad), None, "{bad:?}");
138        }
139        assert_eq!(TokenScope::default(), TokenScope::Full);
140    }
141
142    #[test]
143    fn full_tokens_reach_everything() {
144        for (m, p) in [
145            ("GET", "/api/v1/items"),
146            ("POST", "/api/v1/items"),
147            ("DELETE", "/api/v1/tokens/x"),
148            ("POST", MCP_PATH),
149        ] {
150            assert!(TokenScope::Full.allows_request(m, p), "{m} {p}");
151        }
152        assert!(TokenScope::Full.allows_tool(false));
153    }
154
155    #[test]
156    fn write_tokens_do_everything_but_approve() {
157        let s = TokenScope::Write;
158        for (m, p) in [
159            ("GET", "/api/v1/items"),
160            ("POST", "/api/v1/items"),
161            ("PATCH", "/api/v1/items/PRT-000001"),
162            ("POST", "/api/v1/changes/ECO-1/submit"),
163            ("POST", "/api/v1/changes/ECO-1/withdraw"),
164            ("POST", "/api/v1/change-requests/CR-1/triage"),
165            ("POST", MCP_PATH),
166        ] {
167            assert!(s.allows_request(m, p), "{m} {p}");
168        }
169        for (m, p) in [
170            ("POST", "/api/v1/changes/ECO-1/review"),
171            ("POST", "/api/v1/changes/ECO-1/review/"),
172            ("POST", "/api/v1/change-requests/CR-1/decide"),
173            ("POST", "/api/v1/items/PRT-000001/revisions/B/obsolete"),
174        ] {
175            assert!(!s.allows_request(m, p), "{m} {p}");
176            assert!(TokenScope::Full.allows_request(m, p), "{m} {p}");
177        }
178        assert!(s.allows_tool(false));
179    }
180
181    #[test]
182    fn read_only_tokens_only_read() {
183        let s = TokenScope::ReadOnly;
184        assert!(s.allows_request("GET", "/api/v1/items"));
185        assert!(s.allows_request("HEAD", "/api/v1/files/x/content"));
186        assert!(s.allows_request("POST", MCP_PATH));
187        for (m, p) in [
188            ("POST", "/api/v1/items"),
189            ("PATCH", "/api/v1/items/PRT-000001"),
190            ("PUT", "/api/v1/projects/ROVER/visibility"),
191            ("DELETE", "/api/v1/tokens/x"),
192            ("POST", "/api/v1/assistant"),
193            ("POST", "/api/v1/mcp/"),
194            ("OPTIONS", "/api/v1/items"),
195        ] {
196            assert!(!s.allows_request(m, p), "{m} {p}");
197        }
198        assert!(s.allows_tool(true));
199        assert!(!s.allows_tool(false));
200    }
201
202    #[test]
203    fn mcp_only_tokens_only_reach_the_mcp_endpoint() {
204        let s = TokenScope::McpOnly;
205        assert!(s.allows_request("POST", MCP_PATH));
206        for (m, p) in [
207            ("GET", "/api/v1/items"),
208            ("GET", "/api/v1/me"),
209            ("GET", MCP_PATH),
210            ("POST", "/api/v1/items"),
211            ("POST", "/api/v1/assistant"),
212        ] {
213            assert!(!s.allows_request(m, p), "{m} {p}");
214        }
215        assert!(s.allows_tool(true));
216        assert!(s.allows_tool(false));
217    }
218}
219
220/// Found by cargo mutants: the stored values, labels and refusal messages of
221/// the scopes were not pinned.
222#[cfg(test)]
223mod text_tests {
224    use super::*;
225
226    #[test]
227    fn scope_texts() {
228        let scopes: Vec<(&str, &str)> = TokenScope::ALL
229            .iter()
230            .map(|s| (s.as_str(), s.label()))
231            .collect();
232        assert_eq!(
233            scopes,
234            [
235                ("full", "full access"),
236                ("write", "read and write, no approval"),
237                ("read_only", "read-only"),
238                ("mcp_only", "MCP only")
239            ]
240        );
241        // a full token is never refused; every other scope says why
242        assert_eq!(TokenScope::Full.refusal(), "");
243        assert!(TokenScope::Write.refusal().contains("may not approve"));
244        assert!(TokenScope::ReadOnly.refusal().contains("read-only"));
245        assert!(TokenScope::McpOnly.refusal().contains("MCP endpoint"));
246    }
247}