1use exo_core::{Did, Timestamp};
20use serde::{Deserialize, Serialize};
21
22use crate::bailment::{self, Bailment, BailmentType};
23
24#[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#[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#[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#[derive(Debug, Clone)]
53pub struct ActionRequest {
54 pub actor: Did,
55 pub action_type: String,
56}
57
58#[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#[derive(Debug, Default)]
68pub struct PolicyEngine;
69
70impl PolicyEngine {
71 #[must_use]
72 pub fn new() -> Self {
73 Self
74 }
75
76 #[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 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 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 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}