1use crate::config::{CodeModeConfig, OperationRegistry};
54use crate::javascript::{HttpMethod, JavaScriptCodeInfo};
55use crate::policy::types::normalize_operation_format;
56use crate::types::{PolicyViolation, UnifiedAction};
57use std::collections::HashSet;
58
59const POLICY_NAME: &str = "code_mode";
60
61const HTTP_METHODS: [&str; 7] = ["GET", "HEAD", "OPTIONS", "POST", "PUT", "PATCH", "DELETE"];
62
63#[derive(Debug, Clone, PartialEq, Eq)]
68#[non_exhaustive]
69pub enum ClassMode {
70 DenyAll,
72 AllowAll,
74 Allowlist(HashSet<String>),
78}
79
80impl ClassMode {
81 #[must_use]
83 pub fn name(&self) -> &'static str {
84 match self {
85 Self::DenyAll => "deny_all",
86 Self::AllowAll => "allow_all",
87 Self::Allowlist(_) => "allowlist",
88 }
89 }
90
91 fn allowlist(entries: &HashSet<String>) -> Self {
92 Self::Allowlist(
93 entries
94 .iter()
95 .map(|e| normalize_operation_format(e))
96 .collect(),
97 )
98 }
99
100 fn normalized(self) -> Self {
101 match self {
102 Self::Allowlist(entries) => Self::allowlist(&entries),
103 other => other,
104 }
105 }
106}
107
108#[derive(Debug, Clone)]
110pub struct OpenApiClassPolicy {
111 read: ClassMode,
112 write: ClassMode,
113 delete: ClassMode,
114 admin: ClassMode,
115 blocked_methods: HashSet<String>,
116 blocked_operations: HashSet<String>,
117 blocked_paths: Vec<String>,
118}
119
120struct Call<'a> {
122 method: &'a str,
123 path: &'a str,
124 dynamic: bool,
126 label: String,
131}
132
133impl OpenApiClassPolicy {
134 #[must_use]
137 pub fn from_config(config: &CodeModeConfig) -> Self {
138 let read = if config.openapi_reads_enabled {
139 ClassMode::AllowAll
140 } else {
141 ClassMode::DenyAll
142 };
143 let write = gated_mode(config.openapi_allow_writes, &config.openapi_allowed_writes);
144 let delete = gated_mode(
145 config.openapi_allow_deletes,
146 &config.openapi_allowed_deletes,
147 );
148
149 Self {
150 read,
151 write,
152 delete,
153 admin: ClassMode::DenyAll,
154 blocked_methods: HashSet::new(),
155 blocked_operations: HashSet::new(),
156 blocked_paths: Vec::new(),
157 }
158 .with_blocked_operations(&config.openapi_blocked_writes)
159 .with_blocked_paths(&config.openapi_blocked_paths)
160 }
161
162 #[must_use]
169 pub fn with_mode(mut self, class: UnifiedAction, mode: ClassMode) -> Self {
170 let mode = mode.normalized();
171 match class {
172 UnifiedAction::Read => self.read = mode,
173 UnifiedAction::Write => self.write = mode,
174 UnifiedAction::Delete => self.delete = mode,
175 UnifiedAction::Admin => self.admin = mode,
176 }
177 self
178 }
179
180 #[must_use]
184 pub fn with_blocked_operations<I, S>(mut self, entries: I) -> Self
185 where
186 I: IntoIterator<Item = S>,
187 S: AsRef<str>,
188 {
189 self.blocked_methods.clear();
190 self.blocked_operations.clear();
191 for entry in entries {
192 let entry = entry.as_ref();
193 let upper = entry.trim().to_ascii_uppercase();
194 if HTTP_METHODS.contains(&upper.as_str()) {
195 self.blocked_methods.insert(upper);
196 } else {
197 self.blocked_operations
198 .insert(normalize_operation_format(entry));
199 }
200 }
201 self
202 }
203
204 #[must_use]
207 pub fn with_blocked_paths<I, S>(mut self, patterns: I) -> Self
208 where
209 I: IntoIterator<Item = S>,
210 S: AsRef<str>,
211 {
212 self.blocked_paths = patterns
213 .into_iter()
214 .map(|p| normalize_blocked_path(p.as_ref()))
215 .collect();
216 self
217 }
218
219 #[must_use]
221 pub fn mode(&self, class: UnifiedAction) -> &ClassMode {
222 match class {
223 UnifiedAction::Read => &self.read,
224 UnifiedAction::Write => &self.write,
225 UnifiedAction::Delete => &self.delete,
226 UnifiedAction::Admin => &self.admin,
227 }
228 }
229
230 #[must_use]
233 pub fn check_script(
234 &self,
235 info: &JavaScriptCodeInfo,
236 registry: &OperationRegistry,
237 ) -> Vec<PolicyViolation> {
238 info.api_calls
239 .iter()
240 .filter_map(|api_call| {
241 let method = method_name(api_call.method);
242 let call = Call {
243 method,
244 path: &api_call.path,
245 dynamic: api_call.is_dynamic_path || api_call.path.contains('{'),
246 label: format!("the {method} call on line {}", api_call.line),
247 };
248 self.check_call(&call, registry).err()
249 })
250 .collect()
251 }
252
253 pub fn check_request(
264 &self,
265 method: &str,
266 path: &str,
267 registry: &OperationRegistry,
268 ) -> Result<(), PolicyViolation> {
269 let upper = method.to_ascii_uppercase();
270 if HttpMethod::from_str(&upper).is_none() {
271 return Err(violation(
272 "unknown_method",
273 format!("HTTP method '{method}' is not supported"),
274 ));
275 }
276 let call = Call {
277 method: &upper,
278 path: strip_query(path),
279 dynamic: false,
280 label: format!("this {upper} request"),
281 };
282 self.check_call(&call, registry)
283 }
284
285 fn check_call(
286 &self,
287 call: &Call<'_>,
288 registry: &OperationRegistry,
289 ) -> Result<(), PolicyViolation> {
290 let entry = registry.lookup_entry(Some(call.method), call.path);
291 let label = match entry {
292 Some(e) => format!("{} (operation '{}')", call.label, e.id),
293 None => call.label.clone(),
294 };
295 if self.blocked_methods.contains(call.method) {
296 return Err(violation(
297 "blocked_method",
298 format!("HTTP method '{}' is blocked for this server", call.method),
299 ));
300 }
301 if self.path_blocked(call) {
302 return Err(violation(
303 "blocked_path",
304 format!("{label} is under a blocked path"),
305 ));
306 }
307
308 let class = classify(call, entry.map(|e| e.category.as_str()))
309 .ok_or_else(|| {
310 violation(
311 "unknown_category",
312 format!(
313 "{label} is declared with category '{}', which is not read, write, delete or admin",
314 entry.map_or("", |e| e.category.as_str())
315 ),
316 )
317 })?;
318
319 let candidates = operation_candidates(call, entry.map(|e| e.id.as_str()));
320 if candidates.iter().any(|c| {
321 self.blocked_operations
322 .iter()
323 .any(|b| operation_matches(b, c))
324 }) {
325 return Err(violation(
326 "blocked_operation",
327 format!("{label} is a blocked operation"),
328 ));
329 }
330
331 let class_name = class_label(class);
332 match self.mode(class) {
333 ClassMode::AllowAll => Ok(()),
334 ClassMode::DenyAll => Err(violation(
335 "class_denied",
336 format!(
337 "{label} is a {class_name} operation, and {class_name} operations are deny_all on this server"
338 ),
339 )
340 .with_suggestion(format!(
341 "Only operations whose class is allowed can be called. This server does not allow {class_name} operations."
342 ))),
343 ClassMode::Allowlist(allowed) => {
344 if candidates
345 .iter()
346 .any(|c| allowed.iter().any(|a| operation_matches(a, c)))
347 {
348 Ok(())
349 } else {
350 Err(violation(
351 "not_in_allowlist",
352 format!(
353 "{label} is a {class_name} operation that is not in this server's {class_name} allowlist"
354 ),
355 ))
356 }
357 },
358 }
359 }
360
361 fn path_blocked(&self, call: &Call<'_>) -> bool {
362 if self.blocked_paths.is_empty() {
363 return false;
364 }
365 let path = call.path.to_ascii_lowercase();
366 if call.dynamic {
367 let prefix = if path.starts_with('/') {
370 path.split('{').next().unwrap_or("")
371 } else {
372 ""
373 };
374 return self.blocked_paths.iter().any(|pattern| {
375 let literal = pattern.split('*').next().unwrap_or("");
376 prefix.starts_with(literal) || literal.starts_with(prefix)
377 });
378 }
379 self.blocked_paths
380 .iter()
381 .any(|pattern| blocked_path_matches(pattern, &path))
382 }
383}
384
385impl std::fmt::Display for OpenApiClassPolicy {
389 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
390 write!(
391 f,
392 "read={} write={} delete={} admin={} blocked_operations={} blocked_paths={}",
393 self.read.name(),
394 self.write.name(),
395 self.delete.name(),
396 self.admin.name(),
397 self.blocked_methods.len() + self.blocked_operations.len(),
398 self.blocked_paths.len()
399 )
400 }
401}
402
403fn gated_mode(allowed: bool, allowlist: &HashSet<String>) -> ClassMode {
404 if !allowed {
405 ClassMode::DenyAll
406 } else if allowlist.is_empty() {
407 ClassMode::AllowAll
408 } else {
409 ClassMode::allowlist(allowlist)
410 }
411}
412
413fn violation(rule: &str, message: String) -> PolicyViolation {
414 PolicyViolation::new(POLICY_NAME, rule, message)
415}
416
417fn method_name(method: HttpMethod) -> &'static str {
418 match method {
419 HttpMethod::Get => "GET",
420 HttpMethod::Post => "POST",
421 HttpMethod::Put => "PUT",
422 HttpMethod::Delete => "DELETE",
423 HttpMethod::Patch => "PATCH",
424 HttpMethod::Head => "HEAD",
425 HttpMethod::Options => "OPTIONS",
426 }
427}
428
429fn class_label(class: UnifiedAction) -> &'static str {
430 match class {
431 UnifiedAction::Read => "read",
432 UnifiedAction::Write => "write",
433 UnifiedAction::Delete => "delete",
434 UnifiedAction::Admin => "admin",
435 }
436}
437
438pub(crate) fn parse_category(category: &str) -> Option<UnifiedAction> {
441 match category.trim().to_ascii_lowercase().as_str() {
442 "read" => Some(UnifiedAction::Read),
443 "write" => Some(UnifiedAction::Write),
444 "delete" => Some(UnifiedAction::Delete),
445 "admin" => Some(UnifiedAction::Admin),
446 _ => None,
447 }
448}
449
450pub(crate) fn strictness(class: UnifiedAction) -> u8 {
452 match class {
453 UnifiedAction::Read => 0,
454 UnifiedAction::Write => 1,
455 UnifiedAction::Delete => 2,
456 UnifiedAction::Admin => 3,
457 }
458}
459
460fn classify(call: &Call<'_>, declared: Option<&str>) -> Option<UnifiedAction> {
463 let by_method = UnifiedAction::from_http_method(call.method);
464 let Some(category) = declared.filter(|c| !c.trim().is_empty()) else {
465 return Some(by_method);
466 };
467 let declared = parse_category(category)?;
468 if call.dynamic && strictness(by_method) > strictness(declared) {
469 Some(by_method)
470 } else {
471 Some(declared)
472 }
473}
474
475fn operation_candidates(call: &Call<'_>, catalog_id: Option<&str>) -> Vec<String> {
478 let path: Vec<&str> = call
479 .path
480 .split('/')
481 .map(|segment| if segment.contains('{') { "{}" } else { segment })
482 .collect();
483 let mut candidates = vec![normalize_operation_format(&format!(
484 "{}:{}",
485 call.method,
486 path.join("/")
487 ))];
488 if let Some(id) = catalog_id {
489 candidates.push(id.to_string());
490 }
491 candidates
492}
493
494fn operation_matches(entry: &str, candidate: &str) -> bool {
503 if entry == candidate {
504 return true;
505 }
506 let (Some((entry_method, entry_path)), Some((cand_method, cand_path))) =
507 (entry.split_once(':'), candidate.split_once(':'))
508 else {
509 return false;
510 };
511 if entry_method != cand_method {
512 return false;
513 }
514 let entry_segments: Vec<&str> = entry_path.split('/').collect();
515 let cand_segments: Vec<&str> = cand_path.split('/').collect();
516 entry_segments.len() == cand_segments.len()
517 && entry_segments
518 .iter()
519 .zip(&cand_segments)
520 .all(|(e, c)| *e == "*" || e == c)
521}
522
523fn strip_query(path: &str) -> &str {
524 path.split(['?', '#']).next().unwrap_or(path)
525}
526
527fn normalize_blocked_path(pattern: &str) -> String {
528 let lower = pattern.trim().to_ascii_lowercase();
529 if lower.len() > 1 {
530 lower.trim_end_matches('/').to_string()
531 } else {
532 lower
533 }
534}
535
536fn blocked_path_matches(pattern: &str, path: &str) -> bool {
537 if pattern.contains('*') {
538 return glob_matches(pattern.as_bytes(), path.as_bytes());
539 }
540 if pattern == "/" {
541 return true;
542 }
543 path == pattern
544 || path
545 .strip_prefix(pattern)
546 .is_some_and(|rest| rest.starts_with('/'))
547}
548
549fn glob_matches(pattern: &[u8], text: &[u8]) -> bool {
551 let (mut p, mut t) = (0, 0);
552 let mut star: Option<(usize, usize)> = None;
553 while t < text.len() {
554 if p < pattern.len() && pattern[p] == b'*' {
555 star = Some((p, t));
556 p += 1;
557 } else if p < pattern.len() && pattern[p] == text[t] {
558 p += 1;
559 t += 1;
560 } else if let Some((star_p, star_t)) = star {
561 p = star_p + 1;
562 t = star_t + 1;
563 star = Some((star_p, star_t + 1));
564 } else {
565 return false;
566 }
567 }
568 pattern[p..].iter().all(|&b| b == b'*')
569}
570
571#[cfg(feature = "js-runtime")]
581pub struct ClassPolicyHttpExecutor<H> {
582 inner: H,
583 policy: std::sync::Arc<OpenApiClassPolicy>,
584 registry: std::sync::Arc<OperationRegistry>,
585}
586
587#[cfg(feature = "js-runtime")]
588impl<H> ClassPolicyHttpExecutor<H> {
589 pub fn new(inner: H, config: &CodeModeConfig) -> Self {
591 Self {
592 inner,
593 policy: std::sync::Arc::new(OpenApiClassPolicy::from_config(config)),
594 registry: std::sync::Arc::new(OperationRegistry::from_entries(&config.operations)),
595 }
596 }
597
598 pub fn with_policy(
602 inner: H,
603 policy: OpenApiClassPolicy,
604 operations: &[crate::config::OperationEntry],
605 ) -> Self {
606 Self {
607 inner,
608 policy: std::sync::Arc::new(policy),
609 registry: std::sync::Arc::new(OperationRegistry::from_entries(operations)),
610 }
611 }
612
613 pub fn inner(&self) -> &H {
615 &self.inner
616 }
617}
618
619#[cfg(feature = "js-runtime")]
620impl<H: Clone> Clone for ClassPolicyHttpExecutor<H> {
621 fn clone(&self) -> Self {
622 Self {
623 inner: self.inner.clone(),
624 policy: std::sync::Arc::clone(&self.policy),
625 registry: std::sync::Arc::clone(&self.registry),
626 }
627 }
628}
629
630#[cfg(feature = "js-runtime")]
631#[async_trait::async_trait]
632impl<H: crate::HttpExecutor> crate::HttpExecutor for ClassPolicyHttpExecutor<H> {
633 async fn execute_request(
634 &self,
635 method: &str,
636 path: crate::ResolvedPath<'_>,
637 body: Option<serde_json::Value>,
638 ) -> Result<serde_json::Value, crate::ExecutionError> {
639 self.policy
640 .check_request(method, path.as_str(), &self.registry)
641 .map_err(|v| crate::ExecutionError::RequestRefused { message: v.message })?;
642 self.inner.execute_request(method, path, body).await
643 }
644
645 fn placeholder_rules(
646 &self,
647 method: &str,
648 path_template: &str,
649 param: &str,
650 ) -> crate::PlaceholderRules<'_> {
651 self.inner.placeholder_rules(method, path_template, param)
652 }
653}
654
655#[cfg(test)]
656mod tests {
657 use super::*;
658 use crate::config::OperationEntry;
659 use crate::javascript::JavaScriptValidator;
660
661 fn script(code: &str) -> JavaScriptCodeInfo {
662 JavaScriptValidator::default()
663 .validate(code)
664 .expect("script parses")
665 }
666
667 fn entry(id: &str, category: &str, path: &str) -> OperationEntry {
668 OperationEntry {
669 id: id.into(),
670 category: category.into(),
671 description: String::new(),
672 path: Some(path.into()),
673 }
674 }
675
676 fn rules(policy: &OpenApiClassPolicy, code: &str, registry: &OperationRegistry) -> Vec<String> {
677 policy
678 .check_script(&script(code), registry)
679 .into_iter()
680 .map(|v| v.rule)
681 .collect()
682 }
683
684 fn set(items: &[&str]) -> HashSet<String> {
685 items.iter().map(|s| (*s).to_string()).collect()
686 }
687
688 const GET: &str = "const r = await api.get('/items'); return r;";
689 const POST: &str = "const r = await api.post('/items', {}); return r;";
690 const DELETE: &str = "const r = await api.delete('/items/1'); return r;";
691
692 #[test]
693 fn defaults_allow_reads_and_deny_writes_and_deletes() {
694 let policy = OpenApiClassPolicy::from_config(&CodeModeConfig::enabled());
695 let reg = OperationRegistry::default();
696 assert!(rules(&policy, GET, ®).is_empty());
697 assert_eq!(rules(&policy, POST, ®), ["class_denied"]);
698 assert_eq!(rules(&policy, DELETE, ®), ["class_denied"]);
699 }
700
701 #[test]
703 fn reads_disabled_refuses_a_get_only_script() {
704 let mut config = CodeModeConfig::enabled();
705 config.openapi_reads_enabled = false;
706 let policy = OpenApiClassPolicy::from_config(&config);
707 let violations = policy.check_script(&script(GET), &OperationRegistry::default());
708 assert_eq!(violations.len(), 1);
709 assert_eq!(violations[0].rule, "class_denied");
710 assert!(violations[0]
711 .message
712 .contains("read operations are deny_all"));
713 }
714
715 #[test]
717 fn write_allowlist_admits_only_its_entries() {
718 let mut config = CodeModeConfig::enabled();
719 config.openapi_allow_writes = true;
720 config.openapi_allowed_writes = set(&["POST /items"]);
721 let policy = OpenApiClassPolicy::from_config(&config);
722 let reg = OperationRegistry::default();
723 assert!(rules(&policy, POST, ®).is_empty());
724 assert_eq!(
725 rules(&policy, "await api.put('/items/7', {}); return 1;", ®),
726 ["not_in_allowlist"]
727 );
728 assert_eq!(
729 rules(
730 &policy,
731 "await api.post('/admin/reset', {}); return 1;",
732 ®
733 ),
734 ["not_in_allowlist"]
735 );
736 }
737
738 #[test]
739 fn write_allowlist_without_allow_writes_is_deny_all() {
740 let mut config = CodeModeConfig::enabled();
741 config.openapi_allowed_writes = set(&["POST /items"]);
742 let policy = OpenApiClassPolicy::from_config(&config);
743 assert_eq!(policy.mode(UnifiedAction::Write), &ClassMode::DenyAll);
744 assert_eq!(
745 rules(&policy, POST, &OperationRegistry::default()),
746 ["class_denied"]
747 );
748 }
749
750 #[test]
751 fn allowlist_matches_templates_and_catalog_ids() {
752 let mut config = CodeModeConfig::enabled();
753 config.openapi_allow_writes = true;
754 config.openapi_allowed_writes = set(&["PUT:/items/{id}", "renameItem"]);
755 let policy = OpenApiClassPolicy::from_config(&config);
756 let reg = OperationRegistry::from_entries(&[entry(
757 "renameItem",
758 "write",
759 "POST /items/{id}/rename",
760 )]);
761 assert!(rules(&policy, "await api.put('/items/7', {}); return 1;", ®).is_empty());
762 assert!(rules(
763 &policy,
764 "const id = 3; await api.put(`/items/${id}`, {}); return 1;",
765 ®
766 )
767 .is_empty());
768 assert!(rules(
769 &policy,
770 "await api.post('/items/9/rename', {}); return 1;",
771 ®
772 )
773 .is_empty());
774 assert!(rules(&policy, "await api.put('/items/abc', {}); return 1;", ®).is_empty());
776 assert_eq!(
777 rules(
778 &policy,
779 "await api.put('/items/7/owner', {}); return 1;",
780 ®
781 ),
782 ["not_in_allowlist"]
783 );
784 }
785
786 #[test]
788 fn deletes_are_their_own_class() {
789 let mut config = CodeModeConfig::enabled();
790 config.openapi_allow_writes = true;
791 let policy = OpenApiClassPolicy::from_config(&config);
792 let reg = OperationRegistry::default();
793 assert!(rules(&policy, POST, ®).is_empty());
794 assert_eq!(rules(&policy, DELETE, ®), ["class_denied"]);
795
796 let mut config = CodeModeConfig::enabled();
797 config.openapi_allow_deletes = true;
798 let policy = OpenApiClassPolicy::from_config(&config);
799 assert!(rules(&policy, DELETE, ®).is_empty());
800 assert_eq!(rules(&policy, POST, ®), ["class_denied"]);
801 }
802
803 #[test]
804 fn delete_allowlist_admits_only_its_entries() {
805 let mut config = CodeModeConfig::enabled();
806 config.openapi_allow_deletes = true;
807 config.openapi_allowed_deletes = set(&["DELETE /items/{id}"]);
808 let policy = OpenApiClassPolicy::from_config(&config);
809 let reg = OperationRegistry::default();
810 assert!(rules(&policy, DELETE, ®).is_empty());
811 assert_eq!(
812 rules(&policy, "await api.delete('/users/1'); return 1;", ®),
813 ["not_in_allowlist"]
814 );
815 }
816
817 #[test]
818 fn catalog_reclassification_changes_the_verdict() {
819 let policy = OpenApiClassPolicy::from_config(&CodeModeConfig::enabled());
820 let search = "await api.post('/search', {q: 'x'}); return 1;";
821 let reg = OperationRegistry::default();
822 assert_eq!(rules(&policy, search, ®), ["class_denied"]);
823
824 let reg = OperationRegistry::from_entries(&[entry("search", "read", "/search")]);
825 assert!(rules(&policy, search, ®).is_empty());
826
827 let reg = OperationRegistry::from_entries(&[entry("listItems", "admin", "GET /items")]);
828 let violations = policy.check_script(&script(GET), ®);
829 assert_eq!(violations.len(), 1);
830 assert!(violations[0]
831 .message
832 .contains("admin operations are deny_all"));
833 }
834
835 #[test]
836 fn admin_is_deny_all_even_when_writes_and_deletes_are_allowed() {
837 let mut config = CodeModeConfig::enabled();
838 config.openapi_allow_writes = true;
839 config.openapi_allow_deletes = true;
840 let policy = OpenApiClassPolicy::from_config(&config);
841 let reg = OperationRegistry::from_entries(&[entry("reset", "admin", "/reset")]);
842 let violations =
843 policy.check_script(&script("await api.post('/reset', {}); return 1;"), ®);
844 assert_eq!(violations.len(), 1);
845 assert_eq!(violations[0].rule, "class_denied");
846 assert!(violations[0].message.contains("admin"));
847 }
848
849 #[test]
850 fn unknown_category_fails_closed() {
851 let policy = OpenApiClassPolicy::from_config(&CodeModeConfig::enabled());
852 let reg = OperationRegistry::from_entries(&[entry("listItems", "reed", "/items")]);
853 assert_eq!(rules(&policy, GET, ®), ["unknown_category"]);
854 }
855
856 #[test]
857 fn dynamic_path_cannot_be_relaxed_by_the_catalog() {
858 let policy = OpenApiClassPolicy::from_config(&CodeModeConfig::enabled());
859 let reg =
860 OperationRegistry::from_entries(&[entry("lookup", "read", "POST /lookup/{kind}")]);
861 assert!(rules(&policy, "await api.post('/lookup/a', {}); return 1;", ®).is_empty());
862 assert_eq!(
863 rules(
864 &policy,
865 "const k = 'a'; await api.post(`/lookup/${k}`, {}); return 1;",
866 ®
867 ),
868 ["class_denied"]
869 );
870 }
871
872 #[test]
873 fn catalog_entry_with_a_method_matches_only_that_method() {
874 let mut config = CodeModeConfig::enabled();
875 config.openapi_allow_writes = true;
876 let policy = OpenApiClassPolicy::from_config(&config);
877 let reg = OperationRegistry::from_entries(&[entry("wipe", "admin", "POST /items")]);
878 assert!(rules(&policy, GET, ®).is_empty());
879 assert_eq!(rules(&policy, POST, ®), ["class_denied"]);
880 }
881
882 #[test]
883 fn blocked_writes_block_methods_and_operations_in_any_class() {
884 let mut config = CodeModeConfig::enabled();
885 config.openapi_allow_writes = true;
886 config.openapi_blocked_writes = set(&["PATCH", "POST /items", "GET:/secrets"]);
887 let policy = OpenApiClassPolicy::from_config(&config);
888 let reg = OperationRegistry::default();
889 assert_eq!(
890 rules(&policy, "await api.patch('/x', {}); return 1;", ®),
891 ["blocked_method"]
892 );
893 assert_eq!(rules(&policy, POST, ®), ["blocked_operation"]);
894 assert_eq!(
895 rules(&policy, "await api.get('/secrets'); return 1;", ®),
896 ["blocked_operation"]
897 );
898 assert!(rules(&policy, "await api.put('/items', {}); return 1;", ®).is_empty());
899 }
900
901 #[test]
902 fn blocked_paths_block_reads_and_cover_subtrees() {
903 let mut config = CodeModeConfig::enabled();
904 config.openapi_blocked_paths = set(&["/admin", "/users/*/secrets"]);
905 let policy = OpenApiClassPolicy::from_config(&config);
906 let reg = OperationRegistry::default();
907 for path in ["/admin", "/ADMIN/users", "/users/4/secrets"] {
908 let code = format!("await api.get('{path}'); return 1;");
909 assert_eq!(rules(&policy, &code, ®), ["blocked_path"], "{path}");
910 }
911 for path in ["/administrators", "/users/4", "/items"] {
912 let code = format!("await api.get('{path}'); return 1;");
913 assert!(rules(&policy, &code, ®).is_empty(), "{path}");
914 }
915 }
916
917 #[test]
918 fn blocked_paths_refuse_dynamic_paths_that_could_reach_them() {
919 let mut config = CodeModeConfig::enabled();
920 config.openapi_blocked_paths = set(&["/users/*/secrets"]);
921 let policy = OpenApiClassPolicy::from_config(&config);
922 let reg = OperationRegistry::default();
923 assert_eq!(
924 rules(
925 &policy,
926 "const i = 1; await api.get(`/users/${i}`); return 1;",
927 ®
928 ),
929 ["blocked_path"]
930 );
931 assert_eq!(
932 rules(&policy, "const p = '/x'; await api.get(p); return 1;", ®),
933 ["blocked_path"]
934 );
935 assert!(rules(
936 &policy,
937 "const i = 1; await api.get(`/items/${i}`); return 1;",
938 ®
939 )
940 .is_empty());
941 }
942
943 #[test]
944 fn every_refused_call_is_reported() {
945 let policy = OpenApiClassPolicy::from_config(&CodeModeConfig::enabled());
946 let code =
947 "await api.post('/a', {}); await api.get('/b'); await api.delete('/c'); return 1;";
948 assert_eq!(
949 rules(&policy, code, &OperationRegistry::default()),
950 ["class_denied", "class_denied"]
951 );
952 }
953
954 #[test]
955 fn check_request_classifies_the_resolved_path() {
956 let policy = OpenApiClassPolicy::from_config(&CodeModeConfig::enabled());
957 let reg = OperationRegistry::from_entries(&[
958 entry("lookup", "read", "POST /lookup/{kind}"),
959 entry("purge", "admin", "POST /lookup/purge"),
960 ]);
961 assert!(policy.check_request("post", "/lookup/a?x=1", ®).is_ok());
962 let refused = policy
963 .check_request("POST", "/lookup/purge", ®)
964 .unwrap_err();
965 assert!(refused.message.contains("admin"));
966 assert_eq!(
967 policy.check_request("TRACE", "/x", ®).unwrap_err().rule,
968 "unknown_method"
969 );
970 }
971
972 #[cfg(feature = "js-runtime")]
973 #[derive(Clone)]
974 struct Echo;
975
976 #[cfg(feature = "js-runtime")]
977 #[async_trait::async_trait]
978 impl crate::HttpExecutor for Echo {
979 async fn execute_request(
980 &self,
981 method: &str,
982 path: crate::ResolvedPath<'_>,
983 _body: Option<serde_json::Value>,
984 ) -> Result<serde_json::Value, crate::ExecutionError> {
985 Ok(serde_json::json!(format!("{method} {}", path.as_str())))
986 }
987 }
988
989 #[cfg(feature = "js-runtime")]
992 #[tokio::test]
993 async fn executor_refuses_a_resolved_path_outside_the_allowlist() {
994 use crate::HttpExecutor;
995 let mut config = CodeModeConfig::enabled();
996 config.openapi_allow_writes = true;
997 config.openapi_allowed_writes = set(&["PUT /items/{id}"]);
998 let executor = ClassPolicyHttpExecutor::new(Echo, &config);
999
1000 let ok = crate::ResolvedPath::from_checked("/items/7").unwrap();
1001 assert_eq!(
1002 executor.execute_request("PUT", ok, None).await.unwrap(),
1003 serde_json::json!("PUT /items/7")
1004 );
1005
1006 let escaped = crate::ResolvedPath::from_checked("/items/7/owner").unwrap();
1007 match executor.execute_request("PUT", escaped, None).await {
1008 Err(crate::ExecutionError::RequestRefused { message }) => {
1009 assert!(message.contains("write"), "{message}");
1010 assert!(
1011 !message.contains("owner"),
1012 "refusal must not echo the path: {message}"
1013 );
1014 },
1015 other => panic!("expected RequestRefused, got {other:?}"),
1016 }
1017
1018 let read = crate::ResolvedPath::from_checked("/items/7").unwrap();
1019 assert!(executor.execute_request("GET", read, None).await.is_ok());
1020 }
1021
1022 #[test]
1024 fn violations_never_echo_the_path() {
1025 let mut config = CodeModeConfig::enabled();
1026 config.openapi_reads_enabled = false;
1027 config.openapi_blocked_paths = set(&["/blocked"]);
1028 config.openapi_blocked_writes = set(&["PUT /x/SECRET"]);
1029 config.openapi_allow_writes = true;
1030 config.openapi_allowed_writes = set(&["POST /only"]);
1031 let policy = OpenApiClassPolicy::from_config(&config);
1032 let reg = OperationRegistry::from_entries(&[entry("odd", "reed", "/odd/{id}")]);
1033 let code = "await api.get('/x/SECRET');\n\
1034 await api.get('/blocked/SECRET');\n\
1035 await api.put('/x/SECRET', {});\n\
1036 await api.post('/x/SECRET', {});\n\
1037 await api.post('/odd/SECRET', {});\n\
1038 return 1;";
1039 let violations = policy.check_script(&script(code), ®);
1040 let rules: Vec<&str> = violations.iter().map(|v| v.rule.as_str()).collect();
1041 assert_eq!(
1042 rules,
1043 [
1044 "class_denied",
1045 "blocked_path",
1046 "blocked_operation",
1047 "not_in_allowlist",
1048 "unknown_category"
1049 ]
1050 );
1051 for v in &violations {
1052 assert!(!v.message.contains("SECRET"), "{}", v.message);
1053 assert!(
1054 !v.suggestion.as_deref().unwrap_or("").contains("SECRET"),
1055 "{:?}",
1056 v.suggestion
1057 );
1058 }
1059 assert!(violations[0].message.contains("GET call on line 1"));
1060 assert!(violations[4].message.contains("operation 'odd'"));
1061 }
1062
1063 #[test]
1064 fn builder_sets_modes_the_legacy_keys_cannot_express() {
1065 let policy = OpenApiClassPolicy::from_config(&CodeModeConfig::enabled())
1066 .with_mode(
1067 UnifiedAction::Read,
1068 ClassMode::Allowlist(set(&["GET /items", "searchConcepts"])),
1069 )
1070 .with_mode(UnifiedAction::Admin, ClassMode::AllowAll)
1071 .with_blocked_operations(["PATCH"])
1072 .with_blocked_paths(["/internal/"]);
1073 let reg = OperationRegistry::from_entries(&[
1074 entry("searchConcepts", "read", "POST /search"),
1075 entry("reindex", "admin", "POST /reindex"),
1076 ]);
1077 assert!(rules(&policy, GET, ®).is_empty());
1078 assert!(rules(&policy, "await api.post('/search', {}); return 1;", ®).is_empty());
1079 assert_eq!(
1080 rules(&policy, "await api.get('/other'); return 1;", ®),
1081 ["not_in_allowlist"]
1082 );
1083 assert!(rules(&policy, "await api.post('/reindex', {}); return 1;", ®).is_empty());
1084 assert_eq!(
1085 rules(&policy, "await api.get('/internal/x'); return 1;", ®),
1086 ["blocked_path"]
1087 );
1088 assert_eq!(
1089 policy.to_string(),
1090 "read=allowlist write=deny_all delete=deny_all admin=allow_all \
1091 blocked_operations=1 blocked_paths=1"
1092 );
1093 }
1094
1095 #[test]
1096 fn glob_matching() {
1097 assert!(glob_matches(b"/a/*", b"/a/b/c"));
1098 assert!(glob_matches(b"*secret*", b"/x/secrets"));
1099 assert!(!glob_matches(b"/a/*/c", b"/a/b/d"));
1100 assert!(glob_matches(b"/a/*/c", b"/a/b/c"));
1101 }
1102}