Skip to main content

turnframe_core/
policy.rs

1//! Policy decisions and the policy snapshot the reducer evaluates against.
2//!
3//! Policy is deterministic: given a command's [`CommandPolicy`] and its
4//! [`CommandOrigin`], [`PolicySnapshot::decide`] says whether the command may
5//! execute now and, if not, which interaction would authorize it.
6
7use serde::{Deserialize, Serialize};
8
9use crate::command::{CommandOrigin, CommandPolicy, RiskClass, origin_satisfies};
10use crate::interaction::InteractionKind;
11use crate::reduce::CommandRef;
12
13/// Reason keys used by [`PolicySnapshot::decide`].
14pub mod reason {
15    /// The command may execute with its origin.
16    pub const ALLOWED: &str = "turnframe.policy.allowed";
17    /// A confirmation interaction is required first.
18    pub const CONFIRMATION_REQUIRED: &str = "turnframe.policy.confirmation_required";
19    /// The risk class is forbidden in this configuration.
20    pub const FORBIDDEN_RISK_CLASS: &str = "turnframe.policy.forbidden_risk_class";
21    /// The confirmation must come from somebody other than the end user, so no
22    /// card can unblock the command.
23    pub const HUMAN_REVIEW_REQUIRED: &str = "turnframe.policy.human_review_required";
24}
25
26/// The outcome of evaluating policy for one command.
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct PolicyDecision {
29    /// The command.
30    pub command_ref: CommandRef,
31    /// The policy that applied.
32    pub policy: CommandPolicy,
33    /// Interaction that would authorize the command when not allowed now.
34    pub requires_interaction: Option<InteractionKind>,
35    /// Whether the command may execute with its current origin.
36    pub allowed: bool,
37    /// Key of the user-facing reason (see [`reason`]).
38    pub reason_key: String,
39}
40
41/// Point-in-time policy configuration the reducer evaluates against.
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[non_exhaustive]
44pub struct PolicySnapshot {
45    /// Policy for commands the domain did not classify.
46    pub default_policy: CommandPolicy,
47    /// Risk classes that may never execute (e.g. in a sandbox: everything above
48    /// `ReversibleLowRisk`).
49    pub forbidden_risk_classes: Vec<RiskClass>,
50    /// Highest risk a command may carry while originating from an untrusted
51    /// origin such as [`CommandOrigin::DirectSafeUserAct`]. Default
52    /// `ReversibleLowRisk` (I12).
53    ///
54    /// The setting may only tighten: [`origin_satisfies`] refuses anything
55    /// above `ReversibleLowRisk` from an untrusted origin whatever the
56    /// snapshot says.
57    pub max_direct_risk: RiskClass,
58    /// Whether low-risk interactions may be resolved from interpreted text
59    /// when their stored policy permits it (spec §13.2 rule 8).
60    pub allow_text_resolution_for_low_risk: bool,
61}
62
63impl PolicySnapshot {
64    /// The default snapshot: conservative default policy, nothing forbidden,
65    /// direct acts up to `ReversibleLowRisk`, text resolution allowed for
66    /// low-risk cards.
67    ///
68    /// There is no "fail open" setting: when the policy source cannot be
69    /// consulted the runtime has no snapshot to consult either and must return
70    /// [`PolicyError::Unavailable`](crate::error::PolicyError::Unavailable)
71    /// (I19).
72    #[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    /// A snapshot for sandboxed, reversible domains: everything above
83    /// `ReversibleLowRisk` is forbidden (spec §11.4).
84    #[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    /// Returns `true` when the risk class may never execute.
98    #[must_use]
99    pub fn is_risk_forbidden(&self, risk: RiskClass) -> bool {
100        self.forbidden_risk_classes.contains(&risk)
101    }
102
103    /// Evaluates one command.
104    ///
105    /// The decision answers two questions: may this command execute with the
106    /// origin it carries, and if not, which card would authorize it. A policy
107    /// only somebody other than the end user can satisfy
108    /// ([`ConfirmationPolicy::HumanProfessionalReview`](crate::command::ConfirmationPolicy::HumanProfessionalReview))
109    /// names no card at all.
110    #[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}