Skip to main content

exo_consent/
policy.rs

1// Copyright 2026 Exochain Foundation
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at:
6//
7//     https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14//
15// SPDX-License-Identifier: Apache-2.0
16
17//! Consent policies — rules governing what actions require what consent.
18
19use exo_core::{Did, Timestamp};
20use serde::{Deserialize, Serialize};
21
22use crate::bailment::{self, Bailment, BailmentType};
23
24/// A requirement that must be satisfied for consent to be granted.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct ConsentRequirement {
27    pub action_type: String,
28    pub required_role: String,
29    pub min_clearance_level: u32,
30}
31
32/// A consent policy — a named collection of requirements.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct ConsentPolicy {
35    pub id: String,
36    pub name: String,
37    pub required_consents: Vec<ConsentRequirement>,
38    pub deny_by_default: bool,
39}
40
41/// An active consent backed by a bailment.
42#[derive(Debug, Clone)]
43pub struct ActiveConsent {
44    pub grantor: Did,
45    pub action_type: String,
46    pub role: String,
47    pub clearance_level: u32,
48    pub bailment: Bailment,
49}
50
51/// A request to perform an action requiring consent.
52#[derive(Debug, Clone)]
53pub struct ActionRequest {
54    pub actor: Did,
55    pub action_type: String,
56}
57
58/// The result of evaluating a consent policy.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub enum ConsentDecision {
61    Granted { expires: Option<Timestamp> },
62    Denied { reason: String },
63    Escalated { to: Did },
64}
65
66/// Evaluates consent policies against active consents.
67#[derive(Debug, Default)]
68pub struct PolicyEngine;
69
70impl PolicyEngine {
71    #[must_use]
72    pub fn new() -> Self {
73        Self
74    }
75
76    /// Evaluate a policy against current consents.
77    #[must_use]
78    pub fn evaluate(
79        &self,
80        policy: &ConsentPolicy,
81        consents: &[ActiveConsent],
82        action: &ActionRequest,
83        now: &Timestamp,
84    ) -> ConsentDecision {
85        let applicable: Vec<&ConsentRequirement> = policy
86            .required_consents
87            .iter()
88            .filter(|r| r.action_type == action.action_type)
89            .collect();
90
91        if applicable.is_empty() {
92            return if policy.deny_by_default {
93                ConsentDecision::Denied {
94                    reason: format!(
95                        "no policy covers action '{}' and deny_by_default is true",
96                        action.action_type
97                    ),
98                }
99            } else {
100                ConsentDecision::Granted { expires: None }
101            };
102        }
103
104        let mut earliest_expiry: Option<Timestamp> = None;
105
106        for req in &applicable {
107            let satisfied = consents.iter().any(|c| {
108                c.action_type == req.action_type
109                    && c.role == req.required_role
110                    && c.clearance_level >= req.min_clearance_level
111                    && bailment::is_active(&c.bailment, now)
112            });
113
114            if !satisfied {
115                // Check for escalation via delegation bailment
116                let esc = consents.iter().find(|c| {
117                    c.action_type == req.action_type
118                        && c.bailment.bailment_type == BailmentType::Delegation
119                        && bailment::is_active(&c.bailment, now)
120                });
121                if let Some(e) = esc {
122                    return ConsentDecision::Escalated {
123                        to: e.bailment.bailor_did.clone(),
124                    };
125                }
126                return ConsentDecision::Denied {
127                    reason: format!(
128                        "requirement not met: action='{}', role='{}', clearance>={}",
129                        req.action_type, req.required_role, req.min_clearance_level
130                    ),
131                };
132            }
133
134            // Track earliest expiry
135            for c in consents.iter() {
136                if c.action_type == req.action_type
137                    && c.role == req.required_role
138                    && c.clearance_level >= req.min_clearance_level
139                    && bailment::is_active(&c.bailment, now)
140                {
141                    if let Some(exp) = c.bailment.expires {
142                        earliest_expiry = Some(match earliest_expiry {
143                            Some(cur) if exp < cur => exp,
144                            Some(cur) => cur,
145                            None => exp,
146                        });
147                    }
148                }
149            }
150        }
151
152        ConsentDecision::Granted {
153            expires: earliest_expiry,
154        }
155    }
156}
157
158#[cfg(test)]
159mod tests {
160
161    use super::*;
162    use crate::bailment;
163
164    fn alice() -> Did {
165        Did::new("did:exo:alice").unwrap()
166    }
167    fn bob() -> Did {
168        Did::new("did:exo:bob").unwrap()
169    }
170    fn ts(ms: u64) -> Timestamp {
171        Timestamp::new(ms, 0)
172    }
173    fn now() -> Timestamp {
174        ts(5000)
175    }
176
177    fn make_bailment(
178        bailor: &Did,
179        bailee: &Did,
180        btype: BailmentType,
181        exp: Option<Timestamp>,
182    ) -> Bailment {
183        let mut b = bailment::propose(bailor, bailee, b"terms", btype, "policy-test", ts(1000))
184            .expect("test bailment proposal");
185        // Produce a valid bailee signature for the GAP-012-verified accept().
186        let (pk, sk) = exo_core::crypto::generate_keypair();
187        let payload = bailment::signing_payload(&b).expect("canonical payload");
188        let sig = exo_core::crypto::sign(&payload, &sk);
189        let bailee_did = b.bailee_did.clone();
190        bailment::accept(&mut b, |did| (did == &bailee_did).then_some(pk), &sig)
191            .expect("test bailment accepts");
192        b.expires = exp;
193        b
194    }
195
196    fn consent(grantor: &Did, action: &str, role: &str, cl: u32, b: Bailment) -> ActiveConsent {
197        ActiveConsent {
198            grantor: grantor.clone(),
199            action_type: action.into(),
200            role: role.into(),
201            clearance_level: cl,
202            bailment: b,
203        }
204    }
205
206    fn read_policy() -> ConsentPolicy {
207        ConsentPolicy {
208            id: "pol-1".into(),
209            name: "read-policy".into(),
210            deny_by_default: true,
211            required_consents: vec![ConsentRequirement {
212                action_type: "read".into(),
213                required_role: "data-owner".into(),
214                min_clearance_level: 1,
215            }],
216        }
217    }
218
219    #[test]
220    fn grant_when_satisfied() {
221        let e = PolicyEngine::new();
222        let b = make_bailment(&alice(), &bob(), BailmentType::Custody, None);
223        let c = vec![consent(&alice(), "read", "data-owner", 1, b)];
224        let d = e.evaluate(
225            &read_policy(),
226            &c,
227            &ActionRequest {
228                actor: bob(),
229                action_type: "read".into(),
230            },
231            &now(),
232        );
233        assert_eq!(d, ConsentDecision::Granted { expires: None });
234    }
235
236    #[test]
237    fn grant_with_expiry() {
238        let e = PolicyEngine::new();
239        let exp = ts(10000);
240        let b = make_bailment(&alice(), &bob(), BailmentType::Custody, Some(exp));
241        let c = vec![consent(&alice(), "read", "data-owner", 1, b)];
242        let d = e.evaluate(
243            &read_policy(),
244            &c,
245            &ActionRequest {
246                actor: bob(),
247                action_type: "read".into(),
248            },
249            &now(),
250        );
251        assert_eq!(d, ConsentDecision::Granted { expires: Some(exp) });
252    }
253
254    #[test]
255    fn deny_no_consent() {
256        let e = PolicyEngine::new();
257        let d = e.evaluate(
258            &read_policy(),
259            &[],
260            &ActionRequest {
261                actor: bob(),
262                action_type: "read".into(),
263            },
264            &now(),
265        );
266        assert!(matches!(d, ConsentDecision::Denied { .. }));
267    }
268
269    #[test]
270    fn deny_clearance_too_low() {
271        let e = PolicyEngine::new();
272        let b = make_bailment(&alice(), &bob(), BailmentType::Custody, None);
273        let c = vec![consent(&alice(), "read", "data-owner", 0, b)];
274        let d = e.evaluate(
275            &read_policy(),
276            &c,
277            &ActionRequest {
278                actor: bob(),
279                action_type: "read".into(),
280            },
281            &now(),
282        );
283        assert!(matches!(d, ConsentDecision::Denied { .. }));
284    }
285
286    #[test]
287    fn deny_wrong_role() {
288        let e = PolicyEngine::new();
289        let b = make_bailment(&alice(), &bob(), BailmentType::Custody, None);
290        let c = vec![consent(&alice(), "read", "viewer", 5, b)];
291        let d = e.evaluate(
292            &read_policy(),
293            &c,
294            &ActionRequest {
295                actor: bob(),
296                action_type: "read".into(),
297            },
298            &now(),
299        );
300        assert!(matches!(d, ConsentDecision::Denied { .. }));
301    }
302
303    #[test]
304    fn deny_expired_bailment() {
305        let e = PolicyEngine::new();
306        let b = make_bailment(&alice(), &bob(), BailmentType::Custody, Some(ts(1000)));
307        let c = vec![consent(&alice(), "read", "data-owner", 1, b)];
308        let d = e.evaluate(
309            &read_policy(),
310            &c,
311            &ActionRequest {
312                actor: bob(),
313                action_type: "read".into(),
314            },
315            &now(),
316        );
317        assert!(matches!(d, ConsentDecision::Denied { .. }));
318    }
319
320    #[test]
321    fn escalate_via_delegation() {
322        let e = PolicyEngine::new();
323        let b = make_bailment(&alice(), &bob(), BailmentType::Delegation, None);
324        let c = vec![consent(&alice(), "read", "viewer", 0, b)];
325        let d = e.evaluate(
326            &read_policy(),
327            &c,
328            &ActionRequest {
329                actor: bob(),
330                action_type: "read".into(),
331            },
332            &now(),
333        );
334        assert!(matches!(d, ConsentDecision::Escalated { .. }));
335        if let ConsentDecision::Escalated { to } = d {
336            assert_eq!(to, alice());
337        }
338    }
339
340    #[test]
341    fn no_escalation_when_delegation_expired() {
342        let e = PolicyEngine::new();
343        let b = make_bailment(&alice(), &bob(), BailmentType::Delegation, Some(ts(1000)));
344        let c = vec![consent(&alice(), "read", "viewer", 0, b)];
345        let d = e.evaluate(
346            &read_policy(),
347            &c,
348            &ActionRequest {
349                actor: bob(),
350                action_type: "read".into(),
351            },
352            &now(),
353        );
354        assert!(matches!(d, ConsentDecision::Denied { .. }));
355    }
356
357    #[test]
358    fn forged_active_bailment_does_not_satisfy_policy() {
359        let e = PolicyEngine::new();
360        let mut b = bailment::propose(
361            &alice(),
362            &bob(),
363            b"terms",
364            BailmentType::Custody,
365            "forged-active",
366            ts(1000),
367        )
368        .expect("test bailment proposal");
369        b.status = bailment::BailmentStatus::Active;
370        b.signature = exo_core::Signature::from_bytes([0xAB; 64]);
371        let c = vec![consent(&alice(), "read", "data-owner", 1, b)];
372
373        let d = e.evaluate(
374            &read_policy(),
375            &c,
376            &ActionRequest {
377                actor: bob(),
378                action_type: "read".into(),
379            },
380            &now(),
381        );
382
383        assert!(
384            matches!(d, ConsentDecision::Denied { .. }),
385            "policy must deny forged active bailments without verified acceptance proof"
386        );
387    }
388
389    #[test]
390    fn grant_no_requirements_permissive() {
391        let e = PolicyEngine::new();
392        let p = ConsentPolicy {
393            id: "p".into(),
394            name: "p".into(),
395            required_consents: vec![],
396            deny_by_default: false,
397        };
398        let d = e.evaluate(
399            &p,
400            &[],
401            &ActionRequest {
402                actor: bob(),
403                action_type: "x".into(),
404            },
405            &now(),
406        );
407        assert_eq!(d, ConsentDecision::Granted { expires: None });
408    }
409
410    #[test]
411    fn deny_no_requirements_strict() {
412        let e = PolicyEngine::new();
413        let p = ConsentPolicy {
414            id: "p".into(),
415            name: "p".into(),
416            required_consents: vec![],
417            deny_by_default: true,
418        };
419        let d = e.evaluate(
420            &p,
421            &[],
422            &ActionRequest {
423                actor: bob(),
424                action_type: "x".into(),
425            },
426            &now(),
427        );
428        assert!(matches!(d, ConsentDecision::Denied { .. }));
429    }
430
431    #[test]
432    fn deny_unmatched_action() {
433        let e = PolicyEngine::new();
434        let d = e.evaluate(
435            &read_policy(),
436            &[],
437            &ActionRequest {
438                actor: bob(),
439                action_type: "write".into(),
440            },
441            &now(),
442        );
443        assert!(matches!(d, ConsentDecision::Denied { .. }));
444    }
445
446    #[test]
447    fn earliest_expiry_wins() {
448        let e = PolicyEngine::new();
449        let p = ConsentPolicy {
450            id: "m".into(),
451            name: "m".into(),
452            deny_by_default: true,
453            required_consents: vec![
454                ConsentRequirement {
455                    action_type: "read".into(),
456                    required_role: "owner".into(),
457                    min_clearance_level: 1,
458                },
459                ConsentRequirement {
460                    action_type: "read".into(),
461                    required_role: "auditor".into(),
462                    min_clearance_level: 1,
463                },
464            ],
465        };
466        let b1 = make_bailment(&alice(), &bob(), BailmentType::Custody, Some(ts(8000)));
467        let b2 = make_bailment(&alice(), &bob(), BailmentType::Custody, Some(ts(12000)));
468        let c = vec![
469            consent(&alice(), "read", "owner", 2, b1),
470            consent(&alice(), "read", "auditor", 1, b2),
471        ];
472        let d = e.evaluate(
473            &p,
474            &c,
475            &ActionRequest {
476                actor: bob(),
477                action_type: "read".into(),
478            },
479            &now(),
480        );
481        assert_eq!(
482            d,
483            ConsentDecision::Granted {
484                expires: Some(ts(8000))
485            }
486        );
487    }
488
489    #[test]
490    fn active_consent_fields() {
491        let b = make_bailment(&alice(), &bob(), BailmentType::Processing, None);
492        let c = consent(&alice(), "process", "processor", 2, b);
493        assert_eq!(c.grantor, alice());
494        assert_eq!(c.action_type, "process");
495        assert_eq!(c.clearance_level, 2);
496    }
497}