1use serde::{Deserialize, Serialize};
8
9use crate::command::{CommandOrigin, CommandPolicy, RiskClass, origin_satisfies};
10use crate::interaction::InteractionKind;
11use crate::reduce::CommandRef;
12
13pub mod reason {
15 pub const ALLOWED: &str = "turnframe.policy.allowed";
17 pub const CONFIRMATION_REQUIRED: &str = "turnframe.policy.confirmation_required";
19 pub const FORBIDDEN_RISK_CLASS: &str = "turnframe.policy.forbidden_risk_class";
21 pub const HUMAN_REVIEW_REQUIRED: &str = "turnframe.policy.human_review_required";
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct PolicyDecision {
29 pub command_ref: CommandRef,
31 pub policy: CommandPolicy,
33 pub requires_interaction: Option<InteractionKind>,
35 pub allowed: bool,
37 pub reason_key: String,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[non_exhaustive]
44pub struct PolicySnapshot {
45 pub default_policy: CommandPolicy,
47 pub forbidden_risk_classes: Vec<RiskClass>,
50 pub max_direct_risk: RiskClass,
58 pub allow_text_resolution_for_low_risk: bool,
61}
62
63impl PolicySnapshot {
64 #[must_use]
73 pub fn conservative() -> Self {
74 Self {
75 default_policy: CommandPolicy::conservative(),
76 forbidden_risk_classes: Vec::new(),
77 max_direct_risk: RiskClass::ReversibleLowRisk,
78 allow_text_resolution_for_low_risk: true,
79 }
80 }
81
82 #[must_use]
85 pub fn sandbox() -> Self {
86 Self {
87 forbidden_risk_classes: vec![
88 RiskClass::SensitiveDataChange,
89 RiskClass::Destructive,
90 RiskClass::Irreversible,
91 RiskClass::ExternalRegulated,
92 ],
93 ..Self::conservative()
94 }
95 }
96
97 #[must_use]
99 pub fn is_risk_forbidden(&self, risk: RiskClass) -> bool {
100 self.forbidden_risk_classes.contains(&risk)
101 }
102
103 #[must_use]
111 pub fn decide(
112 &self,
113 command_ref: CommandRef,
114 policy: &CommandPolicy,
115 origin: &CommandOrigin,
116 ) -> PolicyDecision {
117 if self.is_risk_forbidden(policy.risk) {
118 return PolicyDecision {
119 command_ref,
120 policy: policy.clone(),
121 requires_interaction: None,
122 allowed: false,
123 reason_key: reason::FORBIDDEN_RISK_CLASS.to_owned(),
124 };
125 }
126 let direct_too_risky = !origin.is_trusted() && policy.risk > self.max_direct_risk;
127 if origin_satisfies(origin, policy) && !direct_too_risky {
128 return PolicyDecision {
129 command_ref,
130 policy: policy.clone(),
131 requires_interaction: None,
132 allowed: true,
133 reason_key: reason::ALLOWED.to_owned(),
134 };
135 }
136 let requires_interaction = policy.confirmation.interaction_kind().or({
137 if policy.confirmation.is_server_side_only() {
138 None
139 } else {
140 Some(InteractionKind::ConfirmCommand)
141 }
142 });
143 let reason_key = if policy.confirmation.is_server_side_only() {
144 reason::HUMAN_REVIEW_REQUIRED
145 } else {
146 reason::CONFIRMATION_REQUIRED
147 };
148 PolicyDecision {
149 command_ref,
150 policy: policy.clone(),
151 requires_interaction,
152 allowed: false,
153 reason_key: reason_key.to_owned(),
154 }
155 }
156}
157
158impl Default for PolicySnapshot {
159 fn default() -> Self {
160 Self::conservative()
161 }
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167 use crate::command::{ConfirmationPolicy, ResolutionChannel};
168 use crate::hash::Digest;
169 use crate::ids::{BatchId, CommandId, InteractionId};
170 use crate::interaction::ActionClass;
171
172 fn cref() -> CommandRef {
173 CommandRef {
174 batch_id: BatchId::nil(),
175 command_id: CommandId::nil(),
176 }
177 }
178
179 fn direct() -> CommandOrigin {
180 CommandOrigin::DirectSafeUserAct {
181 evidence_digest: Digest::of_bytes(b"e"),
182 }
183 }
184
185 fn card(kind: InteractionKind, action_class: ActionClass) -> CommandOrigin {
186 CommandOrigin::ConfirmedInteraction {
187 interaction_id: InteractionId::nil(),
188 payload_hash: Digest::of_bytes(b"p"),
189 interaction_kind: kind,
190 action_class,
191 channel: ResolutionChannel::Click,
192 }
193 }
194
195 fn confirmed() -> CommandOrigin {
196 card(
197 InteractionKind::ConfirmCommand,
198 ActionClass::ConfirmsCommands,
199 )
200 }
201
202 #[test]
203 fn direct_low_risk_allowed_high_risk_needs_card() {
204 let snap = PolicySnapshot::conservative();
205 let low = snap.decide(cref(), &CommandPolicy::low_risk(), &direct());
206 assert!(low.allowed);
207 let high = snap.decide(cref(), &CommandPolicy::conservative(), &direct());
208 assert!(!high.allowed);
209 assert_eq!(
210 high.requires_interaction,
211 Some(InteractionKind::ConfirmCommand)
212 );
213 assert!(
214 snap.decide(cref(), &CommandPolicy::conservative(), &confirmed())
215 .allowed
216 );
217 }
218
219 #[test]
220 fn review_card_maps_to_review_changes() {
221 let snap = PolicySnapshot::conservative();
222 let mut policy = CommandPolicy::low_risk();
223 policy.confirmation = ConfirmationPolicy::ReviewCard;
224 let d = snap.decide(cref(), &policy, &direct());
225 assert_eq!(d.requires_interaction, Some(InteractionKind::ReviewChanges));
226 }
227
228 #[test]
229 fn sandbox_forbids_destructive_even_when_confirmed() {
230 let snap = PolicySnapshot::sandbox();
231 let mut policy = CommandPolicy::conservative();
232 policy.risk = RiskClass::Destructive;
233 let d = snap.decide(cref(), &policy, &confirmed());
234 assert!(!d.allowed);
235 assert_eq!(d.reason_key, reason::FORBIDDEN_RISK_CLASS);
236 assert_eq!(d.requires_interaction, None);
237 }
238
239 #[test]
240 fn a_selection_click_does_not_authorize_the_command_it_disambiguates() {
241 let snap = PolicySnapshot::conservative();
242 let selection = card(InteractionKind::SelectTarget, ActionClass::NoCommands);
243 let d = snap.decide(cref(), &CommandPolicy::conservative(), &selection);
244 assert!(!d.allowed);
245 assert_eq!(d.reason_key, reason::CONFIRMATION_REQUIRED);
246 assert_eq!(
247 d.requires_interaction,
248 Some(InteractionKind::ConfirmCommand)
249 );
250 }
251
252 #[test]
253 fn an_internal_policy_key_is_not_a_qualified_signature() {
254 let snap = PolicySnapshot::conservative();
255 let policy = CommandPolicy {
256 confirmation: ConfirmationPolicy::QualifiedSignature,
257 ..CommandPolicy::conservative()
258 };
259 let internal = CommandOrigin::InternalPolicy {
260 policy_key: "auto".into(),
261 };
262 assert!(!snap.decide(cref(), &policy, &internal).allowed);
263 assert!(
264 !snap.decide(cref(), &policy, &confirmed()).allowed,
265 "a plain confirmation card is not a signature"
266 );
267 let signed = card(
268 InteractionKind::ExternalSignature,
269 ActionClass::ConfirmsCommands,
270 );
271 assert!(snap.decide(cref(), &policy, &signed).allowed);
272 }
273
274 #[test]
275 fn human_review_names_no_card_the_user_could_click() {
276 let snap = PolicySnapshot::conservative();
277 let policy = CommandPolicy {
278 confirmation: ConfirmationPolicy::HumanProfessionalReview,
279 ..CommandPolicy::conservative()
280 };
281 let d = snap.decide(cref(), &policy, &confirmed());
282 assert!(!d.allowed);
283 assert_eq!(d.requires_interaction, None);
284 assert_eq!(d.reason_key, reason::HUMAN_REVIEW_REQUIRED);
285 assert!(
286 snap.decide(
287 cref(),
288 &policy,
289 &CommandOrigin::InternalPolicy {
290 policy_key: "reviewed_by_accountant".into()
291 }
292 )
293 .allowed
294 );
295 }
296}