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}