idprova_core/dat/
scope.rs1use serde::{Deserialize, Serialize};
2use std::fmt;
3
4use crate::{IdprovaError, Result};
5
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
36pub struct Scope {
37 pub namespace: String,
39 pub protocol: String,
41 pub resource: String,
43 pub action: String,
45}
46
47impl Scope {
48 pub fn parse(s: &str) -> Result<Self> {
52 let parts: Vec<&str> = s.splitn(5, ':').collect();
53 if parts.len() != 4 {
54 return Err(IdprovaError::ScopeNotPermitted(format!(
55 "scope must have 4 parts (namespace:protocol:resource:action), got: {s}"
56 )));
57 }
58
59 Ok(Self {
60 namespace: parts[0].to_string(),
61 protocol: parts[1].to_string(),
62 resource: parts[2].to_string(),
63 action: parts[3].to_string(),
64 })
65 }
66
67 pub fn covers(&self, requested: &Scope) -> bool {
72 (self.namespace == "*" || self.namespace == requested.namespace)
73 && (self.protocol == "*" || self.protocol == requested.protocol)
74 && (self.resource == "*" || self.resource == requested.resource)
75 && (self.action == "*" || self.action == requested.action)
76 }
77
78 pub fn to_string_repr(&self) -> String {
80 format!(
81 "{}:{}:{}:{}",
82 self.namespace, self.protocol, self.resource, self.action
83 )
84 }
85}
86
87impl fmt::Display for Scope {
88 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89 write!(f, "{}", self.to_string_repr())
90 }
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct ScopeSet {
96 scopes: Vec<Scope>,
97}
98
99impl ScopeSet {
100 pub fn new(scopes: Vec<Scope>) -> Self {
101 Self { scopes }
102 }
103
104 pub fn parse(scope_strings: &[String]) -> Result<Self> {
106 let scopes: Result<Vec<Scope>> = scope_strings.iter().map(|s| Scope::parse(s)).collect();
107 Ok(Self { scopes: scopes? })
108 }
109
110 pub fn permits(&self, requested: &Scope) -> bool {
112 self.scopes.iter().any(|s| s.covers(requested))
113 }
114
115 pub fn is_subset_of(&self, parent: &ScopeSet) -> bool {
118 self.scopes.iter().all(|s| parent.permits(s))
119 }
120
121 pub fn to_strings(&self) -> Vec<String> {
123 self.scopes.iter().map(|s| s.to_string_repr()).collect()
124 }
125
126 pub fn iter(&self) -> impl Iterator<Item = &Scope> {
127 self.scopes.iter()
128 }
129
130 pub fn len(&self) -> usize {
131 self.scopes.len()
132 }
133
134 pub fn is_empty(&self) -> bool {
135 self.scopes.is_empty()
136 }
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142
143 #[test]
144 fn test_parse_scope() {
145 let s = Scope::parse("mcp:tool:filesystem:read").unwrap();
146 assert_eq!(s.namespace, "mcp");
147 assert_eq!(s.protocol, "tool");
148 assert_eq!(s.resource, "filesystem");
149 assert_eq!(s.action, "read");
150 }
151
152 #[test]
153 fn test_parse_scope_rejects_3_parts() {
154 assert!(
155 Scope::parse("mcp:tool:read").is_err(),
156 "3-part scopes must be rejected — use 4 parts: mcp:tool:*:read"
157 );
158 }
159
160 #[test]
161 fn test_parse_scope_rejects_2_parts() {
162 assert!(Scope::parse("mcp:tool").is_err());
163 }
164
165 #[test]
166 fn test_scope_covers_exact() {
167 let parent = Scope::parse("mcp:tool:filesystem:read").unwrap();
168 let child = Scope::parse("mcp:tool:filesystem:read").unwrap();
169 assert!(parent.covers(&child));
170 }
171
172 #[test]
173 fn test_scope_wildcard_covers() {
174 let parent = Scope::parse("mcp:*:*:*").unwrap();
176 let child = Scope::parse("mcp:tool:filesystem:read").unwrap();
177 assert!(parent.covers(&child));
178
179 let partial = Scope::parse("mcp:tool:*:*").unwrap();
181 assert!(partial.covers(&child));
182 assert!(!partial.covers(&Scope::parse("a2a:agent:billing:execute").unwrap()));
183 }
184
185 #[test]
186 fn test_scope_wildcard_action_only() {
187 let parent = Scope::parse("mcp:tool:filesystem:*").unwrap();
188 assert!(parent.covers(&Scope::parse("mcp:tool:filesystem:read").unwrap()));
189 assert!(parent.covers(&Scope::parse("mcp:tool:filesystem:write").unwrap()));
190 assert!(!parent.covers(&Scope::parse("mcp:tool:search:read").unwrap()));
191 }
192
193 #[test]
194 fn test_scope_does_not_cover() {
195 let parent = Scope::parse("mcp:tool:filesystem:read").unwrap();
196 let child = Scope::parse("mcp:tool:filesystem:write").unwrap();
197 assert!(!parent.covers(&child));
198 }
199
200 #[test]
201 fn test_scope_set_permits() {
202 let set = ScopeSet::parse(&[
203 "mcp:tool:filesystem:read".to_string(),
204 "mcp:resource:data:read".to_string(),
205 ])
206 .unwrap();
207
208 assert!(set.permits(&Scope::parse("mcp:tool:filesystem:read").unwrap()));
209 assert!(set.permits(&Scope::parse("mcp:resource:data:read").unwrap()));
210 assert!(!set.permits(&Scope::parse("mcp:tool:filesystem:write").unwrap()));
211 assert!(!set.permits(&Scope::parse("a2a:agent:billing:execute").unwrap()));
212 }
213
214 #[test]
215 fn test_scope_set_narrowing() {
216 let parent = ScopeSet::parse(&["mcp:*:*:*".to_string()]).unwrap();
217 let child = ScopeSet::parse(&["mcp:tool:filesystem:read".to_string()]).unwrap();
218 assert!(child.is_subset_of(&parent));
219 assert!(!parent.is_subset_of(&child));
220 }
221
222 #[test]
223 fn test_scope_set_narrowing_partial_wildcard() {
224 let parent = ScopeSet::parse(&["mcp:tool:*:read".to_string()]).unwrap();
225 let child = ScopeSet::parse(&["mcp:tool:filesystem:read".to_string()]).unwrap();
226 assert!(child.is_subset_of(&parent));
227
228 let wider = ScopeSet::parse(&["mcp:tool:*:*".to_string()]).unwrap();
230 assert!(!wider.is_subset_of(&parent));
231 }
232
233 #[test]
234 fn test_scope_display() {
235 let s = Scope::parse("mcp:tool:filesystem:read").unwrap();
236 assert_eq!(s.to_string(), "mcp:tool:filesystem:read");
237 }
238}