Skip to main content

auth_framework/protocols/
scim.rs

1//! SCIM 2.0 (RFC 7643 / RFC 7644) — System for Cross-domain Identity Management
2//!
3//! Provides types and a client for provisioning and managing user/group
4//! identities across domains using the SCIM 2.0 protocol.
5//!
6//! # Supported Operations
7//!
8//! - **Users**: Create, Read, Replace, Patch, Delete, List (with filtering)
9//! - **Groups**: Create, Read, Replace, Patch, Delete, List
10//! - **Bulk**: Batch operations for efficient provisioning
11//! - **Service Provider Config**: Capability discovery
12//!
13//! # Security
14//!
15//! - All requests use Bearer token authentication
16//! - TLS is required (enforced by the client)
17//! - Attribute filtering prevents over-exposure of PII
18
19use crate::errors::{AuthError, Result};
20use serde::{Deserialize, Serialize};
21use std::collections::HashMap;
22
23// ─── SCIM Core Schema Types (RFC 7643) ───────────────────────────────────────
24
25/// SCIM 2.0 schema URN constants.
26pub mod schema {
27    pub const USER: &str = "urn:ietf:params:scim:schemas:core:2.0:User";
28    pub const GROUP: &str = "urn:ietf:params:scim:schemas:core:2.0:Group";
29    pub const ENTERPRISE_USER: &str = "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User";
30    pub const LIST_RESPONSE: &str = "urn:ietf:params:scim:api:messages:2.0:ListResponse";
31    pub const PATCH_OP: &str = "urn:ietf:params:scim:api:messages:2.0:PatchOp";
32    pub const BULK_REQUEST: &str = "urn:ietf:params:scim:api:messages:2.0:BulkRequest";
33    pub const BULK_RESPONSE: &str = "urn:ietf:params:scim:api:messages:2.0:BulkResponse";
34    pub const ERROR: &str = "urn:ietf:params:scim:api:messages:2.0:Error";
35    pub const SEARCH_REQUEST: &str = "urn:ietf:params:scim:api:messages:2.0:SearchRequest";
36    pub const SERVICE_PROVIDER_CONFIG: &str =
37        "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig";
38}
39
40/// Common SCIM metadata present on every resource.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42#[serde(rename_all = "camelCase")]
43pub struct Meta {
44    pub resource_type: String,
45    pub created: Option<String>,
46    pub last_modified: Option<String>,
47    pub location: Option<String>,
48    pub version: Option<String>,
49}
50
51/// SCIM multi-valued attribute with canonical type/primary flags.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct MultiValuedAttr {
54    pub value: String,
55    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
56    pub attr_type: Option<String>,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub primary: Option<bool>,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub display: Option<String>,
61}
62
63/// SCIM Name component (RFC 7643 §4.1.1).
64#[derive(Debug, Clone, Default, Serialize, Deserialize)]
65#[serde(rename_all = "camelCase")]
66pub struct Name {
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub formatted: Option<String>,
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub family_name: Option<String>,
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub given_name: Option<String>,
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub middle_name: Option<String>,
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub honorific_prefix: Option<String>,
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub honorific_suffix: Option<String>,
79}
80
81/// SCIM User resource (RFC 7643 §4.1).
82#[derive(Debug, Clone, Serialize, Deserialize)]
83#[serde(rename_all = "camelCase")]
84pub struct ScimUser {
85    /// Schema URNs.
86    pub schemas: Vec<String>,
87
88    /// Unique server-assigned identifier.
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub id: Option<String>,
91
92    /// Unique identifier for the user (typically login name).
93    pub user_name: String,
94
95    /// User's name components.
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub name: Option<Name>,
98
99    /// Display name.
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub display_name: Option<String>,
102
103    /// Email addresses.
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub emails: Option<Vec<MultiValuedAttr>>,
106
107    /// Phone numbers.
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub phone_numbers: Option<Vec<MultiValuedAttr>>,
110
111    /// Whether the user account is active.
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub active: Option<bool>,
114
115    /// Resource metadata.
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub meta: Option<Meta>,
118
119    /// Groups the user belongs to (read-only).
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub groups: Option<Vec<MultiValuedAttr>>,
122
123    /// Additional extension attributes.
124    #[serde(flatten)]
125    pub extensions: HashMap<String, serde_json::Value>,
126}
127
128impl ScimUser {
129    /// Create a minimal SCIM User with only the required `userName`.
130    pub fn new(user_name: impl Into<String>) -> Self {
131        Self {
132            schemas: vec![schema::USER.to_string()],
133            id: None,
134            user_name: user_name.into(),
135            name: None,
136            display_name: None,
137            emails: None,
138            phone_numbers: None,
139            active: Some(true),
140            meta: None,
141            groups: None,
142            extensions: HashMap::new(),
143        }
144    }
145}
146
147/// SCIM Group resource (RFC 7643 §4.2).
148#[derive(Debug, Clone, Serialize, Deserialize)]
149#[serde(rename_all = "camelCase")]
150pub struct ScimGroup {
151    pub schemas: Vec<String>,
152
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub id: Option<String>,
155
156    pub display_name: String,
157
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub members: Option<Vec<MultiValuedAttr>>,
160
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub meta: Option<Meta>,
163}
164
165impl ScimGroup {
166    pub fn new(display_name: impl Into<String>) -> Self {
167        Self {
168            schemas: vec![schema::GROUP.to_string()],
169            id: None,
170            display_name: display_name.into(),
171            members: None,
172            meta: None,
173        }
174    }
175}
176
177// ─── SCIM Protocol Messages (RFC 7644) ───────────────────────────────────────
178
179/// SCIM ListResponse (RFC 7644 §3.4.2).
180#[derive(Debug, Clone, Serialize, Deserialize)]
181#[serde(rename_all = "camelCase")]
182pub struct ListResponse<T> {
183    pub schemas: Vec<String>,
184    pub total_results: u64,
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub start_index: Option<u64>,
187    #[serde(skip_serializing_if = "Option::is_none")]
188    pub items_per_page: Option<u64>,
189    #[serde(rename = "Resources")]
190    pub resources: Vec<T>,
191}
192
193/// SCIM Patch operation (RFC 7644 §3.5.2).
194#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct PatchOp {
196    pub schemas: Vec<String>,
197    #[serde(rename = "Operations")]
198    pub operations: Vec<PatchOperation>,
199}
200
201/// A single patch operation entry.
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct PatchOperation {
204    pub op: PatchOpType,
205    #[serde(skip_serializing_if = "Option::is_none")]
206    pub path: Option<String>,
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub value: Option<serde_json::Value>,
209}
210
211/// Patch operation type.
212#[derive(Debug, Clone, Serialize, Deserialize)]
213#[serde(rename_all = "lowercase")]
214pub enum PatchOpType {
215    Add,
216    Remove,
217    Replace,
218}
219
220/// SCIM Bulk request (RFC 7644 §3.7).
221#[derive(Debug, Clone, Serialize, Deserialize)]
222#[serde(rename_all = "camelCase")]
223pub struct BulkRequest {
224    pub schemas: Vec<String>,
225    #[serde(skip_serializing_if = "Option::is_none")]
226    pub fail_on_errors: Option<u32>,
227    #[serde(rename = "Operations")]
228    pub operations: Vec<BulkOperation>,
229}
230
231/// SCIM Bulk response.
232#[derive(Debug, Clone, Serialize, Deserialize)]
233#[serde(rename_all = "camelCase")]
234pub struct BulkResponse {
235    pub schemas: Vec<String>,
236    #[serde(rename = "Operations")]
237    pub operations: Vec<BulkOperationResponse>,
238}
239
240/// A single operation inside a bulk request.
241#[derive(Debug, Clone, Serialize, Deserialize)]
242#[serde(rename_all = "camelCase")]
243pub struct BulkOperation {
244    pub method: BulkMethod,
245    #[serde(skip_serializing_if = "Option::is_none")]
246    pub bulk_id: Option<String>,
247    #[serde(skip_serializing_if = "Option::is_none")]
248    pub path: Option<String>,
249    #[serde(skip_serializing_if = "Option::is_none")]
250    pub data: Option<serde_json::Value>,
251}
252
253/// A single operation response inside a bulk response.
254#[derive(Debug, Clone, Serialize, Deserialize)]
255#[serde(rename_all = "camelCase")]
256pub struct BulkOperationResponse {
257    pub method: BulkMethod,
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub bulk_id: Option<String>,
260    #[serde(skip_serializing_if = "Option::is_none")]
261    pub location: Option<String>,
262    #[serde(skip_serializing_if = "Option::is_none")]
263    pub status: Option<String>,
264    #[serde(skip_serializing_if = "Option::is_none")]
265    pub response: Option<serde_json::Value>,
266}
267
268#[derive(Debug, Clone, Serialize, Deserialize)]
269#[serde(rename_all = "UPPERCASE")]
270pub enum BulkMethod {
271    Post,
272    Put,
273    Patch,
274    Delete,
275}
276
277/// SCIM Error response (RFC 7644 §3.12).
278#[derive(Debug, Clone, Serialize, Deserialize)]
279#[serde(rename_all = "camelCase")]
280pub struct ScimError {
281    pub schemas: Vec<String>,
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub status: Option<String>,
284    #[serde(skip_serializing_if = "Option::is_none")]
285    pub scim_type: Option<String>,
286    #[serde(skip_serializing_if = "Option::is_none")]
287    pub detail: Option<String>,
288}
289
290/// SCIM Search request (RFC 7644 §3.4.3).
291#[derive(Debug, Clone, Serialize, Deserialize)]
292#[serde(rename_all = "camelCase")]
293pub struct SearchRequest {
294    pub schemas: Vec<String>,
295    #[serde(skip_serializing_if = "Option::is_none")]
296    pub filter: Option<String>,
297    #[serde(skip_serializing_if = "Option::is_none")]
298    pub sort_by: Option<String>,
299    #[serde(skip_serializing_if = "Option::is_none")]
300    pub sort_order: Option<String>,
301    #[serde(skip_serializing_if = "Option::is_none")]
302    pub start_index: Option<u64>,
303    #[serde(skip_serializing_if = "Option::is_none")]
304    pub count: Option<u64>,
305    #[serde(skip_serializing_if = "Option::is_none")]
306    pub attributes: Option<Vec<String>>,
307    #[serde(skip_serializing_if = "Option::is_none")]
308    pub excluded_attributes: Option<Vec<String>>,
309}
310
311/// Service provider configuration (RFC 7643 §5).
312#[derive(Debug, Clone, Serialize, Deserialize)]
313#[serde(rename_all = "camelCase")]
314pub struct ServiceProviderConfig {
315    pub schemas: Vec<String>,
316    pub patch: Supported,
317    pub bulk: BulkSupported,
318    pub filter: FilterSupported,
319    pub change_password: Supported,
320    pub sort: Supported,
321    pub etag: Supported,
322    pub authentication_schemes: Vec<AuthenticationScheme>,
323}
324
325#[derive(Debug, Clone, Serialize, Deserialize)]
326pub struct Supported {
327    pub supported: bool,
328}
329
330#[derive(Debug, Clone, Serialize, Deserialize)]
331#[serde(rename_all = "camelCase")]
332pub struct BulkSupported {
333    pub supported: bool,
334    pub max_operations: u32,
335    pub max_payload_size: u64,
336}
337
338#[derive(Debug, Clone, Serialize, Deserialize)]
339#[serde(rename_all = "camelCase")]
340pub struct FilterSupported {
341    pub supported: bool,
342    pub max_results: u32,
343}
344
345#[derive(Debug, Clone, Serialize, Deserialize)]
346#[serde(rename_all = "camelCase")]
347pub struct AuthenticationScheme {
348    pub name: String,
349    pub description: String,
350    #[serde(rename = "type")]
351    pub scheme_type: String,
352    #[serde(skip_serializing_if = "Option::is_none")]
353    pub spec_uri: Option<String>,
354    #[serde(skip_serializing_if = "Option::is_none")]
355    pub documentation_uri: Option<String>,
356    pub primary: bool,
357}
358
359// ─── SCIM Client ─────────────────────────────────────────────────────────────
360
361/// Configuration for the SCIM 2.0 client.
362#[derive(Debug, Clone)]
363pub struct ScimClientConfig {
364    /// Base URL of the SCIM service (e.g. `https://idp.example.com/scim/v2`).
365    pub base_url: String,
366
367    /// Bearer token for authentication.
368    pub bearer_token: String,
369
370    /// Request timeout in seconds.
371    pub timeout_secs: u64,
372}
373
374/// SCIM 2.0 client for provisioning users and groups.
375pub struct ScimClient {
376    config: ScimClientConfig,
377    http: reqwest::Client,
378}
379
380impl ScimClient {
381    /// Create a new SCIM client.
382    pub fn new(config: ScimClientConfig) -> Result<Self> {
383        if !config.base_url.starts_with("https://") {
384            return Err(AuthError::config(
385                "SCIM base URL must use HTTPS for security",
386            ));
387        }
388
389        let http = reqwest::Client::builder()
390            .timeout(std::time::Duration::from_secs(config.timeout_secs))
391            .build()
392            .map_err(|e| AuthError::internal(format!("Failed to build HTTP client: {e}")))?;
393
394        Ok(Self { config, http })
395    }
396
397    // ── Users ────────────────────────────────────────────────────────────
398
399    /// Create a user (POST /Users).
400    pub async fn create_user(&self, user: &ScimUser) -> Result<ScimUser> {
401        let url = format!("{}/Users", self.config.base_url);
402        let resp = self
403            .http
404            .post(&url)
405            .bearer_auth(&self.config.bearer_token)
406            .json(user)
407            .send()
408            .await
409            .map_err(|e| AuthError::internal(format!("SCIM create user request failed: {e}")))?;
410
411        if !resp.status().is_success() {
412            let status = resp.status();
413            let body = resp.text().await.unwrap_or_default();
414            return Err(AuthError::internal(format!(
415                "SCIM create user failed (HTTP {status}): {body}"
416            )));
417        }
418
419        resp.json::<ScimUser>()
420            .await
421            .map_err(|e| AuthError::internal(format!("SCIM create user parse error: {e}")))
422    }
423
424    /// Get a user by ID (GET /Users/{id}).
425    pub async fn get_user(&self, id: &str) -> Result<ScimUser> {
426        let url = format!("{}/Users/{}", self.config.base_url, id);
427        let resp = self
428            .http
429            .get(&url)
430            .bearer_auth(&self.config.bearer_token)
431            .send()
432            .await
433            .map_err(|e| AuthError::internal(format!("SCIM get user request failed: {e}")))?;
434
435        if !resp.status().is_success() {
436            let status = resp.status();
437            let body = resp.text().await.unwrap_or_default();
438            return Err(AuthError::internal(format!(
439                "SCIM get user failed (HTTP {status}): {body}"
440            )));
441        }
442
443        resp.json::<ScimUser>()
444            .await
445            .map_err(|e| AuthError::internal(format!("SCIM get user parse error: {e}")))
446    }
447
448    /// Replace a user (PUT /Users/{id}).
449    pub async fn replace_user(&self, id: &str, user: &ScimUser) -> Result<ScimUser> {
450        let url = format!("{}/Users/{}", self.config.base_url, id);
451        let resp = self
452            .http
453            .put(&url)
454            .bearer_auth(&self.config.bearer_token)
455            .json(user)
456            .send()
457            .await
458            .map_err(|e| AuthError::internal(format!("SCIM replace user request failed: {e}")))?;
459
460        if !resp.status().is_success() {
461            let status = resp.status();
462            let body = resp.text().await.unwrap_or_default();
463            return Err(AuthError::internal(format!(
464                "SCIM replace user failed (HTTP {status}): {body}"
465            )));
466        }
467
468        resp.json::<ScimUser>()
469            .await
470            .map_err(|e| AuthError::internal(format!("SCIM replace user parse error: {e}")))
471    }
472
473    /// Patch a user (PATCH /Users/{id}).
474    pub async fn patch_user(&self, id: &str, patch: &PatchOp) -> Result<ScimUser> {
475        let url = format!("{}/Users/{}", self.config.base_url, id);
476        let resp = self
477            .http
478            .patch(&url)
479            .bearer_auth(&self.config.bearer_token)
480            .json(patch)
481            .send()
482            .await
483            .map_err(|e| AuthError::internal(format!("SCIM patch user request failed: {e}")))?;
484
485        if !resp.status().is_success() {
486            let status = resp.status();
487            let body = resp.text().await.unwrap_or_default();
488            return Err(AuthError::internal(format!(
489                "SCIM patch user failed (HTTP {status}): {body}"
490            )));
491        }
492
493        resp.json::<ScimUser>()
494            .await
495            .map_err(|e| AuthError::internal(format!("SCIM patch user parse error: {e}")))
496    }
497
498    /// Delete a user (DELETE /Users/{id}).
499    pub async fn delete_user(&self, id: &str) -> Result<()> {
500        let url = format!("{}/Users/{}", self.config.base_url, id);
501        let resp = self
502            .http
503            .delete(&url)
504            .bearer_auth(&self.config.bearer_token)
505            .send()
506            .await
507            .map_err(|e| AuthError::internal(format!("SCIM delete user request failed: {e}")))?;
508
509        if !resp.status().is_success() {
510            let status = resp.status();
511            let body = resp.text().await.unwrap_or_default();
512            return Err(AuthError::internal(format!(
513                "SCIM delete user failed (HTTP {status}): {body}"
514            )));
515        }
516
517        Ok(())
518    }
519
520    /// List users with optional filter (GET /Users?filter=...).
521    pub async fn list_users(
522        &self,
523        filter: Option<&str>,
524        start_index: Option<u64>,
525        count: Option<u64>,
526    ) -> Result<ListResponse<ScimUser>> {
527        let mut url = format!("{}/Users", self.config.base_url);
528        let mut params = Vec::new();
529        if let Some(f) = filter {
530            params.push(format!("filter={}", urlencoding::encode(f)));
531        }
532        if let Some(si) = start_index {
533            params.push(format!("startIndex={si}"));
534        }
535        if let Some(c) = count {
536            params.push(format!("count={c}"));
537        }
538        if !params.is_empty() {
539            url = format!("{}?{}", url, params.join("&"));
540        }
541
542        let resp = self
543            .http
544            .get(&url)
545            .bearer_auth(&self.config.bearer_token)
546            .send()
547            .await
548            .map_err(|e| AuthError::internal(format!("SCIM list users request failed: {e}")))?;
549
550        if !resp.status().is_success() {
551            let status = resp.status();
552            let body = resp.text().await.unwrap_or_default();
553            return Err(AuthError::internal(format!(
554                "SCIM list users failed (HTTP {status}): {body}"
555            )));
556        }
557
558        resp.json::<ListResponse<ScimUser>>()
559            .await
560            .map_err(|e| AuthError::internal(format!("SCIM list users parse error: {e}")))
561    }
562
563    // ── Groups ───────────────────────────────────────────────────────────
564
565    /// Create a group (POST /Groups).
566    pub async fn create_group(&self, group: &ScimGroup) -> Result<ScimGroup> {
567        let url = format!("{}/Groups", self.config.base_url);
568        let resp = self
569            .http
570            .post(&url)
571            .bearer_auth(&self.config.bearer_token)
572            .json(group)
573            .send()
574            .await
575            .map_err(|e| AuthError::internal(format!("SCIM create group request failed: {e}")))?;
576
577        if !resp.status().is_success() {
578            let status = resp.status();
579            let body = resp.text().await.unwrap_or_default();
580            return Err(AuthError::internal(format!(
581                "SCIM create group failed (HTTP {status}): {body}"
582            )));
583        }
584
585        resp.json::<ScimGroup>()
586            .await
587            .map_err(|e| AuthError::internal(format!("SCIM create group parse error: {e}")))
588    }
589
590    /// Get a group by ID (GET /Groups/{id}).
591    pub async fn get_group(&self, id: &str) -> Result<ScimGroup> {
592        let url = format!("{}/Groups/{}", self.config.base_url, id);
593        let resp = self
594            .http
595            .get(&url)
596            .bearer_auth(&self.config.bearer_token)
597            .send()
598            .await
599            .map_err(|e| AuthError::internal(format!("SCIM get group request failed: {e}")))?;
600
601        if !resp.status().is_success() {
602            let status = resp.status();
603            let body = resp.text().await.unwrap_or_default();
604            return Err(AuthError::internal(format!(
605                "SCIM get group failed (HTTP {status}): {body}"
606            )));
607        }
608
609        resp.json::<ScimGroup>()
610            .await
611            .map_err(|e| AuthError::internal(format!("SCIM get group parse error: {e}")))
612    }
613
614    /// Delete a group (DELETE /Groups/{id}).
615    pub async fn delete_group(&self, id: &str) -> Result<()> {
616        let url = format!("{}/Groups/{}", self.config.base_url, id);
617        let resp = self
618            .http
619            .delete(&url)
620            .bearer_auth(&self.config.bearer_token)
621            .send()
622            .await
623            .map_err(|e| AuthError::internal(format!("SCIM delete group request failed: {e}")))?;
624
625        if !resp.status().is_success() {
626            let status = resp.status();
627            let body = resp.text().await.unwrap_or_default();
628            return Err(AuthError::internal(format!(
629                "SCIM delete group failed (HTTP {status}): {body}"
630            )));
631        }
632
633        Ok(())
634    }
635
636    /// List groups with optional filter (GET /Groups?filter=...).
637    pub async fn list_groups(
638        &self,
639        filter: Option<&str>,
640        start_index: Option<u64>,
641        count: Option<u64>,
642    ) -> Result<ListResponse<ScimGroup>> {
643        let mut url = format!("{}/Groups", self.config.base_url);
644        let mut params = Vec::new();
645        if let Some(f) = filter {
646            params.push(format!("filter={}", urlencoding::encode(f)));
647        }
648        if let Some(si) = start_index {
649            params.push(format!("startIndex={si}"));
650        }
651        if let Some(c) = count {
652            params.push(format!("count={c}"));
653        }
654        if !params.is_empty() {
655            url = format!("{}?{}", url, params.join("&"));
656        }
657
658        let resp = self
659            .http
660            .get(&url)
661            .bearer_auth(&self.config.bearer_token)
662            .send()
663            .await
664            .map_err(|e| AuthError::internal(format!("SCIM list groups request failed: {e}")))?;
665
666        if !resp.status().is_success() {
667            let status = resp.status();
668            let body = resp.text().await.unwrap_or_default();
669            return Err(AuthError::internal(format!(
670                "SCIM list groups failed (HTTP {status}): {body}"
671            )));
672        }
673
674        resp.json::<ListResponse<ScimGroup>>()
675            .await
676            .map_err(|e| AuthError::internal(format!("SCIM list groups parse error: {e}")))
677    }
678
679    // ── Bulk ─────────────────────────────────────────────────────────────
680
681    /// Execute a bulk request (POST /Bulk).
682    pub async fn bulk(&self, request: &BulkRequest) -> Result<BulkResponse> {
683        let url = format!("{}/Bulk", self.config.base_url);
684        let resp = self
685            .http
686            .post(&url)
687            .bearer_auth(&self.config.bearer_token)
688            .json(request)
689            .send()
690            .await
691            .map_err(|e| AuthError::internal(format!("SCIM bulk request failed: {e}")))?;
692
693        if !resp.status().is_success() {
694            let status = resp.status();
695            let body = resp.text().await.unwrap_or_default();
696            return Err(AuthError::internal(format!(
697                "SCIM bulk request failed (HTTP {status}): {body}"
698            )));
699        }
700
701        resp.json::<BulkResponse>()
702            .await
703            .map_err(|e| AuthError::internal(format!("SCIM bulk response parse error: {e}")))
704    }
705
706    // ── Service Provider Config ──────────────────────────────────────────
707
708    /// Retrieve the service provider configuration.
709    pub async fn get_service_provider_config(&self) -> Result<ServiceProviderConfig> {
710        let url = format!("{}/ServiceProviderConfig", self.config.base_url);
711        let resp = self
712            .http
713            .get(&url)
714            .bearer_auth(&self.config.bearer_token)
715            .send()
716            .await
717            .map_err(|e| {
718                AuthError::internal(format!("SCIM service provider config request failed: {e}"))
719            })?;
720
721        if !resp.status().is_success() {
722            let status = resp.status();
723            let body = resp.text().await.unwrap_or_default();
724            return Err(AuthError::internal(format!(
725                "SCIM service provider config failed (HTTP {status}): {body}"
726            )));
727        }
728
729        resp.json::<ServiceProviderConfig>().await.map_err(|e| {
730            AuthError::internal(format!("SCIM service provider config parse error: {e}"))
731        })
732    }
733}
734
735// ─── Helper constructors ─────────────────────────────────────────────────────
736
737impl PatchOp {
738    /// Create a new PatchOp with a list of operations.
739    pub fn new(operations: Vec<PatchOperation>) -> Self {
740        Self {
741            schemas: vec![schema::PATCH_OP.to_string()],
742            operations,
743        }
744    }
745}
746
747impl SearchRequest {
748    /// Create a search request with a filter.
749    pub fn with_filter(filter: impl Into<String>) -> Self {
750        Self {
751            schemas: vec![schema::SEARCH_REQUEST.to_string()],
752            filter: Some(filter.into()),
753            sort_by: None,
754            sort_order: None,
755            start_index: None,
756            count: None,
757            attributes: None,
758            excluded_attributes: None,
759        }
760    }
761}
762
763impl BulkRequest {
764    /// Create a new bulk request.
765    pub fn new(operations: Vec<BulkOperation>, fail_on_errors: Option<u32>) -> Self {
766        Self {
767            schemas: vec![schema::BULK_REQUEST.to_string()],
768            fail_on_errors,
769            operations,
770        }
771    }
772}
773
774#[cfg(test)]
775mod tests {
776    use super::*;
777
778    #[test]
779    fn test_scim_user_serialization() {
780        let mut user = ScimUser::new("jdoe");
781        user.name = Some(Name {
782            given_name: Some("John".into()),
783            family_name: Some("Doe".into()),
784            ..Default::default()
785        });
786        user.emails = Some(vec![MultiValuedAttr {
787            value: "jdoe@example.com".into(),
788            attr_type: Some("work".into()),
789            primary: Some(true),
790            display: None,
791        }]);
792
793        let json = serde_json::to_string(&user).expect("serialize");
794        assert!(json.contains("\"userName\":\"jdoe\""));
795        assert!(json.contains(schema::USER));
796    }
797
798    #[test]
799    fn test_scim_group_serialization() {
800        let group = ScimGroup::new("Engineering");
801        let json = serde_json::to_string(&group).expect("serialize");
802        assert!(json.contains("\"displayName\":\"Engineering\""));
803        assert!(json.contains(schema::GROUP));
804    }
805
806    #[test]
807    fn test_patch_op_construction() {
808        let patch = PatchOp::new(vec![PatchOperation {
809            op: PatchOpType::Replace,
810            path: Some("active".into()),
811            value: Some(serde_json::Value::Bool(false)),
812        }]);
813        assert_eq!(patch.schemas[0], schema::PATCH_OP);
814        assert_eq!(patch.operations.len(), 1);
815    }
816
817    #[test]
818    fn test_scim_user_roundtrip() {
819        let user = ScimUser::new("alice");
820        let json = serde_json::to_value(&user).expect("to value");
821        let parsed: ScimUser = serde_json::from_value(json).expect("from value");
822        assert_eq!(parsed.user_name, "alice");
823        assert_eq!(parsed.active, Some(true));
824    }
825
826    #[test]
827    fn test_bulk_request_construction() {
828        let bulk = BulkRequest::new(
829            vec![BulkOperation {
830                method: BulkMethod::Post,
831                bulk_id: Some("op1".into()),
832                path: Some("/Users".into()),
833                data: Some(serde_json::to_value(ScimUser::new("bulk_user")).expect("val")),
834            }],
835            Some(1),
836        );
837        assert_eq!(bulk.schemas[0], schema::BULK_REQUEST);
838        assert_eq!(bulk.fail_on_errors, Some(1));
839    }
840
841    #[test]
842    fn test_scim_user_empty_username() {
843        let user = ScimUser::new("");
844        assert_eq!(user.user_name, "");
845        // Roundtrip should preserve the empty string
846        let json = serde_json::to_value(&user).unwrap();
847        let parsed: ScimUser = serde_json::from_value(json).unwrap();
848        assert_eq!(parsed.user_name, "");
849    }
850
851    #[test]
852    fn test_scim_user_all_optional_fields() {
853        let mut user = ScimUser::new("fulluser");
854        user.id = Some("u-123".into());
855        user.display_name = Some("Full User".into());
856        user.name = Some(Name {
857            formatted: Some("Dr. Full A. User Jr.".into()),
858            family_name: Some("User".into()),
859            given_name: Some("Full".into()),
860            middle_name: Some("A".into()),
861            honorific_prefix: Some("Dr.".into()),
862            honorific_suffix: Some("Jr.".into()),
863        });
864        user.emails = Some(vec![
865            MultiValuedAttr {
866                value: "work@example.com".into(),
867                attr_type: Some("work".into()),
868                primary: Some(true),
869                display: Some("Work Email".into()),
870            },
871            MultiValuedAttr {
872                value: "home@example.com".into(),
873                attr_type: Some("home".into()),
874                primary: Some(false),
875                display: None,
876            },
877        ]);
878        user.phone_numbers = Some(vec![MultiValuedAttr {
879            value: "+1-555-0100".into(),
880            attr_type: Some("mobile".into()),
881            primary: Some(true),
882            display: None,
883        }]);
884        user.active = Some(false);
885        user.groups = Some(vec![MultiValuedAttr {
886            value: "g-eng".into(),
887            attr_type: None,
888            primary: None,
889            display: Some("Engineering".into()),
890        }]);
891
892        let json = serde_json::to_string(&user).unwrap();
893        let parsed: ScimUser =
894            serde_json::from_value(serde_json::from_str(&json).unwrap()).unwrap();
895        assert_eq!(parsed.id.as_deref(), Some("u-123"));
896        assert_eq!(parsed.display_name.as_deref(), Some("Full User"));
897        assert_eq!(parsed.active, Some(false));
898        assert_eq!(parsed.emails.as_ref().unwrap().len(), 2);
899        assert_eq!(parsed.phone_numbers.as_ref().unwrap().len(), 1);
900        assert_eq!(parsed.groups.as_ref().unwrap().len(), 1);
901        let name = parsed.name.as_ref().unwrap();
902        assert_eq!(name.honorific_prefix.as_deref(), Some("Dr."));
903        assert_eq!(name.honorific_suffix.as_deref(), Some("Jr."));
904    }
905
906    #[test]
907    fn test_scim_user_deserialization_from_json() {
908        let json_str = r#"{
909            "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
910            "id": "server-1",
911            "userName": "jdoe",
912            "displayName": "Jane Doe",
913            "active": true,
914            "meta": {
915                "resourceType": "User",
916                "created": "2024-01-01T00:00:00Z",
917                "lastModified": "2024-06-01T00:00:00Z",
918                "location": "https://scim.example.com/Users/server-1",
919                "version": "W/\"1\""
920            }
921        }"#;
922
923        let user: ScimUser = serde_json::from_str(json_str).unwrap();
924        assert_eq!(user.user_name, "jdoe");
925        assert_eq!(user.id.as_deref(), Some("server-1"));
926        assert_eq!(user.display_name.as_deref(), Some("Jane Doe"));
927        let meta = user.meta.as_ref().unwrap();
928        assert_eq!(meta.resource_type, "User");
929        assert_eq!(meta.version.as_deref(), Some("W/\"1\""));
930    }
931
932    #[test]
933    fn test_scim_group_with_members() {
934        let mut group = ScimGroup::new("DevOps");
935        group.members = Some(vec![
936            MultiValuedAttr {
937                value: "u-1".into(),
938                attr_type: Some("User".into()),
939                primary: None,
940                display: Some("Alice".into()),
941            },
942            MultiValuedAttr {
943                value: "u-2".into(),
944                attr_type: Some("User".into()),
945                primary: None,
946                display: Some("Bob".into()),
947            },
948        ]);
949
950        let json = serde_json::to_string(&group).unwrap();
951        let parsed: ScimGroup = serde_json::from_str(&json).unwrap();
952        assert_eq!(parsed.display_name, "DevOps");
953        let members = parsed.members.unwrap();
954        assert_eq!(members.len(), 2);
955        assert_eq!(members[0].display.as_deref(), Some("Alice"));
956    }
957
958    #[test]
959    fn test_patch_op_add_and_remove() {
960        let patch = PatchOp::new(vec![
961            PatchOperation {
962                op: PatchOpType::Add,
963                path: Some("emails".into()),
964                value: Some(serde_json::json!([{"value": "new@example.com", "type": "work"}])),
965            },
966            PatchOperation {
967                op: PatchOpType::Remove,
968                path: Some("phoneNumbers".into()),
969                value: None,
970            },
971        ]);
972
973        assert_eq!(patch.operations.len(), 2);
974
975        // Verify JSON serialization of patch ops
976        let json = serde_json::to_string(&patch).unwrap();
977        assert!(json.contains("\"add\""));
978        assert!(json.contains("\"remove\""));
979        assert!(json.contains("new@example.com"));
980    }
981
982    #[test]
983    fn test_bulk_request_multiple_operations() {
984        let bulk = BulkRequest::new(
985            vec![
986                BulkOperation {
987                    method: BulkMethod::Post,
988                    bulk_id: Some("create-1".into()),
989                    path: Some("/Users".into()),
990                    data: Some(serde_json::to_value(ScimUser::new("user1")).unwrap()),
991                },
992                BulkOperation {
993                    method: BulkMethod::Put,
994                    bulk_id: Some("update-1".into()),
995                    path: Some("/Users/existing-id".into()),
996                    data: Some(serde_json::to_value(ScimUser::new("user1_updated")).unwrap()),
997                },
998                BulkOperation {
999                    method: BulkMethod::Delete,
1000                    bulk_id: Some("delete-1".into()),
1001                    path: Some("/Users/old-id".into()),
1002                    data: None,
1003                },
1004            ],
1005            Some(2),
1006        );
1007
1008        assert_eq!(bulk.operations.len(), 3);
1009        assert_eq!(bulk.fail_on_errors, Some(2));
1010
1011        // Roundtrip
1012        let json = serde_json::to_string(&bulk).unwrap();
1013        let parsed: BulkRequest = serde_json::from_str(&json).unwrap();
1014        assert_eq!(parsed.operations.len(), 3);
1015    }
1016
1017    #[test]
1018    fn test_search_request_with_filter() {
1019        let search = SearchRequest::with_filter("userName eq \"jdoe\"");
1020        assert_eq!(search.filter.as_deref(), Some("userName eq \"jdoe\""));
1021        assert_eq!(search.schemas[0], schema::SEARCH_REQUEST);
1022        assert!(search.sort_by.is_none());
1023        assert!(search.count.is_none());
1024    }
1025
1026    #[test]
1027    fn test_scim_error_serialization() {
1028        let error = ScimError {
1029            schemas: vec![schema::ERROR.to_string()],
1030            status: Some("400".into()),
1031            scim_type: Some("invalidFilter".into()),
1032            detail: Some("The filter syntax is invalid".into()),
1033        };
1034
1035        let json = serde_json::to_string(&error).unwrap();
1036        let parsed: ScimError = serde_json::from_str(&json).unwrap();
1037        assert_eq!(parsed.status.as_deref(), Some("400"));
1038        assert_eq!(parsed.scim_type.as_deref(), Some("invalidFilter"));
1039    }
1040
1041    #[test]
1042    fn test_scim_user_active_defaults_to_true() {
1043        let user = ScimUser::new("defaultuser");
1044        assert_eq!(user.active, Some(true));
1045    }
1046
1047    #[test]
1048    fn test_scim_user_extensions_roundtrip() {
1049        let mut user = ScimUser::new("extuser");
1050        user.extensions.insert(
1051            "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User".to_string(),
1052            serde_json::json!({
1053                "employeeNumber": "12345",
1054                "department": "Engineering"
1055            }),
1056        );
1057
1058        let json = serde_json::to_string(&user).unwrap();
1059        let parsed: ScimUser = serde_json::from_str(&json).unwrap();
1060        let ext = &parsed.extensions["urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"];
1061        assert_eq!(ext["employeeNumber"], "12345");
1062        assert_eq!(ext["department"], "Engineering");
1063    }
1064
1065    #[test]
1066    fn test_scim_client_rejects_http_url() {
1067        let config = ScimClientConfig {
1068            base_url: "http://insecure.example.com/scim/v2".to_string(),
1069            bearer_token: "tok".to_string(),
1070            timeout_secs: 10,
1071        };
1072        match ScimClient::new(config) {
1073            Err(e) => {
1074                let msg = format!("{e}");
1075                assert!(msg.contains("HTTPS"), "got: {msg}");
1076            }
1077            Ok(_) => panic!("expected error for HTTP URL"),
1078        }
1079    }
1080
1081    #[test]
1082    fn test_list_response_deserialization() {
1083        let json_str = r#"{
1084            "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
1085            "totalResults": 2,
1086            "startIndex": 1,
1087            "itemsPerPage": 10,
1088            "Resources": [
1089                {
1090                    "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
1091                    "userName": "alice"
1092                },
1093                {
1094                    "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
1095                    "userName": "bob"
1096                }
1097            ]
1098        }"#;
1099
1100        let list: ListResponse<ScimUser> = serde_json::from_str(json_str).unwrap();
1101        assert_eq!(list.total_results, 2);
1102        assert_eq!(list.resources.len(), 2);
1103        assert_eq!(list.resources[0].user_name, "alice");
1104        assert_eq!(list.resources[1].user_name, "bob");
1105    }
1106}