use serde::{Deserialize, Serialize};
use crate::command::{CommandOrigin, CommandPolicy, RiskClass, origin_satisfies};
use crate::interaction::InteractionKind;
use crate::reduce::CommandRef;
pub mod reason {
pub const ALLOWED: &str = "turnframe.policy.allowed";
pub const CONFIRMATION_REQUIRED: &str = "turnframe.policy.confirmation_required";
pub const FORBIDDEN_RISK_CLASS: &str = "turnframe.policy.forbidden_risk_class";
pub const HUMAN_REVIEW_REQUIRED: &str = "turnframe.policy.human_review_required";
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PolicyDecision {
pub command_ref: CommandRef,
pub policy: CommandPolicy,
pub requires_interaction: Option<InteractionKind>,
pub allowed: bool,
pub reason_key: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct PolicySnapshot {
pub default_policy: CommandPolicy,
pub forbidden_risk_classes: Vec<RiskClass>,
pub max_direct_risk: RiskClass,
pub allow_text_resolution_for_low_risk: bool,
}
impl PolicySnapshot {
#[must_use]
pub fn conservative() -> Self {
Self {
default_policy: CommandPolicy::conservative(),
forbidden_risk_classes: Vec::new(),
max_direct_risk: RiskClass::ReversibleLowRisk,
allow_text_resolution_for_low_risk: true,
}
}
#[must_use]
pub fn sandbox() -> Self {
Self {
forbidden_risk_classes: vec![
RiskClass::SensitiveDataChange,
RiskClass::Destructive,
RiskClass::Irreversible,
RiskClass::ExternalRegulated,
],
..Self::conservative()
}
}
#[must_use]
pub fn is_risk_forbidden(&self, risk: RiskClass) -> bool {
self.forbidden_risk_classes.contains(&risk)
}
#[must_use]
pub fn decide(
&self,
command_ref: CommandRef,
policy: &CommandPolicy,
origin: &CommandOrigin,
) -> PolicyDecision {
if self.is_risk_forbidden(policy.risk) {
return PolicyDecision {
command_ref,
policy: policy.clone(),
requires_interaction: None,
allowed: false,
reason_key: reason::FORBIDDEN_RISK_CLASS.to_owned(),
};
}
let direct_too_risky = !origin.is_trusted() && policy.risk > self.max_direct_risk;
if origin_satisfies(origin, policy) && !direct_too_risky {
return PolicyDecision {
command_ref,
policy: policy.clone(),
requires_interaction: None,
allowed: true,
reason_key: reason::ALLOWED.to_owned(),
};
}
let requires_interaction = policy.confirmation.interaction_kind().or({
if policy.confirmation.is_server_side_only() {
None
} else {
Some(InteractionKind::ConfirmCommand)
}
});
let reason_key = if policy.confirmation.is_server_side_only() {
reason::HUMAN_REVIEW_REQUIRED
} else {
reason::CONFIRMATION_REQUIRED
};
PolicyDecision {
command_ref,
policy: policy.clone(),
requires_interaction,
allowed: false,
reason_key: reason_key.to_owned(),
}
}
}
impl Default for PolicySnapshot {
fn default() -> Self {
Self::conservative()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::command::{ConfirmationPolicy, ResolutionChannel};
use crate::hash::Digest;
use crate::ids::{BatchId, CommandId, InteractionId};
use crate::interaction::ActionClass;
fn cref() -> CommandRef {
CommandRef {
batch_id: BatchId::nil(),
command_id: CommandId::nil(),
}
}
fn direct() -> CommandOrigin {
CommandOrigin::DirectSafeUserAct {
evidence_digest: Digest::of_bytes(b"e"),
}
}
fn card(kind: InteractionKind, action_class: ActionClass) -> CommandOrigin {
CommandOrigin::ConfirmedInteraction {
interaction_id: InteractionId::nil(),
payload_hash: Digest::of_bytes(b"p"),
interaction_kind: kind,
action_class,
channel: ResolutionChannel::Click,
}
}
fn confirmed() -> CommandOrigin {
card(
InteractionKind::ConfirmCommand,
ActionClass::ConfirmsCommands,
)
}
#[test]
fn direct_low_risk_allowed_high_risk_needs_card() {
let snap = PolicySnapshot::conservative();
let low = snap.decide(cref(), &CommandPolicy::low_risk(), &direct());
assert!(low.allowed);
let high = snap.decide(cref(), &CommandPolicy::conservative(), &direct());
assert!(!high.allowed);
assert_eq!(
high.requires_interaction,
Some(InteractionKind::ConfirmCommand)
);
assert!(
snap.decide(cref(), &CommandPolicy::conservative(), &confirmed())
.allowed
);
}
#[test]
fn review_card_maps_to_review_changes() {
let snap = PolicySnapshot::conservative();
let mut policy = CommandPolicy::low_risk();
policy.confirmation = ConfirmationPolicy::ReviewCard;
let d = snap.decide(cref(), &policy, &direct());
assert_eq!(d.requires_interaction, Some(InteractionKind::ReviewChanges));
}
#[test]
fn sandbox_forbids_destructive_even_when_confirmed() {
let snap = PolicySnapshot::sandbox();
let mut policy = CommandPolicy::conservative();
policy.risk = RiskClass::Destructive;
let d = snap.decide(cref(), &policy, &confirmed());
assert!(!d.allowed);
assert_eq!(d.reason_key, reason::FORBIDDEN_RISK_CLASS);
assert_eq!(d.requires_interaction, None);
}
#[test]
fn a_selection_click_does_not_authorize_the_command_it_disambiguates() {
let snap = PolicySnapshot::conservative();
let selection = card(InteractionKind::SelectTarget, ActionClass::NoCommands);
let d = snap.decide(cref(), &CommandPolicy::conservative(), &selection);
assert!(!d.allowed);
assert_eq!(d.reason_key, reason::CONFIRMATION_REQUIRED);
assert_eq!(
d.requires_interaction,
Some(InteractionKind::ConfirmCommand)
);
}
#[test]
fn an_internal_policy_key_is_not_a_qualified_signature() {
let snap = PolicySnapshot::conservative();
let policy = CommandPolicy {
confirmation: ConfirmationPolicy::QualifiedSignature,
..CommandPolicy::conservative()
};
let internal = CommandOrigin::InternalPolicy {
policy_key: "auto".into(),
};
assert!(!snap.decide(cref(), &policy, &internal).allowed);
assert!(
!snap.decide(cref(), &policy, &confirmed()).allowed,
"a plain confirmation card is not a signature"
);
let signed = card(
InteractionKind::ExternalSignature,
ActionClass::ConfirmsCommands,
);
assert!(snap.decide(cref(), &policy, &signed).allowed);
}
#[test]
fn human_review_names_no_card_the_user_could_click() {
let snap = PolicySnapshot::conservative();
let policy = CommandPolicy {
confirmation: ConfirmationPolicy::HumanProfessionalReview,
..CommandPolicy::conservative()
};
let d = snap.decide(cref(), &policy, &confirmed());
assert!(!d.allowed);
assert_eq!(d.requires_interaction, None);
assert_eq!(d.reason_key, reason::HUMAN_REVIEW_REQUIRED);
assert!(
snap.decide(
cref(),
&policy,
&CommandOrigin::InternalPolicy {
policy_key: "reviewed_by_accountant".into()
}
)
.allowed
);
}
}