1use std::future::Future;
17use std::pin::Pin;
18use std::time::Instant;
19
20use serde::{Deserialize, Serialize};
21use wasmtime::Caller;
22
23use super::StoreData;
24use super::decision::CallSite;
25use super::durable::EffectId;
26use super::host::{
27 DenialSource, fatal_host_error, permission_denied, permission_denied_by, quota_exceeded_error,
28};
29use super::metrics::MetricsSink;
30use super::security::{AuditDecision, SecurityCheck, denial_reason};
31
32pub const APPROVAL_OPERATION: &str = "policy.approval";
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum Approver {
39 Human,
41 Admin,
43}
44
45impl Approver {
46 pub fn as_str(self) -> &'static str {
47 match self {
48 Self::Human => "human",
49 Self::Admin => "admin",
50 }
51 }
52
53 pub fn action(self) -> &'static str {
55 match self {
56 Self::Human => "ask-human",
57 Self::Admin => "ask-admin",
58 }
59 }
60
61 pub fn denial_source(self) -> DenialSource {
63 match self {
64 Self::Human => DenialSource::Human,
65 Self::Admin => DenialSource::Admin,
66 }
67 }
68
69 fn who(self) -> &'static str {
70 match self {
71 Self::Human => "the person asked",
72 Self::Admin => "an administrator",
73 }
74 }
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub struct AskRule {
81 pub caller: String,
82 pub index: usize,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub name: Option<String>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub filter: Option<String>,
87}
88
89#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
92pub struct ApprovalRequest {
93 pub approver: Approver,
94 pub caller: String,
95 pub capability: String,
96 pub context: serde_json::Value,
97 pub rule: AskRule,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103pub enum ApprovalOutcome {
104 Approved {
105 reason: Option<String>,
106 },
107 Denied {
109 reason: Option<String>,
110 },
111 Expired,
113 Unanswerable {
117 message: String,
118 },
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123pub enum ApprovalError {
124 QuotaExceeded { message: String },
127 Cancelled,
129}
130
131pub trait ApprovalProvider: Send + Sync {
135 fn request<'a>(
136 &'a self,
137 effect_id: EffectId,
138 request: &'a ApprovalRequest,
139 ) -> Pin<Box<dyn Future<Output = Result<ApprovalOutcome, ApprovalError>> + Send + 'a>>;
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144#[non_exhaustive]
145pub enum Unasked {
146 NoProvider,
148 CannotWait,
150 Unanswerable,
153}
154
155impl Unasked {
156 pub fn as_str(self) -> &'static str {
157 match self {
158 Self::NoProvider => "no_provider",
159 Self::CannotWait => "cannot_wait",
160 Self::Unanswerable => "unanswerable",
161 }
162 }
163}
164
165impl ApprovalRequest {
166 pub fn denial(&self) -> String {
168 denial_reason(&self.caller, &self.capability, &self.context)
169 }
170
171 pub fn report_unasked(&self, why: Unasked, detail: Option<&str>, metrics: &dyn MetricsSink) {
175 tracing::warn!(
176 caller = %self.caller,
177 capability = %self.capability,
178 approver = self.approver.as_str(),
179 rule_caller = %self.rule.caller,
180 rule = self.rule.index,
181 why = why.as_str(),
182 detail,
183 "denied a call that needs approval without asking"
184 );
185 metrics.approval_unasked(self.approver, why);
186 }
187}
188
189pub(crate) fn deny_unasked(
192 request: &ApprovalRequest,
193 security: &dyn SecurityCheck,
194 site: CallSite,
195) -> String {
196 let denial = request.denial();
197 audit(
198 security,
199 request,
200 &Err((DenialSource::Policy, denial.clone())),
201 site,
202 );
203 denial
204}
205
206pub(crate) fn refuse(
209 request: ApprovalRequest,
210 why: Unasked,
211 security: &dyn SecurityCheck,
212 metrics: &dyn MetricsSink,
213 site: CallSite,
214) -> wasmtime::Error {
215 request.report_unasked(why, None, metrics);
216 let denial = deny_unasked(&request, security, site);
217 permission_denied(request.caller, request.capability, denial)
218}
219
220pub fn recorded_outcome(bytes: &super::effects::OutcomeBytes) -> Option<ApprovalOutcome> {
223 super::effects::decode(&bytes.0).ok()
224}
225
226type Decision = Result<Option<String>, (DenialSource, String)>;
229
230pub(crate) async fn ask(
233 caller: &mut Caller<'_, StoreData>,
234 request: ApprovalRequest,
235 site: CallSite,
236) -> wasmtime::Result<()> {
237 let Some(provider) = caller.data().approval_provider.clone() else {
238 let data = caller.data();
239 return Err(refuse(
240 request,
241 Unasked::NoProvider,
242 data.security_check.as_ref(),
243 data.metrics.as_ref(),
244 site,
245 ));
246 };
247 let outcome = super::effects::execute(
248 caller,
249 APPROVAL_OPERATION,
250 request.clone(),
251 move |caller, effect_id, request| {
252 Box::pin(async move {
253 let waited = Instant::now();
254 let outcome = provider.request(effect_id, &request).await;
255 let data = caller.data_mut();
256 data.clock_paused = data.clock_paused.saturating_add(waited.elapsed());
257 let outcome = outcome.map_err(|error| match error {
258 ApprovalError::QuotaExceeded { message } => {
259 quota_exceeded_error(format!("{}: {message}", request.capability))
260 }
261 ApprovalError::Cancelled => fatal_host_error(format!(
262 "{}: the approval was cancelled",
263 request.capability
264 )),
265 })?;
266 let data = caller.data();
269 if let ApprovalOutcome::Unanswerable { message } = &outcome {
270 request.report_unasked(
271 Unasked::Unanswerable,
272 Some(message),
273 data.metrics.as_ref(),
274 );
275 }
276 let decision = decision_of(&request, &outcome);
277 audit(data.security_check.as_ref(), &request, &decision, site);
278 Ok(outcome)
279 })
280 },
281 )
282 .await?;
283 match decision_of(&request, &outcome) {
284 Ok(_) => Ok(()),
285 Err((source, reason)) => Err(permission_denied_by(
286 source,
287 request.caller,
288 request.capability,
289 reason,
290 )),
291 }
292}
293
294fn decision_of(request: &ApprovalRequest, outcome: &ApprovalOutcome) -> Decision {
296 let approver = request.approver;
297 let who = approver.who();
298 let action = approver.action();
299 match outcome {
300 ApprovalOutcome::Approved { reason } => Ok(reason.clone()),
301 ApprovalOutcome::Denied { reason } => Err((
302 approver.denial_source(),
303 match reason {
304 Some(reason) if !reason.is_empty() => {
305 format!("{who} declined the call ({action}): {reason}")
306 }
307 _ => format!("{who} declined the call ({action})"),
308 },
309 )),
310 ApprovalOutcome::Expired => Err((
311 approver.denial_source(),
312 format!("{who} did not decide in time ({action})"),
313 )),
314 ApprovalOutcome::Unanswerable { .. } => Err((DenialSource::Policy, request.denial())),
315 }
316}
317
318fn audit(
319 security: &dyn SecurityCheck,
320 request: &ApprovalRequest,
321 decision: &Decision,
322 site: CallSite,
323) {
324 let (source, reason) = match decision {
325 Ok(reason) => (request.approver.as_str(), reason.as_deref()),
326 Err((source, reason)) => (source.as_str(), Some(reason.as_str())),
327 };
328 let explanation = security.recorder().and_then(|_| {
330 security.explain(&request.caller, &request.capability, &request.context, "/")
331 });
332 security.audit(
333 AuditDecision::new(
334 &request.caller,
335 &request.capability,
336 &request.context,
337 decision.is_ok(),
338 source,
339 Some(request.rule.index),
340 reason,
341 )
342 .with_explanation(explanation.as_ref())
343 .with_site(site),
344 );
345}
346
347#[cfg(test)]
348mod tests {
349 use std::sync::{Arc, Mutex};
350 use std::time::Duration;
351
352 use wasmtime::{Linker, Module};
353
354 use super::*;
355 use crate::runtime::host::DenialSource;
356 use crate::runtime::security::CheckOutcome;
357 use crate::runtime::{
358 RuntimeConfig, StoreData, Vfs, dispatch_main_async, install_runtime_async,
359 install_tenant_limits,
360 };
361
362 struct AsksRefunds(Approver);
364
365 impl SecurityCheck for AsksRefunds {
366 fn check(&self, caller: &str, capability: &str, _: &serde_json::Value) -> CheckOutcome {
367 if capability != "test.refund" {
368 return CheckOutcome::Allow { rule: None };
369 }
370 CheckOutcome::Ask {
371 approver: self.0,
372 rule: AskRule {
373 caller: caller.to_owned(),
374 index: 0,
375 name: Some("refunds".to_owned()),
376 filter: None,
377 },
378 }
379 }
380 }
381
382 type Asked = Arc<Mutex<Vec<ApprovalRequest>>>;
383
384 struct Decides {
386 outcome: ApprovalOutcome,
387 delay: Duration,
388 asked: Asked,
389 }
390
391 impl ApprovalProvider for Decides {
392 fn request<'a>(
393 &'a self,
394 _effect_id: EffectId,
395 request: &'a ApprovalRequest,
396 ) -> Pin<Box<dyn Future<Output = Result<ApprovalOutcome, ApprovalError>> + Send + 'a>>
397 {
398 self.asked.lock().unwrap().push(request.clone());
399 Box::pin(async move {
400 tokio::time::sleep(self.delay).await;
401 Ok(self.outcome.clone())
402 })
403 }
404 }
405
406 #[derive(Default)]
408 struct Unaskeds(Mutex<Vec<(Approver, Unasked)>>);
409
410 impl MetricsSink for Unaskeds {
411 fn approval_unasked(&self, approver: Approver, why: Unasked) {
412 self.0.lock().unwrap().push((approver, why));
413 }
414 }
415
416 struct Run {
417 result: wasmtime::Result<Option<String>>,
418 asked: Vec<ApprovalRequest>,
419 unasked: Vec<(Approver, Unasked)>,
420 clock_paused: Duration,
421 }
422
423 async fn run(
424 source: &str,
425 approver: Approver,
426 outcome: Option<ApprovalOutcome>,
427 delay: Duration,
428 ) -> Run {
429 let compiled =
430 crate::compile_script(source, "<test>", crate::FileId(0), &[], &[]).expect("compiles");
431 let cfg = RuntimeConfig::default();
432 let engine = cfg.engine().unwrap();
433 let asked = Asked::default();
434 let unasked = Arc::new(Unaskeds::default());
435 let mut data = StoreData::with_vfs("test-run".into(), Vfs::none());
436 data.install_type_info(compiled.type_info.clone());
437 data.security_check = Arc::new(AsksRefunds(approver));
438 data.metrics = unasked.clone();
439 if let Some(outcome) = outcome {
440 data.approval_provider = Some(Arc::new(Decides {
441 outcome,
442 delay,
443 asked: asked.clone(),
444 }));
445 }
446 let mut store = cfg.store_async(&engine, data).unwrap();
447 install_tenant_limits(&mut store);
448 let module = Module::new(&engine, &compiled.wasm).unwrap();
449 let mut linker = Linker::<StoreData>::new(&engine);
450 install_runtime_async(&mut linker, &mut store)
451 .await
452 .unwrap();
453 let instance = linker.instantiate_async(&mut store, &module).await.unwrap();
454 let result = dispatch_main_async(&mut store, &instance).await;
455 let asked = asked.lock().unwrap().clone();
456 let unasked = unasked.0.lock().unwrap().clone();
457 Run {
458 result,
459 asked,
460 unasked,
461 clock_paused: store.data().clock_paused,
462 }
463 }
464
465 const REFUND: &str = r#"import { check } from "submilli:security";
466 function main(): string { check("test.refund", { amount: 250 }); return "refunded"; }"#;
467
468 const REFUND_OR_REASON: &str = r#"import { check } from "submilli:security";
469 function main(): string {
470 try {
471 check("test.refund", { amount: 250 });
472 return "refunded";
473 } catch (e) {
474 if (e instanceof PermissionDeniedError) { return e.reason; }
475 throw e;
476 }
477 }"#;
478
479 fn escaped_source(error: &wasmtime::Error) -> Option<DenialSource> {
480 error
481 .downcast_ref::<crate::backtrace::ThrownError>()
482 .and_then(|thrown| thrown.denial.as_ref())
483 .map(|denial| denial.source)
484 }
485
486 #[tokio::test]
487 async fn an_approved_call_runs_once_and_the_wait_is_not_running_time() {
488 let run = run(
489 REFUND,
490 Approver::Human,
491 Some(ApprovalOutcome::Approved { reason: None }),
492 Duration::from_millis(30),
493 )
494 .await;
495 assert_eq!(run.result.unwrap().as_deref(), Some("refunded"));
496 assert_eq!(run.asked.len(), 1);
497 let asked = &run.asked[0];
498 assert_eq!(asked.approver, Approver::Human);
499 assert_eq!(asked.caller, "main");
500 assert_eq!(asked.capability, "test.refund");
501 assert_eq!(asked.context, serde_json::json!({ "amount": 250 }));
502 assert_eq!(asked.rule.name.as_deref(), Some("refunds"));
503 assert!(run.clock_paused >= Duration::from_millis(30));
504 }
505
506 #[tokio::test]
507 async fn a_denial_is_catchable_and_names_who_declined() {
508 let run = run(
509 REFUND_OR_REASON,
510 Approver::Human,
511 Some(ApprovalOutcome::Denied {
512 reason: Some("not this customer".to_owned()),
513 }),
514 Duration::ZERO,
515 )
516 .await;
517 assert_eq!(
518 run.result.unwrap().as_deref(),
519 Some("the person asked declined the call (ask-human): not this customer")
520 );
521 }
522
523 #[tokio::test]
524 async fn an_escaped_refusal_reports_the_approver_as_its_source() {
525 for (approver, outcome, source) in [
526 (
527 Approver::Human,
528 ApprovalOutcome::Denied { reason: None },
529 DenialSource::Human,
530 ),
531 (
532 Approver::Admin,
533 ApprovalOutcome::Expired,
534 DenialSource::Admin,
535 ),
536 (
537 Approver::Human,
538 ApprovalOutcome::Unanswerable {
539 message: "no terminal".to_owned(),
540 },
541 DenialSource::Policy,
542 ),
543 ] {
544 let run = run(REFUND, approver, Some(outcome), Duration::ZERO).await;
545 let error = run.result.expect_err("refused");
546 assert_eq!(escaped_source(&error), Some(source), "{error:#}");
547 }
548 }
549
550 const REFUND_DENIED: &str = "policy denied test.refund with amount = 250 for main";
552
553 #[tokio::test]
554 async fn without_an_approver_an_ask_is_denied_as_a_deny_rule_would_deny_it() {
555 let caught = run(REFUND_OR_REASON, Approver::Admin, None, Duration::ZERO).await;
556 assert_eq!(caught.result.unwrap().as_deref(), Some(REFUND_DENIED));
557 assert_eq!(caught.unasked, [(Approver::Admin, Unasked::NoProvider)]);
558 let escaped = run(REFUND, Approver::Human, None, Duration::ZERO).await;
559 let error = escaped.result.expect_err("refused");
560 assert_eq!(escaped_source(&error), Some(DenialSource::Policy));
561 }
562
563 #[tokio::test]
564 async fn an_unanswerable_request_is_denied_as_a_deny_rule_would_deny_it() {
565 let run = run(
566 REFUND_OR_REASON,
567 Approver::Human,
568 Some(ApprovalOutcome::Unanswerable {
569 message: "the harness answered it like a tool call".to_owned(),
570 }),
571 Duration::ZERO,
572 )
573 .await;
574 assert_eq!(run.result.unwrap().as_deref(), Some(REFUND_DENIED));
575 assert_eq!(run.unasked, [(Approver::Human, Unasked::Unanswerable)]);
576 }
577
578 #[tokio::test]
579 async fn a_decided_request_is_not_reported_as_unasked() {
580 let run = run(
581 REFUND,
582 Approver::Human,
583 Some(ApprovalOutcome::Denied { reason: None }),
584 Duration::ZERO,
585 )
586 .await;
587 assert!(run.result.is_err());
588 assert_eq!(run.unasked, []);
589 }
590
591 #[test]
592 fn a_package_check_recorded_before_approvals_replays_as_allowed() {
593 let legacy = crate::runtime::effects::encode(&()).unwrap();
594 let request: Option<ApprovalRequest> = crate::runtime::effects::decode(&legacy).unwrap();
595 assert_eq!(request, None);
596 }
597
598 #[test]
599 fn a_recorded_outcome_reads_back() {
600 let bytes = crate::runtime::effects::OutcomeBytes(
601 crate::runtime::effects::encode(&ApprovalOutcome::Expired).unwrap(),
602 );
603 assert_eq!(recorded_outcome(&bytes), Some(ApprovalOutcome::Expired));
604 }
605}