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#[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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26pub enum ReplayPolicy {
27 Disabled,
29 DryRunOnly,
31 Allowed,
33}
34
35#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37pub struct ReplayLimits {
38 pub max_actions: u64,
40 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
55pub enum SideEffectsPolicy {
56 DenyAll,
58 AllowAll,
60 RequireApproval(ApprovalGate),
62}
63
64#[derive(Clone, Copy, Debug, PartialEq, Eq)]
66pub struct ApprovalGate {
67 pub token_name: &'static str,
69}
70
71#[derive(Clone, Copy, Debug, PartialEq, Eq)]
73pub enum ActionCapability {
74 NonSideEffecting,
76 SideEffecting,
78}
79
80#[derive(Clone, Copy, Debug, PartialEq, Eq)]
82pub struct ActionDecl<'a> {
83 pub name: &'a str,
85 pub capability: ActionCapability,
87}
88
89#[derive(Clone, Copy, Debug, PartialEq, Eq)]
91pub struct ActionRequest<'a> {
92 pub env: Environment,
94 pub is_replay: bool,
96 pub dry_run: bool,
98 pub action: ActionDecl<'a>,
100 pub approval_token_present: bool,
102 pub replay: ReplayAccounting,
104}
105
106#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
108pub struct ReplayAccounting {
109 pub actions_so_far: u64,
111 pub input_bytes_so_far: u64,
113}
114
115#[derive(Clone, Copy, Debug, PartialEq, Eq)]
117pub struct Policy {
118 pub env: Environment,
120 pub replay: ReplayPolicy,
122 pub replay_limits: ReplayLimits,
124 pub side_effects: SideEffectsPolicy,
126}
127
128impl Policy {
129 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
151pub enum Decision {
152 Allow,
154 DryRunOnly,
156 RequireApproval(ApprovalGate),
158 Deny(DenyReason),
160}
161
162#[derive(Clone, Copy, Debug, PartialEq, Eq)]
164pub enum DenyReason {
165 ReplayDisabled,
167 ReplayDryRunOnly,
169 ReplayQuotaExceeded,
171 SideEffectsDisabled,
173}
174
175pub fn decide(policy: &Policy, req: &ActionRequest<'_>) -> Decision {
177 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}