Skip to main content

pmcp_code_mode/
schema_exposure.rs

1//! Schema Exposure Architecture for MCP Built-in Servers.
2//!
3//! This module implements the Three-Layer Schema Model:
4//! - Layer 1: Source Schema (original API specification)
5//! - Layer 2: Exposure Policies (what gets exposed)
6//! - Layer 3: Derived Schemas (computed views)
7//!
8//! See SCHEMA_EXPOSURE_ARCHITECTURE.md for full design documentation.
9
10use serde::{Deserialize, Serialize};
11use std::collections::{HashMap, HashSet};
12
13// Re-export GraphQLOperationType from the existing graphql module
14pub use crate::graphql::GraphQLOperationType;
15
16// ============================================================================
17// LAYER 1: SOURCE SCHEMA
18// ============================================================================
19
20/// Schema format identifier.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "lowercase")]
23pub enum SchemaFormat {
24    /// OpenAPI 3.x REST APIs
25    OpenAPI3,
26    /// GraphQL APIs
27    GraphQL,
28    /// SQL databases (future)
29    Sql,
30    /// AsyncAPI event-driven APIs (future)
31    AsyncAPI,
32}
33
34/// Where the schema came from.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36#[serde(tag = "type", rename_all = "lowercase")]
37pub enum SchemaSource {
38    /// Schema embedded in config file
39    Embedded { path: String },
40    /// Schema fetched from remote URL
41    Remote {
42        url: String,
43        #[serde(default)]
44        refresh_interval_seconds: Option<u64>,
45    },
46    /// Schema discovered via introspection
47    Introspection { endpoint: String },
48}
49
50/// Metadata about the source schema.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct SchemaMetadata {
53    /// Where the schema came from
54    pub source: SchemaSource,
55
56    /// When it was last fetched/updated (Unix timestamp)
57    #[serde(default)]
58    pub last_updated: Option<i64>,
59
60    /// Content hash for change detection (SHA-256)
61    #[serde(default)]
62    pub content_hash: Option<String>,
63
64    /// Schema version (if available from spec)
65    #[serde(default)]
66    pub version: Option<String>,
67
68    /// Title from the spec
69    #[serde(default)]
70    pub title: Option<String>,
71}
72
73/// Operation category for filtering.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
75#[serde(rename_all = "lowercase")]
76pub enum OperationCategory {
77    /// Read operations (GET, Query, SELECT)
78    Read,
79    /// Create operations (POST create, Mutation create, INSERT)
80    Create,
81    /// Update operations (PUT/PATCH, Mutation update, UPDATE)
82    Update,
83    /// Delete operations (DELETE, Mutation delete, DELETE)
84    Delete,
85    /// Administrative operations
86    Admin,
87    /// Internal/debug operations
88    Internal,
89}
90
91impl OperationCategory {
92    /// Returns true if this is a read-only category.
93    pub fn is_read_only(&self) -> bool {
94        matches!(self, OperationCategory::Read)
95    }
96
97    /// Returns true if this is a write category (create or update).
98    pub fn is_write(&self) -> bool {
99        matches!(self, OperationCategory::Create | OperationCategory::Update)
100    }
101
102    /// Returns true if this is a delete category.
103    pub fn is_delete(&self) -> bool {
104        matches!(self, OperationCategory::Delete)
105    }
106}
107
108/// Risk level for an operation.
109#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
110#[serde(rename_all = "lowercase")]
111pub enum OperationRiskLevel {
112    /// Read-only, no side effects
113    Safe,
114    /// Creates data, generally reversible
115    Low,
116    /// Modifies data, potentially reversible
117    #[default]
118    Medium,
119    /// Deletes data, difficult to reverse
120    High,
121    /// System-wide impact, irreversible
122    Critical,
123}
124
125/// Normalized operation model that works across all schema formats.
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct Operation {
128    /// Unique identifier for this operation.
129    /// - OpenAPI: operationId or "{method} {path}"
130    /// - GraphQL: "{Type}.{field}" (e.g., "Query.users", "Mutation.createUser")
131    /// - SQL: "{action}_{table}" (e.g., "select_users", "insert_orders")
132    pub id: String,
133
134    /// Human-readable name.
135    pub name: String,
136
137    /// Description of what the operation does.
138    #[serde(default)]
139    pub description: Option<String>,
140
141    /// Operation category.
142    pub category: OperationCategory,
143
144    /// Whether this is a read-only operation.
145    pub is_read_only: bool,
146
147    /// Risk level for UI hints.
148    #[serde(default)]
149    pub risk_level: OperationRiskLevel,
150
151    /// Tags/categories for grouping.
152    #[serde(default)]
153    pub tags: Vec<String>,
154
155    /// Format-specific details.
156    pub details: OperationDetails,
157}
158
159impl Operation {
160    /// Create a new operation with minimal required fields.
161    pub fn new(
162        id: impl Into<String>,
163        name: impl Into<String>,
164        category: OperationCategory,
165    ) -> Self {
166        let is_read_only = category.is_read_only();
167        Self {
168            id: id.into(),
169            name: name.into(),
170            description: None,
171            category,
172            is_read_only,
173            risk_level: if is_read_only {
174                OperationRiskLevel::Safe
175            } else if category.is_delete() {
176                OperationRiskLevel::High
177            } else {
178                OperationRiskLevel::Low
179            },
180            tags: Vec::new(),
181            details: OperationDetails::Unknown,
182        }
183    }
184
185    /// Check if this operation matches a pattern.
186    /// Patterns support glob-style wildcards: * (any characters)
187    pub fn matches_pattern(&self, pattern: &str) -> bool {
188        match &self.details {
189            OperationDetails::OpenAPI { method, path, .. } => {
190                // Pattern format: "METHOD /path/*" or "* /path/*"
191                let endpoint = format!("{} {}", method.to_uppercase(), path);
192                pattern_matches(pattern, &endpoint) || pattern_matches(pattern, &self.id)
193            },
194            OperationDetails::GraphQL {
195                operation_type,
196                field_name,
197                ..
198            } => {
199                // Pattern format: "Type.field*" or "*.field"
200                let full_name = format!("{:?}.{}", operation_type, field_name);
201                pattern_matches(pattern, &full_name) || pattern_matches(pattern, &self.id)
202            },
203            OperationDetails::Sql {
204                statement_type,
205                table,
206                ..
207            } => {
208                // Pattern format: "action table" or "* table"
209                let full_name = format!("{:?} {}", statement_type, table);
210                pattern_matches(pattern, &full_name.to_lowercase())
211                    || pattern_matches(pattern, &self.id)
212            },
213            OperationDetails::Unknown => pattern_matches(pattern, &self.id),
214        }
215    }
216}
217
218/// Format-specific operation details.
219#[derive(Debug, Clone, Serialize, Deserialize)]
220#[serde(tag = "format", rename_all = "lowercase")]
221pub enum OperationDetails {
222    /// OpenAPI operation details.
223    #[serde(rename = "openapi")]
224    OpenAPI {
225        method: String,
226        path: String,
227        #[serde(default)]
228        parameters: Vec<OperationParameter>,
229        #[serde(default)]
230        has_request_body: bool,
231    },
232
233    /// GraphQL operation details.
234    #[serde(rename = "graphql")]
235    GraphQL {
236        operation_type: GraphQLOperationType,
237        field_name: String,
238        #[serde(default)]
239        arguments: Vec<OperationParameter>,
240        #[serde(default)]
241        return_type: Option<String>,
242    },
243
244    /// SQL operation details.
245    #[serde(rename = "sql")]
246    Sql {
247        statement_type: SqlStatementType,
248        table: String,
249        #[serde(default)]
250        columns: Vec<String>,
251    },
252
253    /// Unknown/generic operation.
254    Unknown,
255}
256
257/// SQL statement type.
258#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
259#[serde(rename_all = "UPPERCASE")]
260pub enum SqlStatementType {
261    Select,
262    Insert,
263    Update,
264    Delete,
265}
266
267/// Operation parameter.
268#[derive(Debug, Clone, Serialize, Deserialize)]
269pub struct OperationParameter {
270    pub name: String,
271    #[serde(default)]
272    pub description: Option<String>,
273    pub required: bool,
274    #[serde(default)]
275    pub param_type: Option<String>,
276}
277
278// ============================================================================
279// LAYER 2: EXPOSURE POLICIES
280// ============================================================================
281
282/// Complete exposure policy configuration.
283#[derive(Debug, Clone, Default, Serialize, Deserialize)]
284pub struct McpExposurePolicy {
285    /// Operations NEVER exposed via MCP (highest priority).
286    #[serde(default)]
287    pub global_blocklist: GlobalBlocklist,
288
289    /// Policy for MCP tool exposure.
290    #[serde(default)]
291    pub tools: ToolExposurePolicy,
292
293    /// Policy for Code Mode exposure.
294    #[serde(default)]
295    pub code_mode: CodeModeExposurePolicy,
296}
297
298/// Global blocklist - these operations are never exposed.
299#[derive(Debug, Clone, Default, Serialize, Deserialize)]
300pub struct GlobalBlocklist {
301    /// Blocked operation IDs (exact match).
302    #[serde(default)]
303    pub operations: HashSet<String>,
304
305    /// Blocked patterns (glob matching).
306    /// - OpenAPI: "METHOD /path/*" or "* /path/*"
307    /// - GraphQL: "Type.field*" or "*.field"
308    /// - SQL: "action table" or "* table"
309    #[serde(default)]
310    pub patterns: HashSet<String>,
311
312    /// Blocked categories.
313    #[serde(default)]
314    pub categories: HashSet<OperationCategory>,
315
316    /// Blocked risk levels.
317    #[serde(default)]
318    pub risk_levels: HashSet<OperationRiskLevel>,
319}
320
321impl GlobalBlocklist {
322    /// Check if an operation is blocked by this blocklist.
323    pub fn is_blocked(&self, operation: &Operation) -> Option<FilterReason> {
324        // Check exact operation ID match
325        if self.operations.contains(&operation.id) {
326            return Some(FilterReason::GlobalBlocklistOperation {
327                operation_id: operation.id.clone(),
328            });
329        }
330
331        // Check pattern matches
332        for pattern in &self.patterns {
333            if operation.matches_pattern(pattern) {
334                return Some(FilterReason::GlobalBlocklistPattern {
335                    pattern: pattern.clone(),
336                });
337            }
338        }
339
340        // Check category
341        if self.categories.contains(&operation.category) {
342            return Some(FilterReason::GlobalBlocklistCategory {
343                category: operation.category,
344            });
345        }
346
347        // Check risk level
348        if self.risk_levels.contains(&operation.risk_level) {
349            return Some(FilterReason::GlobalBlocklistRiskLevel {
350                level: operation.risk_level,
351            });
352        }
353
354        None
355    }
356}
357
358/// Tool exposure policy.
359#[derive(Debug, Clone, Default, Serialize, Deserialize)]
360pub struct ToolExposurePolicy {
361    /// Exposure mode.
362    #[serde(default)]
363    pub mode: ExposureMode,
364
365    /// Operations to include (for allowlist mode).
366    #[serde(default)]
367    pub allowlist: HashSet<String>,
368
369    /// Operations to exclude (for blocklist mode).
370    #[serde(default)]
371    pub blocklist: HashSet<String>,
372
373    /// Per-operation customization.
374    #[serde(default)]
375    pub overrides: HashMap<String, ToolOverride>,
376}
377
378impl ToolExposurePolicy {
379    /// Check if an operation is allowed by this policy.
380    pub fn is_allowed(&self, operation: &Operation) -> Option<FilterReason> {
381        // Check blocklist first (always applied)
382        if self.blocklist.contains(&operation.id) {
383            return Some(FilterReason::ToolBlocklist);
384        }
385
386        // Check patterns in blocklist
387        for pattern in &self.blocklist {
388            if pattern.contains('*') && operation.matches_pattern(pattern) {
389                return Some(FilterReason::ToolBlocklistPattern {
390                    pattern: pattern.clone(),
391                });
392            }
393        }
394
395        match self.mode {
396            ExposureMode::AllowAll => None,
397            ExposureMode::DenyAll => Some(FilterReason::ToolDenyAllMode),
398            ExposureMode::Allowlist => {
399                // Check if in allowlist
400                if self.allowlist.contains(&operation.id) {
401                    return None;
402                }
403                // Check patterns in allowlist
404                for pattern in &self.allowlist {
405                    if pattern.contains('*') && operation.matches_pattern(pattern) {
406                        return None;
407                    }
408                }
409                Some(FilterReason::ToolNotInAllowlist)
410            },
411            ExposureMode::Blocklist => None, // Already checked blocklist above
412        }
413    }
414}
415
416/// Code Mode exposure policy.
417#[derive(Debug, Clone, Default, Serialize, Deserialize)]
418pub struct CodeModeExposurePolicy {
419    /// Policy for read operations.
420    #[serde(default)]
421    pub reads: MethodExposurePolicy,
422
423    /// Policy for write operations (create/update).
424    #[serde(default)]
425    pub writes: MethodExposurePolicy,
426
427    /// Policy for delete operations.
428    #[serde(default)]
429    pub deletes: MethodExposurePolicy,
430
431    /// Additional blocklist (applies on top of method policies).
432    #[serde(default)]
433    pub blocklist: HashSet<String>,
434}
435
436impl CodeModeExposurePolicy {
437    /// Check if an operation is allowed by this policy.
438    pub fn is_allowed(&self, operation: &Operation) -> Option<FilterReason> {
439        // Check additional blocklist first
440        if self.blocklist.contains(&operation.id) {
441            return Some(FilterReason::CodeModeBlocklist);
442        }
443
444        // Check patterns in blocklist
445        for pattern in &self.blocklist {
446            if pattern.contains('*') && operation.matches_pattern(pattern) {
447                return Some(FilterReason::CodeModeBlocklistPattern {
448                    pattern: pattern.clone(),
449                });
450            }
451        }
452
453        // Get the appropriate method policy
454        let method_policy = self.get_method_policy(operation);
455        method_policy.is_allowed(operation)
456    }
457
458    /// Get the method policy for an operation based on its category.
459    fn get_method_policy(&self, operation: &Operation) -> &MethodExposurePolicy {
460        match operation.category {
461            OperationCategory::Read => &self.reads,
462            OperationCategory::Delete => &self.deletes,
463            OperationCategory::Create | OperationCategory::Update => &self.writes,
464            OperationCategory::Admin | OperationCategory::Internal => &self.writes,
465        }
466    }
467}
468
469/// Per-method-type exposure policy.
470#[derive(Debug, Clone, Default, Serialize, Deserialize)]
471pub struct MethodExposurePolicy {
472    /// Exposure mode.
473    #[serde(default)]
474    pub mode: ExposureMode,
475
476    /// Operations to include (for allowlist mode).
477    /// Can be operation IDs or patterns.
478    #[serde(default)]
479    pub allowlist: HashSet<String>,
480
481    /// Operations to exclude (for blocklist mode).
482    #[serde(default)]
483    pub blocklist: HashSet<String>,
484}
485
486impl MethodExposurePolicy {
487    /// Check if an operation is allowed by this policy.
488    pub fn is_allowed(&self, operation: &Operation) -> Option<FilterReason> {
489        // Check blocklist first
490        if self.blocklist.contains(&operation.id) {
491            return Some(FilterReason::MethodBlocklist {
492                method_type: Self::method_type_name(operation),
493            });
494        }
495
496        // Check patterns in blocklist
497        for pattern in &self.blocklist {
498            if pattern.contains('*') && operation.matches_pattern(pattern) {
499                return Some(FilterReason::MethodBlocklistPattern {
500                    method_type: Self::method_type_name(operation),
501                    pattern: pattern.clone(),
502                });
503            }
504        }
505
506        match self.mode {
507            ExposureMode::AllowAll => None,
508            ExposureMode::DenyAll => Some(FilterReason::MethodDenyAllMode {
509                method_type: Self::method_type_name(operation),
510            }),
511            ExposureMode::Allowlist => {
512                // Check if in allowlist
513                if self.allowlist.contains(&operation.id) {
514                    return None;
515                }
516                // Check patterns in allowlist
517                for pattern in &self.allowlist {
518                    if pattern.contains('*') && operation.matches_pattern(pattern) {
519                        return None;
520                    }
521                }
522                Some(FilterReason::MethodNotInAllowlist {
523                    method_type: Self::method_type_name(operation),
524                })
525            },
526            ExposureMode::Blocklist => None, // Already checked blocklist above
527        }
528    }
529
530    fn method_type_name(operation: &Operation) -> String {
531        match operation.category {
532            OperationCategory::Read => "reads".to_string(),
533            OperationCategory::Delete => "deletes".to_string(),
534            _ => "writes".to_string(),
535        }
536    }
537}
538
539/// Exposure mode determines how allowlist/blocklist are interpreted.
540#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
541#[serde(rename_all = "snake_case")]
542pub enum ExposureMode {
543    /// All operations exposed except those in blocklist.
544    #[default]
545    AllowAll,
546    /// No operations exposed (allowlist/blocklist ignored).
547    DenyAll,
548    /// Only operations in allowlist are exposed (blocklist still applies).
549    Allowlist,
550    /// All operations exposed except those in blocklist (same as AllowAll).
551    Blocklist,
552}
553
554/// Per-operation customization for tools.
555#[derive(Debug, Clone, Default, Serialize, Deserialize)]
556pub struct ToolOverride {
557    /// Custom tool name (instead of operation ID).
558    #[serde(default)]
559    pub name: Option<String>,
560
561    /// Custom description.
562    #[serde(default)]
563    pub description: Option<String>,
564
565    /// Mark as dangerous (requires confirmation in Claude).
566    #[serde(default)]
567    pub dangerous: bool,
568
569    /// Hide from tool list but still callable.
570    #[serde(default)]
571    pub hidden: bool,
572}
573
574// ============================================================================
575// LAYER 3: DERIVED SCHEMAS
576// ============================================================================
577
578/// Derived schema for a specific exposure context.
579#[derive(Debug, Clone, Serialize, Deserialize)]
580pub struct DerivedSchema {
581    /// Operations included in this derived view.
582    pub operations: Vec<Operation>,
583
584    /// Human-readable documentation (for MCP resources).
585    pub documentation: String,
586
587    /// Derivation metadata (for audit).
588    pub metadata: DerivationMetadata,
589}
590
591impl DerivedSchema {
592    /// Get an operation by ID.
593    pub fn get_operation(&self, id: &str) -> Option<&Operation> {
594        self.operations.iter().find(|op| op.id == id)
595    }
596
597    /// Check if an operation is included.
598    pub fn contains(&self, id: &str) -> bool {
599        self.operations.iter().any(|op| op.id == id)
600    }
601
602    /// Get all operation IDs.
603    pub fn operation_ids(&self) -> HashSet<String> {
604        self.operations.iter().map(|op| op.id.clone()).collect()
605    }
606}
607
608/// Metadata about schema derivation.
609#[derive(Debug, Clone, Serialize, Deserialize)]
610pub struct DerivationMetadata {
611    /// Context (tools or code_mode).
612    pub context: String,
613
614    /// When the schema was derived (Unix timestamp).
615    pub derived_at: i64,
616
617    /// Source schema hash (for cache invalidation).
618    pub source_hash: String,
619
620    /// Policy hash (for cache invalidation).
621    pub policy_hash: String,
622
623    /// Combined hash for caching.
624    pub cache_key: String,
625
626    /// What was filtered and why.
627    pub filtered: Vec<FilteredOperation>,
628
629    /// Statistics.
630    pub stats: DerivationStats,
631}
632
633/// An operation that was filtered during derivation.
634#[derive(Debug, Clone, Serialize, Deserialize)]
635pub struct FilteredOperation {
636    /// Operation that was filtered.
637    pub operation_id: String,
638
639    /// Operation name for display.
640    pub operation_name: String,
641
642    /// Why it was filtered.
643    pub reason: FilterReason,
644
645    /// Which policy caused the filter.
646    pub policy: String,
647}
648
649/// Why an operation was filtered.
650#[derive(Debug, Clone, Serialize, Deserialize)]
651#[serde(tag = "type", rename_all = "snake_case")]
652pub enum FilterReason {
653    /// Blocked by global blocklist (exact operation ID match).
654    GlobalBlocklistOperation { operation_id: String },
655
656    /// Blocked by global blocklist (pattern match).
657    GlobalBlocklistPattern { pattern: String },
658
659    /// Blocked by global blocklist (category).
660    GlobalBlocklistCategory { category: OperationCategory },
661
662    /// Blocked by global blocklist (risk level).
663    GlobalBlocklistRiskLevel { level: OperationRiskLevel },
664
665    /// Blocked by tool blocklist.
666    ToolBlocklist,
667
668    /// Blocked by tool blocklist pattern.
669    ToolBlocklistPattern { pattern: String },
670
671    /// Not in tool allowlist.
672    ToolNotInAllowlist,
673
674    /// Tool policy is deny_all.
675    ToolDenyAllMode,
676
677    /// Blocked by code mode blocklist.
678    CodeModeBlocklist,
679
680    /// Blocked by code mode blocklist pattern.
681    CodeModeBlocklistPattern { pattern: String },
682
683    /// Blocked by method blocklist.
684    MethodBlocklist { method_type: String },
685
686    /// Blocked by method blocklist pattern.
687    MethodBlocklistPattern {
688        method_type: String,
689        pattern: String,
690    },
691
692    /// Not in method allowlist.
693    MethodNotInAllowlist { method_type: String },
694
695    /// Method policy is deny_all.
696    MethodDenyAllMode { method_type: String },
697}
698
699impl FilterReason {
700    /// Get a human-readable description of the filter reason.
701    pub fn description(&self) -> String {
702        match self {
703            FilterReason::GlobalBlocklistOperation { operation_id } => {
704                format!("Operation '{}' is in the global blocklist", operation_id)
705            },
706            FilterReason::GlobalBlocklistPattern { pattern } => {
707                format!("Matches global blocklist pattern '{}'", pattern)
708            },
709            FilterReason::GlobalBlocklistCategory { category } => {
710                format!("Category '{:?}' is blocked globally", category)
711            },
712            FilterReason::GlobalBlocklistRiskLevel { level } => {
713                format!("Risk level '{:?}' is blocked globally", level)
714            },
715            FilterReason::ToolBlocklist => "Operation is in the tool blocklist".to_string(),
716            FilterReason::ToolBlocklistPattern { pattern } => {
717                format!("Matches tool blocklist pattern '{}'", pattern)
718            },
719            FilterReason::ToolNotInAllowlist => {
720                "Operation is not in the tool allowlist".to_string()
721            },
722            FilterReason::ToolDenyAllMode => "Tool exposure is set to deny_all".to_string(),
723            FilterReason::CodeModeBlocklist => {
724                "Operation is in the Code Mode blocklist".to_string()
725            },
726            FilterReason::CodeModeBlocklistPattern { pattern } => {
727                format!("Matches Code Mode blocklist pattern '{}'", pattern)
728            },
729            FilterReason::MethodBlocklist { method_type } => {
730                format!("Operation is in the {} blocklist", method_type)
731            },
732            FilterReason::MethodBlocklistPattern {
733                method_type,
734                pattern,
735            } => {
736                format!("Matches {} blocklist pattern '{}'", method_type, pattern)
737            },
738            FilterReason::MethodNotInAllowlist { method_type } => {
739                format!("Operation is not in the {} allowlist", method_type)
740            },
741            FilterReason::MethodDenyAllMode { method_type } => {
742                format!("{} exposure is set to deny_all", method_type)
743            },
744        }
745    }
746}
747
748/// Statistics about schema derivation.
749#[derive(Debug, Clone, Default, Serialize, Deserialize)]
750pub struct DerivationStats {
751    /// Total operations in source.
752    pub source_total: usize,
753
754    /// Operations in derived schema.
755    pub derived_total: usize,
756
757    /// Operations filtered.
758    pub filtered_total: usize,
759
760    /// Breakdown by filter reason type.
761    pub filtered_by_reason: HashMap<String, usize>,
762}
763
764// ============================================================================
765// PATTERN MATCHING
766// ============================================================================
767
768/// Check if a pattern matches a string using glob-style wildcards.
769/// Supports: * (match any characters)
770pub fn pattern_matches(pattern: &str, text: &str) -> bool {
771    let pattern = pattern.to_lowercase();
772    let text = text.to_lowercase();
773
774    let parts: Vec<&str> = pattern.split('*').collect();
775    if parts.len() == 1 {
776        return pattern == text;
777    }
778
779    match_glob_parts(&parts, &text)
780}
781
782/// Glob-match `text` against `parts` (split of the original pattern on `*`).
783/// Caller has already handled the no-wildcard case.
784fn match_glob_parts(parts: &[&str], text: &str) -> bool {
785    let last_idx = parts.len() - 1;
786    let mut pos = 0;
787    for (i, part) in parts.iter().enumerate() {
788        if part.is_empty() {
789            continue;
790        }
791        match match_glob_segment(part, text, pos, i, last_idx) {
792            Some(new_pos) => pos = new_pos,
793            None => return false,
794        }
795    }
796    true
797}
798
799/// Match a single non-empty glob segment, returning the new scan position or `None`
800/// on mismatch. Position semantics:
801///   - First part (i==0): must anchor at the start of `text`.
802///   - Last part: must anchor at the end of `text[pos..]`.
803///   - Middle parts: may appear anywhere in `text[pos..]`.
804fn match_glob_segment(
805    part: &str,
806    text: &str,
807    pos: usize,
808    i: usize,
809    last_idx: usize,
810) -> Option<usize> {
811    if i == 0 {
812        if !text.starts_with(part) {
813            return None;
814        }
815        return Some(part.len());
816    }
817    if i == last_idx {
818        if !text[pos..].ends_with(part) {
819            return None;
820        }
821        return Some(pos);
822    }
823    // Middle segment: scan for the next occurrence after `pos`.
824    text[pos..].find(part).map(|found| pos + found + part.len())
825}
826
827// ============================================================================
828// SCHEMA DERIVER
829// ============================================================================
830
831/// Derives schemas from source + policy.
832pub struct SchemaDeriver {
833    /// Source operations.
834    operations: Vec<Operation>,
835
836    /// Exposure policy.
837    policy: McpExposurePolicy,
838
839    /// Source schema hash.
840    source_hash: String,
841
842    /// Policy hash.
843    policy_hash: String,
844}
845
846impl SchemaDeriver {
847    /// Create a new schema deriver.
848    pub fn new(operations: Vec<Operation>, policy: McpExposurePolicy, source_hash: String) -> Self {
849        let policy_hash = Self::compute_policy_hash(&policy);
850        Self {
851            operations,
852            policy,
853            source_hash,
854            policy_hash,
855        }
856    }
857
858    /// Derive the MCP Tools schema.
859    pub fn derive_tools_schema(&self) -> DerivedSchema {
860        let mut included = Vec::new();
861        let mut filtered = Vec::new();
862
863        for op in &self.operations {
864            // Step 1: Check global blocklist (highest priority)
865            if let Some(reason) = self.policy.global_blocklist.is_blocked(op) {
866                filtered.push(FilteredOperation {
867                    operation_id: op.id.clone(),
868                    operation_name: op.name.clone(),
869                    reason,
870                    policy: "global_blocklist".to_string(),
871                });
872                continue;
873            }
874
875            // Step 2: Check tool exposure policy
876            if let Some(reason) = self.policy.tools.is_allowed(op) {
877                filtered.push(FilteredOperation {
878                    operation_id: op.id.clone(),
879                    operation_name: op.name.clone(),
880                    reason,
881                    policy: "tools".to_string(),
882                });
883                continue;
884            }
885
886            // Step 3: Apply overrides and include
887            let op = self.apply_tool_overrides(op);
888            included.push(op);
889        }
890
891        self.build_derived_schema(included, filtered, "tools")
892    }
893
894    /// Derive the Code Mode schema.
895    pub fn derive_code_mode_schema(&self) -> DerivedSchema {
896        let mut included = Vec::new();
897        let mut filtered = Vec::new();
898
899        for op in &self.operations {
900            // Step 1: Check global blocklist
901            if let Some(reason) = self.policy.global_blocklist.is_blocked(op) {
902                filtered.push(FilteredOperation {
903                    operation_id: op.id.clone(),
904                    operation_name: op.name.clone(),
905                    reason,
906                    policy: "global_blocklist".to_string(),
907                });
908                continue;
909            }
910
911            // Step 2: Check code mode policy
912            if let Some(reason) = self.policy.code_mode.is_allowed(op) {
913                let policy_name = match op.category {
914                    OperationCategory::Read => "code_mode.reads",
915                    OperationCategory::Delete => "code_mode.deletes",
916                    _ => "code_mode.writes",
917                };
918                filtered.push(FilteredOperation {
919                    operation_id: op.id.clone(),
920                    operation_name: op.name.clone(),
921                    reason,
922                    policy: policy_name.to_string(),
923                });
924                continue;
925            }
926
927            included.push(op.clone());
928        }
929
930        self.build_derived_schema(included, filtered, "code_mode")
931    }
932
933    /// Check if an operation is allowed in tools.
934    pub fn is_tool_allowed(&self, operation_id: &str) -> bool {
935        self.operations
936            .iter()
937            .find(|op| op.id == operation_id)
938            .map(|op| {
939                self.policy.global_blocklist.is_blocked(op).is_none()
940                    && self.policy.tools.is_allowed(op).is_none()
941            })
942            .unwrap_or(false)
943    }
944
945    /// Check if an operation is allowed in code mode.
946    pub fn is_code_mode_allowed(&self, operation_id: &str) -> bool {
947        self.operations
948            .iter()
949            .find(|op| op.id == operation_id)
950            .map(|op| {
951                self.policy.global_blocklist.is_blocked(op).is_none()
952                    && self.policy.code_mode.is_allowed(op).is_none()
953            })
954            .unwrap_or(false)
955    }
956
957    /// Get the filter reason for an operation in tools context.
958    pub fn get_tool_filter_reason(&self, operation_id: &str) -> Option<FilterReason> {
959        self.operations
960            .iter()
961            .find(|op| op.id == operation_id)
962            .and_then(|op| {
963                self.policy
964                    .global_blocklist
965                    .is_blocked(op)
966                    .or_else(|| self.policy.tools.is_allowed(op))
967            })
968    }
969
970    /// Get the filter reason for an operation in code mode context.
971    pub fn get_code_mode_filter_reason(&self, operation_id: &str) -> Option<FilterReason> {
972        self.operations
973            .iter()
974            .find(|op| op.id == operation_id)
975            .and_then(|op| {
976                self.policy
977                    .global_blocklist
978                    .is_blocked(op)
979                    .or_else(|| self.policy.code_mode.is_allowed(op))
980            })
981    }
982
983    /// Find operation ID by HTTP method and path pattern.
984    ///
985    /// This enables looking up human-readable operationIds (like "updateProduct")
986    /// from METHOD:/path patterns (like "PUT:/products/*").
987    ///
988    /// # Arguments
989    /// * `method` - HTTP method (e.g., "PUT", "POST")
990    /// * `path_pattern` - Path pattern with wildcards (e.g., "/products/*")
991    ///
992    /// # Returns
993    /// The operationId if a matching operation is found.
994    pub fn find_operation_id(&self, method: &str, path_pattern: &str) -> Option<String> {
995        let method_upper = method.to_uppercase();
996        let normalized_pattern = Self::normalize_path_for_matching(path_pattern);
997
998        for op in &self.operations {
999            if let OperationDetails::OpenAPI {
1000                method: op_method,
1001                path: op_path,
1002                ..
1003            } = &op.details
1004            {
1005                if op_method.to_uppercase() == method_upper {
1006                    let normalized_op_path = Self::normalize_path_for_matching(op_path);
1007                    if Self::paths_match(&normalized_pattern, &normalized_op_path) {
1008                        return Some(op.id.clone());
1009                    }
1010                }
1011            }
1012        }
1013        None
1014    }
1015
1016    /// Get all operations in a format suitable for display to administrators.
1017    ///
1018    /// Returns tuples of (operationId, METHOD:/path, description).
1019    pub fn get_operations_for_allowlist(&self) -> Vec<(String, String, String)> {
1020        self.operations
1021            .iter()
1022            .filter_map(|op| {
1023                if let OperationDetails::OpenAPI { method, path, .. } = &op.details {
1024                    let method_path = format!("{}:{}", method.to_uppercase(), path);
1025                    let description = op.description.clone().unwrap_or_else(|| op.name.clone());
1026                    Some((op.id.clone(), method_path, description))
1027                } else {
1028                    None
1029                }
1030            })
1031            .collect()
1032    }
1033
1034    /// Normalize a path for matching by replacing parameter placeholders with *.
1035    fn normalize_path_for_matching(path: &str) -> String {
1036        path.split('/')
1037            .map(|segment| {
1038                if segment.starts_with('{') && segment.ends_with('}') {
1039                    "*" // {id} -> *
1040                } else if segment.starts_with(':') {
1041                    "*" // :id -> *
1042                } else if segment == "*" {
1043                    "*"
1044                } else {
1045                    segment
1046                }
1047            })
1048            .collect::<Vec<_>>()
1049            .join("/")
1050    }
1051
1052    /// Check if two normalized paths match.
1053    fn paths_match(pattern: &str, path: &str) -> bool {
1054        let pattern_parts: Vec<_> = pattern.split('/').collect();
1055        let path_parts: Vec<_> = path.split('/').collect();
1056
1057        if pattern_parts.len() != path_parts.len() {
1058            return false;
1059        }
1060
1061        for (p, s) in pattern_parts.iter().zip(path_parts.iter()) {
1062            if *p == "*" || *s == "*" {
1063                continue; // Wildcard matches anything
1064            }
1065            if p != s {
1066                return false;
1067            }
1068        }
1069        true
1070    }
1071
1072    /// Apply tool overrides to an operation.
1073    fn apply_tool_overrides(&self, op: &Operation) -> Operation {
1074        let mut op = op.clone();
1075
1076        if let Some(override_config) = self.policy.tools.overrides.get(&op.id) {
1077            if let Some(name) = &override_config.name {
1078                op.name = name.clone();
1079            }
1080            if let Some(description) = &override_config.description {
1081                op.description = Some(description.clone());
1082            }
1083            if override_config.dangerous {
1084                op.risk_level = OperationRiskLevel::High;
1085            }
1086        }
1087
1088        op
1089    }
1090
1091    /// Build a derived schema from included and filtered operations.
1092    fn build_derived_schema(
1093        &self,
1094        operations: Vec<Operation>,
1095        filtered: Vec<FilteredOperation>,
1096        context: &str,
1097    ) -> DerivedSchema {
1098        // Build statistics
1099        let mut filtered_by_reason: HashMap<String, usize> = HashMap::new();
1100        for f in &filtered {
1101            let reason_type = match &f.reason {
1102                FilterReason::GlobalBlocklistOperation { .. } => "global_blocklist_operation",
1103                FilterReason::GlobalBlocklistPattern { .. } => "global_blocklist_pattern",
1104                FilterReason::GlobalBlocklistCategory { .. } => "global_blocklist_category",
1105                FilterReason::GlobalBlocklistRiskLevel { .. } => "global_blocklist_risk_level",
1106                FilterReason::ToolBlocklist => "tool_blocklist",
1107                FilterReason::ToolBlocklistPattern { .. } => "tool_blocklist_pattern",
1108                FilterReason::ToolNotInAllowlist => "tool_not_in_allowlist",
1109                FilterReason::ToolDenyAllMode => "tool_deny_all",
1110                FilterReason::CodeModeBlocklist => "code_mode_blocklist",
1111                FilterReason::CodeModeBlocklistPattern { .. } => "code_mode_blocklist_pattern",
1112                FilterReason::MethodBlocklist { .. } => "method_blocklist",
1113                FilterReason::MethodBlocklistPattern { .. } => "method_blocklist_pattern",
1114                FilterReason::MethodNotInAllowlist { .. } => "method_not_in_allowlist",
1115                FilterReason::MethodDenyAllMode { .. } => "method_deny_all",
1116            };
1117            *filtered_by_reason
1118                .entry(reason_type.to_string())
1119                .or_default() += 1;
1120        }
1121
1122        let stats = DerivationStats {
1123            source_total: self.operations.len(),
1124            derived_total: operations.len(),
1125            filtered_total: filtered.len(),
1126            filtered_by_reason,
1127        };
1128
1129        // Generate documentation
1130        let documentation = self.generate_documentation(&operations, context);
1131
1132        // Compute cache key
1133        let cache_key = format!("{}:{}:{}", context, self.source_hash, self.policy_hash);
1134
1135        let now = std::time::SystemTime::now()
1136            .duration_since(std::time::UNIX_EPOCH)
1137            .map(|d| d.as_secs() as i64)
1138            .unwrap_or(0);
1139
1140        DerivedSchema {
1141            operations,
1142            documentation,
1143            metadata: DerivationMetadata {
1144                context: context.to_string(),
1145                derived_at: now,
1146                source_hash: self.source_hash.clone(),
1147                policy_hash: self.policy_hash.clone(),
1148                cache_key,
1149                filtered,
1150                stats,
1151            },
1152        }
1153    }
1154
1155    /// Generate human-readable documentation for a derived schema.
1156    fn generate_documentation(&self, operations: &[Operation], context: &str) -> String {
1157        let mut doc = String::new();
1158
1159        if context == "code_mode" {
1160            doc.push_str("# API Operations Available in Code Mode\n\n");
1161        } else {
1162            doc.push_str("# API Operations Available as MCP Tools\n\n");
1163        }
1164
1165        doc.push_str(&format!(
1166            "**{} of {} operations available**\n\n",
1167            operations.len(),
1168            self.operations.len()
1169        ));
1170
1171        // Group by category
1172        let reads: Vec<_> = operations
1173            .iter()
1174            .filter(|o| o.category == OperationCategory::Read)
1175            .collect();
1176        let writes: Vec<_> = operations
1177            .iter()
1178            .filter(|o| {
1179                matches!(
1180                    o.category,
1181                    OperationCategory::Create | OperationCategory::Update
1182                )
1183            })
1184            .collect();
1185        let deletes: Vec<_> = operations
1186            .iter()
1187            .filter(|o| o.category == OperationCategory::Delete)
1188            .collect();
1189
1190        // Read operations
1191        doc.push_str(&format!(
1192            "## Read Operations ({} available)\n\n",
1193            reads.len()
1194        ));
1195        if reads.is_empty() {
1196            doc.push_str("_No read operations available._\n\n");
1197        } else {
1198            for op in reads {
1199                self.document_operation(&mut doc, op, context);
1200            }
1201        }
1202
1203        // Write operations
1204        doc.push_str(&format!(
1205            "\n## Write Operations ({} available)\n\n",
1206            writes.len()
1207        ));
1208        if writes.is_empty() {
1209            doc.push_str("_No write operations available._\n\n");
1210        } else {
1211            for op in writes {
1212                self.document_operation(&mut doc, op, context);
1213            }
1214        }
1215
1216        // Delete operations
1217        doc.push_str(&format!(
1218            "\n## Delete Operations ({} available)\n\n",
1219            deletes.len()
1220        ));
1221        if deletes.is_empty() {
1222            doc.push_str("_No delete operations available._\n\n");
1223        } else {
1224            for op in deletes {
1225                self.document_operation(&mut doc, op, context);
1226            }
1227        }
1228
1229        doc
1230    }
1231
1232    /// Document a single operation.
1233    fn document_operation(&self, doc: &mut String, op: &Operation, context: &str) {
1234        match &op.details {
1235            OperationDetails::OpenAPI { method, path, .. } => {
1236                if context == "code_mode" {
1237                    let method_lower = method.to_lowercase();
1238                    doc.push_str(&format!(
1239                        "- `api.{}(\"{}\")` - {}\n",
1240                        method_lower, path, op.name
1241                    ));
1242                } else {
1243                    doc.push_str(&format!("- **{}**: `{} {}`\n", op.name, method, path));
1244                }
1245            },
1246            OperationDetails::GraphQL {
1247                operation_type,
1248                field_name,
1249                ..
1250            } => {
1251                doc.push_str(&format!(
1252                    "- **{}**: `{:?}.{}`\n",
1253                    op.name, operation_type, field_name
1254                ));
1255            },
1256            OperationDetails::Sql {
1257                statement_type,
1258                table,
1259                ..
1260            } => {
1261                doc.push_str(&format!(
1262                    "- **{}**: `{:?} {}`\n",
1263                    op.name, statement_type, table
1264                ));
1265            },
1266            OperationDetails::Unknown => {
1267                doc.push_str(&format!("- **{}** ({})\n", op.name, op.id));
1268            },
1269        }
1270
1271        if let Some(desc) = &op.description {
1272            doc.push_str(&format!("  {}\n", desc));
1273        }
1274    }
1275
1276    /// Compute a hash of the policy for caching.
1277    fn compute_policy_hash(policy: &McpExposurePolicy) -> String {
1278        use std::collections::hash_map::DefaultHasher;
1279        use std::hash::{Hash, Hasher};
1280
1281        let mut hasher = DefaultHasher::new();
1282
1283        // Hash global blocklist
1284        let mut ops: Vec<_> = policy.global_blocklist.operations.iter().collect();
1285        ops.sort();
1286        for op in ops {
1287            op.hash(&mut hasher);
1288        }
1289
1290        let mut patterns: Vec<_> = policy.global_blocklist.patterns.iter().collect();
1291        patterns.sort();
1292        for p in patterns {
1293            p.hash(&mut hasher);
1294        }
1295
1296        // Hash tool policy
1297        format!("{:?}", policy.tools.mode).hash(&mut hasher);
1298        let mut allowlist: Vec<_> = policy.tools.allowlist.iter().collect();
1299        allowlist.sort();
1300        for a in allowlist {
1301            a.hash(&mut hasher);
1302        }
1303
1304        // Hash code mode policy
1305        format!("{:?}", policy.code_mode.reads.mode).hash(&mut hasher);
1306        format!("{:?}", policy.code_mode.writes.mode).hash(&mut hasher);
1307        format!("{:?}", policy.code_mode.deletes.mode).hash(&mut hasher);
1308
1309        format!("{:016x}", hasher.finish())
1310    }
1311}
1312
1313// ============================================================================
1314// TESTS
1315// ============================================================================
1316
1317#[cfg(test)]
1318mod tests {
1319    use super::*;
1320
1321    #[test]
1322    fn test_pattern_matching() {
1323        // Exact match
1324        assert!(pattern_matches("GET /users", "GET /users"));
1325        assert!(!pattern_matches("GET /users", "POST /users"));
1326
1327        // Wildcard at end
1328        assert!(pattern_matches("GET /users/*", "GET /users/123"));
1329        assert!(pattern_matches("GET /users/*", "GET /users/123/posts"));
1330        assert!(!pattern_matches("GET /users/*", "GET /posts/123"));
1331
1332        // Wildcard at start
1333        assert!(pattern_matches("* /admin/*", "GET /admin/users"));
1334        assert!(pattern_matches("* /admin/*", "DELETE /admin/config"));
1335
1336        // Wildcard in middle
1337        assert!(pattern_matches(
1338            "GET /users/*/posts",
1339            "GET /users/123/posts"
1340        ));
1341
1342        // Multiple wildcards
1343        assert!(pattern_matches("*/admin/*", "DELETE /admin/all"));
1344
1345        // Case insensitive
1346        assert!(pattern_matches("GET /USERS", "get /users"));
1347    }
1348
1349    #[test]
1350    fn test_global_blocklist() {
1351        let blocklist = GlobalBlocklist {
1352            operations: ["factoryReset".to_string()].into_iter().collect(),
1353            patterns: ["* /admin/*".to_string()].into_iter().collect(),
1354            categories: [OperationCategory::Internal].into_iter().collect(),
1355            risk_levels: [OperationRiskLevel::Critical].into_iter().collect(),
1356        };
1357
1358        // Blocked by operation ID
1359        let op = Operation {
1360            id: "factoryReset".to_string(),
1361            name: "Factory Reset".to_string(),
1362            description: None,
1363            category: OperationCategory::Admin,
1364            is_read_only: false,
1365            risk_level: OperationRiskLevel::Critical,
1366            tags: vec![],
1367            details: OperationDetails::Unknown,
1368        };
1369        assert!(blocklist.is_blocked(&op).is_some());
1370
1371        // Blocked by pattern
1372        let op = Operation {
1373            id: "listAdminUsers".to_string(),
1374            name: "List Admin Users".to_string(),
1375            description: None,
1376            category: OperationCategory::Read,
1377            is_read_only: true,
1378            risk_level: OperationRiskLevel::Safe,
1379            tags: vec![],
1380            details: OperationDetails::OpenAPI {
1381                method: "GET".to_string(),
1382                path: "/admin/users".to_string(),
1383                parameters: vec![],
1384                has_request_body: false,
1385            },
1386        };
1387        assert!(blocklist.is_blocked(&op).is_some());
1388
1389        // Blocked by category
1390        let op = Operation {
1391            id: "internalSync".to_string(),
1392            name: "Internal Sync".to_string(),
1393            description: None,
1394            category: OperationCategory::Internal,
1395            is_read_only: false,
1396            risk_level: OperationRiskLevel::Low,
1397            tags: vec![],
1398            details: OperationDetails::Unknown,
1399        };
1400        assert!(blocklist.is_blocked(&op).is_some());
1401
1402        // Not blocked
1403        let op = Operation {
1404            id: "listUsers".to_string(),
1405            name: "List Users".to_string(),
1406            description: None,
1407            category: OperationCategory::Read,
1408            is_read_only: true,
1409            risk_level: OperationRiskLevel::Safe,
1410            tags: vec![],
1411            details: OperationDetails::OpenAPI {
1412                method: "GET".to_string(),
1413                path: "/users".to_string(),
1414                parameters: vec![],
1415                has_request_body: false,
1416            },
1417        };
1418        assert!(blocklist.is_blocked(&op).is_none());
1419    }
1420
1421    #[test]
1422    fn test_exposure_modes() {
1423        // Test AllowAll mode
1424        let policy = ToolExposurePolicy {
1425            mode: ExposureMode::AllowAll,
1426            blocklist: ["blocked".to_string()].into_iter().collect(),
1427            ..Default::default()
1428        };
1429
1430        let allowed_op = Operation::new("allowed", "Allowed", OperationCategory::Read);
1431        let blocked_op = Operation::new("blocked", "Blocked", OperationCategory::Read);
1432
1433        assert!(policy.is_allowed(&allowed_op).is_none());
1434        assert!(policy.is_allowed(&blocked_op).is_some());
1435
1436        // Test Allowlist mode
1437        let policy = ToolExposurePolicy {
1438            mode: ExposureMode::Allowlist,
1439            allowlist: ["allowed".to_string()].into_iter().collect(),
1440            ..Default::default()
1441        };
1442
1443        assert!(policy.is_allowed(&allowed_op).is_none());
1444        assert!(policy.is_allowed(&blocked_op).is_some());
1445
1446        // Test DenyAll mode
1447        let policy = ToolExposurePolicy {
1448            mode: ExposureMode::DenyAll,
1449            ..Default::default()
1450        };
1451
1452        assert!(policy.is_allowed(&allowed_op).is_some());
1453    }
1454
1455    #[test]
1456    fn test_schema_deriver() {
1457        let operations = vec![
1458            Operation::new("listUsers", "List Users", OperationCategory::Read),
1459            Operation::new("createUser", "Create User", OperationCategory::Create),
1460            Operation::new("deleteUser", "Delete User", OperationCategory::Delete),
1461            Operation::new("factoryReset", "Factory Reset", OperationCategory::Admin),
1462        ];
1463
1464        let policy = McpExposurePolicy {
1465            global_blocklist: GlobalBlocklist {
1466                operations: ["factoryReset".to_string()].into_iter().collect(),
1467                ..Default::default()
1468            },
1469            tools: ToolExposurePolicy {
1470                mode: ExposureMode::AllowAll,
1471                ..Default::default()
1472            },
1473            code_mode: CodeModeExposurePolicy {
1474                reads: MethodExposurePolicy {
1475                    mode: ExposureMode::AllowAll,
1476                    ..Default::default()
1477                },
1478                writes: MethodExposurePolicy {
1479                    mode: ExposureMode::Allowlist,
1480                    allowlist: ["createUser".to_string()].into_iter().collect(),
1481                    ..Default::default()
1482                },
1483                deletes: MethodExposurePolicy {
1484                    mode: ExposureMode::DenyAll,
1485                    ..Default::default()
1486                },
1487                ..Default::default()
1488            },
1489        };
1490
1491        let deriver = SchemaDeriver::new(operations, policy, "test-hash".to_string());
1492
1493        // Tools schema should have 3 operations (factoryReset blocked globally)
1494        let tools = deriver.derive_tools_schema();
1495        assert_eq!(tools.operations.len(), 3);
1496        assert!(tools.contains("listUsers"));
1497        assert!(tools.contains("createUser"));
1498        assert!(tools.contains("deleteUser"));
1499        assert!(!tools.contains("factoryReset"));
1500
1501        // Code mode schema should have 2 operations
1502        // - listUsers (read, allowed)
1503        // - createUser (write, in allowlist)
1504        // - deleteUser (delete, deny_all)
1505        // - factoryReset (blocked globally)
1506        let code_mode = deriver.derive_code_mode_schema();
1507        assert_eq!(code_mode.operations.len(), 2);
1508        assert!(code_mode.contains("listUsers"));
1509        assert!(code_mode.contains("createUser"));
1510        assert!(!code_mode.contains("deleteUser"));
1511        assert!(!code_mode.contains("factoryReset"));
1512    }
1513}