byteor_policy/
lib.rs

1#![cfg_attr(not(feature = "std"), no_std)]
2#![deny(missing_docs)]
3#![deny(unreachable_pub, rust_2018_idioms)]
4#![forbid(unsafe_code)]
5
6//! ByteOr policy layer.
7//!
8//! This crate is intended to be the "guardrails" brain:
9//! - replay safety policies
10//! - action allow/deny rules per environment
11//! - stage capability declarations
12
13#[cfg(all(not(feature = "std"), not(feature = "alloc")))]
14compile_error!("byteor-policy requires feature `alloc` when built without `std`.");
15
16#[cfg(feature = "std")]
17extern crate std;
18
19#[cfg(feature = "alloc")]
20extern crate alloc;
21
22use byteor_core::config::Environment;
23
24/// Replay policy.
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26pub enum ReplayPolicy {
27    /// Disable replay.
28    Disabled,
29    /// Allow replay only in dry-run mode.
30    DryRunOnly,
31    /// Allow replay.
32    Allowed,
33}
34
35/// Limits applied to replay runs.
36#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37pub struct ReplayLimits {
38    /// Maximum number of actions (or action-like side effects) that may be attempted.
39    pub max_actions: u64,
40    /// Maximum number of input bytes that may be processed under replay.
41    pub max_input_bytes: u64,
42}
43
44impl Default for ReplayLimits {
45    fn default() -> Self {
46        Self {
47            max_actions: 10_000,
48            max_input_bytes: 128 * 1024 * 1024,
49        }
50    }
51}
52
53/// Side effect policy.
54#[derive(Clone, Copy, Debug, PartialEq, Eq)]
55pub enum SideEffectsPolicy {
56    /// Deny any side effects (always safe).
57    DenyAll,
58    /// Allow side effects without additional gates.
59    AllowAll,
60    /// Require an explicit approval token.
61    RequireApproval(ApprovalGate),
62}
63
64/// Approval gate for side effects.
65#[derive(Clone, Copy, Debug, PartialEq, Eq)]
66pub struct ApprovalGate {
67    /// The name of the token expected via CLI/env/wrapper.
68    pub token_name: &'static str,
69}
70
71/// Declares the action's capability class for policy decisions.
72#[derive(Clone, Copy, Debug, PartialEq, Eq)]
73pub enum ActionCapability {
74    /// No side effects (e.g. formatting, parsing, pure transforms).
75    NonSideEffecting,
76    /// Side effects possible (HTTP calls, exec, ticket writes, etc.).
77    SideEffecting,
78}
79
80/// An action declaration (what the stage *could* do).
81#[derive(Clone, Copy, Debug, PartialEq, Eq)]
82pub struct ActionDecl<'a> {
83    /// Stable action identifier (for logs/audit).
84    pub name: &'a str,
85    /// Capability class.
86    pub capability: ActionCapability,
87}
88
89/// A policy evaluation input for a single action.
90#[derive(Clone, Copy, Debug, PartialEq, Eq)]
91pub struct ActionRequest<'a> {
92    /// Target environment.
93    pub env: Environment,
94    /// Whether this request is part of a replay execution.
95    pub is_replay: bool,
96    /// Whether this request is a dry-run (must not execute side effects).
97    pub dry_run: bool,
98    /// Declared capability.
99    pub action: ActionDecl<'a>,
100    /// Whether an approval token is present.
101    pub approval_token_present: bool,
102    /// Replay accounting snapshot.
103    pub replay: ReplayAccounting,
104}
105
106/// Replay accounting snapshot used for quota decisions.
107#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
108pub struct ReplayAccounting {
109    /// Actions attempted so far.
110    pub actions_so_far: u64,
111    /// Input bytes processed so far.
112    pub input_bytes_so_far: u64,
113}
114
115/// Policy bundle for a runtime.
116#[derive(Clone, Copy, Debug, PartialEq, Eq)]
117pub struct Policy {
118    /// Environment.
119    pub env: Environment,
120    /// Replay policy.
121    pub replay: ReplayPolicy,
122    /// Replay limits.
123    pub replay_limits: ReplayLimits,
124    /// Side effects gating.
125    pub side_effects: SideEffectsPolicy,
126}
127
128impl Policy {
129    /// A conservative default policy.
130    pub fn safe_defaults(env: Environment) -> Self {
131        let side_effects = match env {
132            Environment::Dev => SideEffectsPolicy::AllowAll,
133            Environment::Staging | Environment::Prod => {
134                SideEffectsPolicy::RequireApproval(ApprovalGate {
135                    token_name: "BYTEOR_APPROVE",
136                })
137            }
138        };
139
140        Self {
141            env,
142            replay: ReplayPolicy::DryRunOnly,
143            replay_limits: ReplayLimits::default(),
144            side_effects,
145        }
146    }
147}
148
149/// A deterministic, explainable decision from policy.
150#[derive(Clone, Copy, Debug, PartialEq, Eq)]
151pub enum Decision {
152    /// Allow the action.
153    Allow,
154    /// Allow only as dry-run (must not execute side effects).
155    DryRunOnly,
156    /// Require an explicit approval gate.
157    RequireApproval(ApprovalGate),
158    /// Deny the action.
159    Deny(DenyReason),
160}
161
162/// Machine-readable deny reason.
163#[derive(Clone, Copy, Debug, PartialEq, Eq)]
164pub enum DenyReason {
165    /// Replay is disabled.
166    ReplayDisabled,
167    /// Replay requires dry-run.
168    ReplayDryRunOnly,
169    /// Replay quota exceeded.
170    ReplayQuotaExceeded,
171    /// Side effects are disabled by policy.
172    SideEffectsDisabled,
173}
174
175/// Decide whether an action may execute under policy.
176pub fn decide(policy: &Policy, req: &ActionRequest<'_>) -> Decision {
177    // Environment must match; this avoids accidental cross-env evaluation.
178    if req.env != policy.env {
179        return Decision::Deny(DenyReason::SideEffectsDisabled);
180    }
181
182    if req.is_replay {
183        match policy.replay {
184            ReplayPolicy::Disabled => return Decision::Deny(DenyReason::ReplayDisabled),
185            ReplayPolicy::DryRunOnly if !req.dry_run => {
186                return Decision::Deny(DenyReason::ReplayDryRunOnly);
187            }
188            _ => {}
189        }
190
191        if req.replay.actions_so_far > policy.replay_limits.max_actions
192            || req.replay.input_bytes_so_far > policy.replay_limits.max_input_bytes
193        {
194            return Decision::Deny(DenyReason::ReplayQuotaExceeded);
195        }
196    }
197
198    if req.dry_run {
199        return Decision::DryRunOnly;
200    }
201
202    if req.action.capability == ActionCapability::SideEffecting {
203        match policy.side_effects {
204            SideEffectsPolicy::DenyAll => return Decision::Deny(DenyReason::SideEffectsDisabled),
205            SideEffectsPolicy::AllowAll => return Decision::Allow,
206            SideEffectsPolicy::RequireApproval(gate) => {
207                if req.approval_token_present {
208                    return Decision::Allow;
209                }
210                return Decision::RequireApproval(gate);
211            }
212        }
213    }
214
215    Decision::Allow
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    #[test]
223    fn replay_disabled_denies() {
224        let policy = Policy {
225            env: Environment::Dev,
226            replay: ReplayPolicy::Disabled,
227            replay_limits: ReplayLimits::default(),
228            side_effects: SideEffectsPolicy::AllowAll,
229        };
230
231        let req = ActionRequest {
232            env: Environment::Dev,
233            is_replay: true,
234            dry_run: true,
235            action: ActionDecl {
236                name: "http_post",
237                capability: ActionCapability::SideEffecting,
238            },
239            approval_token_present: false,
240            replay: ReplayAccounting::default(),
241        };
242
243        assert_eq!(
244            decide(&policy, &req),
245            Decision::Deny(DenyReason::ReplayDisabled)
246        );
247    }
248
249    #[test]
250    fn side_effects_require_approval() {
251        let policy = Policy {
252            env: Environment::Prod,
253            replay: ReplayPolicy::Allowed,
254            replay_limits: ReplayLimits::default(),
255            side_effects: SideEffectsPolicy::RequireApproval(ApprovalGate {
256                token_name: "BYTEOR_APPROVE",
257            }),
258        };
259
260        let req = ActionRequest {
261            env: Environment::Prod,
262            is_replay: false,
263            dry_run: false,
264            action: ActionDecl {
265                name: "http_post",
266                capability: ActionCapability::SideEffecting,
267            },
268            approval_token_present: false,
269            replay: ReplayAccounting::default(),
270        };
271
272        assert_eq!(
273            decide(&policy, &req),
274            Decision::RequireApproval(ApprovalGate {
275                token_name: "BYTEOR_APPROVE"
276            })
277        );
278
279        let req2 = ActionRequest {
280            approval_token_present: true,
281            ..req
282        };
283
284        assert_eq!(decide(&policy, &req2), Decision::Allow);
285    }
286
287    #[test]
288    fn dry_run_forces_dry_run_decision() {
289        let policy = Policy {
290            env: Environment::Dev,
291            replay: ReplayPolicy::Allowed,
292            replay_limits: ReplayLimits::default(),
293            side_effects: SideEffectsPolicy::AllowAll,
294        };
295
296        let req = ActionRequest {
297            env: Environment::Dev,
298            is_replay: false,
299            dry_run: true,
300            action: ActionDecl {
301                name: "http_post",
302                capability: ActionCapability::SideEffecting,
303            },
304            approval_token_present: true,
305            replay: ReplayAccounting::default(),
306        };
307
308        assert_eq!(decide(&policy, &req), Decision::DryRunOnly);
309    }
310}