1use super::ScimPlugin;
2use alibi_core::endpoint::{EndpointCall, EndpointDefinition, EndpointInput, EndpointResponse};
3use alibi_core::store::AuthTransaction;
4pub use alibi_core::store::{
5 ScimAuthorizationSource, ScimIdentitySource, ScimIdentityState, ScimProjectedUserState,
6 ScimRoleExistenceInput, ScimRoleMappingInput,
7};
8use alibi_core::{
9 AuthContext, AuthInitContext, AuthPlugin, AuthRequest, AuthResponse, AuthResult, AuthRoute,
10 AuthSchema,
11};
12use async_trait::async_trait;
13use std::sync::Arc;
14#[async_trait]
16pub trait ScimProjection<S: AuthSchema>: Send + Sync {
17 async fn reconcile_user(
18 &self,
19 input: &ScimProjectedUserState,
20 database: &dyn AuthTransaction<S>,
21 ) -> AuthResult<()>;
22}
23#[async_trait]
24pub trait ScimRoleProjection<S: AuthSchema>: Send + Sync {
25 async fn map(
26 &self,
27 input: &ScimRoleMappingInput,
28 database: &dyn AuthTransaction<S>,
29 ) -> AuthResult<Option<Vec<String>>>;
30 async fn exists(
31 &self,
32 input: &ScimRoleExistenceInput,
33 database: &dyn AuthTransaction<S>,
34 ) -> AuthResult<bool>;
35}
36#[derive(Clone)]
38pub enum ScimIdentityResolution {
39 Create,
40 Link {
41 user_id: String,
42 manage_profile: bool,
43 },
44}
45pub struct ScimIdentityResolutionInput {
47 pub connection_id: String,
48 pub provisioning_domain_id: String,
49 pub resource: serde_json::Value,
50}
51#[async_trait]
52pub trait ScimIdentity<S: AuthSchema>: Send + Sync {
53 async fn resolve_user(
55 &self,
56 _input: &ScimIdentityResolutionInput,
57 _database: &dyn alibi_core::AuthStore<S>,
58 ) -> AuthResult<ScimIdentityResolution> {
59 Ok(ScimIdentityResolution::Create)
60 }
61
62 async fn reconcile_user(
63 &self,
64 input: &ScimIdentityState,
65 database: &dyn AuthTransaction<S>,
66 ) -> AuthResult<()>;
67}
68pub(super) struct ScimIdentityHandle<S: AuthSchema>(pub Arc<dyn ScimIdentity<S>>);
69pub(super) struct ScimProjectionHandle<S: AuthSchema>(
70 pub Arc<dyn ScimProjection<S>>,
71 pub Option<Arc<dyn ScimRoleProjection<S>>>,
72);
73
74pub struct ConfiguredScimPlugin<S: AuthSchema> {
76 base: ScimPlugin,
77 projection: Option<Arc<dyn ScimProjection<S>>>,
78 identity: Option<Arc<dyn ScimIdentity<S>>>,
79 roles: Option<Arc<dyn ScimRoleProjection<S>>>,
80 verifier: Option<Arc<dyn super::ScimBearerTokenVerifier<S>>>,
81}
82impl ScimPlugin {
83 #[must_use]
84 pub fn projection<S: AuthSchema>(
85 self,
86 projection: Arc<dyn ScimProjection<S>>,
87 ) -> ConfiguredScimPlugin<S> {
88 ConfiguredScimPlugin {
89 base: self,
90 projection: Some(projection),
91 identity: None,
92 roles: None,
93 verifier: None,
94 }
95 }
96}
97impl ScimPlugin {
98 #[must_use]
99 pub fn identity<S: AuthSchema>(
100 self,
101 identity: Arc<dyn ScimIdentity<S>>,
102 ) -> ConfiguredScimPlugin<S> {
103 ConfiguredScimPlugin {
104 base: self,
105 projection: None,
106 identity: Some(identity),
107 roles: None,
108 verifier: None,
109 }
110 }
111}
112impl ScimPlugin {
113 #[must_use]
114 pub fn authentication<S: AuthSchema>(
115 mut self,
116 verifier: Arc<dyn super::ScimBearerTokenVerifier<S>>,
117 ) -> ConfiguredScimPlugin<S> {
118 self.has_token_verifier = true;
119 ConfiguredScimPlugin {
120 base: self,
121 projection: None,
122 identity: None,
123 roles: None,
124 verifier: Some(verifier),
125 }
126 }
127}
128impl<S: AuthSchema> ConfiguredScimPlugin<S> {
129 #[must_use]
130 pub fn authentication(mut self, verifier: Arc<dyn super::ScimBearerTokenVerifier<S>>) -> Self {
131 self.base.has_token_verifier = true;
132 self.verifier = Some(verifier);
133 self
134 }
135 #[must_use]
136 pub fn identity(mut self, identity: Arc<dyn ScimIdentity<S>>) -> Self {
137 self.identity = Some(identity);
138 self
139 }
140
141 #[must_use]
142 pub fn roles(mut self, roles: Arc<dyn ScimRoleProjection<S>>) -> Self {
143 self.roles = Some(roles);
144 self
145 }
146}
147#[async_trait]
148impl<S: AuthSchema> AuthPlugin<S> for ConfiguredScimPlugin<S> {
149 route_openapi_metadata!(S);
150 fn name(&self) -> &'static str {
151 "scim"
152 }
153 fn allowed_media_types(&self, route: &AuthRoute) -> Vec<&'static str> {
154 <ScimPlugin as AuthPlugin<S>>::allowed_media_types(&self.base, route)
155 }
156 fn routes(&self) -> Vec<AuthRoute> {
157 <ScimPlugin as AuthPlugin<S>>::routes(&self.base)
158 }
159 fn server_endpoints(&self) -> Vec<EndpointDefinition> {
160 <ScimPlugin as AuthPlugin<S>>::server_endpoints(&self.base)
161 }
162 fn validate_endpoint(
163 &self,
164 call: &EndpointCall,
165 ctx: &AuthContext<S>,
166 ) -> AuthResult<EndpointInput> {
167 self.base.validate_endpoint(call, ctx)
168 }
169 async fn on_init(&self, ctx: &mut AuthInitContext<S>) -> AuthResult<()> {
170 self.base.on_init(ctx).await?;
171 if let Some(verifier) = &self.verifier {
172 ctx.extensions
173 .insert(super::authentication::ScimBearerVerifierHandle(
174 verifier.clone(),
175 ));
176 }
177 if let Some(projection) = &self.projection {
178 ctx.extensions
179 .insert(ScimProjectionHandle(projection.clone(), self.roles.clone()));
180 }
181 if let Some(identity) = &self.identity {
182 ctx.extensions.insert(ScimIdentityHandle(identity.clone()));
183 }
184 Ok(())
185 }
186 async fn on_endpoint(
187 &self,
188 call: &EndpointCall,
189 ctx: &AuthContext<S>,
190 ) -> AuthResult<EndpointResponse> {
191 self.base.on_endpoint(call, ctx).await
192 }
193 async fn on_http_request_action(
194 &self,
195 req: &AuthRequest,
196 ctx: &AuthContext<S>,
197 ) -> AuthResult<Option<alibi_core::HttpRequestAction>> {
198 self.base.on_http_request_action(req, ctx).await
199 }
200 async fn on_request(
201 &self,
202 req: &AuthRequest,
203 ctx: &AuthContext<S>,
204 ) -> AuthResult<Option<AuthResponse>> {
205 self.base.on_request(req, ctx).await
206 }
207}
208
209impl ScimPlugin {
210 pub(super) async fn reconcile_projection<S: AuthSchema>(
211 &self,
212 call: &EndpointCall,
213 ctx: &AuthContext<S>,
214 ) -> AuthResult<EndpointResponse> {
215 use alibi_core::{AuthError, store::transaction, utils::json::JsValue};
216 let projection=ctx.extensions.get::<ScimProjectionHandle<S>>().ok_or_else(||AuthError::config("SCIM projection reconciliation requires projection.reconcileUser to be configured."))?;
217 let domain = call
218 .body()
219 .and_then(|body| body.get("provisioningDomainId"))
220 .and_then(JsValue::as_str)
221 .ok_or_else(|| AuthError::internal("Validated domain missing"))?
222 .to_owned();
223 let mut cursor = None;
224 let mut reconciled = 0;
225 let mut batches = 0;
226 loop {
227 let candidates = ctx
228 .database
229 .scim_projection_candidates(&domain, cursor.as_deref())
230 .await?;
231 if candidates.is_empty() {
232 break;
233 }
234 let more = candidates.len() > 50;
235 let mut by_user = indexmap::IndexMap::new();
236 for candidate in candidates.into_iter().take(50) {
237 cursor = Some(candidate.user_id.clone());
238 _ = by_user.insert(candidate.user_id, candidate.id);
239 }
240 let count = by_user.len();
241 let ids = by_user.into_values().collect::<Vec<_>>();
242 let callback = projection.0.clone();
243 let roles = projection.1.clone();
244 let batch_domain = domain.clone();
245 let result = transaction::<S, (), _>(ctx.database.as_ref(), move |database| {
246 Box::pin(async move {
247 let states = database
248 .prepare_scim_projection(&batch_domain, &ids, roles.is_some())
249 .await?;
250 for mut prepared in states {
251 if let Some(roles) = &roles {
252 apply_roles(database, roles.as_ref(), &mut prepared).await?;
253 }
254
255 database
256 .remove_scim_projection_grants(
257 &batch_domain,
258 &prepared.state.user_id,
259 &prepared.removed_grant_ids,
260 )
261 .await?;
262 callback
263 .reconcile_user(&prepared.state, database)
264 .await
265 .map_err(|error| {
266 if alibi_core::endpoint::is_endpoint_api_error(&error) {
267 error
268 } else {
269 AuthError::CallbackFailure(Box::new(error))
270 }
271 })?;
272 }
273 Ok(())
274 })
275 })
276 .await;
277 match result {
278 Ok(()) => {}
279 Err(AuthError::CallbackFailure(cause)) => {
280 let detail = match cause.as_ref() {
281 AuthError::Api { message, .. }
282 if matches!(
283 message.as_str(),
284 "SCIM role mapping failed"
285 | "SCIM role validation failed"
286 | "SCIM identity reconciliation failed"
287 ) =>
288 {
289 message.as_str()
290 }
291 _ => "SCIM projection reconciliation failed",
292 };
293 return Ok(EndpointResponse::error(AuthError::Api{status:500,code:None,message:detail.into()}).with_error_body(JsValue::from(serde_json::json!({"schemas":["urn:ietf:params:scim:api:messages:2.0:Error"],"status":"500","detail":detail}))));
294 }
295 Err(error) => return Err(error),
296 }
297 batches += 1;
298 reconciled += count;
299 if !more {
300 break;
301 }
302 }
303 EndpointResponse::json(
304 &serde_json::json!({"provisioningDomainId":domain,"reconciledUsers":reconciled,"batches":batches}),
305 )
306 }
307}
308
309pub(super) async fn apply_roles<S: AuthSchema>(
310 database: &dyn AuthTransaction<S>,
311 roles: &dyn ScimRoleProjection<S>,
312 prepared: &mut alibi_core::store::ScimProjectionPreparedUser,
313) -> AuthResult<()> {
314 use alibi_core::store::CreateScimProjectionGrant;
315 use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
316 use chrono::SubsecRound;
317 use sha2::{Digest, Sha256};
318 let mut desired = indexmap::IndexMap::new();
319 let mut existence = std::collections::HashMap::new();
320 for input in &prepared.role_sources {
321 let mapped = roles
322 .map(input, database)
323 .await
324 .map_err(|error| application_callback_error(error, "SCIM role mapping failed"))?
325 .unwrap_or_default();
326 let mut unique = std::collections::HashSet::new();
327 for candidate in mapped {
328 let role = alibi_core::utils::javascript::trim(&candidate);
329 if role.is_empty() || !unique.insert(role.to_string()) {
330 continue;
331 }
332 let existence_key = (
333 input.connection_id.clone(),
334 input.provisioning_domain_id.clone(),
335 role.to_string(),
336 );
337 let exists = if let Some(exists) = existence.get(&existence_key) {
338 *exists
339 } else {
340 let exists = roles
341 .exists(
342 &ScimRoleExistenceInput {
343 connection_id: input.connection_id.clone(),
344 provisioning_domain_id: input.provisioning_domain_id.clone(),
345 role: role.into(),
346 },
347 database,
348 )
349 .await
350 .map_err(|error| {
351 application_callback_error(error, "SCIM role validation failed")
352 })?;
353 _ = existence.insert(existence_key, exists);
354 exists
355 };
356 if !exists {
357 continue;
358 }
359 let ScimAuthorizationSource::Group {
360 id,
361 external_id,
362 display_name,
363 } = &input.source;
364 let key = URL_SAFE_NO_PAD.encode(Sha256::digest(serde_json::to_vec(&[
365 "scim-projection-grant",
366 &input.connection_id,
367 &input.scim_user_id,
368 "group",
369 id,
370 role,
371 ])?));
372 _ = desired.insert(
373 key.clone(),
374 (
375 CreateScimProjectionGrant {
376 connection_id: input.connection_id.clone(),
377 provisioning_domain_id: input.provisioning_domain_id.clone(),
378 scim_user_id: input.scim_user_id.clone(),
379 user_id: input.user_id.clone(),
380 source_id: id.clone(),
381 source_value: external_id.clone().unwrap_or_else(|| display_name.clone()),
382 role: role.into(),
383 grant_key: key,
384 created_at: chrono::Utc::now().trunc_subsecs(3),
385 },
386 serde_json::json!({"source":input.source,"role":role}),
387 ),
388 );
389 }
390 }
391 prepared.removed_grant_ids = prepared
392 .existing_grants
393 .iter()
394 .filter(|(_, key)| !desired.contains_key(key))
395 .map(|(id, _)| id.clone())
396 .collect();
397 database
398 .remove_scim_projection_grants(
399 &prepared.state.provisioning_domain_id,
400 &prepared.state.user_id,
401 &prepared.removed_grant_ids,
402 )
403 .await?;
404 prepared.removed_grant_ids.clear();
405 let now = chrono::Utc::now().trunc_subsecs(3);
406 for (key, (data, _)) in &desired {
407 if !prepared
408 .existing_grants
409 .iter()
410 .any(|(_, existing)| existing == key)
411 {
412 let mut data = data.clone();
413 data.created_at = now;
414 database.create_scim_projection_grant(data).await?;
415 }
416 }
417 let mut ordered = desired.into_iter().collect::<Vec<_>>();
418 let collator = icu_collator::Collator::try_new(
422 icu_collator::CollatorPreferences::default(),
423 icu_collator::options::CollatorOptions::default(),
424 )
425 .map_err(|error| alibi_core::AuthError::internal(error.to_string()))?;
426 ordered.sort_by(|left, right| collator.compare(&left.0, &right.0));
427 prepared.state.grants = ordered.into_iter().map(|(_, (_, state))| state).collect();
428 Ok(())
429}
430pub(super) fn application_callback_error(
431 error: alibi_core::AuthError,
432 detail: &str,
433) -> alibi_core::AuthError {
434 if alibi_core::endpoint::is_endpoint_api_error(&error) {
435 error
436 } else {
437 tracing::error!(error=%error,detail,"SCIM application callback failed");
438 alibi_core::AuthError::CallbackFailure(Box::new(alibi_core::AuthError::Api {
439 status: 500,
440 code: None,
441 message: detail.into(),
442 }))
443 }
444}
445impl ScimPlugin {
446 pub(super) async fn decommission_binding<S: AuthSchema>(
447 &self,
448 ctx: &AuthContext<S>,
449 mut binding: alibi_core::store::ScimConnectionBinding,
450 ) -> AuthResult<alibi_core::store::ScimConnectionBinding> {
451 use alibi_core::{AuthError, store::transaction};
452 use chrono::SubsecRound;
453 let active_lease = |binding: &alibi_core::store::ScimConnectionBinding| {
454 binding.decommission_status == "reconciling"
455 && binding
456 .decommission_lease_id
457 .as_ref()
458 .is_some_and(|lease| !lease.is_empty())
459 && binding
460 .decommission_lease_expires_at
461 .is_some_and(|expiry| expiry > chrono::Utc::now().trunc_subsecs(3))
462 };
463 if binding.decommission_status == "complete" || active_lease(&binding) {
464 return Ok(binding);
465 }
466 let mut acquired = None;
467 for _ in 0..10 {
468 if let Some(leased) = ctx
469 .database
470 .acquire_scim_decommission_lease(binding.clone())
471 .await?
472 {
473 acquired = Some(leased);
474 break;
475 }
476 binding = ctx
477 .database
478 .terminal_scim_connection_binding(
479 &binding.connection_id,
480 Some(&binding.provisioning_domain_id),
481 &binding.connection_key,
482 )
483 .await?;
484 if binding.decommission_status == "complete" || active_lease(&binding) {
485 return Ok(binding);
486 }
487 }
488 let binding=acquired.ok_or_else(||AuthError::internal(format!("SCIM connection \"{}\" changed repeatedly while decommissioning; retry the operation.",binding.connection_id)))?;
489 let lease = binding
490 .decommission_lease_id
491 .clone()
492 .ok_or_else(|| AuthError::internal("Acquired SCIM lease missing"))?;
493 let projection = ctx.extensions.get::<ScimProjectionHandle<S>>();
494 let identity = ctx.extensions.get::<ScimIdentityHandle<S>>();
495 let result=async {
496 loop {
497 let binding_id=binding.id.clone();let connection_id=binding.connection_id.clone();let lease=lease.clone();let projection=projection.clone();let identity=identity.clone();
498 let checkpoint=transaction::<S,alibi_core::store::ScimConnectionBinding,_>(ctx.database.as_ref(),move|database|Box::pin(async move{
499 let stored=database.get_scim_connection_binding(&binding_id).await?.ok_or_else(||AuthError::internal(format!("SCIM connection \"{connection_id}\" binding disappeared during decommissioning.")))?;
500 if stored.decommission_status=="complete"{return Ok(stored);}
501 if stored.decommission_lease_id.as_deref()!=Some(&lease){return Err(AuthError::internal(format!("SCIM connection \"{connection_id}\" decommission lease was taken over by another worker.")));}
502 let candidates=database.scim_projection_candidates(&stored.provisioning_domain_id,stored.decommission_cursor_user_id.as_deref()).await?;
503 let more=candidates.len()>50;let mut cursor=None;let mut by_user=indexmap::IndexMap::new();for candidate in candidates.into_iter().take(50){cursor=Some(candidate.user_id.clone());_=by_user.insert(candidate.user_id,candidate.id);}
504 let user_ids=by_user.keys().cloned().collect::<Vec<_>>();let ids=by_user.into_values().collect::<Vec<_>>();
505 let roles=projection.as_ref().and_then(|projection|projection.1.clone());
506 let states=database.prepare_scim_projection(&stored.provisioning_domain_id,&ids,roles.is_some()).await?;
507 if !database.renew_scim_decommission_lease(&stored,&lease).await?{return Err(AuthError::internal(format!("SCIM connection \"{connection_id}\" decommission lease changed before reconciliation.")));}
508 for mut prepared in states {
509 if let Some(roles)=&roles{apply_roles(database,roles.as_ref(),&mut prepared).await?;}
510 database.remove_scim_projection_grants(&stored.provisioning_domain_id,&prepared.state.user_id,&prepared.removed_grant_ids).await?;
511 if let Some(projection)=&projection{projection.0.reconcile_user(&prepared.state,database).await.map_err(|error|if alibi_core::endpoint::is_endpoint_api_error(&error){error}else{AuthError::CallbackFailure(Box::new(error))})?;}
512 }
513 for user_id in &user_ids{
514 let state=database.reconcile_scim_identity(user_id).await?;
515 if let Some(identity)=&identity{identity.0.reconcile_user(&state,database).await.map_err(|error|application_callback_error(error,"SCIM identity reconciliation failed"))?;}
516 if !state.active{database.delete_user_sessions(user_id).await?;}
517 }
518 database.advance_scim_decommission(&stored,&lease,cursor.as_deref(),user_ids.len(),more).await?.ok_or_else(||AuthError::internal(format!("SCIM connection \"{connection_id}\" decommission checkpoint changed concurrently.")))
519 })).await?;
520 if checkpoint.decommission_status=="complete"{return Ok(checkpoint);}
521 }
522 }.await;
523 if result.is_err() {
524 let _ignored = ctx
525 .database
526 .release_scim_decommission_lease(&binding.id, &lease)
527 .await;
528 }
529 result
530 }
531 pub(super) fn scim_callback_response(cause: &alibi_core::AuthError) -> EndpointResponse {
532 use alibi_core::{AuthError, utils::json::JsValue};
533 let detail = match cause {
534 AuthError::Api { message, .. }
535 if matches!(
536 message.as_str(),
537 "SCIM role mapping failed"
538 | "SCIM role validation failed"
539 | "SCIM identity reconciliation failed"
540 ) =>
541 {
542 message.as_str()
543 }
544 _ => "SCIM projection reconciliation failed",
545 };
546 EndpointResponse::error(AuthError::Api{status:500,code:None,message:detail.into()}).with_error_body(JsValue::from(serde_json::json!({"schemas":["urn:ietf:params:scim:api:messages:2.0:Error"],"status":"500","detail":detail})))
547 }
548}