scim_server/providers/helpers/
validation.rs

1//! SCIM validation helper trait.
2//!
3//! This module provides reusable validation functionality for SCIM attribute paths,
4//! attribute types, and schema compliance. Any ResourceProvider can implement this
5//! trait to get comprehensive SCIM validation without reimplementing the logic.
6//!
7//! # RFC 7644 Compliance
8//!
9//! This implementation follows RFC 7644 and RFC 7643 specifications for:
10//! - Attribute name validation
11//! - Path expression parsing
12//! - Multi-valued attribute detection
13//! - Schema URN handling
14//! - Standard SCIM attribute recognition
15//!
16//! # Usage
17//!
18//! ```rust,no_run
19//! // ScimValidator provides SCIM-compliant validation methods:
20//! // - is_valid_scim_path(): Validate SCIM attribute paths
21//! // - validate_schema_compliance(): Check resource against schemas
22//! // - validate_required_attributes(): Ensure required fields present
23//! //
24//! // Example SCIM paths:
25//! // "name.givenName" - Valid nested attribute path
26//! // "emails[type eq \"work\"].value" - Valid filtered multi-valued path
27//! // "phoneNumbers[primary eq true]" - Valid filtered array path
28//! //
29//! // When implemented by a ResourceProvider, automatically provides
30//! // validation for PATCH operations, resource creation, and updates
31//! ```
32
33use crate::providers::ResourceProvider;
34
35/// Trait providing SCIM attribute validation and path parsing functionality.
36///
37/// This trait extends ResourceProvider with validation capabilities for SCIM
38/// attributes, paths, and schema compliance. Most implementers can use the
39/// default implementations which provide RFC-compliant behavior.
40pub trait ScimValidator: ResourceProvider {
41    /// Validate if a path represents a valid SCIM attribute.
42    ///
43    /// Validates SCIM attribute paths according to RFC specifications:
44    /// - Simple attributes: "userName", "active"
45    /// - Complex attributes: "name.givenName", "name.familyName"
46    /// - Schema URN prefixed: "urn:ietf:params:scim:schemas:core:2.0:User:userName"
47    /// - Multi-valued with filters: "emails[type eq \"work\"].value"
48    ///
49    /// # Arguments
50    /// * `path` - The attribute path to validate
51    ///
52    /// # Returns
53    /// `true` if the path is valid according to SCIM specifications
54    fn is_valid_scim_path(&self, path: &str) -> bool {
55        if path.is_empty() {
56            return false;
57        }
58
59        // Handle schema URN prefixed paths
60        let actual_path = if path.contains(':') && path.contains("urn:ietf:params:scim:schemas:") {
61            // Extract the attribute name after the schema URN
62            // e.g., "urn:ietf:params:scim:schemas:core:2.0:User:userName" -> "userName"
63            if let Some(colon_pos) = path.rfind(':') {
64                &path[colon_pos + 1..]
65            } else {
66                path
67            }
68        } else {
69            path
70        };
71
72        // Handle filter expressions in brackets (e.g., emails[type eq "work"])
73        let clean_path = if actual_path.contains('[') {
74            if let Some(bracket_pos) = actual_path.find('[') {
75                &actual_path[..bracket_pos]
76            } else {
77                actual_path
78            }
79        } else {
80            actual_path
81        };
82
83        // Check if it's a simple or complex path
84        if clean_path.contains('.') {
85            self.is_valid_complex_path(clean_path)
86        } else {
87            self.is_valid_simple_path(clean_path)
88        }
89    }
90
91    /// Validate a complex SCIM path (containing dots).
92    ///
93    /// Examples: "name.givenName", "addresses.streetAddress", "meta.created"
94    ///
95    /// # Arguments
96    /// * `path` - The complex path to validate
97    fn is_valid_complex_path(&self, path: &str) -> bool {
98        let parts: Vec<&str> = path.split('.').collect();
99
100        if parts.len() < 2 {
101            return false;
102        }
103
104        // All parts must be valid simple attributes
105        parts.iter().all(|part| {
106            !part.is_empty()
107                && part.chars().all(|c| c.is_alphanumeric() || c == '_')
108                && part.chars().next().map_or(false, |c| c.is_alphabetic())
109        })
110    }
111
112    /// Validate a simple SCIM path (single attribute name).
113    ///
114    /// Checks against known SCIM User and Group attributes from RFC 7643.
115    ///
116    /// # Arguments
117    /// * `attribute` - The simple attribute name to validate
118    fn is_valid_simple_path(&self, attribute: &str) -> bool {
119        // Standard SCIM User attributes (RFC 7643 Section 4.1)
120        let user_attributes = [
121            "id",
122            "externalId",
123            "userName",
124            "password",
125            "displayName",
126            "nickName",
127            "profileUrl",
128            "title",
129            "userType",
130            "preferredLanguage",
131            "locale",
132            "timezone",
133            "active",
134            "name",
135            "emails",
136            "phoneNumbers",
137            "ims",
138            "photos",
139            "addresses",
140            "groups",
141            "entitlements",
142            "roles",
143            "x509Certificates",
144            "meta",
145        ];
146
147        // Standard SCIM Group attributes (RFC 7643 Section 4.2)
148        let group_attributes = ["id", "externalId", "displayName", "members", "meta"];
149
150        // Core meta attributes
151        let meta_attributes = [
152            "resourceType",
153            "created",
154            "lastModified",
155            "location",
156            "version",
157        ];
158
159        // Complex attribute sub-components
160        let complex_sub_attributes = [
161            // Name sub-attributes
162            "formatted",
163            "familyName",
164            "givenName",
165            "middleName",
166            "honorificPrefix",
167            "honorificSuffix",
168            // Multi-valued sub-attributes
169            "value",
170            "display",
171            "type",
172            "primary",
173            "operation",
174            "$ref",
175            // Address sub-attributes
176            "streetAddress",
177            "locality",
178            "region",
179            "postalCode",
180            "country",
181            // Enterprise extension commonly used attributes
182            "employeeNumber",
183            "costCenter",
184            "organization",
185            "division",
186            "department",
187            "manager",
188        ];
189
190        // Check if attribute matches any known SCIM attribute
191        user_attributes.contains(&attribute) ||
192        group_attributes.contains(&attribute) ||
193        meta_attributes.contains(&attribute) ||
194        complex_sub_attributes.contains(&attribute) ||
195        // Allow custom extension attributes (starting with letter, containing alphanumeric + underscore)
196        (attribute.chars().next().map_or(false, |c| c.is_alphabetic()) &&
197         attribute.chars().all(|c| c.is_alphanumeric() || c == '_'))
198    }
199
200    /// Check if an attribute is multi-valued according to SCIM specifications.
201    ///
202    /// Multi-valued attributes can contain arrays of complex objects with
203    /// common sub-attributes like "value", "type", "primary", etc.
204    ///
205    /// # Arguments
206    /// * `attribute_name` - The attribute name to check
207    ///
208    /// # Returns
209    /// `true` if the attribute is multi-valued according to SCIM specs
210    fn is_multivalued_attribute(&self, attribute_name: &str) -> bool {
211        matches!(
212            attribute_name,
213            "emails"
214                | "phoneNumbers"
215                | "ims"
216                | "photos"
217                | "addresses"
218                | "groups"
219                | "entitlements"
220                | "roles"
221                | "x509Certificates"
222                | "members" // For Group resources
223        )
224    }
225
226    /// Check if an attribute path refers to a readonly attribute.
227    ///
228    /// Readonly attributes cannot be modified through PATCH or PUT operations
229    /// according to SCIM specifications.
230    ///
231    /// # Arguments
232    /// * `path` - The attribute path to check
233    ///
234    /// # Returns
235    /// `true` if the attribute is readonly and cannot be modified
236    fn is_readonly_attribute(&self, path: &str) -> bool {
237        match path.to_lowercase().as_str() {
238            // Core readonly attributes per RFC 7643
239            "id" => true,
240            "meta" => true,
241            "meta.resourcetype" => true,
242            "meta.created" => true,
243            "meta.location" => true,
244            // Pattern matching for nested meta attributes
245            path if path.starts_with("meta.")
246                && (path.ends_with(".resourcetype")
247                    || path.ends_with(".created")
248                    || path.ends_with(".location")) =>
249            {
250                true
251            }
252            // Groups members can be readonly in some implementations
253            "groups.display" => true,
254            "groups.$ref" => true,
255            _ => false,
256        }
257    }
258
259    /// Validate that a username meets SCIM requirements.
260    ///
261    /// SCIM usernames should be unique within a tenant and follow
262    /// reasonable formatting rules for identifiers.
263    ///
264    /// # Arguments
265    /// * `username` - The username to validate
266    ///
267    /// # Returns
268    /// `true` if the username is valid according to SCIM guidelines
269    fn is_valid_username(&self, username: &str) -> bool {
270        if username.is_empty() || username.len() > 256 {
271            return false;
272        }
273
274        // Username should not contain control characters or be only whitespace
275        if username.trim().is_empty() || username.chars().any(|c| c.is_control()) {
276            return false;
277        }
278
279        // Allow common username formats:
280        // - email addresses: user@domain.com
281        // - alphanumeric with common separators: john.doe, john_doe, john-doe
282        // - numbers: user123
283        username.chars().all(|c| {
284            c.is_alphanumeric() || c == '.' || c == '_' || c == '-' || c == '@' || c == '+'
285        })
286    }
287
288    /// Validate an external ID format.
289    ///
290    /// External IDs are used to correlate SCIM resources with external systems
291    /// and should follow reasonable identifier patterns.
292    ///
293    /// # Arguments
294    /// * `external_id` - The external ID to validate
295    ///
296    /// # Returns
297    /// `true` if the external ID format is acceptable
298    fn is_valid_external_id(&self, external_id: &str) -> bool {
299        if external_id.is_empty() || external_id.len() > 512 {
300            return false;
301        }
302
303        // External ID should not be only whitespace or contain control characters
304        !external_id.trim().is_empty() && !external_id.chars().any(|c| c.is_control())
305    }
306
307    /// Check if a schema URI is valid for SCIM.
308    ///
309    /// SCIM uses URNs to identify schemas and extensions.
310    /// Common patterns: "urn:ietf:params:scim:schemas:core:2.0:User"
311    ///
312    /// # Arguments
313    /// * `schema_uri` - The schema URI to validate
314    ///
315    /// # Returns
316    /// `true` if the schema URI follows SCIM URN conventions
317    fn is_valid_schema_uri(&self, schema_uri: &str) -> bool {
318        if schema_uri.is_empty() {
319            return false;
320        }
321
322        // Must start with urn: and contain scim schemas
323        schema_uri.starts_with("urn:") &&
324        schema_uri.contains("scim:schemas") &&
325        // Should have reasonable length
326        schema_uri.len() <= 512 &&
327        // Should not contain control characters
328        !schema_uri.chars().any(|c| c.is_control())
329    }
330
331    /// Extract the attribute name from a potentially complex path.
332    ///
333    /// Handles various SCIM path formats and extracts the base attribute name:
334    /// - "userName" -> "userName"
335    /// - "name.givenName" -> "name"
336    /// - "emails[type eq \"work\"].value" -> "emails"
337    /// - "urn:ietf:params:scim:schemas:core:2.0:User:userName" -> "userName"
338    ///
339    /// # Arguments
340    /// * `path` - The SCIM path to parse
341    ///
342    /// # Returns
343    /// The base attribute name, or the original path if parsing fails
344    fn extract_base_attribute<'a>(&self, path: &'a str) -> &'a str {
345        // Handle schema URN prefixed paths first
346        let clean_path = if path.contains(':') && path.contains("urn:ietf:params:scim:schemas:") {
347            if let Some(colon_pos) = path.rfind(':') {
348                &path[colon_pos + 1..]
349            } else {
350                path
351            }
352        } else {
353            path
354        };
355
356        // Handle filter expressions in brackets
357        let without_filter = if let Some(bracket_pos) = clean_path.find('[') {
358            &clean_path[..bracket_pos]
359        } else {
360            clean_path
361        };
362
363        // Handle complex paths (take the first part before dot)
364        if let Some(dot_pos) = without_filter.find('.') {
365            &without_filter[..dot_pos]
366        } else {
367            without_filter
368        }
369    }
370}
371
372/// Default implementation for any ResourceProvider
373impl<T: ResourceProvider> ScimValidator for T {}