Skip to main content

hessra_cap_token/
mint.rs

1extern crate biscuit_auth as biscuit;
2
3use biscuit::macros::{biscuit, check};
4use chrono::Utc;
5use hessra_token_core::{KeyPair, TokenTimeConfig};
6use std::error::Error;
7use tracing::info;
8
9/// Builder for creating Hessra capability tokens with flexible configuration.
10///
11/// Capability tokens grant access to a resource+operation. The subject field is retained
12/// for auditing, but the token no longer requires the verifier to prove who is presenting it.
13/// Presenting the capability IS the authorization.
14///
15/// # Example
16/// ```rust
17/// use hessra_cap_token::HessraCapability;
18/// use hessra_token_core::{KeyPair, TokenTimeConfig};
19///
20/// let keypair = KeyPair::new();
21///
22/// // Basic capability token
23/// let token = HessraCapability::new(
24///     "alice".to_string(),
25///     "resource1".to_string(),
26///     "read".to_string(),
27///     TokenTimeConfig::default()
28/// )
29/// .issue(&keypair)
30/// .expect("Failed to create token");
31/// ```
32pub struct HessraCapability {
33    subject: Option<String>,
34    resource: Option<String>,
35    operation: Option<String>,
36    time_config: TokenTimeConfig,
37    namespace: Option<String>,
38}
39
40impl HessraCapability {
41    /// Creates a new capability token builder.
42    ///
43    /// # Arguments
44    /// * `subject` - The subject (user) identifier (retained for auditing)
45    /// * `resource` - The resource identifier to grant access to
46    /// * `operation` - The operation to grant access to
47    /// * `time_config` - Time configuration for token validity
48    pub fn new(
49        subject: String,
50        resource: String,
51        operation: String,
52        time_config: TokenTimeConfig,
53    ) -> Self {
54        Self {
55            subject: Some(subject),
56            resource: Some(resource),
57            operation: Some(operation),
58            time_config,
59            namespace: None,
60        }
61    }
62
63    /// Restricts the capability to a specific namespace.
64    ///
65    /// Adds a namespace restriction check to the authority block:
66    /// - `check if namespace({namespace})`
67    ///
68    /// # Arguments
69    /// * `namespace` - The namespace to restrict to (e.g., "myapp.hessra.dev")
70    pub fn namespace_restricted(mut self, namespace: String) -> Self {
71        self.namespace = Some(namespace);
72        self
73    }
74
75    /// Issues (builds and signs) the capability token.
76    ///
77    /// # Arguments
78    /// * `keypair` - The keypair to sign the token with
79    ///
80    /// # Returns
81    /// Base64-encoded biscuit token
82    pub fn issue(self, keypair: &KeyPair) -> Result<String, Box<dyn Error>> {
83        let start_time = self
84            .time_config
85            .start_time
86            .unwrap_or_else(|| Utc::now().timestamp());
87        let expiration = start_time + self.time_config.duration;
88
89        let namespace = self.namespace;
90
91        let subject = self.subject.ok_or("Token requires subject")?;
92        let resource = self.resource.ok_or("Token requires resource")?;
93        let operation = self.operation.ok_or("Token requires operation")?;
94
95        // Build authority block -- subject removed from the check.
96        // The right fact still has 3 fields (subject stays for auditing).
97        // $sub becomes a free variable -- the token no longer demands the
98        // verifier prove who is presenting it.
99        let mut biscuit_builder = biscuit!(
100            r#"
101                right({subject}, {resource}, {operation});
102                check if resource($res), operation($op), right($sub, $res, $op);
103                check if time($time), $time < {expiration};
104            "#
105        );
106
107        // Add namespace restriction if specified
108        if let Some(namespace) = namespace {
109            biscuit_builder = biscuit_builder.check(check!(
110                r#"
111                    check if namespace({namespace});
112                "#
113            ))?;
114        }
115
116        // Build and sign the biscuit
117        let biscuit = biscuit_builder.build(keypair)?;
118        info!("biscuit (authority): {}", biscuit);
119        let token = biscuit.to_base64()?;
120        Ok(token)
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use crate::verify::CapabilityVerifier;
128    use chrono::Utc;
129
130    #[test]
131    fn test_create_and_verify_capability() {
132        let subject = "test@test.com".to_owned();
133        let resource = "res1".to_string();
134        let operation = "read".to_string();
135        let root = KeyPair::new();
136        let public_key = root.public();
137
138        let token = HessraCapability::new(
139            subject.clone(),
140            resource.clone(),
141            operation.clone(),
142            TokenTimeConfig::default(),
143        )
144        .issue(&root)
145        .expect("Failed to create token");
146
147        let res = CapabilityVerifier::new(token, public_key, resource, operation).verify();
148        assert!(res.is_ok());
149    }
150
151    #[test]
152    fn test_capability_without_subject() {
153        let subject = "alice".to_owned();
154        let resource = "res1".to_string();
155        let operation = "read".to_string();
156        let root = KeyPair::new();
157        let public_key = root.public();
158
159        let token = HessraCapability::new(
160            subject.clone(),
161            resource.clone(),
162            operation.clone(),
163            TokenTimeConfig::default(),
164        )
165        .issue(&root)
166        .expect("Failed to create token");
167
168        // Verify without subject -- the core capability change
169        let res = CapabilityVerifier::new(token, public_key, resource, operation).verify();
170        assert!(
171            res.is_ok(),
172            "Capability verification without subject should succeed"
173        );
174    }
175
176    #[test]
177    fn test_capability_with_optional_subject() {
178        let subject = "alice".to_owned();
179        let resource = "res1".to_string();
180        let operation = "read".to_string();
181        let root = KeyPair::new();
182        let public_key = root.public();
183
184        let token = HessraCapability::new(
185            subject.clone(),
186            resource.clone(),
187            operation.clone(),
188            TokenTimeConfig::default(),
189        )
190        .issue(&root)
191        .expect("Failed to create token");
192
193        // Verify with optional subject check
194        let res = CapabilityVerifier::new(
195            token.clone(),
196            public_key,
197            resource.clone(),
198            operation.clone(),
199        )
200        .with_subject(subject.clone())
201        .verify();
202        assert!(
203            res.is_ok(),
204            "Verification with correct subject should succeed"
205        );
206
207        // Verify with wrong subject should fail
208        let res = CapabilityVerifier::new(
209            token.clone(),
210            public_key,
211            resource.clone(),
212            operation.clone(),
213        )
214        .with_subject("bob".to_string())
215        .verify();
216        assert!(res.is_err(), "Verification with wrong subject should fail");
217    }
218
219    #[test]
220    fn test_wrong_resource_rejected() {
221        let root = KeyPair::new();
222        let public_key = root.public();
223
224        let token = HessraCapability::new(
225            "alice".to_string(),
226            "res1".to_string(),
227            "read".to_string(),
228            TokenTimeConfig::default(),
229        )
230        .issue(&root)
231        .expect("Failed to create token");
232
233        let res =
234            CapabilityVerifier::new(token, public_key, "res2".to_string(), "read".to_string())
235                .verify();
236        assert!(res.is_err(), "Wrong resource should be rejected");
237    }
238
239    #[test]
240    fn test_wrong_operation_rejected() {
241        let root = KeyPair::new();
242        let public_key = root.public();
243
244        let token = HessraCapability::new(
245            "alice".to_string(),
246            "res1".to_string(),
247            "read".to_string(),
248            TokenTimeConfig::default(),
249        )
250        .issue(&root)
251        .expect("Failed to create token");
252
253        let res =
254            CapabilityVerifier::new(token, public_key, "res1".to_string(), "write".to_string())
255                .verify();
256        assert!(res.is_err(), "Wrong operation should be rejected");
257    }
258
259    #[test]
260    fn test_biscuit_expiration() {
261        let subject = "test@test.com".to_owned();
262        let resource = "res1".to_string();
263        let operation = "read".to_string();
264        let root = KeyPair::new();
265        let public_key = root.public();
266
267        // Create a valid token
268        let token = HessraCapability::new(
269            subject.clone(),
270            resource.clone(),
271            operation.clone(),
272            TokenTimeConfig::default(),
273        )
274        .issue(&root)
275        .expect("Failed to create token");
276
277        let res = CapabilityVerifier::new(token, public_key, resource.clone(), operation.clone())
278            .verify();
279        assert!(res.is_ok());
280
281        // Create an expired token
282        let root = KeyPair::new();
283        let public_key = root.public();
284        let token = HessraCapability::new(
285            subject.clone(),
286            resource.clone(),
287            operation.clone(),
288            TokenTimeConfig {
289                start_time: Some(Utc::now().timestamp() - 301),
290                duration: 300,
291            },
292        )
293        .issue(&root)
294        .expect("Failed to create expired token");
295
296        let res = CapabilityVerifier::new(token, public_key, resource, operation).verify();
297        assert!(res.is_err(), "Expired token should be rejected");
298    }
299
300    #[test]
301    fn test_namespace_restricted_capability() {
302        let keypair = KeyPair::new();
303        let public_key = keypair.public();
304
305        let token = HessraCapability::new(
306            "alice".to_string(),
307            "resource1".to_string(),
308            "read".to_string(),
309            TokenTimeConfig::default(),
310        )
311        .namespace_restricted("myapp.hessra.dev".to_string())
312        .issue(&keypair)
313        .expect("Failed to create namespace-restricted token");
314
315        // Should pass with matching namespace
316        let res = CapabilityVerifier::new(
317            token.clone(),
318            public_key,
319            "resource1".to_string(),
320            "read".to_string(),
321        )
322        .with_namespace("myapp.hessra.dev".to_string())
323        .verify();
324        assert!(res.is_ok(), "Should pass with matching namespace");
325
326        // Should fail without namespace
327        let res = CapabilityVerifier::new(
328            token.clone(),
329            public_key,
330            "resource1".to_string(),
331            "read".to_string(),
332        )
333        .verify();
334        assert!(res.is_err(), "Should fail without namespace");
335
336        // Should fail with wrong namespace
337        let res = CapabilityVerifier::new(
338            token.clone(),
339            public_key,
340            "resource1".to_string(),
341            "read".to_string(),
342        )
343        .with_namespace("wrong.com".to_string())
344        .verify();
345        assert!(res.is_err(), "Should fail with wrong namespace");
346    }
347
348    #[test]
349    fn test_designation_attenuation() {
350        let keypair = KeyPair::new();
351        let public_key = keypair.public();
352
353        // Mint a basic token
354        let token = HessraCapability::new(
355            "alice".to_string(),
356            "resource1".to_string(),
357            "read".to_string(),
358            TokenTimeConfig::default(),
359        )
360        .issue(&keypair)
361        .expect("Failed to create token");
362
363        // Attenuate with a designation
364        let attenuated = crate::attenuate::DesignationBuilder::from_base64(token, public_key)
365            .expect("Failed to create designation builder")
366            .designate("tenant_id".to_string(), "t-123".to_string())
367            .attenuate_base64()
368            .expect("Failed to attenuate");
369
370        // Verify with matching designation
371        let res = CapabilityVerifier::new(
372            attenuated.clone(),
373            public_key,
374            "resource1".to_string(),
375            "read".to_string(),
376        )
377        .with_designation("tenant_id".to_string(), "t-123".to_string())
378        .verify();
379        assert!(res.is_ok(), "Should pass with matching designation");
380
381        // Verify with wrong designation value
382        let res = CapabilityVerifier::new(
383            attenuated.clone(),
384            public_key,
385            "resource1".to_string(),
386            "read".to_string(),
387        )
388        .with_designation("tenant_id".to_string(), "t-999".to_string())
389        .verify();
390        assert!(res.is_err(), "Should fail with wrong designation value");
391
392        // Verify without designation should fail
393        let res = CapabilityVerifier::new(
394            attenuated.clone(),
395            public_key,
396            "resource1".to_string(),
397            "read".to_string(),
398        )
399        .verify();
400        assert!(res.is_err(), "Should fail without designation");
401    }
402
403    #[test]
404    fn test_multi_designation() {
405        let keypair = KeyPair::new();
406        let public_key = keypair.public();
407
408        let token = HessraCapability::new(
409            "alice".to_string(),
410            "resource1".to_string(),
411            "read".to_string(),
412            TokenTimeConfig::default(),
413        )
414        .issue(&keypair)
415        .expect("Failed to create token");
416
417        // Attenuate with multiple designations
418        let attenuated = crate::attenuate::DesignationBuilder::from_base64(token, public_key)
419            .expect("Failed to create designation builder")
420            .designate("tenant_id".to_string(), "t-123".to_string())
421            .designate("user_id".to_string(), "u-456".to_string())
422            .attenuate_base64()
423            .expect("Failed to attenuate");
424
425        // Verify with both designations
426        let res = CapabilityVerifier::new(
427            attenuated.clone(),
428            public_key,
429            "resource1".to_string(),
430            "read".to_string(),
431        )
432        .with_designation("tenant_id".to_string(), "t-123".to_string())
433        .with_designation("user_id".to_string(), "u-456".to_string())
434        .verify();
435        assert!(res.is_ok(), "Should pass with both designations");
436
437        // Verify with only one designation should fail (missing the other)
438        let res = CapabilityVerifier::new(
439            attenuated.clone(),
440            public_key,
441            "resource1".to_string(),
442            "read".to_string(),
443        )
444        .with_designation("tenant_id".to_string(), "t-123".to_string())
445        .verify();
446        assert!(res.is_err(), "Should fail with missing designation");
447    }
448
449    #[test]
450    fn test_namespace_plus_designation() {
451        let keypair = KeyPair::new();
452        let public_key = keypair.public();
453
454        let token = HessraCapability::new(
455            "alice".to_string(),
456            "resource1".to_string(),
457            "read".to_string(),
458            TokenTimeConfig::default(),
459        )
460        .namespace_restricted("myapp.hessra.dev".to_string())
461        .issue(&keypair)
462        .expect("Failed to create token");
463
464        // Attenuate with designation
465        let attenuated = crate::attenuate::DesignationBuilder::from_base64(token, public_key)
466            .expect("Failed to create designation builder")
467            .designate("tenant_id".to_string(), "t-123".to_string())
468            .attenuate_base64()
469            .expect("Failed to attenuate");
470
471        // Verify with both namespace and designation
472        let res = CapabilityVerifier::new(
473            attenuated.clone(),
474            public_key,
475            "resource1".to_string(),
476            "read".to_string(),
477        )
478        .with_namespace("myapp.hessra.dev".to_string())
479        .with_designation("tenant_id".to_string(), "t-123".to_string())
480        .verify();
481        assert!(
482            res.is_ok(),
483            "Should pass with both namespace and designation"
484        );
485
486        // Should fail without namespace
487        let res = CapabilityVerifier::new(
488            attenuated.clone(),
489            public_key,
490            "resource1".to_string(),
491            "read".to_string(),
492        )
493        .with_designation("tenant_id".to_string(), "t-123".to_string())
494        .verify();
495        assert!(res.is_err(), "Should fail without namespace");
496    }
497
498    #[test]
499    fn test_builder_issue() {
500        let keypair = KeyPair::new();
501        let public_key = keypair.public();
502
503        let token = HessraCapability::new(
504            "alice".to_string(),
505            "resource1".to_string(),
506            "read".to_string(),
507            TokenTimeConfig::default(),
508        )
509        .issue(&keypair)
510        .expect("Failed to create token");
511
512        let res = CapabilityVerifier::new(
513            token,
514            public_key,
515            "resource1".to_string(),
516            "read".to_string(),
517        )
518        .verify();
519        assert!(res.is_ok());
520    }
521
522    #[test]
523    fn test_custom_time_config() {
524        let root = KeyPair::new();
525        let public_key = root.public();
526
527        // Create token with custom start time (1 hour in the past) and longer duration (2 hours)
528        let past_time = Utc::now().timestamp() - 3600;
529        let time_config = TokenTimeConfig {
530            start_time: Some(past_time),
531            duration: 7200,
532        };
533
534        let token = HessraCapability::new(
535            "alice".to_string(),
536            "res1".to_string(),
537            "read".to_string(),
538            time_config,
539        )
540        .issue(&root)
541        .expect("Failed to create token");
542
543        let res =
544            CapabilityVerifier::new(token, public_key, "res1".to_string(), "read".to_string())
545                .verify();
546        assert!(res.is_ok());
547    }
548}