Skip to main content

alibi_core/store/
scim.rs

1//! Durable, atomic framework-managed provisioning credentials.
2use crate::{AuthError, AuthResult};
3use async_trait::async_trait;
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6#[derive(Clone, Debug, Serialize, Deserialize)]
7#[serde(rename_all = "camelCase")]
8pub struct ScimManagedConnection {
9    pub id: String,
10    pub creation_request_id: String,
11    pub connection_id: String,
12    pub provisioning_domain_id: String,
13    pub status: String,
14    pub revision: i64,
15    pub created_at: DateTime<Utc>,
16    pub created_by: String,
17    pub decommission_started_at: Option<DateTime<Utc>>,
18    pub decommission_started_by: Option<String>,
19    pub decommissioned_at: Option<DateTime<Utc>>,
20    pub decommissioned_by: Option<String>,
21}
22#[derive(Clone, Debug, Serialize, Deserialize)]
23#[serde(rename_all = "camelCase")]
24pub struct ScimManagedCredential {
25    pub id: String,
26    pub connection_record_id: String,
27    pub credential_id: String,
28    pub token_digest: String,
29    pub hash_version: String,
30    pub active_slot_key: String,
31    pub status: String,
32    pub serialized_scopes: String,
33    pub expires_at: DateTime<Utc>,
34    pub created_at: DateTime<Utc>,
35    pub created_by: String,
36    pub last_used_at: Option<DateTime<Utc>>,
37    pub revoked_at: Option<DateTime<Utc>>,
38    pub revoked_by: Option<String>,
39    pub decommissioned_at: Option<DateTime<Utc>>,
40}
41pub struct CreateScimManagedConnection {
42    pub creation_request_id: String,
43    pub connection_id: String,
44    pub provisioning_domain_id: String,
45    pub actor_id: String,
46    pub credential_id: String,
47    pub token_digest: String,
48    pub serialized_scopes: String,
49    pub expires_at: DateTime<Utc>,
50    pub created_at: DateTime<Utc>,
51}
52#[derive(Clone)]
53pub struct RotateScimManagedCredential {
54    pub connection_id: String,
55    pub provisioning_domain_id: String,
56    pub actor_id: String,
57    pub credential_id: String,
58    pub token_digest: String,
59    pub serialized_scopes: String,
60    pub expires_at: DateTime<Utc>,
61    pub created_at: DateTime<Utc>,
62    pub max_active_credentials: usize,
63}
64#[derive(Clone)]
65pub struct RevokeScimManagedCredential {
66    pub connection_id: String,
67    pub provisioning_domain_id: String,
68    pub credential_id: String,
69    pub actor_id: String,
70}
71#[derive(Clone, Debug, Serialize, Deserialize)]
72#[serde(rename_all = "camelCase")]
73pub struct ScimManagedConnectionEvent {
74    pub sequence: i64,
75    #[serde(rename = "type")]
76    pub kind: String,
77    pub actor_id: String,
78    pub credential_id: Option<String>,
79    pub created_at: DateTime<Utc>,
80}
81#[derive(Clone, Debug, Serialize, Deserialize)]
82#[serde(rename_all = "camelCase")]
83pub struct ScimConnectionBinding {
84    pub id: String,
85    pub connection_id: String,
86    pub connection_key: String,
87    pub provisioning_domain_id: String,
88    pub created_at: DateTime<Utc>,
89    pub decommission_status: String,
90    pub decommission_cursor_user_id: Option<String>,
91    pub decommission_reconciled_user_count: i64,
92    pub decommission_batch_count: i64,
93    pub decommission_revision: i64,
94    pub decommissioned_at: Option<DateTime<Utc>>,
95    pub decommission_completed_at: Option<DateTime<Utc>>,
96    pub decommission_lease_id: Option<String>,
97    pub decommission_lease_expires_at: Option<DateTime<Utc>>,
98}
99#[derive(Clone)]
100pub struct DecommissionScimManagedConnection {
101    pub connection_id: String,
102    pub provisioning_domain_id: String,
103    pub actor_id: String,
104}
105pub struct ScimManagedConnectionState {
106    pub connection: ScimManagedConnection,
107    pub credentials: Vec<ScimManagedCredential>,
108}
109pub struct ScimManagedCreation {
110    pub connection: ScimManagedConnection,
111    pub credential: ScimManagedCredential,
112}
113/// Canonical connection-owned User fields required for protocol serialization.
114#[derive(Clone)]
115pub struct ScimUserRecord {
116    pub id: String,
117    pub connection_id: String,
118    pub provisioning_domain_id: String,
119    pub user_name: String,
120    pub primary_email: String,
121    pub display_name: String,
122    pub active: bool,
123    pub external_id: Option<String>,
124    pub formatted_name: String,
125    pub given_name: Option<String>,
126    pub family_name: Option<String>,
127    pub serialized_emails: String,
128    pub serialized_attributes: Option<String>,
129    pub created_at: DateTime<Utc>,
130    pub updated_at: DateTime<Utc>,
131}
132/// Normalized canonical User creation, committed with its identity subject.
133#[derive(Clone)]
134pub struct ScimUserAttributes {
135    pub user_name: String,
136    pub user_name_key: String,
137    pub primary_email: String,
138    pub work_email_value_index: String,
139    pub email_value_index: String,
140    pub display_name: String,
141    pub formatted_name: String,
142    pub given_name: Option<String>,
143    pub family_name: Option<String>,
144    pub serialized_emails: String,
145    pub serialized_attributes: Option<String>,
146    pub external_id: Option<String>,
147    pub external_id_key: Option<String>,
148    pub active: bool,
149}
150pub struct ReplaceScimUser {
151    pub connection_id: String,
152    pub provisioning_domain_id: String,
153    pub attributes: ScimUserAttributes,
154    pub updated_at: DateTime<Utc>,
155}
156pub struct ScimUserMutationContext {
157    pub user_id: String,
158    pub source: ScimUserRecord,
159    pub manage_profile: bool,
160}
161pub struct CreateScimUser {
162    pub attributes: ScimUserAttributes,
163    pub connection_id: String,
164    pub provisioning_domain_id: String,
165    pub user_id: String,
166    pub connection_user_key: String,
167    pub order_key: String,
168    pub created_at: DateTime<Utc>,
169    pub manage_profile: bool,
170    pub tombstone_id: Option<String>,
171}
172#[derive(Clone)]
173pub struct ScimIdentityTombstone {
174    pub id: String,
175    pub provisioning_domain_id: String,
176    pub user_id: String,
177    pub profile: String,
178}
179/// Prepared durable Group creation inside a provisioning transaction.
180pub struct CreateScimGroup {
181    pub connection_id: String,
182    pub provisioning_domain_id: String,
183    pub display_name: String,
184    pub display_name_key: String,
185    pub external_id: Option<String>,
186    pub external_id_key: Option<String>,
187    pub order_key: String,
188    pub member_ids: Vec<String>,
189    pub created_at: DateTime<Utc>,
190}
191/// Complete replacement of the mutable fields of a canonical Group.
192#[derive(Clone)]
193pub struct ReplaceScimGroup {
194    pub connection_id: String,
195    pub provisioning_domain_id: String,
196    pub display_name: String,
197    pub display_name_key: String,
198    pub external_id: Option<String>,
199    pub external_id_key: Option<String>,
200    pub member_ids: Vec<String>,
201}
202/// Canonical connection-owned group metadata.
203pub struct ScimGroupRecord {
204    pub id: String,
205    pub connection_id: String,
206    pub provisioning_domain_id: String,
207    pub display_name: String,
208    pub external_id: Option<String>,
209    pub created_at: DateTime<Utc>,
210    pub updated_at: DateTime<Utc>,
211}
212pub struct ScimGroupMemberRecord {
213    pub id: String,
214    pub display_name: String,
215}
216pub struct ScimUserPage {
217    pub total_results: i64,
218    pub users: Vec<ScimUserRecord>,
219}
220pub struct ScimGroupPage {
221    pub total_results: i64,
222    pub groups: Vec<ScimGroupRecord>,
223}
224pub struct ScimGroupMemberships {
225    pub persisted_count: usize,
226    pub members: Vec<ScimGroupMemberRecord>,
227}
228#[async_trait]
229pub trait ScimManagedStore: Send + Sync {
230    async fn find_scim_identity_tombstone(
231        &self,
232        _connection: &str,
233        _key: &str,
234    ) -> AuthResult<Option<ScimIdentityTombstone>> {
235        Err(AuthError::not_implemented(
236            "SCIM identity tombstones are unsupported",
237        ))
238    }
239
240    async fn scim_user_domain_stable(&self, _connection: &str, _domain: &str) -> AuthResult<bool> {
241        Err(AuthError::config(
242            "SCIM resource persistence is not supported by this store",
243        ))
244    }
245    async fn list_scim_users(
246        &self,
247        _connection: &str,
248        _filters: &[(String, String)],
249        _offset: i64,
250        _count: i64,
251    ) -> AuthResult<ScimUserPage> {
252        Err(AuthError::config(
253            "SCIM resource persistence is not supported by this store",
254        ))
255    }
256
257    async fn scim_group_domain_stable(&self, _connection: &str, _domain: &str) -> AuthResult<bool> {
258        Err(AuthError::config(
259            "SCIM resource persistence is not supported by this store",
260        ))
261    }
262    async fn list_scim_groups(
263        &self,
264        _connection: &str,
265        _filters: &[(String, String)],
266        _offset: i64,
267        _count: i64,
268    ) -> AuthResult<ScimGroupPage> {
269        Err(AuthError::config(
270            "SCIM resource persistence is not supported by this store",
271        ))
272    }
273
274    async fn get_scim_user(
275        &self,
276        _id: &str,
277        _connection: &str,
278    ) -> AuthResult<Option<ScimUserRecord>> {
279        Err(AuthError::config(
280            "SCIM resource persistence is not supported by this store",
281        ))
282    }
283
284    async fn find_scim_managed_credential(
285        &self,
286        _credential_id: &str,
287    ) -> AuthResult<Option<ScimManagedCredential>> {
288        Err(AuthError::config(
289            "Managed SCIM persistence is not supported by this store",
290        ))
291    }
292    async fn find_scim_active_managed_connection(
293        &self,
294        _id: &str,
295    ) -> AuthResult<Option<ScimManagedConnection>> {
296        Err(AuthError::config(
297            "Managed SCIM persistence is not supported by this store",
298        ))
299    }
300    async fn record_scim_credential_use(
301        &self,
302        _id: &str,
303        _previous: Option<DateTime<Utc>>,
304        _at: DateTime<Utc>,
305    ) -> AuthResult<()> {
306        Err(AuthError::config(
307            "Managed SCIM persistence is not supported by this store",
308        ))
309    }
310
311    async fn active_scim_connection_binding(
312        &self,
313        _id: &str,
314        _domain: &str,
315        _key: &str,
316    ) -> AuthResult<ScimConnectionBinding> {
317        Err(AuthError::config(
318            "SCIM binding persistence is not supported by this store",
319        ))
320    }
321    async fn get_scim_group(
322        &self,
323        _id: &str,
324        _connection: &str,
325    ) -> AuthResult<Option<ScimGroupRecord>> {
326        Err(AuthError::config(
327            "SCIM resource persistence is not supported by this store",
328        ))
329    }
330    async fn scim_group_members(
331        &self,
332        _id: &str,
333        _connection: &str,
334    ) -> AuthResult<ScimGroupMemberships> {
335        Err(AuthError::config(
336            "SCIM resource persistence is not supported by this store",
337        ))
338    }
339
340    async fn acquire_scim_decommission_lease(
341        &self,
342        _binding: ScimConnectionBinding,
343    ) -> AuthResult<Option<ScimConnectionBinding>> {
344        Err(AuthError::config(
345            "SCIM binding persistence is not supported by this store",
346        ))
347    }
348    async fn release_scim_decommission_lease(&self, _id: &str, _lease: &str) -> AuthResult<()> {
349        Err(AuthError::config(
350            "SCIM binding persistence is not supported by this store",
351        ))
352    }
353
354    async fn scim_projection_candidates(
355        &self,
356        _domain: &str,
357        _cursor: Option<&str>,
358    ) -> AuthResult<Vec<ScimProjectionCandidate>> {
359        Err(AuthError::config(
360            "SCIM projection persistence is not supported by this store",
361        ))
362    }
363
364    async fn decommission_scim_managed_connection(
365        &self,
366        _data: DecommissionScimManagedConnection,
367        _complete: bool,
368    ) -> AuthResult<Option<ScimManagedConnection>> {
369        Err(AuthError::config(
370            "Managed SCIM persistence is not supported by this store",
371        ))
372    }
373
374    async fn terminal_scim_connection_binding(
375        &self,
376        _id: &str,
377        _domain: Option<&str>,
378        _key: &str,
379    ) -> AuthResult<ScimConnectionBinding> {
380        Err(AuthError::config(
381            "SCIM binding persistence is not supported by this store",
382        ))
383    }
384
385    async fn list_scim_managed_connection_events(
386        &self,
387        _id: &str,
388        _domain: &str,
389    ) -> AuthResult<Option<Vec<ScimManagedConnectionEvent>>> {
390        Err(AuthError::config(
391            "Managed SCIM persistence is not supported by this store",
392        ))
393    }
394
395    async fn revoke_scim_managed_credential(
396        &self,
397        _data: RevokeScimManagedCredential,
398    ) -> AuthResult<Option<ScimManagedConnection>> {
399        Err(AuthError::config(
400            "Managed SCIM persistence is not supported by this store",
401        ))
402    }
403
404    async fn rotate_scim_managed_credential(
405        &self,
406        _data: RotateScimManagedCredential,
407    ) -> AuthResult<Option<ScimManagedCreation>> {
408        Err(AuthError::config(
409            "Managed SCIM persistence is not supported by this store",
410        ))
411    }
412
413    async fn get_scim_managed_connection(
414        &self,
415        _id: &str,
416        _domain: &str,
417    ) -> AuthResult<Option<ScimManagedConnectionState>> {
418        Err(AuthError::config(
419            "Managed SCIM persistence is not supported by this store",
420        ))
421    }
422
423    async fn list_scim_managed_connections(
424        &self,
425        _domain: &str,
426    ) -> AuthResult<Vec<ScimManagedConnection>> {
427        Err(AuthError::config(
428            "Managed SCIM persistence is not supported by this store",
429        ))
430    }
431
432    /// Insert one connection, its credential, and both ordered events in one transaction.
433    /// # Errors
434    /// Returns a conflict when the creation request was already consumed.
435    async fn create_scim_managed_connection(
436        &self,
437        _data: CreateScimManagedConnection,
438    ) -> AuthResult<ScimManagedCreation> {
439        Err(AuthError::config(
440            "Managed SCIM persistence is not supported by this store",
441        ))
442    }
443}
444pub fn scim_creation_conflict() -> AuthError {
445    AuthError::Api {
446        status: 409,
447        code: Some("SCIM_MANAGED_CREATION_REQUEST_ID_CONFLICT".into()),
448        message: "Managed SCIM connection creation request ID already exists".into(),
449    }
450}
451
452pub fn scim_managed_conflict(message: &str) -> AuthError {
453    AuthError::Api {
454        status: 409,
455        code: None,
456        message: message.into(),
457    }
458}
459
460pub fn validate_scim_binding_domain(
461    binding: &ScimConnectionBinding,
462    domain: Option<&str>,
463) -> AuthResult<()> {
464    if let Some(domain) = domain
465        && domain != binding.provisioning_domain_id
466    {
467        return Err(AuthError::internal(format!(
468            "SCIM connection \"{}\" is already bound to provisioning domain \"{}\".",
469            binding.connection_id, binding.provisioning_domain_id
470        )));
471    }
472    Ok(())
473}
474
475#[derive(Clone, Debug, Serialize, Deserialize)]
476#[serde(rename_all = "camelCase")]
477pub struct ScimIdentitySource {
478    pub id: String,
479    pub connection_id: String,
480    pub provisioning_domain_id: String,
481    pub active: bool,
482}
483/// Order callback sources like the reference's `String.localeCompare`.
484///
485/// # Errors
486/// Returns an error if the compiled collation data cannot be loaded.
487pub fn sort_scim_sources(sources: &mut [ScimIdentitySource]) -> AuthResult<()> {
488    let collator = icu_collator::Collator::try_new(
489        icu_collator::CollatorPreferences::default(),
490        icu_collator::options::CollatorOptions::default(),
491    )
492    .map_err(|error| AuthError::internal(error.to_string()))?;
493    sources.sort_by(|left, right| collator.compare(&left.id, &right.id));
494    Ok(())
495}
496
497#[derive(Clone, Debug, Serialize, Deserialize)]
498#[serde(rename_all = "camelCase")]
499pub struct ScimProjectedUserState {
500    pub provisioning_domain_id: String,
501    pub user_id: String,
502    pub active: bool,
503    pub sources: Vec<ScimIdentitySource>,
504    pub grants: Vec<serde_json::Value>,
505}
506
507#[derive(Clone, Debug)]
508pub struct ScimProjectionCandidate {
509    pub id: String,
510    pub user_id: String,
511}
512pub struct ScimProjectionPreparedUser {
513    pub state: ScimProjectedUserState,
514    pub removed_grant_ids: Vec<String>,
515    pub existing_grants: Vec<(String, String)>,
516    pub role_sources: Vec<ScimRoleMappingInput>,
517}
518
519#[derive(Clone, Debug, Serialize, Deserialize)]
520#[serde(tag = "type", rename_all = "lowercase")]
521pub enum ScimAuthorizationSource {
522    Group {
523        id: String,
524        #[serde(rename = "externalId", skip_serializing_if = "Option::is_none")]
525        external_id: Option<String>,
526        #[serde(rename = "displayName")]
527        display_name: String,
528    },
529}
530#[derive(Clone, Debug, Serialize, Deserialize)]
531#[serde(rename_all = "camelCase")]
532pub struct ScimRoleMappingInput {
533    pub connection_id: String,
534    pub provisioning_domain_id: String,
535    pub scim_user_id: String,
536    pub user_id: String,
537    pub source: ScimAuthorizationSource,
538}
539#[derive(Clone, Debug, Serialize, Deserialize)]
540#[serde(rename_all = "camelCase")]
541pub struct ScimRoleExistenceInput {
542    pub connection_id: String,
543    pub provisioning_domain_id: String,
544    pub role: String,
545}
546
547#[derive(Clone)]
548pub struct CreateScimProjectionGrant {
549    pub connection_id: String,
550    pub provisioning_domain_id: String,
551    pub scim_user_id: String,
552    pub user_id: String,
553    pub source_id: String,
554    pub source_value: String,
555    pub role: String,
556    pub grant_key: String,
557    pub created_at: DateTime<Utc>,
558}
559
560#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
561#[serde(rename_all = "camelCase")]
562pub struct ScimIdentityState {
563    pub user_id: String,
564    pub active: bool,
565    #[serde(skip_serializing_if = "Option::is_none")]
566    pub profile_source_id: Option<String>,
567    pub sources: Vec<ScimIdentitySource>,
568}
569
570/// Stable tuple key for one connection-owned Group membership.
571/// # Errors
572/// Returns an error if the key tuple cannot be serialized.
573pub fn scim_group_membership_key(connection: &str, group: &str, user: &str) -> AuthResult<String> {
574    use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
575    use sha2::{Digest, Sha256};
576    Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(serde_json::to_vec(&[
577        "scim-group-member",
578        connection,
579        group,
580        user,
581    ])?)))
582}