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        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            // The run-time value could land anywhere below the static prefix,
369            // so refuse when the prefix could still reach a blocked pattern.
370            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
386/// One line naming each class's mode and the block counts, for a startup
387/// log: `read=allow_all write=deny_all delete=deny_all admin=deny_all
388/// blocked_operations=0 blocked_paths=0`. Lists are counted, not printed.
389impl 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
430/// "an admin", "a read": the four class names are the only words placed after
431/// it, and only "admin" starts with a vowel sound.
432fn 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
449/// Parse a declared category. Only the four class names are accepted
450/// (ASCII case is ignored); anything else is `None`.
451pub(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
461/// Order classes by how much they can change: read < write < delete < admin.
462pub(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
471/// The class of a call. `None` when its catalog entry declares a category
472/// that is not one of the four classes.
473fn 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
486/// The identifiers a call can be listed under: its catalog id, and its
487/// normalized `METHOD:/path`.
488fn 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
505/// Whether a normalized list entry names a call candidate. A `*` segment in
506/// the entry matches any one segment; a `*` in the candidate (a segment the
507/// validator cannot see) matches only a `*` in the entry.
508///
509/// Both sides come from `normalize_operation_format`, which also turns the
510/// empty segment before a leading `/` into `*` (an empty string is "all
511/// digits"). The Cedar side depends on that form, so it is matched as is: the
512/// leading `*` lines up on both sides.
513fn 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
560/// `*` matches any run of bytes, including `/`.
561fn 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/// An [`HttpExecutor`](crate::HttpExecutor) that re-checks every request
583/// against the static class policy once its path is resolved, then delegates.
584///
585/// Validation sees a dynamic path (`/items/${id}`) only as a template; this
586/// wrapper sees the path the request actually goes to, so a run-time value
587/// cannot carry a call into a class, blocked path or blocked operation the
588/// policy refuses. A refusal is
589/// [`ExecutionError::RequestRefused`](crate::ExecutionError::RequestRefused),
590/// and its message never contains the resolved path.
591#[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    /// Wrap `inner` with the policy and catalog of `config`.
601    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    /// Wrap `inner` with an explicit policy and operation catalog — the
610    /// executor counterpart of
611    /// [`ValidationPipeline::with_openapi_class_policy`](crate::ValidationPipeline::with_openapi_class_policy).
612    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    /// The wrapped executor.
625    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, &reg).is_empty());
708        assert_eq!(rules(&policy, POST, &reg), ["class_denied"]);
709        assert_eq!(rules(&policy, DELETE, &reg), ["class_denied"]);
710    }
711
712    /// D2: the read switch was only used for auto-approval.
713    #[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    /// D1: a non-empty allowlist used to skip the static check entirely.
727    #[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, &reg).is_empty());
735        assert_eq!(
736            rules(&policy, "await api.put('/items/7', {}); return 1;", &reg),
737            ["not_in_allowlist"]
738        );
739        assert_eq!(
740            rules(
741                &policy,
742                "await api.post('/admin/reset', {}); return 1;",
743                &reg
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;", &reg).is_empty());
773        assert!(rules(
774            &policy,
775            "const id = 3; await api.put(`/items/${id}`, {}); return 1;",
776            &reg
777        )
778        .is_empty());
779        assert!(rules(
780            &policy,
781            "await api.post('/items/9/rename', {}); return 1;",
782            &reg
783        )
784        .is_empty());
785        // A template entry admits a literal, non-numeric segment.
786        assert!(rules(&policy, "await api.put('/items/abc', {}); return 1;", &reg).is_empty());
787        assert_eq!(
788            rules(
789                &policy,
790                "await api.put('/items/7/owner', {}); return 1;",
791                &reg
792            ),
793            ["not_in_allowlist"]
794        );
795    }
796
797    /// Deletes used to be governed by `openapi_allow_writes` under Noop.
798    #[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, &reg).is_empty());
805        assert_eq!(rules(&policy, DELETE, &reg), ["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, &reg).is_empty());
811        assert_eq!(rules(&policy, POST, &reg), ["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, &reg).is_empty());
822        assert_eq!(
823            rules(&policy, "await api.delete('/users/1'); return 1;", &reg),
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, &reg), ["class_denied"]);
834
835        let reg = OperationRegistry::from_entries(&[entry("search", "read", "/search")]);
836        assert!(rules(&policy, search, &reg).is_empty());
837
838        let reg = OperationRegistry::from_entries(&[entry("listItems", "admin", "GET /items")]);
839        let violations = policy.check_script(&script(GET), &reg);
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;"), &reg);
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;"), &reg);
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, &reg), ["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;", &reg).is_empty());
892        assert_eq!(
893            rules(
894                &policy,
895                "const k = 'a'; await api.post(`/lookup/${k}`, {}); return 1;",
896                &reg
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, &reg).is_empty());
909        assert_eq!(rules(&policy, POST, &reg), ["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;", &reg),
921            ["blocked_method"]
922        );
923        assert_eq!(rules(&policy, POST, &reg), ["blocked_operation"]);
924        assert_eq!(
925            rules(&policy, "await api.get('/secrets'); return 1;", &reg),
926            ["blocked_operation"]
927        );
928        assert!(rules(&policy, "await api.put('/items', {}); return 1;", &reg).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, &reg), ["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, &reg).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                &reg
958            ),
959            ["blocked_path"]
960        );
961        assert_eq!(
962            rules(&policy, "const p = '/x'; await api.get(p); return 1;", &reg),
963            ["blocked_path"]
964        );
965        assert!(rules(
966            &policy,
967            "const i = 1; await api.get(`/items/${i}`); return 1;",
968            &reg
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", &reg).is_ok());
992        let refused = policy
993            .check_request("POST", "/lookup/purge", &reg)
994            .unwrap_err();
995        assert!(refused.message.contains("admin"));
996        assert_eq!(
997            policy.check_request("TRACE", "/x", &reg).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    /// The validator passes `PUT /items/${id}` against an allowlisted
1020    /// template; the run-time check refuses the value that leaves it.
1021    #[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    /// Every refusal names the call by method and line, never by path.
1053    #[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), &reg);
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, &reg).is_empty());
1108        assert!(rules(&policy, "await api.post('/search', {}); return 1;", &reg).is_empty());
1109        assert_eq!(
1110            rules(&policy, "await api.get('/other'); return 1;", &reg),
1111            ["not_in_allowlist"]
1112        );
1113        assert!(rules(&policy, "await api.post('/reindex', {}); return 1;", &reg).is_empty());
1114        assert_eq!(
1115            rules(&policy, "await api.get('/internal/x'); return 1;", &reg),
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}