1use serde::{Deserialize, Serialize};
8
9pub const MCP_PATH: &str = "/api/v1/mcp";
11
12pub 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum TokenScope {
36 #[default]
40 Full,
41 Write,
46 ReadOnly,
48 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 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 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 pub fn allows_tool(self, read_only: bool) -> bool {
106 self != TokenScope::ReadOnly || read_only
107 }
108
109 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#[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 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}