Skip to main content

agentic_identity/trust/
capability.rs

1//! Capability URI parsing and wildcard matching.
2//!
3//! Capabilities use a URI scheme: `action:resource` with wildcards.
4//! Examples:
5//!   - `read:calendar` — read calendar specifically
6//!   - `read:*` — read anything
7//!   - `execute:deploy:production` — execute deploy to production
8//!   - `execute:deploy:*` — execute deploy to any environment
9//!   - `*` — all capabilities (root trust)
10
11use serde::{Deserialize, Serialize};
12
13/// A capability being granted.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct Capability {
16    /// Capability URI (e.g., "read:calendar", "execute:deploy:production").
17    pub uri: String,
18    /// Human-readable description.
19    pub description: Option<String>,
20    /// Capability-specific constraints (arbitrary JSON).
21    pub constraints: Option<serde_json::Value>,
22}
23
24impl Capability {
25    /// Create a new capability from a URI string.
26    pub fn new(uri: impl Into<String>) -> Self {
27        Self {
28            uri: uri.into(),
29            description: None,
30            constraints: None,
31        }
32    }
33
34    /// Create a capability with a description.
35    pub fn with_description(uri: impl Into<String>, description: impl Into<String>) -> Self {
36        Self {
37            uri: uri.into(),
38            description: Some(description.into()),
39            constraints: None,
40        }
41    }
42
43    /// Check whether this capability's URI covers (grants) a requested URI.
44    ///
45    /// Matching rules:
46    /// - `*` matches everything
47    /// - `action:*` matches any resource under `action:`
48    /// - `action:resource` matches exactly
49    /// - `action:resource:*` matches anything under `action:resource:`
50    pub fn covers(&self, requested: &str) -> bool {
51        capability_uri_covers(&self.uri, requested)
52    }
53}
54
55impl PartialEq for Capability {
56    fn eq(&self, other: &Self) -> bool {
57        self.uri == other.uri
58    }
59}
60
61impl Eq for Capability {}
62
63/// Check whether a granted URI covers a requested URI.
64///
65/// This is the core wildcard matching logic for capability URIs.
66pub fn capability_uri_covers(granted: &str, requested: &str) -> bool {
67    // Universal wildcard
68    if granted == "*" {
69        return true;
70    }
71
72    // Exact match
73    if granted == requested {
74        return true;
75    }
76
77    // Wildcard suffix matching: "read:*" covers "read:calendar"
78    if let Some(prefix) = granted.strip_suffix(":*") {
79        // requested must start with the prefix followed by ':'
80        if requested == prefix {
81            return true;
82        }
83        if requested.starts_with(prefix) && requested.as_bytes().get(prefix.len()) == Some(&b':') {
84            return true;
85        }
86    }
87
88    // Wildcard suffix with path-like matching: "storage/*" covers "storage/files/readme.md"
89    if let Some(prefix) = granted.strip_suffix("/*") {
90        if requested == prefix {
91            return true;
92        }
93        if requested.starts_with(prefix) && requested.as_bytes().get(prefix.len()) == Some(&b'/') {
94            return true;
95        }
96    }
97
98    false
99}
100
101/// Check if a set of granted capabilities covers a single requested capability URI.
102pub fn capabilities_cover(granted: &[Capability], requested: &str) -> bool {
103    granted.iter().any(|cap| cap.covers(requested))
104}
105
106/// Check if a set of granted capabilities covers ALL requested capability URIs.
107pub fn capabilities_cover_all(granted: &[Capability], requested: &[&str]) -> bool {
108    requested.iter().all(|req| capabilities_cover(granted, req))
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn test_exact_match() {
117        assert!(capability_uri_covers("read:calendar", "read:calendar"));
118        assert!(!capability_uri_covers("read:calendar", "write:calendar"));
119    }
120
121    #[test]
122    fn test_universal_wildcard() {
123        assert!(capability_uri_covers("*", "read:calendar"));
124        assert!(capability_uri_covers("*", "write:anything:at:all"));
125        assert!(capability_uri_covers("*", "*"));
126    }
127
128    #[test]
129    fn test_action_wildcard() {
130        assert!(capability_uri_covers("read:*", "read:calendar"));
131        assert!(capability_uri_covers("read:*", "read:email"));
132        assert!(capability_uri_covers("read:*", "read:anything:nested"));
133        assert!(!capability_uri_covers("read:*", "write:calendar"));
134        assert!(!capability_uri_covers("read:*", "reading:calendar"));
135    }
136
137    #[test]
138    fn test_nested_wildcard() {
139        assert!(capability_uri_covers(
140            "execute:deploy:*",
141            "execute:deploy:production"
142        ));
143        assert!(capability_uri_covers(
144            "execute:deploy:*",
145            "execute:deploy:staging"
146        ));
147        assert!(!capability_uri_covers(
148            "execute:deploy:*",
149            "execute:build:production"
150        ));
151    }
152
153    #[test]
154    fn test_path_wildcard() {
155        assert!(capability_uri_covers("storage/*", "storage/files"));
156        assert!(capability_uri_covers(
157            "storage/*",
158            "storage/files/readme.md"
159        ));
160        assert!(!capability_uri_covers("storage/*", "other/files"));
161    }
162
163    #[test]
164    fn test_no_partial_prefix_match() {
165        // "read:*" should NOT match "reading:calendar"
166        assert!(!capability_uri_covers("read:*", "reading:calendar"));
167        // "read:cal" should NOT match "read:calendar" (no wildcard)
168        assert!(!capability_uri_covers("read:cal", "read:calendar"));
169    }
170
171    #[test]
172    fn test_capabilities_cover_set() {
173        let caps = vec![Capability::new("read:*"), Capability::new("write:calendar")];
174        assert!(capabilities_cover(&caps, "read:email"));
175        assert!(capabilities_cover(&caps, "write:calendar"));
176        assert!(!capabilities_cover(&caps, "write:email"));
177    }
178
179    #[test]
180    fn test_capabilities_cover_all_set() {
181        let caps = vec![Capability::new("read:*"), Capability::new("write:calendar")];
182        assert!(capabilities_cover_all(
183            &caps,
184            &["read:email", "write:calendar"]
185        ));
186        assert!(!capabilities_cover_all(
187            &caps,
188            &["read:email", "write:email"]
189        ));
190    }
191
192    #[test]
193    fn test_capability_equality() {
194        let a = Capability::new("read:calendar");
195        let b = Capability::new("read:calendar");
196        let c = Capability::with_description("read:calendar", "Can read calendar events");
197        assert_eq!(a, b);
198        // Equality is based on URI only
199        assert_eq!(a, c);
200    }
201}