scim_server/providers/helpers/
patch.rs

1//! SCIM PATCH operations helper trait.
2//!
3//! This module provides a reusable trait for implementing RFC 7644 compliant PATCH operations
4//! on SCIM resources. Any ResourceProvider can implement this trait to get full PATCH
5//! functionality without reimplementing the complex operation logic.
6//!
7//! # RFC 7644 Compliance
8//!
9//! This implementation follows RFC 7644 Section 3.5.2 (Modifying with PATCH) and supports:
10//! - `add` operations for adding attributes or values
11//! - `remove` operations for removing attributes or values
12//! - `replace` operations for replacing attribute values
13//! - Proper handling of multi-valued attributes
14//! - Readonly attribute protection
15//! - Complex attribute path parsing
16//!
17//! # Usage
18//!
19//! ```rust,no_run
20//! use serde_json::{json, Value};
21//!
22//! // ScimPatchOperations provides RFC 7644 compliant PATCH operation handling:
23//! // - add: Add new attributes or values
24//! // - remove: Remove attributes or specific values
25//! // - replace: Replace existing attributes or values
26//! //
27//! // Example PATCH operations:
28//! let patch_ops = json!([
29//!   {"op": "add", "path": "emails", "value": {"value": "new@example.com", "type": "work"}},
30//!   {"op": "replace", "path": "active", "value": false},
31//!   {"op": "remove", "path": "phoneNumbers[type eq \"fax\"]"}
32//! ]);
33//! //
34//! // When implemented by a ResourceProvider, automatically handles:
35//! // - Complex attribute path parsing
36//! // - Multi-valued attribute operations
37//! // - Value filtering and selection
38//! ```
39
40use crate::providers::ResourceProvider;
41use serde_json::{Value, json};
42
43/// Trait providing RFC 7644 compliant PATCH operations for SCIM resources.
44///
45/// This trait extends ResourceProvider with PATCH functionality, implementing
46/// the complex logic for applying PATCH operations according to the SCIM specification.
47/// Most implementers can use the default implementations without modification.
48pub trait ScimPatchOperations: ResourceProvider {
49    /// Apply a single PATCH operation to resource data.
50    ///
51    /// This is the main entry point for PATCH operation processing. It validates
52    /// the operation structure, checks for readonly attributes, and delegates to
53    /// the appropriate operation handler.
54    ///
55    /// # Arguments
56    /// * `resource_data` - The resource JSON to modify
57    /// * `operation` - The PATCH operation as defined in RFC 7644
58    ///
59    /// # Returns
60    /// Result indicating success or failure with appropriate error details
61    ///
62    /// # Default Implementation
63    /// Provides full RFC 7644 compliance including:
64    /// - Operation validation (`op` field required)
65    /// - Readonly attribute protection
66    /// - Delegation to appropriate operation handlers
67    fn apply_patch_operation(
68        &self,
69        resource_data: &mut Value,
70        operation: &Value,
71    ) -> Result<(), Self::Error> {
72        let op = operation
73            .get("op")
74            .and_then(|v| v.as_str())
75            .ok_or_else(|| self.patch_error("PATCH operation must have 'op' field"))?;
76
77        let path = operation.get("path").and_then(|v| v.as_str());
78        let value = operation.get("value");
79
80        // Check if the operation targets a readonly attribute
81        if let Some(path_str) = path {
82            if self.is_readonly_attribute(path_str) {
83                return Err(
84                    self.patch_error(&format!("Cannot modify readonly attribute: {}", path_str))
85                );
86            }
87        }
88
89        match op.to_lowercase().as_str() {
90            "add" => self.apply_add_operation(resource_data, path, value),
91            "remove" => self.apply_remove_operation(resource_data, path),
92            "replace" => self.apply_replace_operation(resource_data, path, value),
93            _ => Err(self.patch_error(&format!("Unsupported PATCH operation: {}", op))),
94        }
95    }
96
97    /// Apply an ADD operation to resource data.
98    ///
99    /// Implements RFC 7644 ADD operation semantics:
100    /// - With path: Sets value at the specified path
101    /// - Without path: Merges value with root object
102    /// - Handles multi-valued attributes appropriately
103    ///
104    /// # Arguments
105    /// * `resource_data` - The resource JSON to modify
106    /// * `path` - Optional attribute path
107    /// * `value` - Value to add (required for ADD operations)
108    fn apply_add_operation(
109        &self,
110        resource_data: &mut Value,
111        path: Option<&str>,
112        value: Option<&Value>,
113    ) -> Result<(), Self::Error> {
114        let value = value.ok_or_else(|| self.patch_error("ADD operation requires a value"))?;
115
116        match path {
117            Some(path_str) => {
118                self.set_value_at_path(resource_data, path_str, value.clone())?;
119            }
120            None => {
121                // No path means add to root - merge objects
122                if let (Some(current_obj), Some(value_obj)) =
123                    (resource_data.as_object_mut(), value.as_object())
124                {
125                    for (key, val) in value_obj {
126                        current_obj.insert(key.clone(), val.clone());
127                    }
128                }
129            }
130        }
131        Ok(())
132    }
133
134    /// Apply a REMOVE operation to resource data.
135    ///
136    /// Implements RFC 7644 REMOVE operation semantics:
137    /// - Removes the attribute or value at the specified path
138    /// - Handles complex path expressions
139    /// - Validates path before removal
140    ///
141    /// # Arguments
142    /// * `resource_data` - The resource JSON to modify
143    /// * `path` - Attribute path to remove (required for REMOVE operations)
144    fn apply_remove_operation(
145        &self,
146        resource_data: &mut Value,
147        path: Option<&str>,
148    ) -> Result<(), Self::Error> {
149        if let Some(path_str) = path {
150            self.remove_value_at_path(resource_data, path_str)?;
151        }
152        Ok(())
153    }
154
155    /// Apply a REPLACE operation to resource data.
156    ///
157    /// Implements RFC 7644 REPLACE operation semantics:
158    /// - With path: Replaces value at specified path
159    /// - Without path: Replaces entire resource (merge semantics)
160    /// - Validates value before replacement
161    ///
162    /// # Arguments
163    /// * `resource_data` - The resource JSON to modify
164    /// * `path` - Optional attribute path
165    /// * `value` - Replacement value (required for REPLACE operations)
166    fn apply_replace_operation(
167        &self,
168        resource_data: &mut Value,
169        path: Option<&str>,
170        value: Option<&Value>,
171    ) -> Result<(), Self::Error> {
172        let value = value.ok_or_else(|| self.patch_error("REPLACE operation requires a value"))?;
173
174        match path {
175            Some(path_str) => {
176                self.set_value_at_path(resource_data, path_str, value.clone())?;
177            }
178            None => {
179                // No path means replace entire resource
180                if let Some(value_obj) = value.as_object() {
181                    if let Some(current_obj) = resource_data.as_object_mut() {
182                        for (key, val) in value_obj {
183                            current_obj.insert(key.clone(), val.clone());
184                        }
185                    }
186                }
187            }
188        }
189        Ok(())
190    }
191
192    /// Set a value at a complex attribute path.
193    ///
194    /// Handles SCIM attribute path expressions including:
195    /// - Simple attributes (e.g., "userName")
196    /// - Complex attributes (e.g., "name.givenName")
197    /// - Multi-valued attributes (e.g., "emails[type eq \"work\"].value")
198    ///
199    /// # Arguments
200    /// * `data` - The JSON object to modify
201    /// * `path` - The SCIM attribute path
202    /// * `value` - The value to set
203    fn set_value_at_path(
204        &self,
205        data: &mut Value,
206        path: &str,
207        value: Value,
208    ) -> Result<(), Self::Error> {
209        if !self.is_valid_scim_path(path) {
210            return Err(self.patch_error(&format!("Invalid SCIM path: {}", path)));
211        }
212
213        // Handle simple path (no dots)
214        if !path.contains('.') {
215            if let Some(obj) = data.as_object_mut() {
216                obj.insert(path.to_string(), value);
217            }
218            return Ok(());
219        }
220
221        // Handle complex path - use pointer navigation to avoid borrow checker issues
222        let parts: Vec<&str> = path.split('.').collect();
223
224        // Build the path string for JSON pointer
225        let mut pointer_path = String::new();
226        for part in &parts {
227            pointer_path.push('/');
228            pointer_path.push_str(part);
229        }
230
231        // Navigate to parent and ensure it exists
232        let parent_parts = &parts[..parts.len() - 1];
233        let mut current = data;
234
235        for part in parent_parts {
236            match current {
237                Value::Object(obj) => {
238                    let entry = obj.entry(part.to_string()).or_insert_with(|| json!({}));
239                    current = entry;
240                }
241                _ => return Ok(()), // Can't navigate further
242            }
243        }
244
245        // Set the final value
246        if let Some(obj) = current.as_object_mut() {
247            obj.insert(parts.last().unwrap().to_string(), value);
248        }
249
250        Ok(())
251    }
252
253    /// Remove a value at a complex attribute path.
254    ///
255    /// Handles removal of values from SCIM attribute paths, including
256    /// validation and proper cleanup of empty parent objects.
257    ///
258    /// # Arguments
259    /// * `data` - The JSON object to modify
260    /// * `path` - The SCIM attribute path to remove
261    fn remove_value_at_path(&self, data: &mut Value, path: &str) -> Result<(), Self::Error> {
262        if !self.is_valid_scim_path(path) {
263            return Err(self.patch_error(&format!("Invalid SCIM path: {}", path)));
264        }
265
266        // Handle simple path
267        if !path.contains('.') {
268            if let Some(obj) = data.as_object_mut() {
269                obj.remove(path);
270            }
271            return Ok(());
272        }
273
274        // Handle complex path by rebuilding the structure without the target
275        let parts: Vec<&str> = path.split('.').collect();
276        self.remove_nested_value(data, &parts, 0)
277    }
278
279    /// Helper function to recursively remove nested values
280    fn remove_nested_value(
281        &self,
282        current: &mut Value,
283        parts: &[&str],
284        depth: usize,
285    ) -> Result<(), Self::Error> {
286        if depth >= parts.len() {
287            return Ok(());
288        }
289
290        let part = parts[depth];
291
292        if depth == parts.len() - 1 {
293            // We're at the final part, remove it
294            if let Some(obj) = current.as_object_mut() {
295                obj.remove(part);
296            }
297        } else {
298            // Navigate deeper
299            if let Some(obj) = current.as_object_mut() {
300                if let Some(child) = obj.get_mut(part) {
301                    self.remove_nested_value(child, parts, depth + 1)?;
302                }
303            }
304        }
305
306        Ok(())
307    }
308
309    /// Check if an attribute path refers to a readonly attribute.
310    ///
311    /// Default implementation covers RFC 7644 readonly attributes:
312    /// - `id` - Resource identifier
313    /// - `meta.created` - Creation timestamp
314    /// - `meta.resourceType` - Resource type
315    /// - `meta.location` - Resource location
316    ///
317    /// Override this method to add custom readonly attributes.
318    fn is_readonly_attribute(&self, path: &str) -> bool {
319        match path.to_lowercase().as_str() {
320            // Core readonly attributes
321            "id" => true,
322            "meta.created" => true,
323            "meta.resourcetype" => true,
324            "meta.location" => true,
325            // Pattern matching for meta attributes
326            path if path.starts_with("meta.")
327                && (path.ends_with(".created")
328                    || path.ends_with(".resourcetype")
329                    || path.ends_with(".location")) =>
330            {
331                true
332            }
333            _ => false,
334        }
335    }
336
337    /// Validate if a path represents a valid SCIM attribute.
338    ///
339    /// Default implementation provides basic validation:
340    /// - Non-empty paths
341    /// - Valid attribute name characters
342    /// - Proper dot notation for complex attributes
343    ///
344    /// Override for more sophisticated validation.
345    fn is_valid_scim_path(&self, path: &str) -> bool {
346        if path.is_empty() {
347            return false;
348        }
349
350        // Handle schema URN prefixed paths
351        let actual_path = if path.contains(':') && path.contains("urn:ietf:params:scim:schemas:") {
352            // Extract the attribute name after the schema URN
353            path.split(':').last().unwrap_or(path)
354        } else {
355            path
356        };
357
358        // Basic validation - can be enhanced by implementers
359        !actual_path.is_empty()
360            && actual_path
361                .chars()
362                .all(|c| c.is_alphanumeric() || c == '.' || c == '_')
363    }
364
365    /// Create a PATCH-specific error.
366    ///
367    /// Helper method for creating errors with appropriate context.
368    /// Default implementation assumes the Error type can be created from strings.
369    /// Override if your error type requires different construction.
370    fn patch_error(&self, message: &str) -> Self::Error;
371}
372
373/// Default error creation for common error types that implement From<String>
374impl<T> ScimPatchOperations for T
375where
376    T: ResourceProvider,
377    T::Error: From<String>,
378{
379    fn patch_error(&self, message: &str) -> Self::Error {
380        Self::Error::from(message.to_string())
381    }
382}