Skip to main content

mixtape_core/permission/
grant.rs

1//! Permission grant types.
2//!
3//! A grant represents stored permission to execute a tool, either for
4//! any invocation or for a specific set of parameters.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9/// Determines how long a permission grant persists.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
11pub enum Scope {
12    /// Grant lives in memory only, cleared when process exits.
13    #[default]
14    Session,
15
16    /// Grant persists to storage (location determined by store).
17    Persistent,
18}
19
20impl std::fmt::Display for Scope {
21    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        match self {
23            Scope::Session => write!(f, "Session"),
24            Scope::Persistent => write!(f, "Persistent"),
25        }
26    }
27}
28
29/// A stored permission grant.
30///
31/// Grants allow tool execution either unconditionally (entire tool) or
32/// for specific parameter combinations (exact match).
33///
34/// # Example
35///
36/// ```rust
37/// use mixtape_core::permission::{Grant, Scope};
38///
39/// // Trust entire tool
40/// let grant = Grant::tool("echo");
41///
42/// // Trust specific parameters (hash computed from JSON)
43/// let grant = Grant::exact("database", "abc123def456");
44///
45/// // With persistence
46/// let grant = Grant::tool("safe_tool").with_scope(Scope::Persistent);
47/// ```
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct Grant {
50    /// Tool name this grant applies to.
51    pub tool: String,
52
53    /// SHA256 hash of canonical JSON parameters, or None for entire tool.
54    ///
55    /// When Some, only invocations with exactly matching parameters are allowed.
56    /// When None, all invocations of this tool are allowed.
57    pub params_hash: Option<String>,
58
59    /// Where this grant should be stored.
60    #[serde(default)]
61    pub scope: Scope,
62
63    /// When the grant was created.
64    pub created_at: DateTime<Utc>,
65}
66
67impl Grant {
68    /// Create a grant that trusts the entire tool (any parameters).
69    pub fn tool(name: impl Into<String>) -> Self {
70        Self {
71            tool: name.into(),
72            params_hash: None,
73            scope: Scope::default(),
74            created_at: Utc::now(),
75        }
76    }
77
78    /// Create a grant that trusts a specific parameter combination.
79    ///
80    /// The hash should be computed from the canonical JSON of the parameters.
81    /// Use [`hash_params`] to compute this.
82    pub fn exact(name: impl Into<String>, params_hash: impl Into<String>) -> Self {
83        Self {
84            tool: name.into(),
85            params_hash: Some(params_hash.into()),
86            scope: Scope::default(),
87            created_at: Utc::now(),
88        }
89    }
90
91    /// Set the scope for this grant.
92    pub fn with_scope(mut self, scope: Scope) -> Self {
93        self.scope = scope;
94        self
95    }
96
97    /// Check if this grant covers the entire tool.
98    pub fn is_tool_wide(&self) -> bool {
99        self.params_hash.is_none()
100    }
101
102    /// Check if this grant matches a specific params hash.
103    pub fn matches(&self, params_hash: &str) -> bool {
104        match &self.params_hash {
105            None => true, // Tool-wide grant matches everything
106            Some(h) => h == params_hash,
107        }
108    }
109}
110
111impl PartialEq for Grant {
112    fn eq(&self, other: &Self) -> bool {
113        self.tool == other.tool
114            && self.params_hash == other.params_hash
115            && self.scope == other.scope
116    }
117}
118
119impl Eq for Grant {}
120
121/// Compute a hash of parameters for exact-match grants.
122///
123/// This creates a deterministic hash from JSON parameters using canonical
124/// JSON (sorted keys) to ensure consistent hashing regardless of key order.
125pub fn hash_params(params: &serde_json::Value) -> String {
126    use sha2::{Digest, Sha256};
127
128    let canonical = canonicalize_json(params);
129    let json = serde_json::to_string(&canonical).unwrap_or_default();
130    let hash = Sha256::digest(json.as_bytes());
131    format!("{:x}", hash)
132}
133
134/// Convert a JSON value to canonical form with sorted keys.
135fn canonicalize_json(value: &serde_json::Value) -> serde_json::Value {
136    use serde_json::Value;
137    use std::collections::BTreeMap;
138
139    match value {
140        Value::Object(map) => {
141            let sorted: BTreeMap<_, _> = map
142                .iter()
143                .map(|(k, v)| (k.clone(), canonicalize_json(v)))
144                .collect();
145            Value::Object(sorted.into_iter().collect())
146        }
147        Value::Array(arr) => Value::Array(arr.iter().map(canonicalize_json).collect()),
148        other => other.clone(),
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn test_grant_tool() {
158        let grant = Grant::tool("echo");
159        assert_eq!(grant.tool, "echo");
160        assert!(grant.params_hash.is_none());
161        assert!(grant.is_tool_wide());
162        assert_eq!(grant.scope, Scope::Session);
163    }
164
165    #[test]
166    fn test_grant_exact() {
167        let grant = Grant::exact("database", "abc123");
168        assert_eq!(grant.tool, "database");
169        assert_eq!(grant.params_hash, Some("abc123".to_string()));
170        assert!(!grant.is_tool_wide());
171    }
172
173    #[test]
174    fn test_grant_with_scope() {
175        let grant = Grant::tool("test").with_scope(Scope::Persistent);
176        assert_eq!(grant.scope, Scope::Persistent);
177    }
178
179    #[test]
180    fn test_grant_matches() {
181        let tool_grant = Grant::tool("test");
182        assert!(tool_grant.matches("any_hash"));
183        assert!(tool_grant.matches("other_hash"));
184
185        let exact_grant = Grant::exact("test", "specific_hash");
186        assert!(exact_grant.matches("specific_hash"));
187        assert!(!exact_grant.matches("other_hash"));
188    }
189
190    #[test]
191    fn test_hash_params() {
192        let params = serde_json::json!({"key": "value"});
193        let hash = hash_params(&params);
194        assert!(!hash.is_empty());
195        assert_eq!(hash.len(), 64); // SHA256 hex = 64 chars
196
197        // Same params = same hash
198        let params2 = serde_json::json!({"key": "value"});
199        assert_eq!(hash_params(&params2), hash);
200
201        // Different params = different hash
202        let params3 = serde_json::json!({"key": "other"});
203        assert_ne!(hash_params(&params3), hash);
204    }
205
206    #[test]
207    fn test_hash_params_canonical_order() {
208        // Different key order should produce same hash
209        let params1 = serde_json::json!({"a": 1, "b": 2, "c": 3});
210        let params2 = serde_json::json!({"c": 3, "b": 2, "a": 1});
211        assert_eq!(hash_params(&params1), hash_params(&params2));
212
213        // Nested objects too
214        let nested1 = serde_json::json!({"outer": {"z": 1, "a": 2}});
215        let nested2 = serde_json::json!({"outer": {"a": 2, "z": 1}});
216        assert_eq!(hash_params(&nested1), hash_params(&nested2));
217    }
218
219    #[test]
220    fn test_scope_display() {
221        assert_eq!(Scope::Session.to_string(), "Session");
222        assert_eq!(Scope::Persistent.to_string(), "Persistent");
223    }
224
225    #[test]
226    fn test_grant_equality() {
227        let g1 = Grant::tool("test");
228        let g2 = Grant::tool("test");
229        assert_eq!(g1, g2); // Same despite different created_at
230
231        let g3 = Grant::exact("test", "hash");
232        assert_ne!(g1, g3); // Different params_hash
233    }
234
235    #[test]
236    fn test_grant_serialization() {
237        let grant = Grant::exact("tool", "hash123").with_scope(Scope::Persistent);
238        let json = serde_json::to_string(&grant).unwrap();
239        let parsed: Grant = serde_json::from_str(&json).unwrap();
240
241        assert_eq!(grant.tool, parsed.tool);
242        assert_eq!(grant.params_hash, parsed.params_hash);
243        assert_eq!(grant.scope, parsed.scope);
244    }
245}