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 let article = indefinite_article(class_name);
333 match self.mode(class) {
334 ClassMode::AllowAll => Ok(()),
335 ClassMode::DenyAll => Err(violation(
336 "class_denied",
337 format!(
338 "{label} is {article} {class_name} operation, and {class_name} operations are deny_all on this server"
339 ),
340 )
341 .with_suggestion(format!(
342 "Only operations whose class is allowed can be called. This server does not allow {class_name} operations."
343 ))),
344 ClassMode::Allowlist(allowed) => {
345 if candidates
346 .iter()
347 .any(|c| allowed.iter().any(|a| operation_matches(a, c)))
348 {
349 Ok(())
350 } else {
351 Err(violation(
352 "not_in_allowlist",
353 format!(
354 "{label} is {article} {class_name} operation that is not in this server's {class_name} allowlist"
355 ),
356 ))
357 }
358 },
359 }
360 }
361
362 fn path_blocked(&self, call: &Call<'_>) -> bool {
363 if self.blocked_paths.is_empty() {
364 return false;
365 }
366 let path = call.path.to_ascii_lowercase();
367 if call.dynamic {
368 let prefix = if path.starts_with('/') {
371 path.split('{').next().unwrap_or("")
372 } else {
373 ""
374 };
375 return self.blocked_paths.iter().any(|pattern| {
376 let literal = pattern.split('*').next().unwrap_or("");
377 prefix.starts_with(literal) || literal.starts_with(prefix)
378 });
379 }
380 self.blocked_paths
381 .iter()
382 .any(|pattern| blocked_path_matches(pattern, &path))
383 }
384}
385
386impl std::fmt::Display for OpenApiClassPolicy {
390 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
391 write!(
392 f,
393 "read={} write={} delete={} admin={} blocked_operations={} blocked_paths={}",
394 self.read.name(),
395 self.write.name(),
396 self.delete.name(),
397 self.admin.name(),
398 self.blocked_methods.len() + self.blocked_operations.len(),
399 self.blocked_paths.len()
400 )
401 }
402}
403
404fn gated_mode(allowed: bool, allowlist: &HashSet<String>) -> ClassMode {
405 if !allowed {
406 ClassMode::DenyAll
407 } else if allowlist.is_empty() {
408 ClassMode::AllowAll
409 } else {
410 ClassMode::allowlist(allowlist)
411 }
412}
413
414fn violation(rule: &str, message: String) -> PolicyViolation {
415 PolicyViolation::new(POLICY_NAME, rule, message)
416}
417
418fn method_name(method: HttpMethod) -> &'static str {
419 match method {
420 HttpMethod::Get => "GET",
421 HttpMethod::Post => "POST",
422 HttpMethod::Put => "PUT",
423 HttpMethod::Delete => "DELETE",
424 HttpMethod::Patch => "PATCH",
425 HttpMethod::Head => "HEAD",
426 HttpMethod::Options => "OPTIONS",
427 }
428}
429
430fn indefinite_article(word: &str) -> &'static str {
433 if word.starts_with(['a', 'e', 'i', 'o', 'u']) {
434 "an"
435 } else {
436 "a"
437 }
438}
439
440fn class_label(class: UnifiedAction) -> &'static str {
441 match class {
442 UnifiedAction::Read => "read",
443 UnifiedAction::Write => "write",
444 UnifiedAction::Delete => "delete",
445 UnifiedAction::Admin => "admin",
446 }
447}
448
449pub(crate) fn parse_category(category: &str) -> Option<UnifiedAction> {
452 match category.trim().to_ascii_lowercase().as_str() {
453 "read" => Some(UnifiedAction::Read),
454 "write" => Some(UnifiedAction::Write),
455 "delete" => Some(UnifiedAction::Delete),
456 "admin" => Some(UnifiedAction::Admin),
457 _ => None,
458 }
459}
460
461pub(crate) fn strictness(class: UnifiedAction) -> u8 {
463 match class {
464 UnifiedAction::Read => 0,
465 UnifiedAction::Write => 1,
466 UnifiedAction::Delete => 2,
467 UnifiedAction::Admin => 3,
468 }
469}
470
471fn classify(call: &Call<'_>, declared: Option<&str>) -> Option<UnifiedAction> {
474 let by_method = UnifiedAction::from_http_method(call.method);
475 let Some(category) = declared.filter(|c| !c.trim().is_empty()) else {
476 return Some(by_method);
477 };
478 let declared = parse_category(category)?;
479 if call.dynamic && strictness(by_method) > strictness(declared) {
480 Some(by_method)
481 } else {
482 Some(declared)
483 }
484}
485
486fn operation_candidates(call: &Call<'_>, catalog_id: Option<&str>) -> Vec<String> {
489 let path: Vec<&str> = call
490 .path
491 .split('/')
492 .map(|segment| if segment.contains('{') { "{}" } else { segment })
493 .collect();
494 let mut candidates = vec![normalize_operation_format(&format!(
495 "{}:{}",
496 call.method,
497 path.join("/")
498 ))];
499 if let Some(id) = catalog_id {
500 candidates.push(id.to_string());
501 }
502 candidates
503}
504
505fn operation_matches(entry: &str, candidate: &str) -> bool {
514 if entry == candidate {
515 return true;
516 }
517 let (Some((entry_method, entry_path)), Some((cand_method, cand_path))) =
518 (entry.split_once(':'), candidate.split_once(':'))
519 else {
520 return false;
521 };
522 if entry_method != cand_method {
523 return false;
524 }
525 let entry_segments: Vec<&str> = entry_path.split('/').collect();
526 let cand_segments: Vec<&str> = cand_path.split('/').collect();
527 entry_segments.len() == cand_segments.len()
528 && entry_segments
529 .iter()
530 .zip(&cand_segments)
531 .all(|(e, c)| *e == "*" || e == c)
532}
533
534fn strip_query(path: &str) -> &str {
535 path.split(['?', '#']).next().unwrap_or(path)
536}
537
538fn normalize_blocked_path(pattern: &str) -> String {
539 let lower = pattern.trim().to_ascii_lowercase();
540 if lower.len() > 1 {
541 lower.trim_end_matches('/').to_string()
542 } else {
543 lower
544 }
545}
546
547fn blocked_path_matches(pattern: &str, path: &str) -> bool {
548 if pattern.contains('*') {
549 return glob_matches(pattern.as_bytes(), path.as_bytes());
550 }
551 if pattern == "/" {
552 return true;
553 }
554 path == pattern
555 || path
556 .strip_prefix(pattern)
557 .is_some_and(|rest| rest.starts_with('/'))
558}
559
560fn glob_matches(pattern: &[u8], text: &[u8]) -> bool {
562 let (mut p, mut t) = (0, 0);
563 let mut star: Option<(usize, usize)> = None;
564 while t < text.len() {
565 if p < pattern.len() && pattern[p] == b'*' {
566 star = Some((p, t));
567 p += 1;
568 } else if p < pattern.len() && pattern[p] == text[t] {
569 p += 1;
570 t += 1;
571 } else if let Some((star_p, star_t)) = star {
572 p = star_p + 1;
573 t = star_t + 1;
574 star = Some((star_p, star_t + 1));
575 } else {
576 return false;
577 }
578 }
579 pattern[p..].iter().all(|&b| b == b'*')
580}
581
582#[cfg(feature = "js-runtime")]
592pub struct ClassPolicyHttpExecutor<H> {
593 inner: H,
594 policy: std::sync::Arc<OpenApiClassPolicy>,
595 registry: std::sync::Arc<OperationRegistry>,
596}
597
598#[cfg(feature = "js-runtime")]
599impl<H> ClassPolicyHttpExecutor<H> {
600 pub fn new(inner: H, config: &CodeModeConfig) -> Self {
602 Self {
603 inner,
604 policy: std::sync::Arc::new(OpenApiClassPolicy::from_config(config)),
605 registry: std::sync::Arc::new(OperationRegistry::from_entries(&config.operations)),
606 }
607 }
608
609 pub fn with_policy(
613 inner: H,
614 policy: OpenApiClassPolicy,
615 operations: &[crate::config::OperationEntry],
616 ) -> Self {
617 Self {
618 inner,
619 policy: std::sync::Arc::new(policy),
620 registry: std::sync::Arc::new(OperationRegistry::from_entries(operations)),
621 }
622 }
623
624 pub fn inner(&self) -> &H {
626 &self.inner
627 }
628}
629
630#[cfg(feature = "js-runtime")]
631impl<H: Clone> Clone for ClassPolicyHttpExecutor<H> {
632 fn clone(&self) -> Self {
633 Self {
634 inner: self.inner.clone(),
635 policy: std::sync::Arc::clone(&self.policy),
636 registry: std::sync::Arc::clone(&self.registry),
637 }
638 }
639}
640
641#[cfg(feature = "js-runtime")]
642#[async_trait::async_trait]
643impl<H: crate::HttpExecutor> crate::HttpExecutor for ClassPolicyHttpExecutor<H> {
644 async fn execute_request(
645 &self,
646 method: &str,
647 path: crate::ResolvedPath<'_>,
648 body: Option<serde_json::Value>,
649 ) -> Result<serde_json::Value, crate::ExecutionError> {
650 self.policy
651 .check_request(method, path.as_str(), &self.registry)
652 .map_err(|v| crate::ExecutionError::RequestRefused { message: v.message })?;
653 self.inner.execute_request(method, path, body).await
654 }
655
656 fn placeholder_rules(
657 &self,
658 method: &str,
659 path_template: &str,
660 param: &str,
661 ) -> crate::PlaceholderRules<'_> {
662 self.inner.placeholder_rules(method, path_template, param)
663 }
664}
665
666#[cfg(test)]
667mod tests {
668 use super::*;
669 use crate::config::OperationEntry;
670 use crate::javascript::JavaScriptValidator;
671
672 fn script(code: &str) -> JavaScriptCodeInfo {
673 JavaScriptValidator::default()
674 .validate(code)
675 .expect("script parses")
676 }
677
678 fn entry(id: &str, category: &str, path: &str) -> OperationEntry {
679 OperationEntry {
680 id: id.into(),
681 category: category.into(),
682 description: String::new(),
683 path: Some(path.into()),
684 }
685 }
686
687 fn rules(policy: &OpenApiClassPolicy, code: &str, registry: &OperationRegistry) -> Vec<String> {
688 policy
689 .check_script(&script(code), registry)
690 .into_iter()
691 .map(|v| v.rule)
692 .collect()
693 }
694
695 fn set(items: &[&str]) -> HashSet<String> {
696 items.iter().map(|s| (*s).to_string()).collect()
697 }
698
699 const GET: &str = "const r = await api.get('/items'); return r;";
700 const POST: &str = "const r = await api.post('/items', {}); return r;";
701 const DELETE: &str = "const r = await api.delete('/items/1'); return r;";
702
703 #[test]
704 fn defaults_allow_reads_and_deny_writes_and_deletes() {
705 let policy = OpenApiClassPolicy::from_config(&CodeModeConfig::enabled());
706 let reg = OperationRegistry::default();
707 assert!(rules(&policy, GET, ®).is_empty());
708 assert_eq!(rules(&policy, POST, ®), ["class_denied"]);
709 assert_eq!(rules(&policy, DELETE, ®), ["class_denied"]);
710 }
711
712 #[test]
714 fn reads_disabled_refuses_a_get_only_script() {
715 let mut config = CodeModeConfig::enabled();
716 config.openapi_reads_enabled = false;
717 let policy = OpenApiClassPolicy::from_config(&config);
718 let violations = policy.check_script(&script(GET), &OperationRegistry::default());
719 assert_eq!(violations.len(), 1);
720 assert_eq!(violations[0].rule, "class_denied");
721 assert!(violations[0]
722 .message
723 .contains("read operations are deny_all"));
724 }
725
726 #[test]
728 fn write_allowlist_admits_only_its_entries() {
729 let mut config = CodeModeConfig::enabled();
730 config.openapi_allow_writes = true;
731 config.openapi_allowed_writes = set(&["POST /items"]);
732 let policy = OpenApiClassPolicy::from_config(&config);
733 let reg = OperationRegistry::default();
734 assert!(rules(&policy, POST, ®).is_empty());
735 assert_eq!(
736 rules(&policy, "await api.put('/items/7', {}); return 1;", ®),
737 ["not_in_allowlist"]
738 );
739 assert_eq!(
740 rules(
741 &policy,
742 "await api.post('/admin/reset', {}); return 1;",
743 ®
744 ),
745 ["not_in_allowlist"]
746 );
747 }
748
749 #[test]
750 fn write_allowlist_without_allow_writes_is_deny_all() {
751 let mut config = CodeModeConfig::enabled();
752 config.openapi_allowed_writes = set(&["POST /items"]);
753 let policy = OpenApiClassPolicy::from_config(&config);
754 assert_eq!(policy.mode(UnifiedAction::Write), &ClassMode::DenyAll);
755 assert_eq!(
756 rules(&policy, POST, &OperationRegistry::default()),
757 ["class_denied"]
758 );
759 }
760
761 #[test]
762 fn allowlist_matches_templates_and_catalog_ids() {
763 let mut config = CodeModeConfig::enabled();
764 config.openapi_allow_writes = true;
765 config.openapi_allowed_writes = set(&["PUT:/items/{id}", "renameItem"]);
766 let policy = OpenApiClassPolicy::from_config(&config);
767 let reg = OperationRegistry::from_entries(&[entry(
768 "renameItem",
769 "write",
770 "POST /items/{id}/rename",
771 )]);
772 assert!(rules(&policy, "await api.put('/items/7', {}); return 1;", ®).is_empty());
773 assert!(rules(
774 &policy,
775 "const id = 3; await api.put(`/items/${id}`, {}); return 1;",
776 ®
777 )
778 .is_empty());
779 assert!(rules(
780 &policy,
781 "await api.post('/items/9/rename', {}); return 1;",
782 ®
783 )
784 .is_empty());
785 assert!(rules(&policy, "await api.put('/items/abc', {}); return 1;", ®).is_empty());
787 assert_eq!(
788 rules(
789 &policy,
790 "await api.put('/items/7/owner', {}); return 1;",
791 ®
792 ),
793 ["not_in_allowlist"]
794 );
795 }
796
797 #[test]
799 fn deletes_are_their_own_class() {
800 let mut config = CodeModeConfig::enabled();
801 config.openapi_allow_writes = true;
802 let policy = OpenApiClassPolicy::from_config(&config);
803 let reg = OperationRegistry::default();
804 assert!(rules(&policy, POST, ®).is_empty());
805 assert_eq!(rules(&policy, DELETE, ®), ["class_denied"]);
806
807 let mut config = CodeModeConfig::enabled();
808 config.openapi_allow_deletes = true;
809 let policy = OpenApiClassPolicy::from_config(&config);
810 assert!(rules(&policy, DELETE, ®).is_empty());
811 assert_eq!(rules(&policy, POST, ®), ["class_denied"]);
812 }
813
814 #[test]
815 fn delete_allowlist_admits_only_its_entries() {
816 let mut config = CodeModeConfig::enabled();
817 config.openapi_allow_deletes = true;
818 config.openapi_allowed_deletes = set(&["DELETE /items/{id}"]);
819 let policy = OpenApiClassPolicy::from_config(&config);
820 let reg = OperationRegistry::default();
821 assert!(rules(&policy, DELETE, ®).is_empty());
822 assert_eq!(
823 rules(&policy, "await api.delete('/users/1'); return 1;", ®),
824 ["not_in_allowlist"]
825 );
826 }
827
828 #[test]
829 fn catalog_reclassification_changes_the_verdict() {
830 let policy = OpenApiClassPolicy::from_config(&CodeModeConfig::enabled());
831 let search = "await api.post('/search', {q: 'x'}); return 1;";
832 let reg = OperationRegistry::default();
833 assert_eq!(rules(&policy, search, ®), ["class_denied"]);
834
835 let reg = OperationRegistry::from_entries(&[entry("search", "read", "/search")]);
836 assert!(rules(&policy, search, ®).is_empty());
837
838 let reg = OperationRegistry::from_entries(&[entry("listItems", "admin", "GET /items")]);
839 let violations = policy.check_script(&script(GET), ®);
840 assert_eq!(violations.len(), 1);
841 assert!(violations[0]
842 .message
843 .contains("admin operations are deny_all"));
844 }
845
846 #[test]
847 fn admin_is_deny_all_even_when_writes_and_deletes_are_allowed() {
848 let mut config = CodeModeConfig::enabled();
849 config.openapi_allow_writes = true;
850 config.openapi_allow_deletes = true;
851 let policy = OpenApiClassPolicy::from_config(&config);
852 let reg = OperationRegistry::from_entries(&[entry("reset", "admin", "/reset")]);
853 let violations =
854 policy.check_script(&script("await api.post('/reset', {}); return 1;"), ®);
855 assert_eq!(violations.len(), 1);
856 assert_eq!(violations[0].rule, "class_denied");
857 assert!(
858 violations[0].message.contains("is an admin operation"),
859 "{}",
860 violations[0].message
861 );
862 }
863
864 #[test]
865 fn allowlist_refusal_uses_the_right_article() {
866 let policy = OpenApiClassPolicy::from_config(&CodeModeConfig::enabled())
867 .with_mode(UnifiedAction::Admin, ClassMode::Allowlist(set(&["other"])));
868 let reg = OperationRegistry::from_entries(&[entry("reset", "admin", "/reset")]);
869 let violations =
870 policy.check_script(&script("await api.post('/reset', {}); return 1;"), ®);
871 assert_eq!(violations[0].rule, "not_in_allowlist");
872 assert!(
873 violations[0].message.contains("is an admin operation"),
874 "{}",
875 violations[0].message
876 );
877 }
878
879 #[test]
880 fn unknown_category_fails_closed() {
881 let policy = OpenApiClassPolicy::from_config(&CodeModeConfig::enabled());
882 let reg = OperationRegistry::from_entries(&[entry("listItems", "reed", "/items")]);
883 assert_eq!(rules(&policy, GET, ®), ["unknown_category"]);
884 }
885
886 #[test]
887 fn dynamic_path_cannot_be_relaxed_by_the_catalog() {
888 let policy = OpenApiClassPolicy::from_config(&CodeModeConfig::enabled());
889 let reg =
890 OperationRegistry::from_entries(&[entry("lookup", "read", "POST /lookup/{kind}")]);
891 assert!(rules(&policy, "await api.post('/lookup/a', {}); return 1;", ®).is_empty());
892 assert_eq!(
893 rules(
894 &policy,
895 "const k = 'a'; await api.post(`/lookup/${k}`, {}); return 1;",
896 ®
897 ),
898 ["class_denied"]
899 );
900 }
901
902 #[test]
903 fn catalog_entry_with_a_method_matches_only_that_method() {
904 let mut config = CodeModeConfig::enabled();
905 config.openapi_allow_writes = true;
906 let policy = OpenApiClassPolicy::from_config(&config);
907 let reg = OperationRegistry::from_entries(&[entry("wipe", "admin", "POST /items")]);
908 assert!(rules(&policy, GET, ®).is_empty());
909 assert_eq!(rules(&policy, POST, ®), ["class_denied"]);
910 }
911
912 #[test]
913 fn blocked_writes_block_methods_and_operations_in_any_class() {
914 let mut config = CodeModeConfig::enabled();
915 config.openapi_allow_writes = true;
916 config.openapi_blocked_writes = set(&["PATCH", "POST /items", "GET:/secrets"]);
917 let policy = OpenApiClassPolicy::from_config(&config);
918 let reg = OperationRegistry::default();
919 assert_eq!(
920 rules(&policy, "await api.patch('/x', {}); return 1;", ®),
921 ["blocked_method"]
922 );
923 assert_eq!(rules(&policy, POST, ®), ["blocked_operation"]);
924 assert_eq!(
925 rules(&policy, "await api.get('/secrets'); return 1;", ®),
926 ["blocked_operation"]
927 );
928 assert!(rules(&policy, "await api.put('/items', {}); return 1;", ®).is_empty());
929 }
930
931 #[test]
932 fn blocked_paths_block_reads_and_cover_subtrees() {
933 let mut config = CodeModeConfig::enabled();
934 config.openapi_blocked_paths = set(&["/admin", "/users/*/secrets"]);
935 let policy = OpenApiClassPolicy::from_config(&config);
936 let reg = OperationRegistry::default();
937 for path in ["/admin", "/ADMIN/users", "/users/4/secrets"] {
938 let code = format!("await api.get('{path}'); return 1;");
939 assert_eq!(rules(&policy, &code, ®), ["blocked_path"], "{path}");
940 }
941 for path in ["/administrators", "/users/4", "/items"] {
942 let code = format!("await api.get('{path}'); return 1;");
943 assert!(rules(&policy, &code, ®).is_empty(), "{path}");
944 }
945 }
946
947 #[test]
948 fn blocked_paths_refuse_dynamic_paths_that_could_reach_them() {
949 let mut config = CodeModeConfig::enabled();
950 config.openapi_blocked_paths = set(&["/users/*/secrets"]);
951 let policy = OpenApiClassPolicy::from_config(&config);
952 let reg = OperationRegistry::default();
953 assert_eq!(
954 rules(
955 &policy,
956 "const i = 1; await api.get(`/users/${i}`); return 1;",
957 ®
958 ),
959 ["blocked_path"]
960 );
961 assert_eq!(
962 rules(&policy, "const p = '/x'; await api.get(p); return 1;", ®),
963 ["blocked_path"]
964 );
965 assert!(rules(
966 &policy,
967 "const i = 1; await api.get(`/items/${i}`); return 1;",
968 ®
969 )
970 .is_empty());
971 }
972
973 #[test]
974 fn every_refused_call_is_reported() {
975 let policy = OpenApiClassPolicy::from_config(&CodeModeConfig::enabled());
976 let code =
977 "await api.post('/a', {}); await api.get('/b'); await api.delete('/c'); return 1;";
978 assert_eq!(
979 rules(&policy, code, &OperationRegistry::default()),
980 ["class_denied", "class_denied"]
981 );
982 }
983
984 #[test]
985 fn check_request_classifies_the_resolved_path() {
986 let policy = OpenApiClassPolicy::from_config(&CodeModeConfig::enabled());
987 let reg = OperationRegistry::from_entries(&[
988 entry("lookup", "read", "POST /lookup/{kind}"),
989 entry("purge", "admin", "POST /lookup/purge"),
990 ]);
991 assert!(policy.check_request("post", "/lookup/a?x=1", ®).is_ok());
992 let refused = policy
993 .check_request("POST", "/lookup/purge", ®)
994 .unwrap_err();
995 assert!(refused.message.contains("admin"));
996 assert_eq!(
997 policy.check_request("TRACE", "/x", ®).unwrap_err().rule,
998 "unknown_method"
999 );
1000 }
1001
1002 #[cfg(feature = "js-runtime")]
1003 #[derive(Clone)]
1004 struct Echo;
1005
1006 #[cfg(feature = "js-runtime")]
1007 #[async_trait::async_trait]
1008 impl crate::HttpExecutor for Echo {
1009 async fn execute_request(
1010 &self,
1011 method: &str,
1012 path: crate::ResolvedPath<'_>,
1013 _body: Option<serde_json::Value>,
1014 ) -> Result<serde_json::Value, crate::ExecutionError> {
1015 Ok(serde_json::json!(format!("{method} {}", path.as_str())))
1016 }
1017 }
1018
1019 #[cfg(feature = "js-runtime")]
1022 #[tokio::test]
1023 async fn executor_refuses_a_resolved_path_outside_the_allowlist() {
1024 use crate::HttpExecutor;
1025 let mut config = CodeModeConfig::enabled();
1026 config.openapi_allow_writes = true;
1027 config.openapi_allowed_writes = set(&["PUT /items/{id}"]);
1028 let executor = ClassPolicyHttpExecutor::new(Echo, &config);
1029
1030 let ok = crate::ResolvedPath::from_checked("/items/7").unwrap();
1031 assert_eq!(
1032 executor.execute_request("PUT", ok, None).await.unwrap(),
1033 serde_json::json!("PUT /items/7")
1034 );
1035
1036 let escaped = crate::ResolvedPath::from_checked("/items/7/owner").unwrap();
1037 match executor.execute_request("PUT", escaped, None).await {
1038 Err(crate::ExecutionError::RequestRefused { message }) => {
1039 assert!(message.contains("write"), "{message}");
1040 assert!(
1041 !message.contains("owner"),
1042 "refusal must not echo the path: {message}"
1043 );
1044 },
1045 other => panic!("expected RequestRefused, got {other:?}"),
1046 }
1047
1048 let read = crate::ResolvedPath::from_checked("/items/7").unwrap();
1049 assert!(executor.execute_request("GET", read, None).await.is_ok());
1050 }
1051
1052 #[test]
1054 fn violations_never_echo_the_path() {
1055 let mut config = CodeModeConfig::enabled();
1056 config.openapi_reads_enabled = false;
1057 config.openapi_blocked_paths = set(&["/blocked"]);
1058 config.openapi_blocked_writes = set(&["PUT /x/SECRET"]);
1059 config.openapi_allow_writes = true;
1060 config.openapi_allowed_writes = set(&["POST /only"]);
1061 let policy = OpenApiClassPolicy::from_config(&config);
1062 let reg = OperationRegistry::from_entries(&[entry("odd", "reed", "/odd/{id}")]);
1063 let code = "await api.get('/x/SECRET');\n\
1064 await api.get('/blocked/SECRET');\n\
1065 await api.put('/x/SECRET', {});\n\
1066 await api.post('/x/SECRET', {});\n\
1067 await api.post('/odd/SECRET', {});\n\
1068 return 1;";
1069 let violations = policy.check_script(&script(code), ®);
1070 let rules: Vec<&str> = violations.iter().map(|v| v.rule.as_str()).collect();
1071 assert_eq!(
1072 rules,
1073 [
1074 "class_denied",
1075 "blocked_path",
1076 "blocked_operation",
1077 "not_in_allowlist",
1078 "unknown_category"
1079 ]
1080 );
1081 for v in &violations {
1082 assert!(!v.message.contains("SECRET"), "{}", v.message);
1083 assert!(
1084 !v.suggestion.as_deref().unwrap_or("").contains("SECRET"),
1085 "{:?}",
1086 v.suggestion
1087 );
1088 }
1089 assert!(violations[0].message.contains("GET call on line 1"));
1090 assert!(violations[4].message.contains("operation 'odd'"));
1091 }
1092
1093 #[test]
1094 fn builder_sets_modes_the_legacy_keys_cannot_express() {
1095 let policy = OpenApiClassPolicy::from_config(&CodeModeConfig::enabled())
1096 .with_mode(
1097 UnifiedAction::Read,
1098 ClassMode::Allowlist(set(&["GET /items", "searchConcepts"])),
1099 )
1100 .with_mode(UnifiedAction::Admin, ClassMode::AllowAll)
1101 .with_blocked_operations(["PATCH"])
1102 .with_blocked_paths(["/internal/"]);
1103 let reg = OperationRegistry::from_entries(&[
1104 entry("searchConcepts", "read", "POST /search"),
1105 entry("reindex", "admin", "POST /reindex"),
1106 ]);
1107 assert!(rules(&policy, GET, ®).is_empty());
1108 assert!(rules(&policy, "await api.post('/search', {}); return 1;", ®).is_empty());
1109 assert_eq!(
1110 rules(&policy, "await api.get('/other'); return 1;", ®),
1111 ["not_in_allowlist"]
1112 );
1113 assert!(rules(&policy, "await api.post('/reindex', {}); return 1;", ®).is_empty());
1114 assert_eq!(
1115 rules(&policy, "await api.get('/internal/x'); return 1;", ®),
1116 ["blocked_path"]
1117 );
1118 assert_eq!(
1119 policy.to_string(),
1120 "read=allowlist write=deny_all delete=deny_all admin=allow_all \
1121 blocked_operations=1 blocked_paths=1"
1122 );
1123 }
1124
1125 #[test]
1126 fn glob_matching() {
1127 assert!(glob_matches(b"/a/*", b"/a/b/c"));
1128 assert!(glob_matches(b"*secret*", b"/x/secrets"));
1129 assert!(!glob_matches(b"/a/*/c", b"/a/b/d"));
1130 assert!(glob_matches(b"/a/*/c", b"/a/b/c"));
1131 }
1132}