Skip to main content

rustauth_scim/
options.rs

1//! SCIM plugin configuration.
2
3use std::future::Future;
4use std::pin::Pin;
5use std::sync::Arc;
6
7use http::StatusCode;
8use rustauth_core::db::User;
9use rustauth_core::error::RustAuthError;
10
11use crate::store::ScimProviderRecord;
12
13/// Boxed future returned by SCIM hooks.
14pub type ScimHookFuture = Pin<Box<dyn Future<Output = Result<(), ScimHookError>> + Send>>;
15
16/// Boxed future returned by custom token storage callbacks.
17pub type ScimTokenStorageFuture =
18    Pin<Box<dyn Future<Output = Result<String, RustAuthError>> + Send>>;
19
20/// Custom token transformation callback.
21pub type ScimTokenTransform = Arc<dyn Fn(String) -> ScimTokenStorageFuture + Send + Sync>;
22
23/// Hook invoked before a SCIM token provider is persisted.
24pub type BeforeScimTokenGeneratedHook =
25    Arc<dyn Fn(BeforeScimTokenGeneratedInput) -> ScimHookFuture + Send + Sync>;
26
27/// Hook invoked after a SCIM token provider is persisted.
28pub type AfterScimTokenGeneratedHook =
29    Arc<dyn Fn(AfterScimTokenGeneratedInput) -> ScimHookFuture + Send + Sync>;
30
31/// How `POST /scim/v2/Bulk` applies database changes.
32///
33/// Better Auth **1.6.9** does not implement Bulk (`bulk.supported: false`). RustAuth
34/// implements RFC 7644 bulk with two modes:
35///
36/// - [`ScimBulkMode::Independent`] (default): each operation commits on its own,
37///   matching typical SCIM deployments and `failOnErrors` stop semantics.
38/// - [`ScimBulkMode::Atomic`]: all mutating operations run in one adapter
39///   transaction; the first error rolls back earlier mutations in the same request.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
41pub enum ScimBulkMode {
42    /// Sequential operations; each mutation uses its own transaction (default).
43    #[default]
44    Independent,
45    /// One transaction for the whole bulk request; rollback on first failing op.
46    Atomic,
47}
48
49/// How `DELETE /scim/v2/Users/:id` (and bulk user delete) deprovisions users.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
51pub enum ScimDeprovisionMode {
52    /// Delete the RustAuth user when they have no linked accounts besides the
53    /// current SCIM provider; otherwise unlink (see [`ScimDeprovisionMode::UnlinkAccount`]).
54    DeleteUser,
55    /// Remove only the current provider account link and SCIM profile.
56    #[default]
57    UnlinkAccount,
58}
59
60/// Severity level for SCIM audit events.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum ScimAuditSeverity {
63    /// Informational audit event.
64    Info,
65    /// Warning-level audit event.
66    Warn,
67    /// Error-level audit event.
68    Error,
69}
70
71/// SCIM audit event kind.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum ScimAuditEventKind {
74    /// Management token generated or rotated.
75    TokenGenerated,
76    /// User created or linked via SCIM.
77    UserProvisioned,
78    /// User deleted or unlinked via SCIM.
79    UserDeprovisioned,
80    /// A bulk operation returned an error status.
81    BulkFailed,
82    /// An atomic bulk request rolled back prior operations.
83    BulkRolledBack,
84}
85
86/// Audit event emitted by the SCIM plugin.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct ScimAuditEvent {
89    /// Event kind.
90    pub kind: ScimAuditEventKind,
91    /// Severity.
92    pub severity: ScimAuditSeverity,
93    /// SCIM provider id when known.
94    pub provider_id: Option<String>,
95    /// RustAuth user id when known.
96    pub user_id: Option<String>,
97    /// Organization id when the provider is org-scoped.
98    pub organization_id: Option<String>,
99    /// Optional detail (error message, rollback reason, etc.).
100    pub reason: Option<String>,
101}
102
103impl ScimAuditEvent {
104    /// Create an audit event with no optional context.
105    pub fn new(kind: ScimAuditEventKind, severity: ScimAuditSeverity) -> Self {
106        Self {
107            kind,
108            severity,
109            provider_id: None,
110            user_id: None,
111            organization_id: None,
112            reason: None,
113        }
114    }
115
116    #[must_use]
117    pub fn with_provider_id(mut self, provider_id: impl Into<String>) -> Self {
118        self.provider_id = Some(provider_id.into());
119        self
120    }
121
122    #[must_use]
123    pub fn with_user_id(mut self, user_id: impl Into<String>) -> Self {
124        self.user_id = Some(user_id.into());
125        self
126    }
127
128    #[must_use]
129    pub fn with_organization_id(mut self, organization_id: impl Into<String>) -> Self {
130        self.organization_id = Some(organization_id.into());
131        self
132    }
133
134    #[must_use]
135    pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
136        self.reason = Some(reason.into());
137        self
138    }
139}
140
141/// SCIM plugin options.
142///
143/// ```
144/// use rustauth_scim::{ScimOptions, ScimTokenStorage};
145///
146/// let options = ScimOptions::default();
147/// assert!(matches!(options.token_storage, ScimTokenStorage::Hashed));
148/// ```
149#[derive(Clone)]
150pub struct ScimOptions {
151    /// Whether provider connections are tied to the user who generated them.
152    pub provider_ownership: ProviderOwnershipOptions,
153    /// Organization roles allowed to manage org-scoped SCIM providers.
154    pub required_role: Option<Vec<String>>,
155    /// Static SCIM providers checked before database-backed providers.
156    pub default_scim: Vec<DefaultScimProvider>,
157    /// How generated SCIM tokens are stored.
158    pub token_storage: ScimTokenStorage,
159    /// Callback invoked after built-in authorization and before persistence.
160    pub before_token_generated: Option<BeforeScimTokenGeneratedHook>,
161    /// Callback invoked after a provider has been persisted.
162    pub after_token_generated: Option<AfterScimTokenGeneratedHook>,
163    /// Bulk commit strategy ([`ScimBulkMode::Independent`] by default).
164    pub bulk_mode: ScimBulkMode,
165    /// User delete semantics for SCIM deprovision.
166    pub deprovision_mode: ScimDeprovisionMode,
167    /// Optional async audit sink (also logged through `AuthContext::logger`).
168    pub audit_event: Option<crate::audit::ScimAuditEventResolver>,
169}
170
171impl Default for ScimOptions {
172    fn default() -> Self {
173        Self {
174            provider_ownership: ProviderOwnershipOptions::default(),
175            required_role: None,
176            default_scim: Vec::new(),
177            token_storage: ScimTokenStorage::Hashed,
178            before_token_generated: None,
179            after_token_generated: None,
180            bulk_mode: ScimBulkMode::default(),
181            deprovision_mode: ScimDeprovisionMode::default(),
182            audit_event: None,
183        }
184    }
185}
186
187impl ScimOptions {
188    /// Create default SCIM plugin options.
189    pub fn new() -> Self {
190        Self::default()
191    }
192
193    #[must_use]
194    /// Configure provider ownership rules.
195    pub fn provider_ownership(mut self, ownership: ProviderOwnershipOptions) -> Self {
196        self.provider_ownership = ownership;
197        self
198    }
199
200    #[must_use]
201    /// Set organization roles allowed to manage org-scoped SCIM providers.
202    pub fn required_role(mut self, roles: Vec<String>) -> Self {
203        self.required_role = Some(roles);
204        self
205    }
206
207    #[must_use]
208    /// Add statically configured SCIM providers.
209    pub fn default_scim(mut self, providers: Vec<DefaultScimProvider>) -> Self {
210        self.default_scim = providers;
211        self
212    }
213
214    #[must_use]
215    /// Set how generated SCIM tokens are stored.
216    pub fn token_storage(mut self, storage: ScimTokenStorage) -> Self {
217        self.token_storage = storage;
218        self
219    }
220
221    #[must_use]
222    /// Set the hook invoked before a SCIM token is persisted.
223    pub fn before_token_generated(mut self, hook: BeforeScimTokenGeneratedHook) -> Self {
224        self.before_token_generated = Some(hook);
225        self
226    }
227
228    #[must_use]
229    /// Set the hook invoked after a SCIM token is persisted.
230    pub fn after_token_generated(mut self, hook: AfterScimTokenGeneratedHook) -> Self {
231        self.after_token_generated = Some(hook);
232        self
233    }
234
235    #[must_use]
236    /// Set bulk commit strategy.
237    pub fn bulk_mode(mut self, mode: ScimBulkMode) -> Self {
238        self.bulk_mode = mode;
239        self
240    }
241
242    #[must_use]
243    /// Set user delete semantics for SCIM deprovision.
244    pub fn deprovision_mode(mut self, mode: ScimDeprovisionMode) -> Self {
245        self.deprovision_mode = mode;
246        self
247    }
248
249    #[must_use]
250    /// Set an async audit event sink.
251    pub fn audit_event(mut self, resolver: crate::audit::ScimAuditEventResolver) -> Self {
252        self.audit_event = Some(resolver);
253        self
254    }
255}
256
257impl std::fmt::Debug for ScimOptions {
258    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
259        formatter
260            .debug_struct("ScimOptions")
261            .field("provider_ownership", &self.provider_ownership)
262            .field("required_role", &self.required_role)
263            .field("default_scim", &self.default_scim)
264            .field("token_storage", &self.token_storage)
265            .field(
266                "before_token_generated",
267                &self.before_token_generated.as_ref().map(|_| "<hook>"),
268            )
269            .field(
270                "after_token_generated",
271                &self.after_token_generated.as_ref().map(|_| "<hook>"),
272            )
273            .field("bulk_mode", &self.bulk_mode)
274            .field("deprovision_mode", &self.deprovision_mode)
275            .field(
276                "audit_event",
277                &self.audit_event.as_ref().map(|_| "<resolver>"),
278            )
279            .finish()
280    }
281}
282
283/// Organization member details passed to SCIM hooks.
284#[derive(Debug, Clone, PartialEq, Eq)]
285pub struct ScimOrganizationMember {
286    /// Organization identifier.
287    pub organization_id: String,
288    /// User identifier.
289    pub user_id: String,
290    /// Persisted organization role string.
291    pub role: String,
292}
293
294/// Payload for `before_token_generated`.
295#[derive(Debug, Clone, PartialEq, Eq)]
296pub struct BeforeScimTokenGeneratedInput {
297    /// Authenticated user generating the token.
298    pub user: User,
299    /// Organization member row when the token is org-scoped.
300    pub member: Option<ScimOrganizationMember>,
301    /// Returned bearer token.
302    pub scim_token: String,
303}
304
305/// Payload for `after_token_generated`.
306#[derive(Debug, Clone, PartialEq, Eq)]
307pub struct AfterScimTokenGeneratedInput {
308    /// Authenticated user generating the token.
309    pub user: User,
310    /// Organization member row when the token is org-scoped.
311    pub member: Option<ScimOrganizationMember>,
312    /// Returned bearer token.
313    pub scim_token: String,
314    /// Persisted provider record.
315    pub provider: ScimProviderRecord,
316}
317
318/// Error returned by SCIM hooks to abort a management request.
319#[derive(Debug, Clone, PartialEq, Eq)]
320pub struct ScimHookError {
321    /// HTTP status returned to the caller.
322    pub status: StatusCode,
323    /// Stable API error code.
324    pub code: String,
325    /// Human-readable error message.
326    pub message: String,
327}
328
329impl ScimHookError {
330    /// Create a hook error with an explicit HTTP status and API code.
331    pub fn new(status: StatusCode, code: impl Into<String>, message: impl Into<String>) -> Self {
332        Self {
333            status,
334            code: code.into(),
335            message: message.into(),
336        }
337    }
338
339    /// Create a forbidden hook error.
340    pub fn forbidden(message: impl Into<String>) -> Self {
341        Self::new(StatusCode::FORBIDDEN, "FORBIDDEN", message)
342    }
343}
344
345/// Provider ownership configuration.
346#[derive(Debug, Clone, Default, PartialEq, Eq)]
347pub struct ProviderOwnershipOptions {
348    /// Enable user ownership for global SCIM provider connections.
349    ///
350    /// When disabled, management routes reject requests without `organizationId`.
351    /// Organization-scoped providers still use [`ScimOptions::required_role`].
352    pub enabled: bool,
353}
354
355/// A statically configured SCIM provider.
356///
357/// `provider_id` is globally unique in storage, like Better Auth: it names one SCIM
358/// connection, not one organization. Pair it with `organization_id` when the token
359/// should only provision members of that organization.
360#[derive(Debug, Clone, PartialEq, Eq)]
361pub struct DefaultScimProvider {
362    /// Stable provider identifier (one persisted SCIM connection per value).
363    pub provider_id: String,
364    /// Plain base token for the provider.
365    pub scim_token: String,
366    /// Optional organization scope.
367    pub organization_id: Option<String>,
368}
369
370/// Built-in SCIM token storage modes.
371#[derive(Clone)]
372pub enum ScimTokenStorage {
373    /// Store the base token directly.
374    Plain,
375    /// Store a SHA-256 digest of the base token.
376    Hashed,
377    /// Store the base token encrypted with RustAuth secret material.
378    Encrypted,
379    /// Store a custom hash of the base token.
380    CustomHash { hash: ScimTokenTransform },
381    /// Store a custom encrypted token and decrypt it during verification.
382    CustomEncryption {
383        encrypt: ScimTokenTransform,
384        decrypt: ScimTokenTransform,
385    },
386}
387
388impl ScimTokenStorage {
389    /// Create a custom hash token storage mode.
390    ///
391    /// ```
392    /// use rustauth_scim::ScimTokenStorage;
393    ///
394    /// let storage = ScimTokenStorage::custom_hash(|token| {
395    ///     Box::pin(async move { Ok(format!("{token}:hashed")) })
396    /// });
397    /// ```
398    pub fn custom_hash(
399        hash: impl Fn(String) -> ScimTokenStorageFuture + Send + Sync + 'static,
400    ) -> Self {
401        Self::CustomHash {
402            hash: Arc::new(hash),
403        }
404    }
405
406    /// Create a custom encrypt/decrypt token storage mode.
407    ///
408    /// ```
409    /// use rustauth_scim::ScimTokenStorage;
410    ///
411    /// let storage = ScimTokenStorage::custom_encryption(
412    ///     |token| Box::pin(async move { Ok(token.chars().rev().collect()) }),
413    ///     |token| Box::pin(async move { Ok(token.chars().rev().collect()) }),
414    /// );
415    /// ```
416    pub fn custom_encryption(
417        encrypt: impl Fn(String) -> ScimTokenStorageFuture + Send + Sync + 'static,
418        decrypt: impl Fn(String) -> ScimTokenStorageFuture + Send + Sync + 'static,
419    ) -> Self {
420        Self::CustomEncryption {
421            encrypt: Arc::new(encrypt),
422            decrypt: Arc::new(decrypt),
423        }
424    }
425}
426
427impl std::fmt::Debug for ScimTokenStorage {
428    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
429        match self {
430            Self::Plain => formatter.write_str("Plain"),
431            Self::Hashed => formatter.write_str("Hashed"),
432            Self::Encrypted => formatter.write_str("Encrypted"),
433            Self::CustomHash { .. } => formatter.write_str("CustomHash"),
434            Self::CustomEncryption { .. } => formatter.write_str("CustomEncryption"),
435        }
436    }
437}