Skip to main content

pmcp_code_mode/
openapi_policy.rs

1//! Static class policy for OpenAPI Code Mode.
2//!
3//! Every OpenAPI policy key in [`CodeModeConfig`] is enforced here, without a
4//! [`PolicyEvaluator`](crate::PolicyEvaluator). A configured evaluator (Cedar,
5//! AVP) runs AFTER this gate and can only narrow its verdict, never widen it.
6//!
7//! Before this module the static gate looked at HTTP methods only and left
8//! every other key — the write allowlist, the read switch, deletes, blocked
9//! paths, catalog classes, `admin` — to the evaluator. Under
10//! [`NoopPolicyEvaluator`](crate::NoopPolicyEvaluator) those keys were inert,
11//! and a non-empty write allowlist widened to allow-all.
12//!
13//! # Classes
14//!
15//! Each API call gets one [`UnifiedAction`] class:
16//!
17//! 1. The declared `category` of the matching `[[code_mode.operations]]`
18//!    entry (see [`OperationRegistry::lookup_entry`]). A category that is not
19//!    `read`, `write`, `delete` or `admin` is refused, never guessed.
20//! 2. Otherwise the HTTP method: GET/HEAD/OPTIONS are `read`, POST/PUT/PATCH
21//!    are `write`, DELETE is `delete`.
22//!
23//! A call whose path is only known at run time keeps the stricter of the two,
24//! so a catalog entry cannot relax a path the validator cannot see. The
25//! run-time half of the check is [`OpenApiClassPolicy::check_request`], which
26//! sees the resolved path.
27//!
28//! # Modes, derived from the existing keys
29//!
30//! | Class  | Mode |
31//! |--------|------|
32//! | read   | `openapi_reads_enabled` ? `allow_all` : `deny_all` |
33//! | write  | `!openapi_allow_writes` → `deny_all`; non-empty `openapi_allowed_writes` → `allowlist`; else `allow_all` |
34//! | delete | `!openapi_allow_deletes` → `deny_all`; non-empty `openapi_allowed_deletes` → `allowlist`; else `allow_all` |
35//! | admin  | always `deny_all` (no key enables it) |
36//!
37//! `openapi_blocked_writes` blocks every call it names, in any class. An entry
38//! that is an HTTP method name (`"POST"`) blocks that method; any other entry
39//! is an operation (`"POST /users"`, `"POST:/users/{id}"` or a catalog id).
40//! `openapi_blocked_paths` blocks every call whose path falls under one of its
41//! patterns (`*` matches any run of characters; a pattern with no `*` blocks
42//! the path itself and everything below it). Both comparisons ignore case.
43//!
44//! The write and delete rules mirror
45//! [`CodeModeConfig::to_openapi_server_entity`]'s `write_mode`, so the static
46//! gate and the Cedar entity describe the same policy.
47//!
48//! # Scope
49//!
50//! SDK-backed Code Mode (`sdk_operations`) issues no HTTP calls and is not
51//! classified here.
52
53use 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/// How one class of operations is governed.
64///
65/// `#[non_exhaustive]`: a later mode is a new variant, so a `match` needs a
66/// wildcard arm.
67#[derive(Debug, Clone, PartialEq, Eq)]
68#[non_exhaustive]
69pub enum ClassMode {
70    /// No operation of the class is allowed.
71    DenyAll,
72    /// Every operation of the class is allowed (subject to the blocklists).
73    AllowAll,
74    /// Only the listed operations are allowed. An entry is a catalog id or an
75    /// operation (`"GET /items/{id}"`, `"GET:/items/{id}"`); the policy stores
76    /// it normalized (`METHOD:/path`, `{param}` segments as `*`).
77    Allowlist(HashSet<String>),
78}
79
80impl ClassMode {
81    /// The mode's config name: `deny_all`, `allow_all` or `allowlist`.
82    #[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/// The static OpenAPI policy of one server, derived from its [`CodeModeConfig`].
109#[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
120/// One call, as the policy sees it.
121struct Call<'a> {
122    method: &'a str,
123    path: &'a str,
124    /// The path holds a segment only known at run time.
125    dynamic: bool,
126    /// How violations name the call: by method and source line, never by
127    /// path. A path can carry caller-supplied values, and a refusal must not
128    /// echo them (the same rule `ValidationResult` explanations follow). A
129    /// matched catalog id is added, since it comes from the operator's config.
130    label: String,
131}
132
133impl OpenApiClassPolicy {
134    /// Derive the policy from the OpenAPI keys of `config` (table in the
135    /// module docs).
136    #[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    /// Replace the mode of one class. Allowlist entries are normalized.
163    ///
164    /// For a caller whose own config can say more than the `openapi_*` keys
165    /// of [`CodeModeConfig`] — a read allowlist, an `admin` mode. Install the
166    /// result with
167    /// [`ValidationPipeline::with_openapi_class_policy`](crate::ValidationPipeline::with_openapi_class_policy).
168    #[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    /// Replace the blocked operations. Same entry forms as
181    /// `openapi_blocked_writes`: an HTTP method name blocks the method, any
182    /// other entry names an operation. A block applies in every class.
183    #[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    /// Replace the blocked path patterns (same rules as
205    /// `openapi_blocked_paths`).
206    #[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    /// The mode governing `class`.
220    #[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    /// Check every API call of a parsed script. Returns one violation per
231    /// refused call; an empty list means the script passes the static policy.
232    #[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    /// Check one request at execution time, after its path is resolved.
254    ///
255    /// The validator classifies a dynamic path conservatively; this is the
256    /// check that sees where the request actually goes. Any query string is
257    /// ignored for matching. Violation messages name the method and class but
258    /// never the path, which carries caller-supplied values.
259    ///
260    /// # Errors
261    ///
262    /// Returns the violation when the request is refused.
263    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            // The run-time value could land anywhere below the static prefix,
368            // so refuse when the prefix could still reach a blocked pattern.
369            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
385/// One line naming each class's mode and the block counts, for a startup
386/// log: `read=allow_all write=deny_all delete=deny_all admin=deny_all
387/// blocked_operations=0 blocked_paths=0`. Lists are counted, not printed.
388impl 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
438/// Parse a declared category. Only the four class names are accepted
439/// (ASCII case is ignored); anything else is `None`.
440pub(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
450/// Order classes by how much they can change: read < write < delete < admin.
451pub(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
460/// The class of a call. `None` when its catalog entry declares a category
461/// that is not one of the four classes.
462fn 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
475/// The identifiers a call can be listed under: its catalog id, and its
476/// normalized `METHOD:/path`.
477fn 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
494/// Whether a normalized list entry names a call candidate. A `*` segment in
495/// the entry matches any one segment; a `*` in the candidate (a segment the
496/// validator cannot see) matches only a `*` in the entry.
497///
498/// Both sides come from `normalize_operation_format`, which also turns the
499/// empty segment before a leading `/` into `*` (an empty string is "all
500/// digits"). The Cedar side depends on that form, so it is matched as is: the
501/// leading `*` lines up on both sides.
502fn 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
549/// `*` matches any run of bytes, including `/`.
550fn 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/// An [`HttpExecutor`](crate::HttpExecutor) that re-checks every request
572/// against the static class policy once its path is resolved, then delegates.
573///
574/// Validation sees a dynamic path (`/items/${id}`) only as a template; this
575/// wrapper sees the path the request actually goes to, so a run-time value
576/// cannot carry a call into a class, blocked path or blocked operation the
577/// policy refuses. A refusal is
578/// [`ExecutionError::RequestRefused`](crate::ExecutionError::RequestRefused),
579/// and its message never contains the resolved path.
580#[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    /// Wrap `inner` with the policy and catalog of `config`.
590    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    /// Wrap `inner` with an explicit policy and operation catalog — the
599    /// executor counterpart of
600    /// [`ValidationPipeline::with_openapi_class_policy`](crate::ValidationPipeline::with_openapi_class_policy).
601    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    /// The wrapped executor.
614    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, &reg).is_empty());
697        assert_eq!(rules(&policy, POST, &reg), ["class_denied"]);
698        assert_eq!(rules(&policy, DELETE, &reg), ["class_denied"]);
699    }
700
701    /// D2: the read switch was only used for auto-approval.
702    #[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    /// D1: a non-empty allowlist used to skip the static check entirely.
716    #[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, &reg).is_empty());
724        assert_eq!(
725            rules(&policy, "await api.put('/items/7', {}); return 1;", &reg),
726            ["not_in_allowlist"]
727        );
728        assert_eq!(
729            rules(
730                &policy,
731                "await api.post('/admin/reset', {}); return 1;",
732                &reg
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;", &reg).is_empty());
762        assert!(rules(
763            &policy,
764            "const id = 3; await api.put(`/items/${id}`, {}); return 1;",
765            &reg
766        )
767        .is_empty());
768        assert!(rules(
769            &policy,
770            "await api.post('/items/9/rename', {}); return 1;",
771            &reg
772        )
773        .is_empty());
774        // A template entry admits a literal, non-numeric segment.
775        assert!(rules(&policy, "await api.put('/items/abc', {}); return 1;", &reg).is_empty());
776        assert_eq!(
777            rules(
778                &policy,
779                "await api.put('/items/7/owner', {}); return 1;",
780                &reg
781            ),
782            ["not_in_allowlist"]
783        );
784    }
785
786    /// Deletes used to be governed by `openapi_allow_writes` under Noop.
787    #[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, &reg).is_empty());
794        assert_eq!(rules(&policy, DELETE, &reg), ["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, &reg).is_empty());
800        assert_eq!(rules(&policy, POST, &reg), ["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, &reg).is_empty());
811        assert_eq!(
812            rules(&policy, "await api.delete('/users/1'); return 1;", &reg),
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, &reg), ["class_denied"]);
823
824        let reg = OperationRegistry::from_entries(&[entry("search", "read", "/search")]);
825        assert!(rules(&policy, search, &reg).is_empty());
826
827        let reg = OperationRegistry::from_entries(&[entry("listItems", "admin", "GET /items")]);
828        let violations = policy.check_script(&script(GET), &reg);
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;"), &reg);
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, &reg), ["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;", &reg).is_empty());
862        assert_eq!(
863            rules(
864                &policy,
865                "const k = 'a'; await api.post(`/lookup/${k}`, {}); return 1;",
866                &reg
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, &reg).is_empty());
879        assert_eq!(rules(&policy, POST, &reg), ["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;", &reg),
891            ["blocked_method"]
892        );
893        assert_eq!(rules(&policy, POST, &reg), ["blocked_operation"]);
894        assert_eq!(
895            rules(&policy, "await api.get('/secrets'); return 1;", &reg),
896            ["blocked_operation"]
897        );
898        assert!(rules(&policy, "await api.put('/items', {}); return 1;", &reg).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, &reg), ["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, &reg).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                &reg
928            ),
929            ["blocked_path"]
930        );
931        assert_eq!(
932            rules(&policy, "const p = '/x'; await api.get(p); return 1;", &reg),
933            ["blocked_path"]
934        );
935        assert!(rules(
936            &policy,
937            "const i = 1; await api.get(`/items/${i}`); return 1;",
938            &reg
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", &reg).is_ok());
962        let refused = policy
963            .check_request("POST", "/lookup/purge", &reg)
964            .unwrap_err();
965        assert!(refused.message.contains("admin"));
966        assert_eq!(
967            policy.check_request("TRACE", "/x", &reg).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    /// The validator passes `PUT /items/${id}` against an allowlisted
990    /// template; the run-time check refuses the value that leaves it.
991    #[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    /// Every refusal names the call by method and line, never by path.
1023    #[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), &reg);
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, &reg).is_empty());
1078        assert!(rules(&policy, "await api.post('/search', {}); return 1;", &reg).is_empty());
1079        assert_eq!(
1080            rules(&policy, "await api.get('/other'); return 1;", &reg),
1081            ["not_in_allowlist"]
1082        );
1083        assert!(rules(&policy, "await api.post('/reindex', {}); return 1;", &reg).is_empty());
1084        assert_eq!(
1085            rules(&policy, "await api.get('/internal/x'); return 1;", &reg),
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}