arcly-http-identity 0.8.0

CIAM building-block engine for arcly-http: identity store traits, password hashing, MFA (TOTP), passwordless & account lifecycle, risk signals, and an OIDC provider — storage-agnostic, zero-lock, all I/O behind traits.
Documentation
//! SCIM 2.0 provisioning / deprovisioning (RFC 7643/7644) — the enterprise
//! lifecycle side of B2B SSO. When an org's IdP (Okta/Azure AD) grants or
//! revokes a user, it calls the app's SCIM endpoint; this maps those operations
//! onto the [`UserStore`].
//!
//! This provides the *core resource types* and a [`ScimProvisioner`] that
//! translates SCIM Users to [`Identity`] and back. Mount it under
//! `/scim/v2/Users` in your app (the HTTP surface is app-side, like controllers).

use std::sync::Arc;

use serde::{Deserialize, Serialize};

use crate::error::{IdentityError, Result};
use crate::identity::new_id;
use crate::model::{AccountStatus, Identity};
use crate::store::UserStore;

const SCHEMA_USER: &str = "urn:ietf:params:scim:schemas:core:2.0:User";

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScimName {
    #[serde(default, rename = "givenName", skip_serializing_if = "Option::is_none")]
    pub given_name: Option<String>,
    #[serde(
        default,
        rename = "familyName",
        skip_serializing_if = "Option::is_none"
    )]
    pub family_name: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScimEmail {
    pub value: String,
    #[serde(default)]
    pub primary: bool,
}

/// A SCIM 2.0 User resource (core-schema subset).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScimUser {
    pub schemas: Vec<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub id: Option<String>,
    #[serde(rename = "userName")]
    pub user_name: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub name: Option<ScimName>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub emails: Vec<ScimEmail>,
    /// SCIM's active flag drives (de)provisioning: `false` ⇒ suspend.
    #[serde(default = "default_true")]
    pub active: bool,
    #[serde(
        default,
        rename = "externalId",
        skip_serializing_if = "Option::is_none"
    )]
    pub external_id: Option<String>,
}

fn default_true() -> bool {
    true
}

impl ScimUser {
    fn primary_email(&self) -> Option<&str> {
        self.emails
            .iter()
            .find(|e| e.primary)
            .or_else(|| self.emails.first())
            .map(|e| e.value.as_str())
    }
}

/// Maps SCIM provisioning operations onto the [`UserStore`], scoped to a tenant.
pub struct ScimProvisioner {
    users: Arc<dyn UserStore>,
    tenant: Option<String>,
}

impl ScimProvisioner {
    pub fn new(users: Arc<dyn UserStore>, tenant: Option<String>) -> Self {
        Self { users, tenant }
    }

    /// `POST /Users` — create (or return existing) provisioned user.
    pub async fn create(&self, scim: &ScimUser) -> Result<ScimUser> {
        let email = scim.primary_email().unwrap_or(&scim.user_name).to_owned();
        if self
            .users
            .find_by_email(self.tenant.as_deref(), &email)
            .await?
            .is_some()
        {
            return Err(IdentityError::AlreadyExists);
        }
        let identity = Identity {
            id: new_id(),
            tenant: self.tenant.clone(),
            email: Some(email),
            // SCIM-provisioned users come from a trusted IdP → email verified.
            email_verified: true,
            phone: None,
            phone_verified: false,
            status: if scim.active {
                AccountStatus::Active
            } else {
                AccountStatus::Suspended
            },
            roles: vec!["user".to_owned()],
            perms: Vec::new(),
            mfa: Default::default(),
            attributes: Default::default(),
        };
        self.users.insert(&identity).await?;
        Ok(to_scim(&identity))
    }

    /// `PUT /Users/{id}` — replace core attributes (name/email/active).
    pub async fn replace(&self, id: &str, scim: &ScimUser) -> Result<ScimUser> {
        let mut identity = self
            .users
            .find_by_id(id)
            .await?
            .ok_or(IdentityError::NotFound)?;
        if let Some(email) = scim.primary_email() {
            identity.email = Some(email.to_owned());
        }
        identity.status = if scim.active {
            AccountStatus::Active
        } else {
            AccountStatus::Suspended
        };
        self.users.update(&identity).await?;
        Ok(to_scim(&identity))
    }

    /// `PATCH /Users/{id}` with `active: false` — the standard **deprovisioning**
    /// signal when a user leaves the org. Suspends rather than deletes so audit
    /// history survives; call [`delete`](Self::delete) for hard removal.
    pub async fn set_active(&self, id: &str, active: bool) -> Result<ScimUser> {
        let mut identity = self
            .users
            .find_by_id(id)
            .await?
            .ok_or(IdentityError::NotFound)?;
        identity.status = if active {
            AccountStatus::Active
        } else {
            AccountStatus::Suspended
        };
        self.users.update(&identity).await?;
        Ok(to_scim(&identity))
    }

    /// `GET /Users/{id}`.
    pub async fn get(&self, id: &str) -> Result<ScimUser> {
        self.users
            .find_by_id(id)
            .await?
            .map(|i| to_scim(&i))
            .ok_or(IdentityError::NotFound)
    }

    /// `DELETE /Users/{id}` — mark deleted (app should crypto-shred PII via the
    /// compliance `CryptoVault` in its store implementation).
    pub async fn delete(&self, id: &str) -> Result<()> {
        let mut identity = self
            .users
            .find_by_id(id)
            .await?
            .ok_or(IdentityError::NotFound)?;
        identity.status = AccountStatus::Deleted;
        self.users.update(&identity).await
    }
}

fn to_scim(identity: &Identity) -> ScimUser {
    ScimUser {
        schemas: vec![SCHEMA_USER.to_owned()],
        id: Some(identity.id.clone()),
        user_name: identity
            .email
            .clone()
            .unwrap_or_else(|| identity.id.clone()),
        name: None,
        emails: identity
            .email
            .as_ref()
            .map(|e| {
                vec![ScimEmail {
                    value: e.clone(),
                    primary: true,
                }]
            })
            .unwrap_or_default(),
        active: matches!(identity.status, AccountStatus::Active),
        external_id: None,
    }
}