Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Basic Validation

This guide covers implementing simple, synchronous validation rules for common business requirements. These validators typically perform local checks without external dependencies.

Simple Business Rule Validator

Here's a comprehensive example of a basic validator that enforces common organizational policies:

#![allow(unused)]
fn main() {
use scim_server::validation::{CustomValidator, ValidationContext, ValidationError};
use scim_server::models::{User, Group};
use regex::Regex;
use async_trait::async_trait;

pub struct BusinessRuleValidator {
    employee_id_pattern: Regex,
    allowed_domains: Vec<String>,
    password_policy: PasswordPolicy,
}

#[derive(Clone)]
pub struct PasswordPolicy {
    pub min_length: usize,
    pub require_uppercase: bool,
    pub require_lowercase: bool,
    pub require_numbers: bool,
    pub require_special_chars: bool,
    pub forbidden_patterns: Vec<Regex>,
}

impl BusinessRuleValidator {
    pub fn new() -> Self {
        Self {
            employee_id_pattern: Regex::new(r"^EMP\d{6}$").unwrap(),
            allowed_domains: vec![
                "company.com".to_string(),
                "subsidiary.com".to_string(),
            ],
            password_policy: PasswordPolicy {
                min_length: 12,
                require_uppercase: true,
                require_lowercase: true,
                require_numbers: true,
                require_special_chars: true,
                forbidden_patterns: vec![
                    Regex::new(r"password").unwrap(),
                    Regex::new(r"123456").unwrap(),
                    Regex::new(r"qwerty").unwrap(),
                ],
            },
        }
    }
}

#[async_trait]
impl CustomValidator for BusinessRuleValidator {
    async fn validate_user(
        &self,
        user: &User,
        context: &ValidationContext,
    ) -> Result<(), ValidationError> {
        // Validate employee ID format
        if let Some(employee_id) = user.external_id.as_ref() {
            if !self.employee_id_pattern.is_match(employee_id) {
                return Err(ValidationError::new(
                    "INVALID_EMPLOYEE_ID",
                    "Employee ID must follow format EMP123456",
                ).with_field("externalId"));
            }
        }

        // Validate email domain
        if let Some(emails) = &user.emails {
            for (index, email) in emails.iter().enumerate() {
                if let Some(domain) = email.value.split('@').nth(1) {
                    if !self.allowed_domains.contains(&domain.to_lowercase()) {
                        return Err(ValidationError::new(
                            "INVALID_EMAIL_DOMAIN",
                            &format!("Email domain '{}' is not allowed", domain),
                        ).with_field(&format!("emails[{}].value", index)));
                    }
                }
            }
        }

        // Validate password policy (if password is being set)
        if let Some(password) = user.password.as_ref() {
            self.validate_password_policy(password)?;
        }

        // Validate name requirements
        if user.name.is_none() {
            return Err(ValidationError::new(
                "MISSING_NAME",
                "User must have a name",
            ).with_field("name"));
        }

        // Validate username format
        if let Some(username) = &user.username {
            if username.len() < 3 {
                return Err(ValidationError::new(
                    "USERNAME_TOO_SHORT",
                    "Username must be at least 3 characters",
                ).with_field("userName"));
            }
            
            if username.contains(' ') {
                return Err(ValidationError::new(
                    "USERNAME_INVALID_CHARS",
                    "Username cannot contain spaces",
                ).with_field("userName"));
            }
        }

        Ok(())
    }

    async fn validate_group(
        &self,
        group: &Group,
        context: &ValidationContext,
    ) -> Result<(), ValidationError> {
        // Validate group display name
        if group.display_name.is_empty() {
            return Err(ValidationError::new(
                "EMPTY_GROUP_NAME",
                "Group display name cannot be empty",
            ).with_field("displayName"));
        }

        // Validate group name format
        if group.display_name.len() > 64 {
            return Err(ValidationError::new(
                "GROUP_NAME_TOO_LONG",
                "Group display name cannot exceed 64 characters",
            ).with_field("displayName"));
        }

        // Check for reserved group names
        let reserved_names = vec!["admin", "root", "system", "administrator"];
        if reserved_names.contains(&group.display_name.to_lowercase().as_str()) {
            return Err(ValidationError::new(
                "RESERVED_GROUP_NAME",
                &format!("'{}' is a reserved group name", group.display_name),
            ).with_field("displayName"));
        }

        // Validate member limit
        if let Some(members) = &group.members {
            if members.len() > 1000 {
                return Err(ValidationError::new(
                    "TOO_MANY_MEMBERS",
                    "Group cannot have more than 1000 members",
                ).with_field("members"));
            }
        }

        Ok(())
    }
}

impl BusinessRuleValidator {
    fn validate_password_policy(&self, password: &str) -> Result<(), ValidationError> {
        let policy = &self.password_policy;

        // Check minimum length
        if password.len() < policy.min_length {
            return Err(ValidationError::new(
                "PASSWORD_TOO_SHORT",
                &format!("Password must be at least {} characters", policy.min_length),
            ).with_field("password"));
        }

        // Check character requirements
        if policy.require_uppercase && !password.chars().any(|c| c.is_uppercase()) {
            return Err(ValidationError::new(
                "PASSWORD_MISSING_UPPERCASE",
                "Password must contain at least one uppercase letter",
            ).with_field("password"));
        }

        if policy.require_lowercase && !password.chars().any(|c| c.is_lowercase()) {
            return Err(ValidationError::new(
                "PASSWORD_MISSING_LOWERCASE",
                "Password must contain at least one lowercase letter",
            ).with_field("password"));
        }

        if policy.require_numbers && !password.chars().any(|c| c.is_numeric()) {
            return Err(ValidationError::new(
                "PASSWORD_MISSING_NUMBER",
                "Password must contain at least one number",
            ).with_field("password"));
        }

        if policy.require_special_chars && !password.chars().any(|c| "!@#$%^&*()".contains(c)) {
            return Err(ValidationError::new(
                "PASSWORD_MISSING_SPECIAL",
                "Password must contain at least one special character",
            ).with_field("password"));
        }

        // Check forbidden patterns
        for pattern in &policy.forbidden_patterns {
            if pattern.is_match(&password.to_lowercase()) {
                return Err(ValidationError::new(
                    "PASSWORD_FORBIDDEN_PATTERN",
                    "Password contains forbidden pattern",
                ).with_field("password"));
            }
        }

        Ok(())
    }
}
}

Department-Based Validation

Validate users based on their department or role:

#![allow(unused)]
fn main() {
use scim_server::validation::{CustomValidator, ValidationContext, ValidationError};
use std::collections::HashMap;

pub struct DepartmentValidator {
    department_rules: HashMap<String, DepartmentRules>,
}

#[derive(Clone)]
pub struct DepartmentRules {
    pub required_attributes: Vec<String>,
    pub allowed_email_domains: Vec<String>,
    pub max_group_memberships: usize,
    pub requires_manager: bool,
}

impl DepartmentValidator {
    pub fn new() -> Self {
        let mut department_rules = HashMap::new();
        
        // IT Department rules
        department_rules.insert("IT".to_string(), DepartmentRules {
            required_attributes: vec!["employeeNumber".to_string(), "title".to_string()],
            allowed_email_domains: vec!["company.com".to_string()],
            max_group_memberships: 20,
            requires_manager: true,
        });
        
        // HR Department rules  
        department_rules.insert("HR".to_string(), DepartmentRules {
            required_attributes: vec!["employeeNumber".to_string(), "title".to_string(), "phoneNumber".to_string()],
            allowed_email_domains: vec!["company.com".to_string()],
            max_group_memberships: 10,
            requires_manager: true,
        });
        
        // Contractor rules
        department_rules.insert("CONTRACTOR".to_string(), DepartmentRules {
            required_attributes: vec!["contractEndDate".to_string()],
            allowed_email_domains: vec!["contractor.company.com".to_string()],
            max_group_memberships: 5,
            requires_manager: false,
        });

        Self { department_rules }
    }
}

#[async_trait]
impl CustomValidator for DepartmentValidator {
    async fn validate_user(
        &self,
        user: &User,
        context: &ValidationContext,
    ) -> Result<(), ValidationError> {
        // Get user's department from custom attributes
        let department = user.extension_attributes
            .as_ref()
            .and_then(|attrs| attrs.get("department"))
            .and_then(|v| v.as_str())
            .unwrap_or("UNKNOWN");

        if let Some(rules) = self.department_rules.get(department) {
            // Check required attributes
            for required_attr in &rules.required_attributes {
                if !user.extension_attributes
                    .as_ref()
                    .map(|attrs| attrs.contains_key(required_attr))
                    .unwrap_or(false) {
                    return Err(ValidationError::new(
                        "MISSING_REQUIRED_ATTRIBUTE",
                        &format!("Department {} requires attribute '{}'", department, required_attr),
                    ).with_field(&format!("enterpriseUser:{}", required_attr)));
                }
            }

            // Validate email domain for department
            if let Some(emails) = &user.emails {
                for (index, email) in emails.iter().enumerate() {
                    if let Some(domain) = email.value.split('@').nth(1) {
                        if !rules.allowed_email_domains.contains(&domain.to_lowercase()) {
                            return Err(ValidationError::new(
                                "INVALID_DEPARTMENT_EMAIL_DOMAIN",
                                &format!("Department {} does not allow email domain '{}'", department, domain),
                            ).with_field(&format!("emails[{}].value", index)));
                        }
                    }
                }
            }

            // Check manager requirement
            if rules.requires_manager {
                let has_manager = user.extension_attributes
                    .as_ref()
                    .and_then(|attrs| attrs.get("manager"))
                    .is_some();
                    
                if !has_manager {
                    return Err(ValidationError::new(
                        "MISSING_MANAGER",
                        &format!("Department {} requires a manager to be assigned", department),
                    ).with_field("enterpriseUser:manager"));
                }
            }
        }

        Ok(())
    }

    async fn validate_group(
        &self,
        group: &Group,
        _context: &ValidationContext,
    ) -> Result<(), ValidationError> {
        // Basic group validation for department-based rules
        Ok(())
    }
}
}

Attribute Format Validation

Validate specific attribute formats beyond basic schema validation:

#![allow(unused)]
fn main() {
pub struct AttributeFormatValidator {
    phone_regex: Regex,
    ssn_regex: Regex,
    employee_id_regex: Regex,
}

impl AttributeFormatValidator {
    pub fn new() -> Self {
        Self {
            phone_regex: Regex::new(r"^\+1-\d{3}-\d{3}-\d{4}$").unwrap(),
            ssn_regex: Regex::new(r"^\d{3}-\d{2}-\d{4}$").unwrap(), 
            employee_id_regex: Regex::new(r"^[A-Z]{2}\d{6}$").unwrap(),
        }
    }

    fn validate_phone_number(&self, phone: &str) -> Result<(), ValidationError> {
        if !self.phone_regex.is_match(phone) {
            return Err(ValidationError::new(
                "INVALID_PHONE_FORMAT",
                "Phone number must be in format +1-XXX-XXX-XXXX",
            ));
        }
        Ok(())
    }

    fn validate_ssn(&self, ssn: &str) -> Result<(), ValidationError> {
        if !self.ssn_regex.is_match(ssn) {
            return Err(ValidationError::new(
                "INVALID_SSN_FORMAT", 
                "SSN must be in format XXX-XX-XXXX",
            ));
        }

        // Additional SSN validation rules
        let parts: Vec<&str> = ssn.split('-').collect();
        if parts.len() == 3 {
            // Check for invalid area numbers
            if let Ok(area) = parts[0].parse::<u32>() {
                if area == 0 || area == 666 || area >= 900 {
                    return Err(ValidationError::new(
                        "INVALID_SSN_AREA",
                        "Invalid SSN area number",
                    ));
                }
            }
        }
        Ok(())
    }
}

#[async_trait]
impl CustomValidator for AttributeFormatValidator {
    async fn validate_user(
        &self,
        user: &User,
        _context: &ValidationContext,
    ) -> Result<(), ValidationError> {
        // Validate phone numbers
        if let Some(phone_numbers) = &user.phone_numbers {
            for (index, phone) in phone_numbers.iter().enumerate() {
                self.validate_phone_number(&phone.value)
                    .map_err(|mut e| {
                        e.field_path = Some(format!("phoneNumbers[{}].value", index));
                        e
                    })?;
            }
        }

        // Validate custom attributes
        if let Some(attrs) = &user.extension_attributes {
            // Validate SSN if present
            if let Some(ssn_value) = attrs.get("ssn") {
                if let Some(ssn_str) = ssn_value.as_str() {
                    self.validate_ssn(ssn_str)
                        .map_err(|mut e| {
                            e.field_path = Some("enterpriseUser:ssn".to_string());
                            e
                        })?;
                }
            }

            // Validate employee ID
            if let Some(emp_id_value) = attrs.get("employeeNumber") {
                if let Some(emp_id_str) = emp_id_value.as_str() {
                    if !self.employee_id_regex.is_match(emp_id_str) {
                        return Err(ValidationError::new(
                            "INVALID_EMPLOYEE_ID_FORMAT",
                            "Employee ID must be in format XX123456",
                        ).with_field("enterpriseUser:employeeNumber"));
                    }
                }
            }
        }

        Ok(())
    }

    async fn validate_group(
        &self,
        _group: &Group,
        _context: &ValidationContext,
    ) -> Result<(), ValidationError> {
        Ok(())
    }
}
}

Usage Example

Here's how to register and use these basic validators:

use scim_server::ScimServerBuilder;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let server = ScimServerBuilder::new()
        .with_provider(my_provider)
        .add_validator(BusinessRuleValidator::new())
        .add_validator(DepartmentValidator::new())
        .add_validator(AttributeFormatValidator::new())
        .build();

    // Start server
    server.run().await?;
    Ok(())
}

Testing Basic Validators

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;
    use scim_server::models::{User, Name, Email};

    #[tokio::test]
    async fn test_business_rule_validation() {
        let validator = BusinessRuleValidator::new();
        let context = ValidationContext::default();

        // Test valid user
        let mut user = User::default();
        user.username = Some("john.doe".to_string());
        user.external_id = Some("EMP123456".to_string());
        user.name = Some(Name {
            formatted: Some("John Doe".to_string()),
            family_name: Some("Doe".to_string()),
            given_name: Some("John".to_string()),
            ..Default::default()
        });
        user.emails = Some(vec![Email {
            value: "john.doe@company.com".to_string(),
            primary: Some(true),
            ..Default::default()
        }]);

        assert!(validator.validate_user(&user, &context).await.is_ok());

        // Test invalid employee ID
        user.external_id = Some("INVALID123".to_string());
        let result = validator.validate_user(&user, &context).await;
        assert!(result.is_err());
        assert_eq!(result.unwrap_err().code, "INVALID_EMPLOYEE_ID");
    }

    #[tokio::test]
    async fn test_password_policy() {
        let validator = BusinessRuleValidator::new();
        
        // Test weak password
        let weak_password = "password123";
        let result = validator.validate_password_policy(weak_password);
        assert!(result.is_err());

        // Test strong password
        let strong_password = "MySecure123!Password";
        let result = validator.validate_password_policy(strong_password);
        assert!(result.is_ok());
    }
}
}

Next Steps