1use std::collections::{BTreeMap, BTreeSet};
43
44use serde::{Deserialize, Serialize};
45
46pub const DECLARATIVE_POLICY_ID: &str = "approvals";
51
52pub const DECLARATIVE_POLICY_NAME: &str = "Declarative approvals";
54
55pub const DECLARATIVE_POLICY_PRIORITY: i32 = 200;
66
67pub const EXT_KEY_RULES: &str = "openvtc.approvals";
73
74pub const EXT_KEY_APPROVER_SETS: &str = "openvtc.approver-sets";
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(rename_all = "camelCase")]
80pub enum Requires {
81 Reauth,
89 Consent,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(rename_all = "camelCase", deny_unknown_fields)]
98pub struct ApprovalRule {
99 pub task_type: String,
105 pub requires: Requires,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub approver_set: Option<String>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub min_approvals: Option<u32>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub exclude_requester: Option<bool>,
119 #[serde(default, skip_serializing_if = "Vec::is_empty")]
126 pub contexts: Vec<String>,
127}
128
129impl ApprovalRule {
130 pub fn reauth(task_type: impl Into<String>) -> Self {
132 Self {
133 task_type: task_type.into(),
134 requires: Requires::Reauth,
135 approver_set: None,
136 min_approvals: None,
137 exclude_requester: None,
138 contexts: Vec::new(),
139 }
140 }
141
142 pub fn consent(task_type: impl Into<String>, approver_set: impl Into<String>) -> Self {
144 Self {
145 task_type: task_type.into(),
146 requires: Requires::Consent,
147 approver_set: Some(approver_set.into()),
148 min_approvals: None,
149 exclude_requester: None,
150 contexts: Vec::new(),
151 }
152 }
153
154 pub fn effective_min_approvals(&self) -> u32 {
157 self.min_approvals.unwrap_or(1).max(1)
158 }
159
160 pub fn effective_exclude_requester(&self) -> bool {
162 self.exclude_requester.unwrap_or(false)
163 }
164}
165
166pub type ApproverSets = BTreeMap<String, Vec<String>>;
171
172#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
179pub enum ApprovalsError {
180 #[error(
181 "rule for `{task_type}` is not a Trust Task Type URI: expected \
182 `https://trusttasks.org/spec/<slug>/<major>.<minor>`"
183 )]
184 MalformedTaskType { task_type: String },
185
186 #[error("rule for `{task_type}` requires consent but names no approverSet")]
187 MissingApproverSet { task_type: String },
188
189 #[error(
190 "rule for `{task_type}` requires reauth but names approverSet `{approver_set}`: \
191 reauth elevates the caller's own session and has no third-party approver — \
192 use requires = \"consent\" if another party must sign off"
193 )]
194 ApproverSetOnReauth {
195 task_type: String,
196 approver_set: String,
197 },
198
199 #[error(
200 "rule for `{task_type}` names approver set `{approver_set}`, which is not defined; \
201 define it before referencing it, or the rule could never be satisfied"
202 )]
203 UnknownApproverSet {
204 task_type: String,
205 approver_set: String,
206 },
207
208 #[error(
209 "approver set `{approver_set}` is empty: a consent rule naming it could never reach \
210 its threshold, so every task it gates would be permanently refused"
211 )]
212 EmptyApproverSet { approver_set: String },
213
214 #[error(
215 "rule for `{task_type}` needs {min_approvals} approvals but set `{approver_set}` has \
216 only {members} member(s)"
217 )]
218 ThresholdExceedsSet {
219 task_type: String,
220 approver_set: String,
221 min_approvals: u32,
222 members: usize,
223 },
224
225 #[error(
226 "two rules name `{task_type}` with overlapping scope: rules for one task type must \
227 either be a single unscoped rule or carry disjoint `contexts`"
228 )]
229 OverlappingRules { task_type: String },
230
231 #[error(
232 "`{field}` is a consent-only field and cannot be set on the reauth rule for `{task_type}`"
233 )]
234 ConsentFieldOnReauth {
235 task_type: String,
236 field: &'static str,
237 },
238}
239
240fn is_task_type_uri(uri: &str) -> bool {
248 const PREFIX: &str = "https://trusttasks.org/spec/";
249 let Some(rest) = uri.strip_prefix(PREFIX) else {
250 return false;
251 };
252 let Some((slug, version)) = rest.rsplit_once('/') else {
253 return false;
254 };
255 if slug.is_empty() {
256 return false;
257 }
258 let Some((major, minor)) = version.split_once('.') else {
260 return false;
261 };
262 !major.is_empty()
263 && !minor.is_empty()
264 && major.bytes().all(|b| b.is_ascii_digit())
265 && minor.bytes().all(|b| b.is_ascii_digit())
266}
267
268pub fn validate(rules: &[ApprovalRule], sets: &ApproverSets) -> Result<(), ApprovalsError> {
274 for rule in rules {
275 if !is_task_type_uri(&rule.task_type) {
276 return Err(ApprovalsError::MalformedTaskType {
277 task_type: rule.task_type.clone(),
278 });
279 }
280 match rule.requires {
281 Requires::Reauth => {
282 if let Some(set) = &rule.approver_set {
283 return Err(ApprovalsError::ApproverSetOnReauth {
284 task_type: rule.task_type.clone(),
285 approver_set: set.clone(),
286 });
287 }
288 for (present, field) in [
291 (rule.min_approvals.is_some(), "minApprovals"),
292 (rule.exclude_requester.is_some(), "excludeRequester"),
293 ] {
294 if present {
295 return Err(ApprovalsError::ConsentFieldOnReauth {
296 task_type: rule.task_type.clone(),
297 field,
298 });
299 }
300 }
301 }
302 Requires::Consent => {
303 let Some(set_name) = rule.approver_set.as_deref() else {
304 return Err(ApprovalsError::MissingApproverSet {
305 task_type: rule.task_type.clone(),
306 });
307 };
308 let Some(members) = sets.get(set_name) else {
309 return Err(ApprovalsError::UnknownApproverSet {
310 task_type: rule.task_type.clone(),
311 approver_set: set_name.to_string(),
312 });
313 };
314 if members.is_empty() {
315 return Err(ApprovalsError::EmptyApproverSet {
316 approver_set: set_name.to_string(),
317 });
318 }
319 let min = rule.effective_min_approvals();
320 if min as usize > members.len() {
321 return Err(ApprovalsError::ThresholdExceedsSet {
322 task_type: rule.task_type.clone(),
323 approver_set: set_name.to_string(),
324 min_approvals: min,
325 members: members.len(),
326 });
327 }
328 }
329 }
330 }
331
332 for (i, rule) in rules.iter().enumerate() {
336 for other in &rules[i + 1..] {
337 if other.task_type != rule.task_type {
338 continue;
339 }
340 let disjoint = !rule.contexts.is_empty()
341 && !other.contexts.is_empty()
342 && rule
343 .contexts
344 .iter()
345 .collect::<BTreeSet<_>>()
346 .is_disjoint(&other.contexts.iter().collect::<BTreeSet<_>>());
347 if !disjoint {
348 return Err(ApprovalsError::OverlappingRules {
349 task_type: rule.task_type.clone(),
350 });
351 }
352 }
353 }
354
355 Ok(())
358}
359
360fn rego_string(s: &str) -> String {
367 let mut out = String::with_capacity(s.len() + 2);
368 out.push('"');
369 for c in s.chars() {
370 match c {
371 '"' => out.push_str("\\\""),
372 '\\' => out.push_str("\\\\"),
373 '\n' => out.push_str("\\n"),
374 '\r' => out.push_str("\\r"),
375 '\t' => out.push_str("\\t"),
376 _ => out.push(c),
377 }
378 }
379 out.push('"');
380 out
381}
382
383const GENERATED_HEADER: &str = "\
386# Generated from the declarative approvals rules — do not hand-edit.
387#
388# The VTA re-derives this module from ext[\"openvtc.approvals\"] on every upsert
389# and refuses the write if the two disagree, so an edit here is not a way to
390# change behaviour: change the rules instead.
391";
392
393pub fn synthesize_rego(rules: &[ApprovalRule]) -> String {
403 let mut out = String::from("package vta.policy\n\nimport rego.v1\n\n");
404 out.push_str(GENERATED_HEADER);
405
406 for rule in rules {
407 out.push('\n');
408 let guard_type = format!("input.request.typeUri == {}", rego_string(&rule.task_type));
409 let guard_ctx = (!rule.contexts.is_empty()).then(|| {
410 let set = rule
411 .contexts
412 .iter()
413 .map(|c| rego_string(c))
414 .collect::<Vec<_>>()
415 .join(", ");
416 format!("input.contextId in {{{set}}}")
417 });
418
419 let head = match rule.requires {
420 Requires::Reauth => "decision := {\n\t\"decision\": \"requireStepUp\",\n}".to_string(),
421 Requires::Consent => format!(
422 "decision := {{\n\t\"decision\": \"requireConsent\",\n\t\"requireConsent\": \
423 {{\"approverSet\": {set}, \"minApprovals\": {min}, \"excludeRequester\": \
424 {exclude}}},\n}}",
425 set = rego_string(rule.approver_set.as_deref().unwrap_or_default()),
426 min = rule.effective_min_approvals(),
427 exclude = rule.effective_exclude_requester(),
428 ),
429 };
430
431 match guard_ctx {
432 None => out.push_str(&format!("{head} if {guard_type}\n")),
433 Some(ctx) => out.push_str(&format!("{head} if {{\n\t{guard_type}\n\t{ctx}\n}}\n")),
434 }
435 }
436
437 out
438}
439
440#[cfg(test)]
441mod tests {
442 use super::*;
443
444 const ACL_GRANT: &str = "https://trusttasks.org/spec/acl/grant/0.1";
445 const WEBVH_UPDATE: &str = "https://trusttasks.org/spec/vta/webvh/dids/update/1.0";
446
447 fn sets(name: &str, members: &[&str]) -> ApproverSets {
448 let mut m = ApproverSets::new();
449 m.insert(
450 name.to_string(),
451 members.iter().map(|s| s.to_string()).collect(),
452 );
453 m
454 }
455
456 #[test]
457 fn synthesis_is_deterministic() {
458 let rules = vec![
459 ApprovalRule::reauth(ACL_GRANT),
460 ApprovalRule::consent(WEBVH_UPDATE, "ops"),
461 ];
462 assert_eq!(synthesize_rego(&rules), synthesize_rego(&rules));
463 }
464
465 #[test]
468 fn synthesis_shape_is_pinned() {
469 let mut consent = ApprovalRule::consent(WEBVH_UPDATE, "webvh-approvers");
470 consent.exclude_requester = Some(true);
471 consent.contexts = vec!["openvtc".into()];
472 let rego = synthesize_rego(&[ApprovalRule::reauth(ACL_GRANT), consent]);
473
474 assert!(rego.starts_with("package vta.policy\n\nimport rego.v1\n\n"));
475 assert!(rego.contains(
476 "decision := {\n\t\"decision\": \"requireStepUp\",\n} if input.request.typeUri == \
477 \"https://trusttasks.org/spec/acl/grant/0.1\"\n"
478 ));
479 assert!(rego.contains(
480 "\t\"requireConsent\": {\"approverSet\": \"webvh-approvers\", \"minApprovals\": 1, \
481 \"excludeRequester\": true},\n"
482 ));
483 assert!(rego.contains("\tinput.contextId in {\"openvtc\"}\n}"));
484 }
485
486 #[test]
489 fn injection_through_an_approver_set_name_is_escaped() {
490 let injected = "x\", \"decision\": \"allow";
491 let rego = synthesize_rego(&[ApprovalRule::consent(WEBVH_UPDATE, injected)]);
492 assert!(rego.contains("\\\""), "quote was not escaped: {rego}");
493 assert_eq!(rego.matches("\"decision\": \"require").count(), 1);
495 assert!(!rego.contains("\"decision\": \"allow\""));
496 }
497
498 #[test]
499 fn empty_rules_produce_an_abstaining_module() {
500 let rego = synthesize_rego(&[]);
501 assert!(!rego.contains("decision :="));
502 }
503
504 #[test]
505 fn consent_without_a_set_is_refused() {
506 let rule = ApprovalRule {
507 approver_set: None,
508 ..ApprovalRule::consent(WEBVH_UPDATE, "ops")
509 };
510 assert!(matches!(
511 validate(&[rule], &ApproverSets::new()),
512 Err(ApprovalsError::MissingApproverSet { .. })
513 ));
514 }
515
516 #[test]
517 fn unknown_and_empty_approver_sets_are_refused_at_write_time() {
518 let rule = ApprovalRule::consent(WEBVH_UPDATE, "ops");
519 assert!(matches!(
520 validate(std::slice::from_ref(&rule), &ApproverSets::new()),
521 Err(ApprovalsError::UnknownApproverSet { .. })
522 ));
523 assert!(matches!(
524 validate(&[rule], &sets("ops", &[])),
525 Err(ApprovalsError::EmptyApproverSet { .. })
526 ));
527 }
528
529 #[test]
530 fn a_threshold_no_set_could_meet_is_refused() {
531 let mut rule = ApprovalRule::consent(WEBVH_UPDATE, "ops");
532 rule.min_approvals = Some(3);
533 assert!(matches!(
534 validate(&[rule], &sets("ops", &["did:key:a", "did:key:b"])),
535 Err(ApprovalsError::ThresholdExceedsSet { .. })
536 ));
537 }
538
539 #[test]
542 fn consent_only_fields_are_refused_on_a_reauth_rule() {
543 let mut rule = ApprovalRule::reauth(ACL_GRANT);
544 rule.min_approvals = Some(2);
545 assert!(matches!(
546 validate(&[rule], &ApproverSets::new()),
547 Err(ApprovalsError::ConsentFieldOnReauth {
548 field: "minApprovals",
549 ..
550 })
551 ));
552
553 let mut rule = ApprovalRule::reauth(ACL_GRANT);
554 rule.approver_set = Some("ops".into());
555 assert!(matches!(
556 validate(&[rule], &sets("ops", &["did:key:a"])),
557 Err(ApprovalsError::ApproverSetOnReauth { .. })
558 ));
559 }
560
561 #[test]
562 fn overlapping_rules_for_one_task_type_are_refused() {
563 let dup = vec![
566 ApprovalRule::reauth(ACL_GRANT),
567 ApprovalRule::reauth(ACL_GRANT),
568 ];
569 assert!(matches!(
570 validate(&dup, &ApproverSets::new()),
571 Err(ApprovalsError::OverlappingRules { .. })
572 ));
573
574 let mut a = ApprovalRule::reauth(ACL_GRANT);
576 a.contexts = vec!["x".into(), "y".into()];
577 let mut b = ApprovalRule::reauth(ACL_GRANT);
578 b.contexts = vec!["y".into()];
579 assert!(matches!(
580 validate(&[a, b], &ApproverSets::new()),
581 Err(ApprovalsError::OverlappingRules { .. })
582 ));
583 }
584
585 #[test]
586 fn disjoint_scoped_rules_for_one_task_type_are_allowed() {
587 let mut a = ApprovalRule::reauth(ACL_GRANT);
588 a.contexts = vec!["x".into()];
589 let mut b = ApprovalRule::consent(ACL_GRANT, "ops");
590 b.contexts = vec!["y".into()];
591 assert!(validate(&[a, b], &sets("ops", &["did:key:a"])).is_ok());
592 }
593
594 #[test]
595 fn a_malformed_task_type_is_refused() {
596 let empty_slug = format!("{}{}", "https://trusttasks.org/spec/", "0.1");
602 for bad in [
603 "acl/grant/0.1",
604 "https://trusttasks.org/acl/grant/0.1",
605 "https://trusttasks.org/spec/acl/grant",
606 "https://trusttasks.org/spec/acl/grant/v1",
607 &empty_slug,
608 ] {
609 let rule = ApprovalRule::reauth(bad);
610 assert!(
611 matches!(
612 validate(&[rule], &ApproverSets::new()),
613 Err(ApprovalsError::MalformedTaskType { .. })
614 ),
615 "{bad} should be refused"
616 );
617 }
618 assert!(validate(&[ApprovalRule::reauth(ACL_GRANT)], &ApproverSets::new()).is_ok());
619 }
620
621 #[test]
622 fn rules_round_trip_as_camel_case_json() {
623 let mut rule = ApprovalRule::consent(WEBVH_UPDATE, "ops");
624 rule.min_approvals = Some(2);
625 rule.exclude_requester = Some(true);
626 rule.contexts = vec!["openvtc".into()];
627 let json = serde_json::to_value(&rule).unwrap();
628 assert_eq!(json["taskType"], WEBVH_UPDATE);
629 assert_eq!(json["requires"], "consent");
630 assert_eq!(json["approverSet"], "ops");
631 assert_eq!(json["minApprovals"], 2);
632 assert_eq!(json["excludeRequester"], true);
633 assert_eq!(serde_json::from_value::<ApprovalRule>(json).unwrap(), rule);
634 }
635
636 #[test]
639 fn absent_optionals_are_omitted() {
640 let json = serde_json::to_value(ApprovalRule::reauth(ACL_GRANT)).unwrap();
641 assert_eq!(
642 json.as_object().unwrap().keys().collect::<BTreeSet<_>>(),
643 BTreeSet::from([&"requires".to_string(), &"taskType".to_string()])
644 );
645 }
646
647 #[test]
648 fn unknown_rule_fields_are_refused() {
649 let json = serde_json::json!({
650 "taskType": ACL_GRANT,
651 "requires": "reauth",
652 "approverSets": ["typo-for-approverSet"],
653 });
654 assert!(serde_json::from_value::<ApprovalRule>(json).is_err());
655 }
656}