Skip to main content

arcly_http_identity/federation/
scim.rs

1//! SCIM 2.0 provisioning / deprovisioning (RFC 7643/7644) — the enterprise
2//! lifecycle side of B2B SSO. When an org's IdP (Okta/Azure AD) grants or
3//! revokes a user, it calls the app's SCIM endpoint; this maps those operations
4//! onto the [`UserStore`].
5//!
6//! This provides the *core resource types* and a [`ScimProvisioner`] that
7//! translates SCIM Users to [`Identity`] and back. Mount it under
8//! `/scim/v2/Users` in your app (the HTTP surface is app-side, like controllers).
9
10use std::sync::Arc;
11
12use serde::{Deserialize, Serialize};
13
14use crate::error::{IdentityError, Result};
15use crate::identity::new_id;
16use crate::model::{AccountStatus, Identity};
17use crate::store::UserStore;
18
19const SCHEMA_USER: &str = "urn:ietf:params:scim:schemas:core:2.0:User";
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct ScimName {
23    #[serde(default, rename = "givenName", skip_serializing_if = "Option::is_none")]
24    pub given_name: Option<String>,
25    #[serde(
26        default,
27        rename = "familyName",
28        skip_serializing_if = "Option::is_none"
29    )]
30    pub family_name: Option<String>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct ScimEmail {
35    pub value: String,
36    #[serde(default)]
37    pub primary: bool,
38}
39
40/// A SCIM 2.0 User resource (core-schema subset).
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct ScimUser {
43    pub schemas: Vec<String>,
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub id: Option<String>,
46    #[serde(rename = "userName")]
47    pub user_name: String,
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub name: Option<ScimName>,
50    #[serde(default, skip_serializing_if = "Vec::is_empty")]
51    pub emails: Vec<ScimEmail>,
52    /// SCIM's active flag drives (de)provisioning: `false` ⇒ suspend.
53    #[serde(default = "default_true")]
54    pub active: bool,
55    #[serde(
56        default,
57        rename = "externalId",
58        skip_serializing_if = "Option::is_none"
59    )]
60    pub external_id: Option<String>,
61}
62
63fn default_true() -> bool {
64    true
65}
66
67impl ScimUser {
68    fn primary_email(&self) -> Option<&str> {
69        self.emails
70            .iter()
71            .find(|e| e.primary)
72            .or_else(|| self.emails.first())
73            .map(|e| e.value.as_str())
74    }
75}
76
77/// Maps SCIM provisioning operations onto the [`UserStore`], scoped to a tenant.
78pub struct ScimProvisioner {
79    users: Arc<dyn UserStore>,
80    tenant: Option<String>,
81}
82
83impl ScimProvisioner {
84    pub fn new(users: Arc<dyn UserStore>, tenant: Option<String>) -> Self {
85        Self { users, tenant }
86    }
87
88    /// `POST /Users` — create (or return existing) provisioned user.
89    pub async fn create(&self, scim: &ScimUser) -> Result<ScimUser> {
90        let email = scim.primary_email().unwrap_or(&scim.user_name).to_owned();
91        if self
92            .users
93            .find_by_email(self.tenant.as_deref(), &email)
94            .await?
95            .is_some()
96        {
97            return Err(IdentityError::AlreadyExists);
98        }
99        let identity = Identity {
100            id: new_id(),
101            tenant: self.tenant.clone(),
102            email: Some(email),
103            // SCIM-provisioned users come from a trusted IdP → email verified.
104            email_verified: true,
105            phone: None,
106            phone_verified: false,
107            status: if scim.active {
108                AccountStatus::Active
109            } else {
110                AccountStatus::Suspended
111            },
112            roles: vec!["user".to_owned()],
113            perms: Vec::new(),
114            mfa: Default::default(),
115            attributes: Default::default(),
116        };
117        self.users.insert(&identity).await?;
118        Ok(to_scim(&identity))
119    }
120
121    /// `PUT /Users/{id}` — replace core attributes (name/email/active).
122    pub async fn replace(&self, id: &str, scim: &ScimUser) -> Result<ScimUser> {
123        let mut identity = self
124            .users
125            .find_by_id(id)
126            .await?
127            .ok_or(IdentityError::NotFound)?;
128        if let Some(email) = scim.primary_email() {
129            identity.email = Some(email.to_owned());
130        }
131        identity.status = if scim.active {
132            AccountStatus::Active
133        } else {
134            AccountStatus::Suspended
135        };
136        self.users.update(&identity).await?;
137        Ok(to_scim(&identity))
138    }
139
140    /// `PATCH /Users/{id}` with `active: false` — the standard **deprovisioning**
141    /// signal when a user leaves the org. Suspends rather than deletes so audit
142    /// history survives; call [`delete`](Self::delete) for hard removal.
143    pub async fn set_active(&self, id: &str, active: bool) -> Result<ScimUser> {
144        let mut identity = self
145            .users
146            .find_by_id(id)
147            .await?
148            .ok_or(IdentityError::NotFound)?;
149        identity.status = if active {
150            AccountStatus::Active
151        } else {
152            AccountStatus::Suspended
153        };
154        self.users.update(&identity).await?;
155        Ok(to_scim(&identity))
156    }
157
158    /// `GET /Users/{id}`.
159    pub async fn get(&self, id: &str) -> Result<ScimUser> {
160        self.users
161            .find_by_id(id)
162            .await?
163            .map(|i| to_scim(&i))
164            .ok_or(IdentityError::NotFound)
165    }
166
167    /// `DELETE /Users/{id}` — mark deleted (app should crypto-shred PII via the
168    /// compliance `CryptoVault` in its store implementation).
169    pub async fn delete(&self, id: &str) -> Result<()> {
170        let mut identity = self
171            .users
172            .find_by_id(id)
173            .await?
174            .ok_or(IdentityError::NotFound)?;
175        identity.status = AccountStatus::Deleted;
176        self.users.update(&identity).await
177    }
178}
179
180fn to_scim(identity: &Identity) -> ScimUser {
181    ScimUser {
182        schemas: vec![SCHEMA_USER.to_owned()],
183        id: Some(identity.id.clone()),
184        user_name: identity
185            .email
186            .clone()
187            .unwrap_or_else(|| identity.id.clone()),
188        name: None,
189        emails: identity
190            .email
191            .as_ref()
192            .map(|e| {
193                vec![ScimEmail {
194                    value: e.clone(),
195                    primary: true,
196                }]
197            })
198            .unwrap_or_default(),
199        active: matches!(identity.status, AccountStatus::Active),
200        external_id: None,
201    }
202}