1use super::{AuthResult, ScimPlugin, Value};
2use alibi_core::endpoint::{EndpointCall, EndpointInput, EndpointResponse, ServerEndpoint};
3use alibi_core::utils::json::JsValue;
4use alibi_core::{AuthContext, AuthError, AuthSchema};
5use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
6use chrono::{DateTime, SubsecRound, Utc};
7use hmac::{Hmac, KeyInit, Mac};
8use rand::seq::IndexedRandom;
9use serde::Serialize;
10use sha2::Sha256;
11#[derive(Clone, Debug, Serialize)]
13#[serde(rename_all = "camelCase")]
14pub struct ScimManagedConnectionInput {
15 pub creation_request_id: String,
16 pub provisioning_domain_id: String,
17 pub actor_id: String,
18 pub scopes: Vec<String>,
19 pub expires_at: DateTime<Utc>,
20}
21fn body_string(body: &JsValue, name: &str, min: usize) -> AuthResult<String> {
22 let value = body.get(name);
23 let Some(text) = value.and_then(JsValue::as_str) else {
24 return Err(crate::endpoint::validation(format!(
25 "[body.{name}] Invalid input: expected string, received {}",
26 crate::authentication_helpers::json_type(value)
27 )));
28 };
29 let text = alibi_core::utils::javascript::trim(text);
30 let length = text.encode_utf16().count();
31 if length < min {
32 return Err(crate::endpoint::validation(format!(
33 "[body.{name}] Too small: expected string to have >={min} characters"
34 )));
35 }
36 if length > 255 {
37 return Err(crate::endpoint::validation(format!(
38 "[body.{name}] Too big: expected string to have <=255 characters"
39 )));
40 }
41 Ok(text.into())
42}
43pub(super) fn validate(call: &EndpointCall) -> AuthResult<EndpointInput> {
44 let body = call.body().ok_or_else(|| {
45 crate::endpoint::validation("[body] Invalid input: expected object, received undefined")
46 })?;
47 if call.operation_id() == "decommissionSCIMConnection" {
48 let mut output = indexmap::IndexMap::new();
49 _ = output.insert(
50 "connectionId".into(),
51 JsValue::String(body_string(body, "connectionId", 1)?),
52 );
53 if body.get("provisioningDomainId").is_some() {
54 _ = output.insert(
55 "provisioningDomainId".into(),
56 JsValue::String(body_string(body, "provisioningDomainId", 1)?),
57 );
58 }
59 return Ok(EndpointInput {
60 body: Some(JsValue::Object(output)),
61 query: call.query().cloned(),
62 });
63 }
64 if matches!(
65 call.operation_id(),
66 "decommissionSCIMManagedConnection"
67 | "getSCIMManagedConnection"
68 | "revokeSCIMManagedCredential"
69 | "listSCIMManagedConnectionEvents"
70 ) {
71 let id = body.get("connectionId");
72 let Some(id) = id.and_then(JsValue::as_str) else {
73 return Err(crate::endpoint::validation(format!(
74 "[body.connectionId] Invalid input: expected string, received {}",
75 crate::authentication_helpers::json_type(id)
76 )));
77 };
78 if !id.starts_with("ba_scim_connection_") {
79 return Err(crate::endpoint::validation(
80 r#"[body.connectionId] Invalid string: must start with "ba_scim_connection_""#,
81 ));
82 }
83 if id.encode_utf16().count() > 255 {
84 return Err(crate::endpoint::validation(
85 "[body.connectionId] Too big: expected string to have <=255 characters",
86 ));
87 }
88 let mut extra = indexmap::IndexMap::new();
89 if call.operation_id() == "revokeSCIMManagedCredential" {
90 let value = body.get("credentialId");
91 let credential = value.and_then(JsValue::as_str).ok_or_else(|| {
92 crate::endpoint::validation(format!(
93 "[body.credentialId] Invalid input: expected string, received {}",
94 crate::authentication_helpers::json_type(value)
95 ))
96 })?;
97 if !credential.starts_with("ba_scim_credential_") {
98 return Err(crate::endpoint::validation(
99 r#"[body.credentialId] Invalid string: must start with "ba_scim_credential_""#,
100 ));
101 }
102 if credential.encode_utf16().count() > 255 {
103 return Err(crate::endpoint::validation(
104 "[body.credentialId] Too big: expected string to have <=255 characters",
105 ));
106 }
107 _ = extra.insert("credentialId".into(), JsValue::String(credential.into()));
108 _ = extra.insert(
109 "actorId".into(),
110 JsValue::String(body_string(body, "actorId", 1)?),
111 );
112 }
113 if call.operation_id() == "decommissionSCIMManagedConnection" {
114 _ = extra.insert(
115 "actorId".into(),
116 JsValue::String(body_string(body, "actorId", 1)?),
117 );
118 }
119 extra.extend([
120 ("connectionId".into(), JsValue::String(id.into())),
121 (
122 "provisioningDomainId".into(),
123 JsValue::String(body_string(body, "provisioningDomainId", 1)?),
124 ),
125 ]);
126 return Ok(EndpointInput {
127 body: Some(JsValue::Object(extra)),
128 query: call.query().cloned(),
129 });
130 }
131 if matches!(
132 call.operation_id(),
133 "listSCIMManagedConnections" | "reconcileSCIMProjection"
134 ) {
135 return Ok(EndpointInput {
136 body: Some(JsValue::Object(
137 [(
138 "provisioningDomainId".into(),
139 JsValue::String(body_string(body, "provisioningDomainId", 1)?),
140 )]
141 .into_iter()
142 .collect(),
143 )),
144 query: call.query().cloned(),
145 });
146 }
147 let rotating = call.operation_id() == "rotateSCIMManagedCredential";
148 let mut output = indexmap::IndexMap::new();
149 if rotating {
150 let id = body.get("connectionId");
151 let Some(id) = id.and_then(JsValue::as_str) else {
152 return Err(crate::endpoint::validation(format!(
153 "[body.connectionId] Invalid input: expected string, received {}",
154 crate::authentication_helpers::json_type(id)
155 )));
156 };
157 if !id.starts_with("ba_scim_connection_") {
158 return Err(crate::endpoint::validation(
159 r#"[body.connectionId] Invalid string: must start with "ba_scim_connection_""#,
160 ));
161 }
162 if id.encode_utf16().count() > 255 {
163 return Err(crate::endpoint::validation(
164 "[body.connectionId] Too big: expected string to have <=255 characters",
165 ));
166 }
167 _ = output.insert("connectionId".into(), JsValue::String(id.into()));
168 }
169
170 for (name, min) in [
171 ("creationRequestId", 16),
172 ("provisioningDomainId", 1),
173 ("actorId", 1),
174 ] {
175 if rotating && name == "creationRequestId" {
176 continue;
177 }
178 _ = output.insert(name.into(), JsValue::String(body_string(body, name, min)?));
179 }
180 let scopes = body.get("scopes");
181 let Some(values) = scopes.and_then(JsValue::as_array) else {
182 return Err(crate::endpoint::validation(format!(
183 "[body.scopes] Invalid input: expected array, received {}",
184 crate::authentication_helpers::json_type(scopes)
185 )));
186 };
187 if values.is_empty() {
188 return Err(crate::endpoint::validation(
189 "[body.scopes] Too small: expected array to have >=1 items",
190 ));
191 }
192 for (index, value) in values.iter().enumerate() {
193 if !value.as_str().is_some_and(|scope| {
194 matches!(
195 scope,
196 "scim.users.read" | "scim.users.write" | "scim.groups.read" | "scim.groups.write"
197 )
198 }) {
199 return Err(crate::endpoint::validation(format!(
200 r#"[body.scopes.{index}] Invalid option: expected one of "scim.users.read"|"scim.users.write"|"scim.groups.read"|"scim.groups.write""#
201 )));
202 }
203 }
204 _ = output.insert("scopes".into(), JsValue::Array(values.to_vec()));
205 let expiry = body.get("expiresAt");
206 let Some(date) = expiry.and_then(JsValue::as_date) else {
207 return Err(crate::endpoint::validation(format!(
208 "[body.expiresAt] Invalid input: expected date, received {}",
209 crate::authentication_helpers::json_type(expiry)
210 )));
211 };
212 _ = output.insert("expiresAt".into(), JsValue::Date(date.trunc_subsecs(3)));
213 Ok(EndpointInput {
214 body: Some(JsValue::Object(output)),
215 query: call.query().cloned(),
216 })
217}
218fn random_string(length: usize) -> String {
219 let alphabet = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_";
220 let mut rng = rand::rng();
221 (0..length)
222 .map(|_| char::from(*alphabet.choose(&mut rng).unwrap_or(&b'a')))
223 .collect()
224}
225impl ScimPlugin {
226 pub fn reconcile_projection_endpoint(domain: &str) -> AuthResult<ServerEndpoint<Value>> {
230 ServerEndpoint::new("scim", "reconcileSCIMProjection")
231 .with_body(&serde_json::json!({"provisioningDomainId":domain}))
232 }
233
234 pub fn decommission_managed_connection_endpoint(
238 id: &str,
239 domain: &str,
240 actor: &str,
241 ) -> AuthResult<ServerEndpoint<Value>> {
242 ServerEndpoint::new("scim", "decommissionSCIMManagedConnection").with_body(
243 &serde_json::json!({"connectionId":id,"provisioningDomainId":domain,"actorId":actor}),
244 )
245 }
246 pub(super) async fn decommission_managed_connection<S: AuthSchema>(
247 &self,
248 call: &EndpointCall,
249 ctx: &AuthContext<S>,
250 ) -> AuthResult<EndpointResponse> {
251 _ = self
252 .managed_connections
253 .as_ref()
254 .ok_or_else(|| AuthError::Api {
255 status: 400,
256 code: None,
257 message: "SCIM managed connections are not configured".into(),
258 })?;
259 let body = call
260 .body()
261 .ok_or_else(|| AuthError::internal("Validated body missing"))?;
262 let data = alibi_core::store::DecommissionScimManagedConnection {
263 connection_id: body
264 .get("connectionId")
265 .and_then(JsValue::as_str)
266 .unwrap_or_default()
267 .into(),
268 provisioning_domain_id: body_string(body, "provisioningDomainId", 1)?,
269 actor_id: body_string(body, "actorId", 1)?,
270 };
271 let mut started = None;
272 for _ in 0..5 {
273 if let Some(connection) = ctx
274 .database
275 .decommission_scim_managed_connection(data.clone(), false)
276 .await?
277 {
278 started = Some(connection);
279 break;
280 }
281 }
282 let started = started.ok_or_else(|| {
283 alibi_core::store::scim::scim_managed_conflict(
284 "Managed SCIM connection changed repeatedly while decommissioning began",
285 )
286 })?;
287 let core = if started.status == "decommissioned" {
288 JsValue::from(serde_json::json!({"status":"complete","retryAfter":null}))
289 } else {
290 let response = self.decommission_connection(call, ctx).await?;
291 if response.result().is_err() {
292 return Ok(response);
293 }
294 response
295 .result()
296 .as_ref()
297 .map_err(|error| AuthError::internal(error.to_string()))?
298 .clone()
299 };
300 if started.status != "decommissioned"
301 && core.get("status").and_then(JsValue::as_str) == Some("complete")
302 {
303 let mut completed = false;
304 for _ in 0..5 {
305 if ctx
306 .database
307 .decommission_scim_managed_connection(data.clone(), true)
308 .await?
309 .is_some()
310 {
311 completed = true;
312 break;
313 }
314 }
315 if !completed {
316 return Err(alibi_core::store::scim::scim_managed_conflict(
317 "Managed SCIM connection changed repeatedly while decommissioning completed",
318 ));
319 }
320 }
321 let mut response = self.get_managed_connection(call, ctx).await?;
322 let mut value = response
323 .result()
324 .as_ref()
325 .map_err(|error| AuthError::internal(error.to_string()))?
326 .clone();
327 if let JsValue::Object(values) = &mut value {
328 _ = values.insert("decommission".into(), core);
329 }
330 response.replace(value);
331 Ok(response)
332 }
333
334 pub fn decommission_connection_endpoint(
338 id: &str,
339 domain: Option<&str>,
340 ) -> AuthResult<ServerEndpoint<Value>> {
341 let mut body = serde_json::json!({"connectionId":id});
342 if let Some(domain) = domain
343 && let Value::Object(values) = &mut body
344 {
345 _ = values.insert("provisioningDomainId".into(), Value::String(domain.into()));
346 }
347 ServerEndpoint::new("scim", "decommissionSCIMConnection").with_body(&body)
348 }
349 pub(super) async fn decommission_connection<S: AuthSchema>(
350 &self,
351 call: &EndpointCall,
352 ctx: &AuthContext<S>,
353 ) -> AuthResult<EndpointResponse> {
354 use sha2::Digest;
355 let body = call
356 .body()
357 .ok_or_else(|| AuthError::internal("Validated body missing"))?;
358 let id = body
359 .get("connectionId")
360 .and_then(JsValue::as_str)
361 .ok_or_else(|| AuthError::internal("Validated ID missing"))?;
362 let domain = body.get("provisioningDomainId").and_then(JsValue::as_str);
363 let key = URL_SAFE_NO_PAD.encode(Sha256::digest(serde_json::to_vec(&[
364 "scim-connection",
365 id,
366 ])?));
367 let binding = ctx
368 .database
369 .terminal_scim_connection_binding(id, domain, &key)
370 .await?;
371 let binding = match self.decommission_binding(ctx, binding).await {
372 Ok(binding) => binding,
373 Err(AuthError::CallbackFailure(cause)) => {
374 return Ok(Self::scim_callback_response(cause.as_ref()));
375 }
376 Err(error) => return Err(error),
377 };
378 let leased = binding.decommission_status == "reconciling";
379 EndpointResponse::json(
380 &serde_json::json!({"connectionId":binding.connection_id,"provisioningDomainId":binding.provisioning_domain_id,"status":binding.decommission_status,"decommissionedAt":binding.decommissioned_at,"completedAt":binding.decommission_completed_at,"retryAfter":if leased {binding.decommission_lease_expires_at}else{None},"reconciledUsers":binding.decommission_reconciled_user_count,"batches":binding.decommission_batch_count}),
381 )
382 }
383
384 pub fn list_managed_connection_events_endpoint(
388 id: &str,
389 domain: &str,
390 ) -> AuthResult<ServerEndpoint<Value>> {
391 ServerEndpoint::new("scim", "listSCIMManagedConnectionEvents")
392 .with_body(&serde_json::json!({"connectionId":id,"provisioningDomainId":domain}))
393 }
394 pub(super) async fn list_managed_connection_events<S: AuthSchema>(
395 &self,
396 call: &EndpointCall,
397 ctx: &AuthContext<S>,
398 ) -> AuthResult<EndpointResponse> {
399 _ = self
400 .managed_connections
401 .as_ref()
402 .ok_or_else(|| AuthError::Api {
403 status: 400,
404 code: None,
405 message: "SCIM managed connections are not configured".into(),
406 })?;
407 let body = call
408 .body()
409 .ok_or_else(|| AuthError::internal("Validated body missing"))?;
410 let id = body
411 .get("connectionId")
412 .and_then(JsValue::as_str)
413 .ok_or_else(|| AuthError::internal("Validated ID missing"))?;
414 let events = ctx
415 .database
416 .list_scim_managed_connection_events(id, &body_string(body, "provisioningDomainId", 1)?)
417 .await?
418 .ok_or_else(|| AuthError::Api {
419 status: 404,
420 code: None,
421 message: "Managed SCIM connection not found".into(),
422 })?;
423 EndpointResponse::json(&serde_json::json!({"events":events}))
424 }
425
426 pub fn revoke_managed_credential_endpoint(
430 id: &str,
431 domain: &str,
432 credential: &str,
433 actor: &str,
434 ) -> AuthResult<ServerEndpoint<Value>> {
435 ServerEndpoint::new("scim","revokeSCIMManagedCredential").with_body(&serde_json::json!({"connectionId":id,"provisioningDomainId":domain,"credentialId":credential,"actorId":actor}))
436 }
437 pub(super) async fn revoke_managed_credential<S: AuthSchema>(
438 &self,
439 call: &EndpointCall,
440 ctx: &AuthContext<S>,
441 ) -> AuthResult<EndpointResponse> {
442 _ = self
443 .managed_connections
444 .as_ref()
445 .ok_or_else(|| AuthError::Api {
446 status: 400,
447 code: None,
448 message: "SCIM managed connections are not configured".into(),
449 })?;
450 let body = call
451 .body()
452 .ok_or_else(|| AuthError::internal("Validated body missing"))?;
453 let data = alibi_core::store::RevokeScimManagedCredential {
454 connection_id: body
455 .get("connectionId")
456 .and_then(JsValue::as_str)
457 .unwrap_or_default()
458 .into(),
459 credential_id: body
460 .get("credentialId")
461 .and_then(JsValue::as_str)
462 .unwrap_or_default()
463 .into(),
464 provisioning_domain_id: body_string(body, "provisioningDomainId", 1)?,
465 actor_id: body_string(body, "actorId", 1)?,
466 };
467 for _ in 0..5 {
468 if ctx
469 .database
470 .revoke_scim_managed_credential(data.clone())
471 .await?
472 .is_some()
473 {
474 return self.get_managed_connection(call, ctx).await;
475 }
476 }
477 Err(alibi_core::store::scim::scim_managed_conflict(
478 "Managed SCIM connection changed repeatedly during credential revocation",
479 ))
480 }
481
482 pub fn rotate_managed_credential_endpoint(
486 input: &ScimManagedCredentialRotationInput,
487 ) -> AuthResult<ServerEndpoint<Value>> {
488 let mut body = JsValue::from(serde_json::to_value(input)?);
489 if let JsValue::Object(values) = &mut body {
490 _ = values.insert("expiresAt".into(), JsValue::Date(input.expires_at));
491 }
492 Ok(ServerEndpoint::new("scim", "rotateSCIMManagedCredential").with_body_value(body))
493 }
494 pub(super) async fn rotate_managed_credential<S: AuthSchema>(
495 &self,
496 call: &EndpointCall,
497 ctx: &AuthContext<S>,
498 ) -> AuthResult<EndpointResponse> {
499 call.queue_response_header("cache-control", "no-store");
500 call.queue_response_header("pragma", "no-cache");
501 let options = self
502 .managed_connections
503 .as_ref()
504 .ok_or_else(|| AuthError::Api {
505 status: 400,
506 code: None,
507 message: "SCIM managed connections are not configured".into(),
508 })?;
509 let body = call
510 .body()
511 .ok_or_else(|| AuthError::internal("Validated body missing"))?;
512 let expires = *body
513 .get("expiresAt")
514 .and_then(JsValue::as_date)
515 .ok_or_else(|| AuthError::internal("Validated expiry missing"))?;
516 if expires <= Utc::now().trunc_subsecs(3) {
517 return Err(AuthError::Api {
518 status: 400,
519 code: None,
520 message: "Managed SCIM credential expiry must be in the future".into(),
521 });
522 }
523 let scopes = body
524 .get("scopes")
525 .and_then(JsValue::as_array)
526 .ok_or_else(|| AuthError::internal("Validated scopes missing"))?
527 .iter()
528 .filter_map(JsValue::as_str)
529 .map(str::to_owned)
530 .collect::<Vec<_>>();
531 if scopes
532 .iter()
533 .collect::<std::collections::HashSet<_>>()
534 .len()
535 != scopes.len()
536 {
537 return Err(AuthError::Api {
538 status: 400,
539 code: None,
540 message: "Managed SCIM credential scopes must be unique".into(),
541 });
542 }
543 let credential_id = format!("ba_scim_credential_{}", random_string(32));
544 let token = format!("{credential_id}.{}", random_string(48));
545 let mut mac = Hmac::<Sha256>::new_from_slice(options.credential_hash_secret.as_bytes())
546 .map_err(|error| AuthError::internal(error.to_string()))?;
547 mac.update(token.as_bytes());
548 let digest = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
549 let data = alibi_core::store::RotateScimManagedCredential {
550 connection_id: body
551 .get("connectionId")
552 .and_then(JsValue::as_str)
553 .ok_or_else(|| AuthError::internal("Validated ID missing"))?
554 .into(),
555 provisioning_domain_id: body_string(body, "provisioningDomainId", 1)?,
556 actor_id: body_string(body, "actorId", 1)?,
557 credential_id,
558 token_digest: digest,
559 serialized_scopes: serde_json::to_string(&scopes)?,
560 expires_at: expires,
561 created_at: Utc::now().trunc_subsecs(3),
562 max_active_credentials: options.max_active_credentials,
563 };
564 for _ in 0..5 {
565 if let Some(created) = ctx
566 .database
567 .rotate_scim_managed_credential(data.clone())
568 .await?
569 {
570 let mut connection = serde_json::to_value(created.connection)?;
571 if let Value::Object(values) = &mut connection {
572 _ = values.remove("id");
573 _ = values.remove("revision");
574 }
575 let credential = created.credential;
576 return EndpointResponse::json(
577 &serde_json::json!({"connection":connection,"credential":{"credentialId":credential.credential_id,"status":credential.status,"scopes":scopes,"expiresAt":credential.expires_at,"createdAt":credential.created_at,"createdBy":credential.created_by,"lastUsedAt":credential.last_used_at,"revokedAt":credential.revoked_at,"revokedBy":credential.revoked_by},"token":token}),
578 );
579 }
580 }
581 Err(alibi_core::store::scim::scim_managed_conflict(
582 "Managed SCIM connection changed repeatedly during credential rotation",
583 ))
584 }
585
586 pub fn get_managed_connection_endpoint(
590 id: &str,
591 domain: &str,
592 ) -> AuthResult<ServerEndpoint<Value>> {
593 ServerEndpoint::new("scim", "getSCIMManagedConnection")
594 .with_body(&serde_json::json!({"connectionId":id,"provisioningDomainId":domain}))
595 }
596 pub(super) async fn get_managed_connection<S: AuthSchema>(
597 &self,
598 call: &EndpointCall,
599 ctx: &AuthContext<S>,
600 ) -> AuthResult<EndpointResponse> {
601 _ = self
602 .managed_connections
603 .as_ref()
604 .ok_or_else(|| AuthError::Api {
605 status: 400,
606 code: None,
607 message: "SCIM managed connections are not configured".into(),
608 })?;
609 let body = call
610 .body()
611 .ok_or_else(|| AuthError::internal("Validated body missing"))?;
612 let id = body
613 .get("connectionId")
614 .and_then(JsValue::as_str)
615 .ok_or_else(|| AuthError::internal("Validated ID missing"))?;
616 let state = ctx
617 .database
618 .get_scim_managed_connection(id, &body_string(body, "provisioningDomainId", 1)?)
619 .await?
620 .ok_or_else(|| AuthError::Api {
621 status: 404,
622 code: None,
623 message: "Managed SCIM connection not found".into(),
624 })?;
625 let mut connection = serde_json::to_value(state.connection)?;
626 if let Value::Object(values) = &mut connection {
627 _ = values.remove("id");
628 _ = values.remove("revision");
629 }
630 let mut credentials = Vec::new();
631 for credential in state.credentials {
632 let scopes: Vec<String> = serde_json::from_str(&credential.serialized_scopes)
633 .map_err(|_| invalid_scope_policy())?;
634 if scopes.is_empty()
635 || scopes.iter().any(|scope| {
636 !matches!(
637 scope.as_str(),
638 "scim.users.read"
639 | "scim.users.write"
640 | "scim.groups.read"
641 | "scim.groups.write"
642 )
643 })
644 || scopes
645 .iter()
646 .collect::<std::collections::HashSet<_>>()
647 .len()
648 != scopes.len()
649 {
650 return Err(invalid_scope_policy());
651 }
652 let status = if credential.status == "active"
653 && credential.expires_at <= Utc::now().trunc_subsecs(3)
654 {
655 "expired"
656 } else {
657 &credential.status
658 };
659 credentials.push(serde_json::json!({"credentialId":credential.credential_id,"status":status,"scopes":scopes,"expiresAt":credential.expires_at,"createdAt":credential.created_at,"createdBy":credential.created_by,"lastUsedAt":credential.last_used_at,"revokedAt":credential.revoked_at,"revokedBy":credential.revoked_by}));
660 }
661 EndpointResponse::json(
662 &serde_json::json!({"connection":connection,"credentials":credentials}),
663 )
664 }
665
666 pub fn list_managed_connections_endpoint(domain: &str) -> AuthResult<ServerEndpoint<Value>> {
670 ServerEndpoint::new("scim", "listSCIMManagedConnections")
671 .with_body(&serde_json::json!({"provisioningDomainId":domain}))
672 }
673 pub(super) async fn list_managed_connections<S: AuthSchema>(
674 &self,
675 call: &EndpointCall,
676 ctx: &AuthContext<S>,
677 ) -> AuthResult<EndpointResponse> {
678 _ = self
679 .managed_connections
680 .as_ref()
681 .ok_or_else(|| AuthError::Api {
682 status: 400,
683 code: None,
684 message: "SCIM managed connections are not configured".into(),
685 })?;
686 let body = call
687 .body()
688 .ok_or_else(|| AuthError::internal("Validated body missing"))?;
689 let rows = ctx
690 .database
691 .list_scim_managed_connections(&body_string(body, "provisioningDomainId", 1)?)
692 .await?;
693 let connections = rows
694 .into_iter()
695 .map(|row| {
696 let mut row = serde_json::to_value(row)?;
697 if let Value::Object(values) = &mut row {
698 _ = values.remove("id");
699 _ = values.remove("revision");
700 }
701 Ok(row)
702 })
703 .collect::<AuthResult<Vec<_>>>()?;
704 EndpointResponse::json(&serde_json::json!({"connections":connections}))
705 }
706
707 pub fn create_managed_connection_endpoint(
711 input: &ScimManagedConnectionInput,
712 ) -> AuthResult<ServerEndpoint<Value>> {
713 let mut body = JsValue::from(serde_json::to_value(input)?);
714 if let JsValue::Object(values) = &mut body {
715 _ = values.insert("expiresAt".into(), JsValue::Date(input.expires_at));
716 }
717 Ok(ServerEndpoint::new("scim", "createSCIMManagedConnection").with_body_value(body))
718 }
719 pub(super) async fn create_managed_connection<S: AuthSchema>(
720 &self,
721 call: &EndpointCall,
722 ctx: &AuthContext<S>,
723 ) -> AuthResult<EndpointResponse> {
724 call.queue_response_header("cache-control", "no-store");
725 call.queue_response_header("pragma", "no-cache");
726 let options = self
727 .managed_connections
728 .as_ref()
729 .ok_or_else(|| AuthError::Api {
730 status: 400,
731 code: None,
732 message: "SCIM managed connections are not configured".into(),
733 })?;
734 let body = call
735 .body()
736 .ok_or_else(|| AuthError::internal("Validated body missing"))?;
737 let expires = *body
738 .get("expiresAt")
739 .and_then(JsValue::as_date)
740 .ok_or_else(|| AuthError::internal("Validated expiry missing"))?;
741 if expires <= Utc::now().trunc_subsecs(3) {
742 return Err(AuthError::Api {
743 status: 400,
744 code: None,
745 message: "Managed SCIM credential expiry must be in the future".into(),
746 });
747 }
748 let scopes = body
749 .get("scopes")
750 .and_then(JsValue::as_array)
751 .ok_or_else(|| AuthError::internal("Validated scopes missing"))?
752 .iter()
753 .filter_map(JsValue::as_str)
754 .map(str::to_owned)
755 .collect::<Vec<_>>();
756 if scopes
757 .iter()
758 .collect::<std::collections::HashSet<_>>()
759 .len()
760 != scopes.len()
761 {
762 return Err(AuthError::Api {
763 status: 400,
764 code: None,
765 message: "Managed SCIM credential scopes must be unique".into(),
766 });
767 }
768 let connection_id = format!("ba_scim_connection_{}", random_string(32));
769 let credential_id = format!("ba_scim_credential_{}", random_string(32));
770 let token = format!("{credential_id}.{}", random_string(48));
771 let mut mac = Hmac::<Sha256>::new_from_slice(options.credential_hash_secret.as_bytes())
772 .map_err(|error| AuthError::internal(error.to_string()))?;
773 mac.update(token.as_bytes());
774 let digest = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
775 let created = ctx
776 .database
777 .create_scim_managed_connection(alibi_core::store::CreateScimManagedConnection {
778 creation_request_id: body_string(body, "creationRequestId", 16)?,
779 connection_id,
780 provisioning_domain_id: body_string(body, "provisioningDomainId", 1)?,
781 actor_id: body_string(body, "actorId", 1)?,
782 credential_id,
783 token_digest: digest,
784 serialized_scopes: serde_json::to_string(&scopes)?,
785 expires_at: expires,
786 created_at: Utc::now().trunc_subsecs(3),
787 })
788 .await?;
789 let mut connection = serde_json::to_value(created.connection)?;
790 if let Value::Object(fields) = &mut connection {
791 _ = fields.remove("id");
792 _ = fields.remove("revision");
793 }
794 let credential = created.credential;
795 EndpointResponse::json(
796 &serde_json::json!({"connection":connection,"credential":{"credentialId":credential.credential_id,"status":credential.status,"scopes":scopes,"expiresAt":credential.expires_at,"createdAt":credential.created_at,"createdBy":credential.created_by,"lastUsedAt":credential.last_used_at,"revokedAt":credential.revoked_at,"revokedBy":credential.revoked_by},"token":token}),
797 )
798 }
799}
800
801fn invalid_scope_policy() -> AuthError {
802 AuthError::Api {
803 status: 500,
804 code: None,
805 message: "Managed SCIM credential scope policy is invalid".into(),
806 }
807}
808
809#[derive(Clone, Debug, Serialize)]
810#[serde(rename_all = "camelCase")]
811pub struct ScimManagedCredentialRotationInput {
812 pub connection_id: String,
813 pub provisioning_domain_id: String,
814 pub actor_id: String,
815 pub scopes: Vec<String>,
816 pub expires_at: DateTime<Utc>,
817}