Skip to main content

hessra_context_token/
inspect.rs

1extern crate biscuit_auth as biscuit;
2
3use biscuit::macros::authorizer;
4use chrono::Utc;
5use hessra_token_core::{Biscuit, PublicKey, TokenError};
6
7/// Result of inspecting a context token.
8#[derive(Debug, Clone)]
9pub struct ContextInspectResult {
10    /// The subject (session owner) in the context token.
11    pub subject: String,
12    /// The exposure labels accumulated in the context token.
13    pub exposure_labels: Vec<String>,
14    /// The exposure sources that contributed exposure labels.
15    pub exposure_sources: Vec<String>,
16    /// Unix timestamp when the token expires (if extractable).
17    pub expiry: Option<i64>,
18    /// Whether the token is currently expired.
19    pub is_expired: bool,
20    /// Number of exposure blocks appended to the token.
21    pub exposure_block_count: usize,
22}
23
24/// Inspects a context token to extract session and exposure information.
25///
26/// This performs signature verification but does not enforce time checks,
27/// so expired tokens can still be inspected.
28///
29/// This is a diagnostic method. No authorization decision should flow from
30/// inspect output. For authorization checks, use
31/// `ContextVerifier::check_precluded_exposures` instead.
32///
33/// # Arguments
34/// * `token` - The base64-encoded context token
35/// * `public_key` - The public key used to verify the token signature
36///
37/// # Returns
38/// Inspection result with subject, exposure labels, sources, and expiry info
39pub fn inspect_context_token(
40    token: String,
41    public_key: PublicKey,
42) -> Result<ContextInspectResult, TokenError> {
43    let biscuit = Biscuit::from_base64(&token, public_key)?;
44    let now = Utc::now().timestamp();
45
46    // Extract the subject from the authority block via an authorizer query
47    let authorizer = authorizer!(
48        r#"
49            time({now});
50            allow if true;
51        "#
52    );
53
54    let mut authorizer = authorizer
55        .build(&biscuit)
56        .map_err(|e| TokenError::internal(format!("failed to build authorizer: {e}")))?;
57
58    let subjects: Vec<(String,)> = authorizer
59        .query("data($name) <- context($name)")
60        .map_err(|e| TokenError::internal(format!("failed to query context subject: {e}")))?;
61
62    let subject = subjects.first().map(|(s,)| s.clone()).unwrap_or_default();
63
64    // Extract exposure labels and sources from block source strings
65    let mut exposure_labels = Vec::new();
66    let mut exposure_sources = Vec::new();
67    let mut exposure_block_count = 0;
68
69    let block_count = biscuit.block_count();
70    for i in 0..block_count {
71        let block_source = biscuit.print_block_source(i).unwrap_or_default();
72        let mut block_has_exposure = false;
73
74        for line in block_source.lines() {
75            let trimmed = line.trim();
76            if let Some(rest) = trimmed.strip_prefix("exposure(") {
77                if let Some(label_str) = rest.strip_suffix(");") {
78                    let label = label_str.trim_matches('"').to_string();
79                    if !exposure_labels.contains(&label) {
80                        exposure_labels.push(label);
81                    }
82                    block_has_exposure = true;
83                }
84            }
85            if let Some(rest) = trimmed.strip_prefix("exposure_source(") {
86                if let Some(source_str) = rest.strip_suffix(");") {
87                    let source = source_str.trim_matches('"').to_string();
88                    if !exposure_sources.contains(&source) {
89                        exposure_sources.push(source);
90                    }
91                }
92            }
93        }
94
95        if block_has_exposure {
96            exposure_block_count += 1;
97        }
98    }
99
100    // Extract expiry from token content
101    let token_content = biscuit.print();
102    let expiry = extract_expiry_from_content(&token_content);
103    let is_expired = expiry.is_some_and(|exp| exp < now);
104
105    Ok(ContextInspectResult {
106        subject,
107        exposure_labels,
108        exposure_sources,
109        expiry,
110        is_expired,
111        exposure_block_count,
112    })
113}
114
115/// Extracts expiry timestamp from token content.
116fn extract_expiry_from_content(content: &str) -> Option<i64> {
117    let mut earliest_expiry: Option<i64> = None;
118
119    for line in content.lines() {
120        if line.contains("check if") && line.contains("time") && line.contains("<") {
121            if let Some(pos) = line.find("$time <") {
122                let after_lt = &line[pos + 8..].trim();
123                let number_str = after_lt
124                    .chars()
125                    .take_while(|c| c.is_ascii_digit() || *c == '-')
126                    .collect::<String>();
127
128                if let Ok(timestamp) = number_str.parse::<i64>() {
129                    earliest_expiry = Some(earliest_expiry.map_or(timestamp, |e| e.min(timestamp)));
130                }
131            }
132        }
133    }
134
135    earliest_expiry
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use crate::exposure::add_exposure;
142    use crate::mint::HessraContext;
143    use hessra_token_core::{KeyPair, TokenTimeConfig};
144
145    #[test]
146    fn test_inspect_fresh_context() {
147        let keypair = KeyPair::new();
148        let public_key = keypair.public();
149
150        let token = HessraContext::new("agent:openclaw".to_string(), TokenTimeConfig::default())
151            .issue(&keypair)
152            .expect("Failed to create context token");
153
154        let result =
155            inspect_context_token(token, public_key).expect("Failed to inspect context token");
156
157        assert_eq!(result.subject, "agent:openclaw");
158        assert!(result.exposure_labels.is_empty());
159        assert!(result.exposure_sources.is_empty());
160        assert!(!result.is_expired);
161        assert!(result.expiry.is_some());
162        assert_eq!(result.exposure_block_count, 0);
163    }
164
165    #[test]
166    fn test_inspect_exposed_context() {
167        let keypair = KeyPair::new();
168        let public_key = keypair.public();
169
170        let token = HessraContext::new("agent:openclaw".to_string(), TokenTimeConfig::default())
171            .issue(&keypair)
172            .expect("Failed to create context token");
173
174        let exposed = add_exposure(
175            &token,
176            public_key,
177            &["PII:SSN".to_string()],
178            "data:user-ssn".to_string(),
179        )
180        .expect("Failed to add exposure");
181
182        let result =
183            inspect_context_token(exposed, public_key).expect("Failed to inspect exposed context");
184
185        assert_eq!(result.subject, "agent:openclaw");
186        assert_eq!(result.exposure_labels, vec!["PII:SSN".to_string()]);
187        assert_eq!(result.exposure_sources, vec!["data:user-ssn".to_string()]);
188        assert_eq!(result.exposure_block_count, 1);
189    }
190
191    #[test]
192    fn test_inspect_multi_exposed_context() {
193        let keypair = KeyPair::new();
194        let public_key = keypair.public();
195
196        let token = HessraContext::new("agent:openclaw".to_string(), TokenTimeConfig::default())
197            .issue(&keypair)
198            .expect("Failed to create context token");
199
200        let exposed = add_exposure(
201            &token,
202            public_key,
203            &["PII:email".to_string(), "PII:address".to_string()],
204            "data:user-profile".to_string(),
205        )
206        .expect("Failed to add profile exposure");
207
208        let more_exposed = add_exposure(
209            &exposed,
210            public_key,
211            &["PII:SSN".to_string()],
212            "data:user-ssn".to_string(),
213        )
214        .expect("Failed to add SSN exposure");
215
216        let result = inspect_context_token(more_exposed, public_key)
217            .expect("Failed to inspect multi-exposed context");
218
219        assert_eq!(result.subject, "agent:openclaw");
220        assert_eq!(result.exposure_labels.len(), 3);
221        assert!(result.exposure_labels.contains(&"PII:email".to_string()));
222        assert!(result.exposure_labels.contains(&"PII:address".to_string()));
223        assert!(result.exposure_labels.contains(&"PII:SSN".to_string()));
224        assert_eq!(result.exposure_sources.len(), 2);
225        assert_eq!(result.exposure_block_count, 2);
226    }
227
228    #[test]
229    fn test_inspect_expired_context() {
230        let keypair = KeyPair::new();
231        let public_key = keypair.public();
232
233        let expired_config = TokenTimeConfig {
234            start_time: Some(0),
235            duration: 1,
236        };
237
238        let token = HessraContext::new("agent:test".to_string(), expired_config)
239            .issue(&keypair)
240            .expect("Failed to create expired context token");
241
242        let result = inspect_context_token(token, public_key)
243            .expect("Should be able to inspect expired token");
244
245        assert_eq!(result.subject, "agent:test");
246        assert!(result.is_expired);
247        assert_eq!(result.expiry, Some(1));
248    }
249}