use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use vti_common::error::AppError;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "effect", content = "with", rename_all = "snake_case")]
pub enum Verdict {
Allow(Allow),
Deny(Deny),
Refer(Refer),
RequestMore(RequestMore),
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Allow {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disposition: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fields: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub obligations: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Deny {
pub code: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Refer {
pub queue: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RequestMore {
#[serde(default)]
pub needs: Vec<String>,
#[serde(default)]
pub presentation_definition: JsonValue,
}
impl Verdict {
pub fn from_decision(decision: JsonValue) -> Result<Self, AppError> {
serde_json::from_value(decision).map_err(|e| {
AppError::Internal(format!("policy returned a malformed decision object: {e}"))
})
}
pub fn effect(&self) -> &'static str {
match self {
Verdict::Allow(_) => "allow",
Verdict::Deny(_) => "deny",
Verdict::Refer(_) => "refer",
Verdict::RequestMore(_) => "request_more",
}
}
pub fn default_deny() -> Self {
Verdict::Deny(Deny {
code: "no-matching-route".into(),
reason: None,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn join_allow_round_trips_against_example_rego() {
let decision = json!({
"effect": "allow",
"with": { "role": "member", "obligations": ["reciprocate_vmc"] }
});
let verdict = Verdict::from_decision(decision.clone()).unwrap();
assert_eq!(
verdict,
Verdict::Allow(Allow {
role: Some("member".into()),
obligations: vec!["reciprocate_vmc".into()],
..Default::default()
})
);
assert_eq!(verdict.effect(), "allow");
assert_eq!(serde_json::to_value(&verdict).unwrap(), decision);
}
#[test]
fn request_more_carries_needs_and_pd() {
let decision = json!({
"effect": "request_more",
"with": {
"needs": ["agreed:code-of-conduct"],
"presentation_definition": { "id": "vtc-join-coc" }
}
});
let verdict = Verdict::from_decision(decision.clone()).unwrap();
match &verdict {
Verdict::RequestMore(rm) => {
assert_eq!(rm.needs, vec!["agreed:code-of-conduct".to_string()]);
assert_eq!(rm.presentation_definition, json!({ "id": "vtc-join-coc" }));
}
other => panic!("expected request_more, got {other:?}"),
}
assert_eq!(serde_json::to_value(&verdict).unwrap(), decision);
}
#[test]
fn default_deny_decision_shape() {
let decision = json!({ "effect": "deny", "with": { "code": "no-matching-route" } });
let verdict = Verdict::from_decision(decision.clone()).unwrap();
assert_eq!(verdict, Verdict::default_deny());
assert_eq!(serde_json::to_value(&verdict).unwrap(), decision);
}
#[test]
fn refer_to_queue() {
let decision = json!({ "effect": "refer", "with": { "queue": "moderator" } });
let verdict = Verdict::from_decision(decision.clone()).unwrap();
assert_eq!(
verdict,
Verdict::Refer(Refer {
queue: "moderator".into(),
reason: None,
})
);
assert_eq!(serde_json::to_value(&verdict).unwrap(), decision);
}
#[test]
fn malformed_decision_is_internal_error() {
let err = Verdict::from_decision(json!({ "effect": "explode", "with": {} }))
.expect_err("unknown effect must fail");
assert!(matches!(err, AppError::Internal(_)), "got {err:?}");
}
#[test]
fn leave_allow_uses_disposition() {
let decision = json!({ "effect": "allow", "with": { "disposition": "revoke-vmc" } });
let verdict = Verdict::from_decision(decision.clone()).unwrap();
assert_eq!(
verdict,
Verdict::Allow(Allow {
disposition: Some("revoke-vmc".into()),
..Default::default()
})
);
assert_eq!(serde_json::to_value(&verdict).unwrap(), decision);
}
}