Custom Resource Types
This tutorial shows you how to extend the SCIM Server with custom resource types beyond the standard User and Group resources. You'll learn to define schemas, implement type-safe resources, and integrate them with your SCIM server.
Why Custom Resources?
While SCIM's User and Group resources cover most identity scenarios, enterprise environments often need additional resource types:
- Projects: Development projects with team assignments
- Roles: Fine-grained permission sets
- Devices: Mobile devices and laptops assigned to users
- Applications: SaaS applications and their configurations
- Departments: Organizational units with hierarchies
- Locations: Office locations and room assignments
Custom resources let you manage these entities with the same SCIM operations and guarantees as built-in resources.
Quick Start Example
Let's start with a simple Device resource:
#![allow(unused)] fn main() { use scim_server::{ScimResource, ResourceMeta, Schema, Attribute}; use serde::{Deserialize, Serialize}; use chrono::{DateTime, Utc}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Device { pub id: String, pub schemas: Vec<String>, pub meta: ResourceMeta, pub external_id: Option<String>, // Custom attributes pub serial_number: String, pub device_type: DeviceType, pub manufacturer: String, pub model: String, pub assigned_to: Option<String>, // User ID pub status: DeviceStatus, pub purchase_date: Option<DateTime<Utc>>, pub warranty_expires: Option<DateTime<Utc>>, } #[derive(Debug, Clone, Serialize, Deserialize)] pub enum DeviceType { Laptop, Desktop, Tablet, Phone, Other(String), } #[derive(Debug, Clone, Serialize, Deserialize)] pub enum DeviceStatus { Available, Assigned, InRepair, Retired, } impl ScimResource for Device { fn id(&self) -> &str { &self.id } fn schemas(&self) -> &[String] { &self.schemas } fn meta(&self) -> &ResourceMeta { &self.meta } fn external_id(&self) -> Option<&str> { self.external_id.as_deref() } } }
Step 1: Define Your Resource Schema
Every custom resource needs a schema that defines its structure and validation rules:
#![allow(unused)] fn main() { use scim_server::{Schema, Attribute, AttributeType, Mutability, Returned, Uniqueness}; pub fn device_schema() -> Schema { Schema::builder() .id("urn:company:params:scim:schemas:core:2.0:Device") .name("Device") .description("IT Device Resource") .attribute( Attribute::builder() .name("serialNumber") .type_(AttributeType::String) .mutability(Mutability::Immutable) // Can't change after creation .returned(Returned::Default) .uniqueness(Uniqueness::Server) // Must be unique .required(true) .case_exact(true) .description("Device serial number") .build() ) .attribute( Attribute::builder() .name("deviceType") .type_(AttributeType::String) .mutability(Mutability::ReadWrite) .returned(Returned::Default) .required(true) .canonical_values(vec![ "Laptop".to_string(), "Desktop".to_string(), "Tablet".to_string(), "Phone".to_string(), ]) .description("Type of device") .build() ) .attribute( Attribute::builder() .name("assignedTo") .type_(AttributeType::Reference) .mutability(Mutability::ReadWrite) .returned(Returned::Default) .reference_types(vec!["User".to_string()]) .description("User this device is assigned to") .build() ) .attribute( Attribute::builder() .name("status") .type_(AttributeType::String) .mutability(Mutability::ReadWrite) .returned(Returned::Default) .required(true) .canonical_values(vec![ "Available".to_string(), "Assigned".to_string(), "InRepair".to_string(), "Retired".to_string(), ]) .build() ) .build() .unwrap() } }
Step 2: Implement the Builder Pattern
Provide a convenient builder for creating resources:
#![allow(unused)] fn main() { impl Device { pub fn builder() -> DeviceBuilder { DeviceBuilder::new() } } pub struct DeviceBuilder { device: Device, } impl DeviceBuilder { pub fn new() -> Self { Self { device: Device { id: uuid::Uuid::new_v4().to_string(), schemas: vec!["urn:company:params:scim:schemas:core:2.0:Device".to_string()], meta: ResourceMeta::new("Device"), external_id: None, serial_number: String::new(), device_type: DeviceType::Laptop, manufacturer: String::new(), model: String::new(), assigned_to: None, status: DeviceStatus::Available, purchase_date: None, warranty_expires: None, }, } } pub fn serial_number(mut self, serial: impl Into<String>) -> Self { self.device.serial_number = serial.into(); self } pub fn device_type(mut self, device_type: DeviceType) -> Self { self.device.device_type = device_type; self } pub fn manufacturer(mut self, manufacturer: impl Into<String>) -> Self { self.device.manufacturer = manufacturer.into(); self } pub fn model(mut self, model: impl Into<String>) -> Self { self.device.model = model.into(); self } pub fn assigned_to(mut self, user_id: Option<impl Into<String>>) -> Self { self.device.assigned_to = user_id.map(|id| id.into()); self } pub fn status(mut self, status: DeviceStatus) -> Self { self.device.status = status; self } pub fn purchase_date(mut self, date: DateTime<Utc>) -> Self { self.device.purchase_date = Some(date); self } pub fn warranty_expires(mut self, date: DateTime<Utc>) -> Self { self.device.warranty_expires = Some(date); self } pub fn build(self) -> Result<Device, ValidationError> { // Validate required fields if self.device.serial_number.is_empty() { return Err(ValidationError::RequiredField("serialNumber")); } if self.device.manufacturer.is_empty() { return Err(ValidationError::RequiredField("manufacturer")); } if self.device.model.is_empty() { return Err(ValidationError::RequiredField("model")); } Ok(self.device) } } }
Step 3: Extend Your Provider
Add support for your custom resource to your storage provider:
#![allow(unused)] fn main() { use async_trait::async_trait; use scim_server::{Provider, ProviderError, ListOptions, ListResponse}; #[async_trait] pub trait DeviceProvider: Provider { async fn create_device(&self, tenant_id: &str, device: Device) -> Result<Device, ProviderError>; async fn get_device(&self, tenant_id: &str, device_id: &str) -> Result<Option<Device>, ProviderError>; async fn update_device(&self, tenant_id: &str, device: Device) -> Result<Device, ProviderError>; async fn delete_device(&self, tenant_id: &str, device_id: &str) -> Result<(), ProviderError>; async fn list_devices(&self, tenant_id: &str, options: &ListOptions) -> Result<ListResponse<Device>, ProviderError>; } // Implement for InMemoryProvider #[async_trait] impl DeviceProvider for InMemoryProvider { async fn create_device(&self, tenant_id: &str, mut device: Device) -> Result<Device, ProviderError> { // Update metadata device.meta.created = Utc::now(); device.meta.last_modified = device.meta.created; device.meta.version = "1".to_string(); let mut devices = self.devices.write().await; let tenant_devices = devices.entry(tenant_id.to_string()).or_insert_with(HashMap::new); // Check for duplicate serial number for existing_device in tenant_devices.values() { if existing_device.serial_number == device.serial_number { return Err(ProviderError::Conflict( format!("Device with serial number {} already exists", device.serial_number) )); } } tenant_devices.insert(device.id.clone(), device.clone()); Ok(device) } async fn get_device(&self, tenant_id: &str, device_id: &str) -> Result<Option<Device>, ProviderError> { let devices = self.devices.read().await; let result = devices .get(tenant_id) .and_then(|tenant_devices| tenant_devices.get(device_id)) .cloned(); Ok(result) } async fn update_device(&self, tenant_id: &str, mut device: Device) -> Result<Device, ProviderError> { let mut devices = self.devices.write().await; let tenant_devices = devices.entry(tenant_id.to_string()).or_insert_with(HashMap::new); // Check if device exists let existing = tenant_devices.get(&device.id) .ok_or_else(|| ProviderError::NotFound { resource_type: "Device".to_string(), id: device.id.clone(), })?; // Version check for concurrency control if existing.meta.version != device.meta.version { return Err(ProviderError::VersionConflict { current_version: existing.meta.version.clone(), provided_version: device.meta.version.clone(), }); } // Update metadata device.meta.last_modified = Utc::now(); device.meta.version = (existing.meta.version.parse::<u64>().unwrap_or(0) + 1).to_string(); tenant_devices.insert(device.id.clone(), device.clone()); Ok(device) } async fn delete_device(&self, tenant_id: &str, device_id: &str) -> Result<(), ProviderError> { let mut devices = self.devices.write().await; let tenant_devices = devices.entry(tenant_id.to_string()).or_insert_with(HashMap::new); tenant_devices.remove(device_id) .ok_or_else(|| ProviderError::NotFound { resource_type: "Device".to_string(), id: device_id.to_string(), })?; Ok(()) } async fn list_devices(&self, tenant_id: &str, options: &ListOptions) -> Result<ListResponse<Device>, ProviderError> { let devices = self.devices.read().await; let tenant_devices = devices.get(tenant_id).map(|d| d.values().cloned().collect::<Vec<_>>()) .unwrap_or_default(); // Apply filtering let filtered: Vec<Device> = if let Some(ref filter) = options.filter { tenant_devices.into_iter() .filter(|device| self.matches_filter(device, filter)) .collect() } else { tenant_devices }; // Apply sorting let mut sorted = filtered; if let Some(ref sort_by) = options.sort_by { sorted.sort_by(|a, b| self.compare_devices(a, b, sort_by, &options.sort_order)); } // Apply pagination let total_results = sorted.len(); let start_index = options.start_index.unwrap_or(1).max(1) - 1; let count = options.count.unwrap_or(100).min(1000); let page: Vec<Device> = sorted .into_iter() .skip(start_index) .take(count) .collect(); Ok(ListResponse { schemas: vec!["urn:ietf:params:scim:api:messages:2.0:ListResponse".to_string()], total_results, start_index: start_index + 1, items_per_page: page.len(), resources: page, }) } } }
Step 4: Add HTTP Endpoints
Create HTTP endpoints for your custom resource:
#![allow(unused)] fn main() { use axum::{ extract::{Path, Query, State}, http::StatusCode, response::Json, routing::{get, post, put, delete}, Router, }; use serde_json::Value; pub fn device_routes() -> Router<AppState> { Router::new() .route("/Devices", get(list_devices).post(create_device)) .route("/Devices/:id", get(get_device).put(update_device).delete(delete_device)) } async fn create_device( State(state): State<AppState>, Path(tenant_id): Path<String>, Json(payload): Json<Value>, ) -> Result<Json<Device>, (StatusCode, Json<ScimError>)> { // Parse the JSON into a Device let device: Device = serde_json::from_value(payload) .map_err(|e| (StatusCode::BAD_REQUEST, Json(ScimError::invalid_syntax(e.to_string()))))?; // Create the device let created_device = state.provider.create_device(&tenant_id, device).await .map_err(|e| match e { ProviderError::Conflict(msg) => (StatusCode::CONFLICT, Json(ScimError::uniqueness(msg))), ProviderError::ValidationError { message } => (StatusCode::BAD_REQUEST, Json(ScimError::invalid_value(message))), _ => (StatusCode::INTERNAL_SERVER_ERROR, Json(ScimError::internal_error())), })?; Ok(Json(created_device)) } async fn get_device( State(state): State<AppState>, Path((tenant_id, device_id)): Path<(String, String)>, ) -> Result<Json<Device>, (StatusCode, Json<ScimError>)> { let device = state.provider.get_device(&tenant_id, &device_id).await .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, Json(ScimError::internal_error())))? .ok_or_else(|| (StatusCode::NOT_FOUND, Json(ScimError::not_found("Device", &device_id))))?; Ok(Json(device)) } async fn update_device( State(state): State<AppState>, Path((tenant_id, device_id)): Path<(String, String)>, Json(mut payload): Json<Device>, ) -> Result<Json<Device>, (StatusCode, Json<ScimError>)> { // Ensure the ID matches the path payload.id = device_id; let updated_device = state.provider.update_device(&tenant_id, payload).await .map_err(|e| match e { ProviderError::NotFound { .. } => (StatusCode::NOT_FOUND, Json(ScimError::not_found("Device", &payload.id))), ProviderError::VersionConflict { .. } => (StatusCode::PRECONDITION_FAILED, Json(ScimError::version_conflict())), _ => (StatusCode::INTERNAL_SERVER_ERROR, Json(ScimError::internal_error())), })?; Ok(Json(updated_device)) } async fn delete_device( State(state): State<AppState>, Path((tenant_id, device_id)): Path<(String, String)>, ) -> Result<StatusCode, (StatusCode, Json<ScimError>)> { state.provider.delete_device(&tenant_id, &device_id).await .map_err(|e| match e { ProviderError::NotFound { .. } => (StatusCode::NOT_FOUND, Json(ScimError::not_found("Device", &device_id))), _ => (StatusCode::INTERNAL_SERVER_ERROR, Json(ScimError::internal_error())), })?; Ok(StatusCode::NO_CONTENT) } async fn list_devices( State(state): State<AppState>, Path(tenant_id): Path<String>, Query(params): Query<ListParameters>, ) -> Result<Json<ListResponse<Device>>, (StatusCode, Json<ScimError>)> { let options = ListOptions::from_query_params(params); let response = state.provider.list_devices(&tenant_id, &options).await .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, Json(ScimError::internal_error())))?; Ok(Json(response)) } }
Step 5: Register Your Resource
Register your custom resource and schema with the SCIM server:
use scim_server::{ScimServer, SchemaRegistry}; #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { // Create provider let provider = InMemoryProvider::new(); // Create schema registry and register schemas let mut schema_registry = SchemaRegistry::new(); schema_registry.register(CoreSchemas::user()); schema_registry.register(CoreSchemas::group()); schema_registry.register(device_schema()); // Register our custom schema // Create SCIM server let scim_server = ScimServer::builder() .provider(provider) .schema_registry(schema_registry) .build(); // Create HTTP router let app = Router::new() .nest("/scim/v2/:tenant_id", user_routes()) .nest("/scim/v2/:tenant_id", group_routes()) .nest("/scim/v2/:tenant_id", device_routes()) // Add device routes .route("/scim/v2/:tenant_id/Schemas", get(get_schemas)) .route("/scim/v2/:tenant_id/ResourceTypes", get(get_resource_types)) .with_state(AppState { provider: scim_server }); // Start server let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?; println!("SCIM server running on http://localhost:3000"); axum::serve(listener, app).await?; Ok(()) }
Step 6: Add Resource Type Configuration
Expose your custom resource through the /ResourceTypes endpoint:
#![allow(unused)] fn main() { async fn get_resource_types( State(state): State<AppState>, Path(tenant_id): Path<String>, ) -> Json<ListResponse<ResourceType>> { let resource_types = vec![ ResourceType { schemas: vec!["urn:ietf:params:scim:schemas:core:2.0:ResourceType".to_string()], id: "User".to_string(), name: "User".to_string(), endpoint: "/Users".to_string(), description: Some("User Account".to_string()), schema: "urn:ietf:params:scim:schemas:core:2.0:User".to_string(), schema_extensions: vec![ SchemaExtension { schema: "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User".to_string(), required: false, } ], meta: ResourceMeta::new("ResourceType"), }, ResourceType { schemas: vec!["urn:ietf:params:scim:schemas:core:2.0:ResourceType".to_string()], id: "Group".to_string(), name: "Group".to_string(), endpoint: "/Groups".to_string(), description: Some("Group".to_string()), schema: "urn:ietf:params:scim:schemas:core:2.0:Group".to_string(), schema_extensions: vec![], meta: ResourceMeta::new("ResourceType"), }, ResourceType { schemas: vec!["urn:ietf:params:scim:schemas:core:2.0:ResourceType".to_string()], id: "Device".to_string(), name: "Device".to_string(), endpoint: "/Devices".to_string(), description: Some("IT Device".to_string()), schema: "urn:company:params:scim:schemas:core:2.0:Device".to_string(), schema_extensions: vec![], meta: ResourceMeta::new("ResourceType"), }, ]; Json(ListResponse { schemas: vec!["urn:ietf:params:scim:api:messages:2.0:ListResponse".to_string()], total_results: resource_types.len(), start_index: 1, items_per_page: resource_types.len(), resources: resource_types, }) } }
Advanced Examples
Complex Resource with Relationships
Here's a more complex example - a Project resource that references users and groups:
#![allow(unused)] fn main() { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Project { pub id: String, pub schemas: Vec<String>, pub meta: ResourceMeta, pub external_id: Option<String>, // Basic attributes pub name: String, pub description: Option<String>, pub status: ProjectStatus, pub priority: Priority, // Relationships pub owner: Reference, pub team_members: Vec<Reference>, pub stakeholder_groups: Vec<Reference>, // Dates pub start_date: DateTime<Utc>, pub end_date: Option<DateTime<Utc>>, pub created_date: DateTime<Utc>, // Business attributes pub budget: Option<Money>, pub tags: Vec<String>, pub custom_fields: HashMap<String, Value>, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Reference { pub value: String, #[serde(rename = "$ref")] pub ref_: Option<String>, #[serde(rename = "type")] pub type_: Option<String>, pub display: Option<String>, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Money { pub amount: f64, pub currency: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub enum ProjectStatus { Planning, Active, OnHold, Completed, Cancelled, } #[derive(Debug, Clone, Serialize, Deserialize)] pub enum Priority { Low, Medium, High, Critical, } }
Resource with Validation Rules
Add complex validation logic:
#![allow(unused)] fn main() { impl Project { pub fn validate(&self) -> Result<(), ValidationError> { // Name validation if self.name.trim().is_empty() { return Err(ValidationError::RequiredField("name")); } if self.name.len() > 100 { return Err(ValidationError::ValueTooLong("name", 100)); } // Date validation if let Some(end_date) = self.end_date { if end_date <= self.start_date { return Err(ValidationError::InvalidDateRange); } } // Budget validation if let Some(ref budget) = self.budget { if budget.amount < 0.0 { return Err(ValidationError::InvalidValue("budget.amount", "must be non-negative")); } } // Team size validation if self.team_members.len() > 50 { return Err(ValidationError::ValueTooLong("teamMembers", 50)); } // Custom business rules if self.status == ProjectStatus::Active && self.team_members.is_empty() { return Err(ValidationError::BusinessRule("Active projects must have team members")); } Ok(()) } } }
Testing Your Custom Resource
Create comprehensive tests for your custom resource:
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; use chrono::Utc; #[tokio::test] async fn test_device_lifecycle() { let provider = InMemoryProvider::new(); let tenant_id = "test-tenant"; // Create device let device = Device::builder() .serial_number("ABC123") .device_type(DeviceType::Laptop) .manufacturer("Dell") .model("XPS 13") .status(DeviceStatus::Available) .build() .unwrap(); let created = provider.create_device(tenant_id, device.clone()).await.unwrap(); assert_eq!(created.serial_number, "ABC123"); assert_eq!(created.status, DeviceStatus::Available); // Assign device to user let mut assigned = created.clone(); assigned.assigned_to = Some("user-123".to_string()); assigned.status = DeviceStatus::Assigned; let updated = provider.update_device(tenant_id, assigned).await.unwrap(); assert_eq!(updated.assigned_to, Some("user-123".to_string())); assert_eq!(updated.status, DeviceStatus::Assigned); // List devices let list_response = provider.list_devices(tenant_id, &ListOptions::default()).await.unwrap(); assert_eq!(list_response.total_results, 1); assert_eq!(list_response.resources[0].id, created.id); // Delete device provider.delete_device(tenant_id, &created.id).await.unwrap(); let deleted = provider.get_device(tenant_id, &created.id).await.unwrap(); assert!(deleted.is_none()); } #[tokio::test] async fn test_device_validation() { // Test missing serial number let result = Device::builder() .manufacturer("Dell") .model("XPS 13") .build(); assert!(result.is_err()); // Test valid device let result = Device::builder() .serial_number("ABC123") .manufacturer("Dell") .model("XPS 13") .build(); assert!(result.is_ok()); } #[tokio::test] async fn test_device_filtering() { let provider = InMemoryProvider::new(); let tenant_id = "test-tenant"; // Create test devices let devices = vec![ Device::builder().serial_number("LAP001").device_type(DeviceType::Laptop).build().unwrap(), Device::builder().serial_number("PHN001").device_type(DeviceType::Phone).build().unwrap(), Device::builder().serial_number("LAP002").device_type(DeviceType::Laptop).build().unwrap(), ]; for device in devices { provider.create_device(tenant_id, device).await.unwrap(); } // List all devices and filter in memory (database filtering not yet implemented) let options = ListOptions::default(); let response = provider.list_devices(tenant_id, &options).await.unwrap(); // Filter by device type in memory let laptops: Vec<_> = response.resources.into_iter() .filter(|device| device.device_type == DeviceType::Laptop) .collect(); assert_eq!(laptops.len(), 2); for device in laptops { assert_eq!(device.device_type, DeviceType::Laptop); } } } }
Best Practices
Schema Design
Use meaningful schema IDs:
#![allow(unused)] fn main() { // Good: Company-specific with versioning "urn:company:params:scim:schemas:core:2.0:Device" // Bad: Generic or unversioned "device" "urn:scim:schemas:device" }
Define appropriate constraints:
#![allow(unused)] fn main() { .attribute( Attribute::builder() .name("serialNumber") .mutability(Mutability::Immutable) // Can't change after creation .uniqueness(Uniqueness::Server) // Must be unique across tenant .required(true) // Must be provided .case_exact(true) // Exact case matching .build() ) }
Type Safety
Use enums for constrained values:
#![allow(unused)] fn main() { #[derive(Debug, Clone, Serialize, Deserialize)] pub enum DeviceStatus { Available, Assigned, InRepair, Retired, } // Instead of just String }
Implement strong validation:
#![allow(unused)] fn main() { impl DeviceBuilder { pub fn serial_number(mut self, serial: impl Into<String>) -> Self { let serial = serial.into(); // Validate format (example: must be alphanumeric, 6-20 chars) if !serial.chars().all(|c| c.is_alphanumeric()) { panic!("Serial number must be alphanumeric"); } if serial.len() < 6 || serial.len() > 20 { panic!("Serial number must be 6-20 characters"); } self.device.serial_number = serial; self } } }
Error Handling
Provide meaningful error messages:
#![allow(unused)] fn main() { #[derive(Debug, thiserror::Error)] pub enum DeviceError { #[error("Serial number {0} is already in use")] DuplicateSerial(String), #[error("Device {device_id} is currently assigned to user {user_id}")] DeviceInUse { device_id: String, user_id: String }, #[error("Cannot assign retired device {0}")] RetiredDevice(String), } }
Performance
Add appropriate indexes for your provider:
-- For database providers
CREATE INDEX idx_devices_serial_number ON devices(tenant_id, serial_number);
CREATE INDEX idx_devices_assigned_to ON devices(tenant_id, assigned_to);
CREATE INDEX idx_devices_status ON devices(tenant_id, status);
Implement efficient querying:
#![allow(unused)] fn main() { // For now, implement pagination and in-memory filtering impl DatabaseProvider { async fn list_devices_paginated(&self, tenant_id: &str, start_index: Option<usize>, count: Option<usize>) -> Result<Vec<Device>, ProviderError> { let skip = start_index.unwrap_or(1).saturating_sub(1); let limit = count.unwrap_or(50).min(1000); // Cap at 1000 for performance // Database query with pagination let devices = sqlx::query_as!( Device, "SELECT * FROM devices WHERE tenant_id = $1 ORDER BY created_at LIMIT $2 OFFSET $3", tenant_id, limit as i64, skip as i64 ) .fetch_all(&self.pool) .await?; Ok(devices) } // Helper method for common filtering patterns async fn find_devices_by_type(&self, tenant_id: &str, device_type: &DeviceType) -> Result<Vec<Device>, ProviderError> { let devices = sqlx::query_as!( Device, "SELECT * FROM devices WHERE tenant_id = $1 AND device_type = $2", tenant_id, device_type.to_string() ) .fetch_all(&self.pool) .await?; Ok(devices) }