Skip to main content

idprova_core/dat/
scope.rs

1use serde::{Deserialize, Serialize};
2use std::fmt;
3
4use crate::{IdprovaError, Result};
5
6/// A single permission scope in the format `namespace:protocol:resource:action`.
7///
8/// ## Grammar (4-part)
9///
10/// ```text
11/// scope = namespace ":" protocol ":" resource ":" action
12/// ```
13///
14/// Each component may be a literal value or `"*"` (wildcard, matches anything).
15///
16/// ## Fields
17///
18/// | Field       | Description                                      | Examples                      |
19/// |-------------|--------------------------------------------------|-------------------------------|
20/// | `namespace` | Protocol family                                  | `mcp`, `a2a`, `idprova`, `http` |
21/// | `protocol`  | Sub-protocol or category within the namespace    | `tool`, `prompt`, `resource`, `agent` |
22/// | `resource`  | Specific resource (tool name, endpoint, etc.)    | `filesystem`, `search`, `billing` |
23/// | `action`    | Operation being requested                        | `read`, `write`, `execute`, `call` |
24///
25/// ## Examples
26///
27/// ```text
28/// mcp:tool:filesystem:read     — read access to the filesystem MCP tool
29/// mcp:tool:*:*                 — all tools, any action
30/// mcp:tool:filesystem:*        — all actions on the filesystem tool
31/// a2a:agent:billing:execute    — execute on the billing A2A agent
32/// idprova:registry:aid:write   — write AIDs to the IDProva registry
33/// *:*:*:*                      — unrestricted (root delegation only)
34/// ```
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
36pub struct Scope {
37    /// Protocol family (e.g., "mcp", "a2a", "idprova").
38    pub namespace: String,
39    /// Sub-protocol within the namespace (e.g., "tool", "prompt", "agent").
40    pub protocol: String,
41    /// Specific resource targeted (e.g., "filesystem", "billing").
42    pub resource: String,
43    /// Action being requested (e.g., "read", "write", "execute").
44    pub action: String,
45}
46
47impl Scope {
48    /// Parse a 4-part scope string `namespace:protocol:resource:action`.
49    ///
50    /// Returns an error if the string does not contain exactly 4 colon-separated parts.
51    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    /// Check if this scope covers (permits) the requested scope.
68    ///
69    /// A scope covers another if each component either matches exactly
70    /// or this scope's component is the wildcard `"*"`.
71    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    /// Convert to the canonical string representation.
79    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/// A set of scopes that can be checked for permission coverage.
94#[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    /// Parse a list of scope strings.
105    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    /// Check if the scope set permits the requested scope.
111    pub fn permits(&self, requested: &Scope) -> bool {
112        self.scopes.iter().any(|s| s.covers(requested))
113    }
114
115    /// Check if this scope set is a subset of (narrower than or equal to) another.
116    /// Used to enforce scope narrowing in delegation chains.
117    pub fn is_subset_of(&self, parent: &ScopeSet) -> bool {
118        self.scopes.iter().all(|s| parent.permits(s))
119    }
120
121    /// Get the scopes as strings.
122    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        // Wildcard on all fields
175        let parent = Scope::parse("mcp:*:*:*").unwrap();
176        let child = Scope::parse("mcp:tool:filesystem:read").unwrap();
177        assert!(parent.covers(&child));
178
179        // Wildcard on resource + action only
180        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        // Cannot expand resource wildcard
229        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}